mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-04-09 18:57:22 +02:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8cf0db1c5 | ||
|
|
412687467b | ||
|
|
416a7d5f40 | ||
|
|
c4605fb60e | ||
|
|
61eed5987c | ||
|
|
cbef7c6181 | ||
|
|
63a09390b8 | ||
|
|
a4e1ab9eb6 | ||
|
|
147f4d9908 | ||
|
|
ddcb2a36ec | ||
|
|
6dc93806a8 | ||
|
|
99a6d48e53 | ||
|
|
935ac32ea2 | ||
|
|
a0a632f245 | ||
|
|
51fed9e593 | ||
|
|
f652d1d961 | ||
|
|
8aacd428b1 | ||
|
|
c2eb5ce1f6 | ||
|
|
fb4dec383b | ||
|
|
c5052dd457 | ||
|
|
d967c7de07 | ||
|
|
d5b6382dd0 | ||
|
|
e8fcb95db0 | ||
|
|
8ad2d60082 | ||
|
|
6ce0510f21 | ||
|
|
c52728a050 | ||
|
|
8f4d0b8eda | ||
|
|
c37d0c0cba | ||
|
|
6c607f065c | ||
|
|
cff1fde90e | ||
|
|
c32ceb6ad8 | ||
|
|
f0a6f2e43b | ||
|
|
828c61cb2f | ||
|
|
e8c5336888 | ||
|
|
b3bea27f40 | ||
|
|
3d3d123484 | ||
|
|
5b0135d694 | ||
|
|
5067f05558 | ||
|
|
b815c1e5ad | ||
|
|
75c9b5bb62 | ||
|
|
f259cf6bf3 | ||
|
|
2ba73ff680 | ||
|
|
e3938caecb | ||
|
|
bd096c3012 | ||
|
|
2355d3f989 | ||
|
|
45bcb03fef | ||
|
|
e86aa559a4 | ||
|
|
d36537b919 | ||
|
|
8adb4423ac | ||
|
|
8dbf992cce | ||
|
|
f382d72ee5 | ||
|
|
9749cd6f3e | ||
|
|
83ecc15332 | ||
|
|
ecd001a082 |
16
.github/templates/release.md
vendored
Normal file
16
.github/templates/release.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
Update description
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
## Fixes
|
||||
|
||||
|
||||
## Minimum macOS Version
|
||||
|
||||
|
||||
## Build
|
||||
https://github.com/maxgoedjen/secretive/actions/runs/RUN_ID
|
||||
|
||||
## Attestation
|
||||
https://github.com/maxgoedjen/secretive/attestations/ATTESTATION_ID
|
||||
16
.github/workflows/add-to-project.yml
vendored
16
.github/workflows/add-to-project.yml
vendored
@@ -1,16 +0,0 @@
|
||||
name: Add bugs to bugs project
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-to-project:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v1.0.1
|
||||
with:
|
||||
project-url: https://github.com/users/maxgoedjen/projects/1
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
28
.github/workflows/nightly.yml
vendored
28
.github/workflows/nightly.yml
vendored
@@ -3,13 +3,19 @@ name: Nightly
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# runs-on: macOS-latest
|
||||
runs-on: macos-15
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
attestations: write
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Setup Signing
|
||||
env:
|
||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||
@@ -20,7 +26,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.0.app
|
||||
- name: Update Build Number
|
||||
env:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
@@ -30,25 +36,23 @@ jobs:
|
||||
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
|
||||
- name: Build
|
||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
||||
- name: Create ZIPs
|
||||
- name: Create ZIP
|
||||
run: |
|
||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.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
|
||||
id: upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Secretive.zip
|
||||
path: Secretive.zip
|
||||
- name: Attest
|
||||
id: attest
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: "Secretive.zip"
|
||||
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
|
||||
|
||||
83
.github/workflows/release.yml
vendored
83
.github/workflows/release.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Setup Signing
|
||||
env:
|
||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||
@@ -21,18 +21,19 @@ 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.0.app
|
||||
- name: Test
|
||||
run: |
|
||||
pushd Sources/Packages
|
||||
swift test
|
||||
popd
|
||||
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
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Setup Signing
|
||||
env:
|
||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||
@@ -43,7 +44,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.0.app
|
||||
- name: Update Build Number
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref }}
|
||||
@@ -55,64 +56,34 @@ jobs:
|
||||
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
|
||||
- name: Build
|
||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
|
||||
- name: Create ZIPs
|
||||
- name: Create ZIP
|
||||
run: |
|
||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.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: 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
|
||||
id: upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Secretive.zip
|
||||
path: Secretive.zip
|
||||
- name: Upload Archive to Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Attest
|
||||
id: attest
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
name: Xcode_Archive.zip
|
||||
path: Archive.zip
|
||||
subject-name: "Secretive.zip"
|
||||
subject-digest: ${{ steps.upload.outputs.artifact-digest }}
|
||||
- name: Create Release
|
||||
run: |
|
||||
sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
|
||||
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
|
||||
gh release create $TAG_NAME -d -F .github/templates/release.md
|
||||
gh release upload Secretive.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG_NAME: ${{ github.ref }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
|
||||
|
||||
13
.github/workflows/test.yml
vendored
13
.github/workflows/test.yml
vendored
@@ -7,11 +7,10 @@ jobs:
|
||||
runs-on: macos-15
|
||||
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.0.app
|
||||
- name: Test Main Packages
|
||||
run: swift test --build-system swiftbuild --package-path Sources/Packages
|
||||
- name: Test SecretKit Packages
|
||||
run: swift test --build-system swiftbuild
|
||||
|
||||
126
APP_CONFIG.md
126
APP_CONFIG.md
@@ -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
2
FAQ.md
@@ -6,7 +6,7 @@ The secure enclave doesn't allow import or export of private keys. For any new c
|
||||
|
||||
### Secretive doesn't work with my git client/app
|
||||
|
||||
Secretive 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
|
||||
|
||||
|
||||
69
Package.swift
Normal file
69
Package.swift
Normal 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),
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Secretive  
|
||||
# Secretive [](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) 
|
||||
|
||||
|
||||
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
|
||||
@@ -49,7 +49,7 @@ 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
|
||||
|
||||
@@ -61,4 +61,4 @@ 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)
|
||||
|
||||
20
SECURITY.md
20
SECURITY.md
@@ -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)
|
||||
|
||||
@@ -5,6 +5,7 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "SecretivePackages",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
@@ -34,47 +35,56 @@ 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
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
.systemLibrary(
|
||||
name: "SecretAgentKitHeaders"
|
||||
name: "SecretAgentKitHeaders",
|
||||
),
|
||||
.testTarget(
|
||||
name: "SecretAgentKitTests",
|
||||
dependencies: ["SecretAgentKit"])
|
||||
,
|
||||
dependencies: ["SecretAgentKit"],
|
||||
),
|
||||
.target(
|
||||
name: "Brief",
|
||||
dependencies: [],
|
||||
swiftSettings: swiftSettings
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
.testTarget(
|
||||
name: "BriefTests",
|
||||
dependencies: ["Brief"]
|
||||
dependencies: ["Brief"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
var localization: Resource {
|
||||
.process("../../Resources/Localizable.xcstrings")
|
||||
}
|
||||
|
||||
var swiftSettings: [PackageDescription.SwiftSetting] {
|
||||
[
|
||||
.swiftLanguageMode(.v6),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
@@ -33,28 +33,26 @@ 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 {
|
||||
/// - data: The data to handle.
|
||||
/// - provenance: The origin of the request.
|
||||
/// - Returns: A response data payload.
|
||||
public func handle(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
||||
logger.debug("Agent handling new data")
|
||||
let data = Data(reader.availableData)
|
||||
guard data.count > 4 else { return false}
|
||||
guard data.count > 4 else {
|
||||
throw InvalidDataProvidedError()
|
||||
}
|
||||
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
|
||||
return SSHAgent.ResponseType.agentFailure.data.lengthAndData
|
||||
}
|
||||
logger.debug("Agent handling request of type \(requestType.debugDescription)")
|
||||
let subData = Data(data[5...])
|
||||
let response = await handle(requestType: requestType, data: subData, reader: reader)
|
||||
writer.write(response)
|
||||
return true
|
||||
let response = await handle(requestType: requestType, data: subData, provenance: provenance)
|
||||
return response
|
||||
}
|
||||
|
||||
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) async -> Data {
|
||||
private func handle(requestType: SSHAgent.RequestType, data: Data, provenance: SigningRequestProvenance) async -> Data {
|
||||
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
||||
await reloadSecretsIfNeccessary()
|
||||
var response = Data()
|
||||
@@ -65,7 +63,6 @@ extension Agent {
|
||||
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)")
|
||||
@@ -75,8 +72,7 @@ extension Agent {
|
||||
response.append(SSHAgent.ResponseType.agentFailure.data)
|
||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
||||
}
|
||||
let full = OpenSSHKeyWriter().lengthAndData(of: response)
|
||||
return full
|
||||
return response.lengthAndData
|
||||
}
|
||||
|
||||
}
|
||||
@@ -88,18 +84,18 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -118,6 +114,7 @@ extension Agent {
|
||||
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
|
||||
@@ -125,60 +122,18 @@ extension Agent {
|
||||
hash = payloadHash
|
||||
}
|
||||
|
||||
guard let (store, secret) = await secret(matching: hash) else {
|
||||
guard let (secret, store) = await secret(matching: hash) else {
|
||||
logger.debug("Agent did not have a key matching \(hash as NSData)")
|
||||
throw AgentError.noMatchingKey
|
||||
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: dataToSign, 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 +158,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,13 +169,8 @@ extension Agent {
|
||||
|
||||
extension Agent {
|
||||
|
||||
/// An error involving agent operations..
|
||||
enum AgentError: Error {
|
||||
case unhandledType
|
||||
case noMatchingKey
|
||||
case unsupportedKeyType
|
||||
case notOpenSSHCertificate
|
||||
}
|
||||
struct InvalidDataProvidedError: Error {}
|
||||
struct NoMatchingKeyError: Error {}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct UncheckedSendable<T>: @unchecked Sendable {
|
||||
|
||||
let value: T
|
||||
|
||||
init(_ value: T) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,25 +34,104 @@ 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 { [fileHandle, sessionsContinuation, logger] in
|
||||
for await notification in NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted) {
|
||||
logger.debug("Socket controller accepted connection")
|
||||
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
|
||||
let session = Session(fileHandle: new)
|
||||
sessionsContinuation.yield(session)
|
||||
await fileHandle.acceptConnectionInBackgroundAndNotifyOnMainActor()
|
||||
}
|
||||
}
|
||||
fileHandle.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
|
||||
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])
|
||||
}
|
||||
|
||||
extension SocketController {
|
||||
|
||||
/// 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 { [messagesContinuation, logger] in
|
||||
for await _ in NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle) {
|
||||
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.")
|
||||
}
|
||||
}
|
||||
Task {
|
||||
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes new data to the socket.
|
||||
/// - Parameter data: The data to write.
|
||||
public func write(_ data: Data) async throws {
|
||||
try fileHandle.write(contentsOf: data)
|
||||
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
|
||||
}
|
||||
|
||||
/// Closes the socket and cleans up resources.
|
||||
public func close() throws {
|
||||
logger.debug("Session closed.")
|
||||
messagesContinuation.finish()
|
||||
try fileHandle.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
}
|
||||
|
||||
private extension FileHandle {
|
||||
|
||||
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
|
||||
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
|
||||
waitForDataInBackgroundAndNotify()
|
||||
}
|
||||
|
||||
|
||||
/// Ensures acceptConnectionInBackgroundAndNotify will be called on the main actor.
|
||||
/// - Parameter modes: the runloop modes to use.
|
||||
@MainActor func acceptConnectionInBackgroundAndNotifyOnMainActor(forModes modes: [RunLoop.Mode]? = [RunLoop.Mode.common]) {
|
||||
acceptConnectionInBackgroundAndNotify(forModes: modes)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension SocketPort {
|
||||
|
||||
convenience init(path: String) {
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
|
||||
@@ -61,51 +149,7 @@ public final class SocketController {
|
||||
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
|
||||
}
|
||||
|
||||
return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
||||
}
|
||||
|
||||
/// Handles a new connection being accepted, invokes the handler, and prepares to accept new connections.
|
||||
/// - Parameter notification: A `Notification` that triggered the call.
|
||||
@objc func handleConnectionAccept(notification: Notification) {
|
||||
logger.debug("Socket controller accepted connection")
|
||||
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return }
|
||||
Task { [handler, fileHandle] in
|
||||
_ = await handler?(new, new)
|
||||
await new.waitForDataInBackgroundAndNotifyOnMainActor()
|
||||
await fileHandle?.acceptConnectionInBackgroundAndNotifyOnMainActor()
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a new connection providing data and invokes the handler callback.
|
||||
/// - Parameter notification: A `Notification` that triggered the call.
|
||||
@objc func handleConnectionDataAvailable(notification: Notification) {
|
||||
logger.debug("Socket controller has new data available")
|
||||
guard let new = notification.object as? FileHandle else { return }
|
||||
logger.debug("Socket controller received new file handle")
|
||||
Task { [handler, logger = UncheckedSendable(logger)] in
|
||||
if((await handler?(new, new)) == true) {
|
||||
logger.value.debug("Socket controller handled data, wait for more data")
|
||||
await new.waitForDataInBackgroundAndNotifyOnMainActor()
|
||||
} else {
|
||||
logger.value.debug("Socket controller called with empty data, socked closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FileHandle {
|
||||
|
||||
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
|
||||
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
|
||||
waitForDataInBackgroundAndNotify()
|
||||
}
|
||||
|
||||
|
||||
/// Ensures acceptConnectionInBackgroundAndNotify will be called on the main actor.
|
||||
/// - Parameter modes: the runloop modes to use.
|
||||
@MainActor func acceptConnectionInBackgroundAndNotifyOnMainActor(forModes modes: [RunLoop.Mode]? = [RunLoop.Mode.common]) {
|
||||
acceptConnectionInBackgroundAndNotify(forModes: modes)
|
||||
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ SecretKit is a collection of protocols describing secrets and stores.
|
||||
|
||||
### OpenSSH
|
||||
|
||||
- ``OpenSSHKeyWriter``
|
||||
- ``OpenSSHPublicKeyWriter``
|
||||
- ``OpenSSHReader``
|
||||
|
||||
### Signing Process
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 () -> [KeyType]
|
||||
|
||||
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: [KeyType] {
|
||||
_supportedKeyTypes()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 Data(bytes: &endian, count: UInt32.bitWidth/8) + 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import OSLog
|
||||
/// 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.
|
||||
@@ -31,7 +31,6 @@ public actor OpenSSHCertificateHandler: Sendable {
|
||||
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",
|
||||
@@ -40,10 +39,10 @@ public actor OpenSSHCertificateHandler: Sendable {
|
||||
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)
|
||||
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
||||
return openSSHIdentifier.lengthAndData +
|
||||
curveIdentifier.lengthAndData +
|
||||
publicKey.lengthAndData
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -78,14 +77,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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
public 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,9 +17,7 @@ public final class OpenSSHReader {
|
||||
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 littleEndianLength = lengthChunk.bytes.unsafeLoad(as: UInt32.self)
|
||||
let length = Int(littleEndianLength.bigEndian)
|
||||
let dataRange = 0..<length
|
||||
let ret = Data(remaining[dataRange])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
@@ -20,19 +20,20 @@ public final class PublicKeyFileStoreController: Sendable {
|
||||
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 contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? []
|
||||
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" }
|
||||
|
||||
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 +45,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 +66,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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
public struct Attributes: Sendable, Codable, Hashable {
|
||||
|
||||
/// The type of key involved.
|
||||
public let keyType: KeyType
|
||||
|
||||
/// The authentication requirements for the key. This is simply a description of the option recorded at creation – modifying it doers not modify the key's authentication requirements.
|
||||
public let authentication: AuthenticationRequirement
|
||||
|
||||
/// The string appended to the end of the SSH Public Key.
|
||||
/// If nil, a default value will be used.
|
||||
public var publicKeyAttribution: String?
|
||||
|
||||
public init(
|
||||
keyType: KeyType,
|
||||
authentication: AuthenticationRequirement,
|
||||
publicKeyAttribution: String? = nil
|
||||
) {
|
||||
self.keyType = keyType
|
||||
self.authentication = authentication
|
||||
self.publicKeyAttribution = publicKeyAttribution
|
||||
}
|
||||
|
||||
public struct UnsupportedOptionError: Error {
|
||||
package init() {}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// The option specified
|
||||
public enum AuthenticationRequirement: String, Hashable, Sendable, Codable, Identifiable {
|
||||
|
||||
/// Authentication is not required for usage.
|
||||
case notRequired
|
||||
|
||||
/// The user needs to authenticate, using either a biometric option, a connected authorized watch, or password entry..
|
||||
case presenceRequired
|
||||
|
||||
/// ONLY the current set of biometric data, as matching at time of creation, is accepted.
|
||||
/// - Warning: This is a dangerous option prone to data loss. The user should be warned before configuring this key that if they modify their enrolled biometry INCLUDING by simply adding a new entry (ie, adding another fingeprting), the key will no longer be able to be accessed. This cannot be overridden with a password.
|
||||
case biometryCurrent
|
||||
|
||||
/// The authentication requirement was not recorded at creation, and is unknown.
|
||||
case unknown
|
||||
|
||||
/// Whether or not the key is known to require authentication.
|
||||
public var required: Bool {
|
||||
self == .presenceRequired || self == .biometryCurrent
|
||||
}
|
||||
|
||||
public var id: AuthenticationRequirement {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -5,43 +5,81 @@ public protocol Secret: Identifiable, Hashable, Sendable {
|
||||
|
||||
/// A user-facing string identifying the Secret.
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +59,10 @@ 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: [KeyType] { get }
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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?
|
||||
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 = 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.")
|
||||
try markMigrated(secret: secret, oldID: id)
|
||||
continue
|
||||
}
|
||||
logger.log("Migrating \(name).")
|
||||
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
|
||||
logger.log("Migrated \(name).")
|
||||
try markMigrated(secret: secret, oldID: id)
|
||||
migratedAny = true
|
||||
} catch {
|
||||
logger.error("Failed to migrate \(name): \(error).")
|
||||
}
|
||||
}
|
||||
if migratedAny {
|
||||
store.reloadSecrets()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public func markMigrated(secret: Secret, oldID: Data) throws {
|
||||
let updateQuery = KeychainDictionary([
|
||||
kSecClass: kSecClassKey,
|
||||
kSecAttrApplicationLabel: secret.id
|
||||
])
|
||||
|
||||
let newID = oldID + Constants.migrationMagicNumber
|
||||
let updatedAttributes = KeychainDictionary([
|
||||
kSecAttrApplicationLabel: newID as CFData
|
||||
])
|
||||
|
||||
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||
if status != errSecSuccess {
|
||||
throw KeychainError(statusCode: 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -50,16 +50,16 @@ 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)")
|
||||
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
||||
} else {
|
||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
|
||||
newContext.localizedReason = String(localized: .authContextPersistForDurationUnknown(secretName: secret.name))
|
||||
}
|
||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||
guard success else { return }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import Foundation
|
||||
import Observation
|
||||
import Security
|
||||
import CryptoKit
|
||||
import LocalAuthentication
|
||||
@preconcurrency 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
|
||||
} 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 = 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,197 @@ 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 =
|
||||
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
flags,
|
||||
&accessError)
|
||||
if let error = accessError {
|
||||
throw 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 var supportedKeyTypes: [KeyType] {
|
||||
if #available(macOS 26, *) {
|
||||
[
|
||||
.ecdsa256,
|
||||
.mldsa65,
|
||||
.mldsa87,
|
||||
]
|
||||
} else {
|
||||
[.ecdsa256]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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?
|
||||
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 {}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import Security
|
||||
import CryptoTokenKit
|
||||
@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,
|
||||
@@ -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 {
|
||||
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
|
||||
guard let signature = SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
|
||||
throw 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 {
|
||||
@@ -179,104 +154,25 @@ extension SmartCard.Store {
|
||||
var untyped: CFTypeRef?
|
||||
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 {}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,89 +6,77 @@ import CryptoKit
|
||||
|
||||
@Suite struct AgentTests {
|
||||
|
||||
let stubWriter = StubFileHandleWriter()
|
||||
|
||||
// MARK: Identity Listing
|
||||
|
||||
@Test func emptyStores() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
||||
|
||||
// let testProvenance = SigningRequestProvenance(root: .init(pid: 0, processName: "Test", appName: "Test", iconURL: nil, path: /, validSignature: true, parentPID: nil))
|
||||
|
||||
@Test func emptyStores() async throws {
|
||||
let agent = Agent(storeList: SecretStoreList())
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesEmpty)
|
||||
let response = try await agent.handle(data: Constants.Requests.requestIdentities, 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 response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
|
||||
#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 response = try await agent.handle(data: Constants.Requests.requestSignatureWithNoneMatching, 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()
|
||||
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()
|
||||
// This is fine IRL, but it freaks out CryptoKit
|
||||
if r[0] == 0 {
|
||||
r.removeFirst()
|
||||
}
|
||||
if s[0] == 0 {
|
||||
s.removeFirst()
|
||||
}
|
||||
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)
|
||||
}
|
||||
// @Test func ecdsaSignature() async throws {
|
||||
// let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
// let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
||||
// _ = requestReader.readNextChunk()
|
||||
// let dataToSign = requestReader.readNextChunk()
|
||||
// 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()
|
||||
// // This is fine IRL, but it freaks out CryptoKit
|
||||
// if r[0] == 0 {
|
||||
// r.removeFirst()
|
||||
// }
|
||||
// if s[0] == 0 {
|
||||
// s.removeFirst()
|
||||
// }
|
||||
// var rs = r
|
||||
// rs.append(s)
|
||||
// let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||
// // Correct signature
|
||||
// #expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
||||
// .isValidSignature(signature, for: 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 = try await agent.handle(data: Constants.Requests.requestSignature, 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 +85,11 @@ import CryptoKit
|
||||
witnessed = true
|
||||
})
|
||||
let agent = Agent(storeList: list, witness: witness)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
_ = try await agent.handle(data: Constants.Requests.requestSignature, 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 +100,38 @@ import CryptoKit
|
||||
witnessTrace = trace
|
||||
})
|
||||
let agent = Agent(storeList: list, witness: witness)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
_ = try await agent.handle(data: Constants.Requests.requestSignature, 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 response = try await agent.handle(data: Constants.Requests.requestSignature, 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 = try await agent.handle(data: Constants.Requests.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 {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import SecretAgentKit
|
||||
import AppKit
|
||||
|
||||
struct StubFileHandleReader: FileHandleReader {
|
||||
|
||||
let availableData: Data
|
||||
var fileDescriptor: Int32 {
|
||||
NSWorkspace.shared.runningApplications.filter({ $0.localizedName == "Finder" }).first!.processIdentifier
|
||||
}
|
||||
var pidOfConnectedProcess: Int32 {
|
||||
fileDescriptor
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import Foundation
|
||||
import SecretAgentKit
|
||||
|
||||
class StubFileHandleWriter: FileHandleWriter, @unchecked Sendable {
|
||||
|
||||
var data = Data()
|
||||
|
||||
func write(_ data: Data) {
|
||||
self.data.append(data)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
self.publicKey = publicKey
|
||||
self.privateKey = privateKey
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"""
|
||||
Key Size \(keySize)
|
||||
Key Size \(attributes.keyType.size)
|
||||
Private: \(privateKey.base64EncodedString())
|
||||
Public: \(publicKey.base64EncodedString())
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Cocoa
|
||||
import OSLog
|
||||
import Combine
|
||||
import SecretKit
|
||||
import SecureEnclaveSecretKit
|
||||
import SmartCardSecretKit
|
||||
@@ -13,13 +12,16 @@ 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)
|
||||
}()
|
||||
@@ -27,14 +29,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let path = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") 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 {
|
||||
for await session in socketController.sessions {
|
||||
Task {
|
||||
do {
|
||||
for await message in session.messages {
|
||||
let agentResponse = try await agent.handle(data: message, provenance: session.provenance)
|
||||
try await session.write(agentResponse)
|
||||
}
|
||||
} catch {
|
||||
try session.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Task {
|
||||
@@ -48,6 +58,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
updater.update
|
||||
} onChange: { [updater, notifier] in
|
||||
Task {
|
||||
guard !updater.testBuild else { return }
|
||||
await notifier.notify(update: updater.update!) { release in
|
||||
await updater.ignore(release: release)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */; };
|
||||
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */; };
|
||||
50020BB024064869003D4025 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50020BAF24064869003D4025 /* AppDelegate.swift */; };
|
||||
50033AC327813F1700253856 /* BundleIDs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50033AC227813F1700253856 /* BundleIDs.swift */; };
|
||||
5003EF3B278005E800DF2006 /* SecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF3A278005E800DF2006 /* SecretKit */; };
|
||||
@@ -18,13 +18,18 @@
|
||||
5003EF612780081600DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF602780081600DF2006 /* SmartCardSecretKit */; };
|
||||
5003EF632780081B00DF2006 /* SecureEnclaveSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF622780081B00DF2006 /* SecureEnclaveSecretKit */; };
|
||||
5003EF652780081B00DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF642780081B00DF2006 /* SmartCardSecretKit */; };
|
||||
5008C23E2E525D8900507AC2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */; };
|
||||
5008C2402E52792400507AC2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8623FCE48E0099B055 /* Assets.xcassets */; };
|
||||
500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; };
|
||||
5008C2412E52D18700507AC2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */; };
|
||||
501421622781262300BBAA70 /* Brief in Frameworks */ = {isa = PBXBuildFile; productRef = 501421612781262300BBAA70 /* Brief */; };
|
||||
501421652781268000BBAA70 /* SecretAgent.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 50A3B78A24026B7500D209EA /* SecretAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
50153E20250AFCB200525160 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E1F250AFCB200525160 /* UpdateView.swift */; };
|
||||
50153E22250DECA300525160 /* SecretListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E21250DECA300525160 /* SecretListItemView.swift */; };
|
||||
5018F54F24064786002EB505 /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018F54E24064786002EB505 /* Notifier.swift */; };
|
||||
504788EC2E680DC800B4556F /* URLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788EB2E680DC400B4556F /* URLs.swift */; };
|
||||
504788F22E681F3A00B4556F /* Instructions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F12E681F3A00B4556F /* Instructions.swift */; };
|
||||
504788F42E681F6900B4556F /* ToolConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F32E681F6900B4556F /* ToolConfigurationView.swift */; };
|
||||
504788F62E68206F00B4556F /* GettingStartedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F52E68206F00B4556F /* GettingStartedView.swift */; };
|
||||
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */; };
|
||||
50571E0524393D1500F76F6C /* LaunchAgentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50571E0424393D1500F76F6C /* LaunchAgentController.swift */; };
|
||||
50617D8323FCE48E0099B055 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D8223FCE48E0099B055 /* App.swift */; };
|
||||
@@ -35,7 +40,6 @@
|
||||
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5065E312295517C500E16645 /* ToolbarButtonStyle.swift */; };
|
||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C12516F303004B5A36 /* SetupView.swift */; };
|
||||
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; };
|
||||
5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */; };
|
||||
506772C72424784600034DED /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 506772C62424784600034DED /* Credits.rtf */; };
|
||||
506772C92425BB8500034DED /* NoStoresView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506772C82425BB8500034DED /* NoStoresView.swift */; };
|
||||
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5079BA0E250F29BF00EA86F4 /* StoreListView.swift */; };
|
||||
@@ -48,10 +52,14 @@
|
||||
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; };
|
||||
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79324026B7600D209EA /* Preview Assets.xcassets */; };
|
||||
50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; };
|
||||
50AE97002E5C1A420018C710 /* IntegrationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */; };
|
||||
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
|
||||
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
|
||||
50BDCB722E63BAF20072D2E7 /* AgentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */; };
|
||||
50BDCB742E6436CA0072D2E7 /* ErrorStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */; };
|
||||
50BDCB762E6450950072D2E7 /* ConfigurationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */; };
|
||||
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
|
||||
50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; };
|
||||
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -98,14 +106,18 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameSecretView.swift; sourceTree = "<group>"; };
|
||||
2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditSecretView.swift; sourceTree = "<group>"; };
|
||||
50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = "<group>"; };
|
||||
5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = "<group>"; };
|
||||
500B93C22B478D8400E157DE /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
5008C23D2E525D8200507AC2 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = Packages/Resources/Localizable.xcstrings; sourceTree = SOURCE_ROOT; };
|
||||
50153E1F250AFCB200525160 /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = "<group>"; };
|
||||
50153E21250DECA300525160 /* SecretListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretListItemView.swift; sourceTree = "<group>"; };
|
||||
5018F54E24064786002EB505 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
|
||||
504788EB2E680DC400B4556F /* URLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLs.swift; sourceTree = "<group>"; };
|
||||
504788F12E681F3A00B4556F /* Instructions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instructions.swift; sourceTree = "<group>"; };
|
||||
504788F32E681F6900B4556F /* ToolConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolConfigurationView.swift; sourceTree = "<group>"; };
|
||||
504788F52E68206F00B4556F /* GettingStartedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GettingStartedView.swift; sourceTree = "<group>"; };
|
||||
50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustUpdatedChecker.swift; sourceTree = "<group>"; };
|
||||
50571E0424393D1500F76F6C /* LaunchAgentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAgentController.swift; sourceTree = "<group>"; };
|
||||
50617D7F23FCE48E0099B055 /* Secretive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Secretive.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -119,7 +131,6 @@
|
||||
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarButtonStyle.swift; sourceTree = "<group>"; };
|
||||
5066A6C12516F303004B5A36 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
||||
5066A6C72516FE6E004B5A36 /* CopyableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableView.swift; sourceTree = "<group>"; };
|
||||
5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellConfigurationController.swift; sourceTree = "<group>"; };
|
||||
506772C62424784600034DED /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = "<group>"; };
|
||||
506772C82425BB8500034DED /* NoStoresView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoStoresView.swift; sourceTree = "<group>"; };
|
||||
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListView.swift; sourceTree = "<group>"; };
|
||||
@@ -137,9 +148,14 @@
|
||||
50A3B79624026B7600D209EA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
50A3B79824026B7600D209EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50A3B79924026B7600D209EA /* SecretAgent.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SecretAgent.entitlements; sourceTree = "<group>"; };
|
||||
50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationsView.swift; sourceTree = "<group>"; };
|
||||
50B8550C24138C4F009958AC /* DeleteSecretView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteSecretView.swift; sourceTree = "<group>"; };
|
||||
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStoreView.swift; sourceTree = "<group>"; };
|
||||
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentStatusView.swift; sourceTree = "<group>"; };
|
||||
50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorStyle.swift; sourceTree = "<group>"; };
|
||||
50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationItemView.swift; sourceTree = "<group>"; };
|
||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretDetailView.swift; sourceTree = "<group>"; };
|
||||
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonStyle.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -177,6 +193,55 @@
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504788ED2E681EB200B4556F /* Styles */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */,
|
||||
50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */,
|
||||
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
|
||||
);
|
||||
path = Styles;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504788EE2E681EC300B4556F /* Secrets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */,
|
||||
50B8550C24138C4F009958AC /* DeleteSecretView.swift */,
|
||||
2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */,
|
||||
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */,
|
||||
506772C82425BB8500034DED /* NoStoresView.swift */,
|
||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
||||
50153E21250DECA300525160 /* SecretListItemView.swift */,
|
||||
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
|
||||
);
|
||||
path = Secrets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504788EF2E681ED700B4556F /* Configuration */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */,
|
||||
50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */,
|
||||
504788F12E681F3A00B4556F /* Instructions.swift */,
|
||||
504788F32E681F6900B4556F /* ToolConfigurationView.swift */,
|
||||
5066A6C12516F303004B5A36 /* SetupView.swift */,
|
||||
504788F52E68206F00B4556F /* GettingStartedView.swift */,
|
||||
);
|
||||
path = Configuration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504788F02E681F0100B4556F /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */,
|
||||
50617D8423FCE48E0099B055 /* ContentView.swift */,
|
||||
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
|
||||
50153E1F250AFCB200525160 /* UpdateView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
50617D7623FCE48D0099B055 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -210,7 +275,7 @@
|
||||
508BF28D25B4F005009EFB7E /* InternetAccessPolicy.plist */,
|
||||
50617D8F23FCE48E0099B055 /* Secretive.entitlements */,
|
||||
506772C62424784600034DED /* Credits.rtf */,
|
||||
500B93C22B478D8400E157DE /* Localizable.xcstrings */,
|
||||
5008C23D2E525D8200507AC2 /* Localizable.xcstrings */,
|
||||
50617D8823FCE48E0099B055 /* Preview Content */,
|
||||
);
|
||||
path = Secretive;
|
||||
@@ -239,19 +304,10 @@
|
||||
508A58B0241ED1C40069DC07 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50617D8423FCE48E0099B055 /* ContentView.swift */,
|
||||
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
|
||||
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
|
||||
50153E21250DECA300525160 /* SecretListItemView.swift */,
|
||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
||||
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */,
|
||||
50B8550C24138C4F009958AC /* DeleteSecretView.swift */,
|
||||
2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */,
|
||||
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */,
|
||||
506772C82425BB8500034DED /* NoStoresView.swift */,
|
||||
50153E1F250AFCB200525160 /* UpdateView.swift */,
|
||||
5066A6C12516F303004B5A36 /* SetupView.swift */,
|
||||
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
|
||||
504788EF2E681ED700B4556F /* Configuration */,
|
||||
504788EE2E681EC300B4556F /* Secrets */,
|
||||
504788ED2E681EB200B4556F /* Styles */,
|
||||
504788F02E681F0100B4556F /* Views */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -259,11 +315,11 @@
|
||||
508A58B1241ED1EA0069DC07 /* Controllers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504788EB2E680DC400B4556F /* URLs.swift */,
|
||||
508A58B2241ED2180069DC07 /* AgentStatusChecker.swift */,
|
||||
5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */,
|
||||
50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */,
|
||||
50571E0424393D1500F76F6C /* LaunchAgentController.swift */,
|
||||
5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */,
|
||||
);
|
||||
path = Controllers;
|
||||
sourceTree = "<group>";
|
||||
@@ -404,7 +460,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */,
|
||||
500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */,
|
||||
5008C23E2E525D8900507AC2 /* Localizable.xcstrings in Resources */,
|
||||
50617D8723FCE48E0099B055 /* Assets.xcassets in Resources */,
|
||||
506772C72424784600034DED /* Credits.rtf in Resources */,
|
||||
508BF28E25B4F005009EFB7E /* InternetAccessPolicy.plist in Resources */,
|
||||
@@ -416,7 +472,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
50A3B79724026B7600D209EA /* Main.storyboard in Resources */,
|
||||
50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */,
|
||||
5008C2412E52D18700507AC2 /* Localizable.xcstrings in Resources */,
|
||||
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */,
|
||||
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */,
|
||||
5008C2402E52792400507AC2 /* Assets.xcassets in Resources */,
|
||||
@@ -430,25 +486,33 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */,
|
||||
504788F22E681F3A00B4556F /* Instructions.swift in Sources */,
|
||||
50BDCB742E6436CA0072D2E7 /* ErrorStyle.swift in Sources */,
|
||||
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */,
|
||||
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
||||
504788EC2E680DC800B4556F /* URLs.swift in Sources */,
|
||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
||||
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
|
||||
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
|
||||
504788F62E68206F00B4556F /* GettingStartedView.swift in Sources */,
|
||||
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */,
|
||||
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
|
||||
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
|
||||
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */,
|
||||
5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */,
|
||||
50033AC327813F1700253856 /* BundleIDs.swift in Sources */,
|
||||
50BDCB722E63BAF20072D2E7 /* AgentStatusView.swift in Sources */,
|
||||
508A58B3241ED2180069DC07 /* AgentStatusChecker.swift in Sources */,
|
||||
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */,
|
||||
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */,
|
||||
50AE97002E5C1A420018C710 /* IntegrationsView.swift in Sources */,
|
||||
50153E20250AFCB200525160 /* UpdateView.swift in Sources */,
|
||||
50571E0524393D1500F76F6C /* LaunchAgentController.swift in Sources */,
|
||||
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */,
|
||||
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */,
|
||||
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */,
|
||||
50BDCB762E6450950072D2E7 /* ConfigurationItemView.swift in Sources */,
|
||||
50617D8323FCE48E0099B055 /* App.swift in Sources */,
|
||||
504788F42E681F6900B4556F /* ToolConfigurationView.swift in Sources */,
|
||||
506772C92425BB8500034DED /* NoStoresView.swift in Sources */,
|
||||
50153E22250DECA300525160 /* SecretListItemView.swift in Sources */,
|
||||
508A58B5241ED48F0069DC07 /* PreviewAgentStatusChecker.swift in Sources */,
|
||||
@@ -526,6 +590,8 @@
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_ENHANCED_SECURITY = YES;
|
||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -598,7 +664,9 @@
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_ENHANCED_SECURITY = YES;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -637,10 +705,20 @@
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = Z72PRUAWF6;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_ENHANCED_SECURITY = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
INFOPLIST_FILE = Secretive/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -667,10 +745,20 @@
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = Z72PRUAWF6;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_ENHANCED_SECURITY = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
INFOPLIST_FILE = Secretive/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -723,6 +811,8 @@
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_ENHANCED_SECURITY = YES;
|
||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -760,16 +850,27 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Secretive/Secretive.entitlements;
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_ENHANCED_SECURITY = YES;
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
INFOPLIST_FILE = Secretive/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -792,8 +893,17 @@
|
||||
DEVELOPMENT_ASSET_PATHS = "\"SecretAgent/Preview Content\"";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
INFOPLIST_FILE = SecretAgent/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -818,8 +928,17 @@
|
||||
DEVELOPMENT_TEAM = Z72PRUAWF6;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
INFOPLIST_FILE = SecretAgent/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -845,8 +964,17 @@
|
||||
DEVELOPMENT_TEAM = Z72PRUAWF6;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
INFOPLIST_FILE = SecretAgent/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,3 @@
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
import SecureEnclaveSecretKit
|
||||
@@ -10,7 +9,10 @@ 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())
|
||||
let cryptoKit = SecureEnclave.Store()
|
||||
let migrator = SecureEnclave.CryptoKitMigrator()
|
||||
try? migrator.migrate(to: cryptoKit)
|
||||
list.add(store: cryptoKit)
|
||||
list.add(store: SmartCard.Store())
|
||||
return list
|
||||
}()
|
||||
@@ -35,6 +37,7 @@ struct Secretive: App {
|
||||
@Environment(\.agentStatusChecker) var agentStatusChecker
|
||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||
@State private var showingSetup = false
|
||||
@State private var showingIntegrations = false
|
||||
@State private var showingCreation = false
|
||||
|
||||
@SceneBuilder var body: some Scene {
|
||||
@@ -56,24 +59,27 @@ struct Secretive: App {
|
||||
forceLaunchAgent()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingIntegrations) {
|
||||
IntegrationsView()
|
||||
}
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(before: CommandGroupPlacement.appSettings) {
|
||||
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
|
||||
showingIntegrations = true
|
||||
}
|
||||
}
|
||||
CommandGroup(after: CommandGroupPlacement.newItem) {
|
||||
Button("app_menu_new_secret_button") {
|
||||
Button(.appMenuNewSecretButton) {
|
||||
showingCreation = true
|
||||
}
|
||||
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
|
||||
}
|
||||
CommandGroup(replacing: .help) {
|
||||
Button("app_menu_help_button") {
|
||||
Button(.appMenuHelpButton) {
|
||||
NSWorkspace.shared.open(Constants.helpURL)
|
||||
}
|
||||
}
|
||||
CommandGroup(after: .help) {
|
||||
Button("app_menu_setup_button") {
|
||||
showingSetup = true
|
||||
}
|
||||
}
|
||||
SidebarCommands()
|
||||
}
|
||||
}
|
||||
@@ -85,7 +91,7 @@ extension Secretive {
|
||||
private func reinstallAgent() {
|
||||
justUpdatedChecker.check()
|
||||
Task {
|
||||
await LaunchAgentController().install()
|
||||
_ = await LaunchAgentController().install()
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
agentStatusChecker.check()
|
||||
if !agentStatusChecker.running {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import AppKit
|
||||
import SecretKit
|
||||
import Observation
|
||||
@@ -7,12 +6,14 @@ import Observation
|
||||
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
|
||||
var running: Bool { get }
|
||||
var developmentBuild: Bool { get }
|
||||
var process: NSRunningApplication? { get }
|
||||
func check()
|
||||
}
|
||||
|
||||
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
|
||||
|
||||
var running: Bool = false
|
||||
var process: NSRunningApplication? = nil
|
||||
|
||||
nonisolated init() {
|
||||
Task { @MainActor in
|
||||
@@ -21,32 +22,39 @@ import Observation
|
||||
}
|
||||
|
||||
func check() {
|
||||
running = instanceSecretAgentProcess != nil
|
||||
process = instanceSecretAgentProcess
|
||||
running = process != nil
|
||||
}
|
||||
|
||||
// All processes, including ones from older versions, etc
|
||||
var secretAgentProcesses: [NSRunningApplication] {
|
||||
NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.agentBundleID)
|
||||
var allSecretAgentProcesses: [NSRunningApplication] {
|
||||
NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.agentBundleID)
|
||||
}
|
||||
|
||||
// The process corresponding to this instance of Secretive
|
||||
var instanceSecretAgentProcess: NSRunningApplication? {
|
||||
let agents = secretAgentProcesses
|
||||
// FIXME: CHECK VERSION
|
||||
let agents = allSecretAgentProcesses
|
||||
for agent in agents {
|
||||
guard let url = agent.bundleURL else { continue }
|
||||
if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) {
|
||||
if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) || (url.isXcodeURL && developmentBuild) {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Whether Secretive is being run in an Xcode environment.
|
||||
var developmentBuild: Bool {
|
||||
Bundle.main.bundleURL.absoluteString.contains("/Library/Developer/Xcode")
|
||||
Bundle.main.bundleURL.isXcodeURL
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension URL {
|
||||
|
||||
var isXcodeURL: Bool {
|
||||
absoluteString.contains("/Library/Developer/Xcode")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import AppKit
|
||||
|
||||
protocol JustUpdatedCheckerProtocol: Observable {
|
||||
|
||||
@@ -8,16 +8,28 @@ struct LaunchAgentController {
|
||||
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
|
||||
|
||||
func install() async {
|
||||
func install() async -> Bool {
|
||||
logger.debug("Installing agent")
|
||||
_ = setEnabled(false)
|
||||
// This is definitely a bit of a "seems to work better" thing but:
|
||||
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
|
||||
// and start new?
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
await MainActor.run {
|
||||
_ = setEnabled(true)
|
||||
let result = await MainActor.run {
|
||||
setEnabled(true)
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
return result
|
||||
}
|
||||
|
||||
func uninstall() async -> Bool {
|
||||
logger.debug("Uninstalling agent")
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
let result = await MainActor.run {
|
||||
setEnabled(false)
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
return result
|
||||
}
|
||||
|
||||
func forceLaunch() async -> Bool {
|
||||
@@ -28,6 +40,7 @@ struct LaunchAgentController {
|
||||
do {
|
||||
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
|
||||
logger.debug("Agent force launched")
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
return true
|
||||
} catch {
|
||||
logger.error("Error force launching \(error.localizedDescription)")
|
||||
@@ -36,7 +49,7 @@ struct LaunchAgentController {
|
||||
}
|
||||
|
||||
private func setEnabled(_ enabled: Bool) -> Bool {
|
||||
let service = SMAppService.loginItem(identifier: Bundle.main.agentBundleID)
|
||||
let service = SMAppService.loginItem(identifier: Bundle.agentBundleID)
|
||||
do {
|
||||
if enabled {
|
||||
try service.register()
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
import Cocoa
|
||||
import SecretKit
|
||||
|
||||
struct ShellConfigurationController {
|
||||
|
||||
let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
|
||||
|
||||
var shellInstructions: [ShellConfigInstruction] {
|
||||
[
|
||||
ShellConfigInstruction(shell: "global",
|
||||
shellConfigDirectory: "~/.ssh/",
|
||||
shellConfigFilename: "config",
|
||||
text: "Host *\n\tIdentityAgent \(socketPath)"),
|
||||
ShellConfigInstruction(shell: "zsh",
|
||||
shellConfigDirectory: "~/",
|
||||
shellConfigFilename: ".zshrc",
|
||||
text: "export SSH_AUTH_SOCK=\(socketPath)"),
|
||||
ShellConfigInstruction(shell: "bash",
|
||||
shellConfigDirectory: "~/",
|
||||
shellConfigFilename: ".bashrc",
|
||||
text: "export SSH_AUTH_SOCK=\(socketPath)"),
|
||||
ShellConfigInstruction(shell: "fish",
|
||||
shellConfigDirectory: "~/.config/fish",
|
||||
shellConfigFilename: "config.fish",
|
||||
text: "set -x SSH_AUTH_SOCK \(socketPath)"),
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
|
||||
@MainActor func addToShell(shellInstructions: ShellConfigInstruction) -> Bool {
|
||||
let openPanel = NSOpenPanel()
|
||||
// This is sync, so no need to strongly retain
|
||||
let delegate = Delegate(name: shellInstructions.shellConfigFilename)
|
||||
openPanel.delegate = delegate
|
||||
openPanel.message = "Select \(shellInstructions.shellConfigFilename) to let Secretive configure your shell automatically."
|
||||
openPanel.prompt = "Add to \(shellInstructions.shellConfigFilename)"
|
||||
openPanel.canChooseFiles = true
|
||||
openPanel.canChooseDirectories = false
|
||||
openPanel.showsHiddenFiles = true
|
||||
openPanel.directoryURL = URL(fileURLWithPath: shellInstructions.shellConfigDirectory)
|
||||
openPanel.nameFieldStringValue = shellInstructions.shellConfigFilename
|
||||
openPanel.allowedContentTypes = [.symbolicLink, .data, .plainText]
|
||||
openPanel.runModal()
|
||||
guard let fileURL = openPanel.urls.first else { return false }
|
||||
let handle: FileHandle
|
||||
do {
|
||||
handle = try FileHandle(forUpdating: fileURL)
|
||||
guard let existing = try handle.readToEnd(),
|
||||
let existingString = String(data: existing, encoding: .utf8) else { return false }
|
||||
guard !existingString.contains(shellInstructions.text) else {
|
||||
return true
|
||||
}
|
||||
try handle.seekToEnd()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
handle.write("\n# Secretive Config\n\(shellInstructions.text)\n".data(using: .utf8)!)
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
12
Sources/Secretive/Controllers/URLs.swift
Normal file
12
Sources/Secretive/Controllers/URLs.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
|
||||
static var agentHomeURL: URL {
|
||||
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
|
||||
}
|
||||
|
||||
static var socketPath: String {
|
||||
URL.agentHomeURL.appendingPathComponent("socket.ssh").path()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
|
||||
extension Bundle {
|
||||
public var agentBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "Host", with: "SecretAgent"))!}
|
||||
public var hostBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "SecretAgent", with: "Host"))!}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import AppKit
|
||||
|
||||
class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {
|
||||
|
||||
let running: Bool
|
||||
let process: NSRunningApplication?
|
||||
let developmentBuild = false
|
||||
|
||||
init(running: Bool = true) {
|
||||
init(running: Bool = true, process: NSRunningApplication? = nil) {
|
||||
self.running = running
|
||||
self.process = process
|
||||
}
|
||||
|
||||
func check() {
|
||||
|
||||
@@ -9,11 +9,13 @@ extension Preview {
|
||||
|
||||
let id = UUID().uuidString
|
||||
let name: String
|
||||
let algorithm = Algorithm.ellipticCurve
|
||||
let keySize = 256
|
||||
let requiresAuthentication: Bool = false
|
||||
let publicKey = UUID().uuidString.data(using: .utf8)!
|
||||
|
||||
let publicKey = Data(UUID().uuidString.utf8)
|
||||
var attributes: Attributes {
|
||||
Attributes(
|
||||
keyType: .init(algorithm: .ecdsa, size: 256),
|
||||
authentication: .presenceRequired,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -40,10 +42,6 @@ extension Preview {
|
||||
return data
|
||||
}
|
||||
|
||||
func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
||||
nil
|
||||
}
|
||||
@@ -62,7 +60,18 @@ extension Preview {
|
||||
let id = UUID()
|
||||
var name: String { "Modifiable Preview Store" }
|
||||
let secrets: [Secret]
|
||||
|
||||
var supportedKeyTypes: [KeyType] {
|
||||
if #available(macOS 26, *) {
|
||||
[
|
||||
.ecdsa256,
|
||||
.mldsa65,
|
||||
.mldsa87,
|
||||
]
|
||||
} else {
|
||||
[.ecdsa256]
|
||||
}
|
||||
}
|
||||
|
||||
init(secrets: [Secret]) {
|
||||
self.secrets = secrets
|
||||
}
|
||||
@@ -76,10 +85,6 @@ extension Preview {
|
||||
return data
|
||||
}
|
||||
|
||||
func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
||||
nil
|
||||
}
|
||||
@@ -91,13 +96,14 @@ extension Preview {
|
||||
}
|
||||
|
||||
|
||||
func create(name: String, requiresAuthentication: Bool) throws {
|
||||
func create(name: String, attributes: Attributes) throws -> Secret {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func delete(secret: Preview.Secret) throws {
|
||||
}
|
||||
|
||||
func update(secret: Preview.Secret, name: String) throws {
|
||||
func update(secret: Preview.Secret, name: String, attributes: Attributes) throws {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.hardened-process</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.dyld-ro</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.enhanced-security-version</key>
|
||||
<integer>1</integer>
|
||||
<key>com.apple.security.hardened-process.hardened-heap</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.platform-restrictions</key>
|
||||
<integer>2</integer>
|
||||
<key>com.apple.security.smartcard</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConfigurationItemView<Content: View>: View {
|
||||
|
||||
enum Action: Hashable {
|
||||
case copy(String)
|
||||
case revealInFinder(String)
|
||||
}
|
||||
|
||||
let title: LocalizedStringResource
|
||||
let content: Content
|
||||
let action: Action?
|
||||
|
||||
init(title: LocalizedStringResource, value: String, action: Action? = nil) where Content == Text {
|
||||
self.title = title
|
||||
self.content = Text(value)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
self.action = action
|
||||
}
|
||||
|
||||
init(title: LocalizedStringResource, action: Action? = nil, content: () -> Content) {
|
||||
self.title = title
|
||||
self.content = content()
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
switch action {
|
||||
case .copy(let string):
|
||||
Button(.copyableClickToCopyButton, systemImage: "document.on.document") {
|
||||
NSPasteboard.general.declareTypes([.string], owner: nil)
|
||||
NSPasteboard.general.setString(string, forType: .string)
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderless)
|
||||
case .revealInFinder(let rawPath):
|
||||
Button(.revealInFinderButton, systemImage: "folder") {
|
||||
// All foundation-based normalization methods replace this with the container directly.
|
||||
let processedPath = rawPath.replacingOccurrences(of: "~", with: "/Users/\(NSUserName())")
|
||||
let url = URL(filePath: processedPath)
|
||||
let folder = url.deletingLastPathComponent().path()
|
||||
NSWorkspace.shared.selectFile(processedPath, inFileViewerRootedAtPath: folder)
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderless)
|
||||
case nil:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GettingStartedView: View {
|
||||
|
||||
private let instructions = Instructions()
|
||||
|
||||
@Binding var selectedInstruction: ConfigurationFileInstructions?
|
||||
|
||||
init(selectedInstruction: Binding<ConfigurationFileInstructions?>) {
|
||||
_selectedInstruction = selectedInstruction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(.integrationsGettingStartedTitle) {
|
||||
Text(.integrationsGettingStartedTitleDescription)
|
||||
}
|
||||
Section {
|
||||
Group {
|
||||
Text(.integrationsGettingStartedSuggestionSsh)
|
||||
.onTapGesture {
|
||||
self.selectedInstruction = instructions.ssh
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(.integrationsGettingStartedSuggestionShell)
|
||||
Text(.integrationsGettingStartedSuggestionShellDefault(shellName: String(localized: instructions.defaultShell.tool)))
|
||||
.font(.caption2)
|
||||
}
|
||||
.onTapGesture {
|
||||
self.selectedInstruction = instructions.defaultShell
|
||||
}
|
||||
Text(.integrationsGettingStartedSuggestionGit)
|
||||
.onTapGesture {
|
||||
self.selectedInstruction = instructions.git
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.link)
|
||||
|
||||
} header: {
|
||||
Text(.integrationsGettingStartedWhatShouldIConfigureTitle)
|
||||
}
|
||||
footer: {
|
||||
Text(.integrationsGettingStartedMultipleConfig)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
}
|
||||
179
Sources/Secretive/Views/Configuration/Instructions.swift
Normal file
179
Sources/Secretive/Views/Configuration/Instructions.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
|
||||
struct Instructions {
|
||||
|
||||
enum Constants {
|
||||
static let publicKeyPathPlaceholder = "_PUBLIC_KEY_PATH_PLACEHOLDER_"
|
||||
static let publicKeyPlaceholder = "_PUBLIC_KEY_PLACEHOLDER_"
|
||||
}
|
||||
|
||||
var defaultShell: ConfigurationFileInstructions {
|
||||
zsh
|
||||
}
|
||||
|
||||
var gettingStarted: ConfigurationFileInstructions = ConfigurationFileInstructions(.integrationsGettingStartedRowTitle, id: .gettingStarted)
|
||||
|
||||
var ssh: ConfigurationFileInstructions {
|
||||
ConfigurationFileInstructions(
|
||||
tool: LocalizedStringResource.integrationsToolNameSsh,
|
||||
configPath: "~/.ssh/config",
|
||||
configText: "Host *\n\tIdentityAgent \(URL.socketPath)",
|
||||
website: URL(string: "https://man.openbsd.org/ssh_config.5")!,
|
||||
note: .integrationsSshSpecificKeyNote,
|
||||
)
|
||||
}
|
||||
|
||||
var git: ConfigurationFileInstructions {
|
||||
ConfigurationFileInstructions(
|
||||
tool: .integrationsToolNameGitSigning,
|
||||
steps: [
|
||||
.init(path: "~/.gitconfig", steps: [
|
||||
.integrationsGitStepGitconfigDescription(publicKeyPathPlaceholder: Constants.publicKeyPathPlaceholder)
|
||||
],
|
||||
note: .integrationsGitStepGitconfigSectionNote
|
||||
),
|
||||
.init(
|
||||
path: "~/.gitallowedsigners",
|
||||
steps: [
|
||||
LocalizedStringResource(stringLiteral: Constants.publicKeyPlaceholder)
|
||||
],
|
||||
note: .integrationsGitStepGitallowedsignersDescription
|
||||
),
|
||||
],
|
||||
website: URL(string: "https://git-scm.com/docs/git-config")!,
|
||||
)
|
||||
}
|
||||
|
||||
var zsh: ConfigurationFileInstructions {
|
||||
ConfigurationFileInstructions(
|
||||
tool: .integrationsToolNameZsh,
|
||||
configPath: "~/.zshrc",
|
||||
configText: "export SSH_AUTH_SOCK=\(URL.socketPath)"
|
||||
)
|
||||
}
|
||||
|
||||
var instructions: [ConfigurationGroup] {
|
||||
[
|
||||
ConfigurationGroup(name: .integrationsGettingStartedSectionTitle, instructions: [
|
||||
gettingStarted
|
||||
]),
|
||||
ConfigurationGroup(
|
||||
name: .integrationsSystemSectionTitle,
|
||||
instructions: [
|
||||
ssh,
|
||||
git,
|
||||
]
|
||||
),
|
||||
ConfigurationGroup(name: .integrationsShellSectionTitle, instructions: [
|
||||
zsh,
|
||||
ConfigurationFileInstructions(
|
||||
tool: .integrationsToolNameBash,
|
||||
configPath: "~/.bashrc",
|
||||
configText: "export SSH_AUTH_SOCK=\(URL.socketPath)"
|
||||
),
|
||||
ConfigurationFileInstructions(
|
||||
tool: .integrationsToolNameFish,
|
||||
configPath: "~/.config/fish/config.fish",
|
||||
configText: "set -x SSH_AUTH_SOCK \(URL.socketPath)"
|
||||
),
|
||||
ConfigurationFileInstructions(.integrationsOtherShellRowTitle, id: .otherShell),
|
||||
]),
|
||||
ConfigurationGroup(name: .integrationsOtherSectionTitle, instructions: [
|
||||
ConfigurationFileInstructions(.integrationsAppsRowTitle, id: .otherApp),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ConfigurationGroup: Identifiable {
|
||||
let id = UUID()
|
||||
var name: LocalizedStringResource
|
||||
var instructions: [ConfigurationFileInstructions] = []
|
||||
}
|
||||
|
||||
struct ConfigurationFileInstructions: Hashable, Identifiable {
|
||||
|
||||
struct StepGroup: Hashable, Identifiable {
|
||||
let path: String
|
||||
let steps: [LocalizedStringResource]
|
||||
let note: LocalizedStringResource?
|
||||
var id: String { path }
|
||||
|
||||
init(path: String, steps: [LocalizedStringResource], note: LocalizedStringResource? = nil) {
|
||||
self.path = path
|
||||
self.steps = steps
|
||||
self.note = note
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
id.hash(into: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
var id: ID
|
||||
var tool: LocalizedStringResource
|
||||
var steps: [StepGroup]
|
||||
var requiresSecret: Bool
|
||||
var website: URL?
|
||||
|
||||
init(
|
||||
tool: LocalizedStringResource,
|
||||
configPath: String,
|
||||
configText: LocalizedStringResource,
|
||||
requiresSecret: Bool = false,
|
||||
website: URL? = nil,
|
||||
note: LocalizedStringResource? = nil
|
||||
) {
|
||||
self.id = .tool(String(localized: tool))
|
||||
self.tool = tool
|
||||
self.steps = [StepGroup(path: configPath, steps: [configText], note: note)]
|
||||
self.requiresSecret = requiresSecret
|
||||
self.website = website
|
||||
}
|
||||
|
||||
init(
|
||||
tool: LocalizedStringResource,
|
||||
steps: [StepGroup],
|
||||
requiresSecret: Bool = false,
|
||||
website: URL? = nil
|
||||
) {
|
||||
self.id = .tool(String(localized: tool))
|
||||
self.tool = tool
|
||||
self.steps = steps
|
||||
self.requiresSecret = true
|
||||
self.website = website
|
||||
}
|
||||
|
||||
init(_ name: LocalizedStringResource, id: ID) {
|
||||
self.id = id
|
||||
tool = name
|
||||
steps = []
|
||||
requiresSecret = false
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
id.hash(into: &hasher)
|
||||
}
|
||||
|
||||
enum ID: Identifiable, Hashable {
|
||||
case gettingStarted
|
||||
case tool(String)
|
||||
case otherShell
|
||||
case otherApp
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .gettingStarted:
|
||||
"getting_started"
|
||||
case .tool(let name):
|
||||
name
|
||||
case .otherShell:
|
||||
"other_shell"
|
||||
case .otherApp:
|
||||
"other_app"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
115
Sources/Secretive/Views/Configuration/IntegrationsView.swift
Normal file
115
Sources/Secretive/Views/Configuration/IntegrationsView.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IntegrationsView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedInstruction: ConfigurationFileInstructions?
|
||||
private let instructions = Instructions()
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selectedInstruction) {
|
||||
ForEach(instructions.instructions) { group in
|
||||
Section(group.name) {
|
||||
ForEach(group.instructions) { instruction in
|
||||
Text(instruction.tool)
|
||||
.padding(.vertical, 8)
|
||||
.tag(instruction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
IntegrationsDetailView(selectedInstruction: $selectedInstruction)
|
||||
.fauxToolbar {
|
||||
Button(.setupDoneButton) {
|
||||
dismiss()
|
||||
}
|
||||
.normalButton()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedInstruction = instructions.gettingStarted
|
||||
}
|
||||
.frame(minHeight: 500)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func fauxToolbar<Content: View>(content: () -> Content) -> some View {
|
||||
modifier(FauxToolbarModifier(toolbarContent: content()))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FauxToolbarModifier<ToolbarContent: View>: ViewModifier {
|
||||
|
||||
var toolbarContent: ToolbarContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
content
|
||||
Divider()
|
||||
HStack {
|
||||
Spacer()
|
||||
toolbarContent
|
||||
.padding(.top, 8)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct IntegrationsDetailView: View {
|
||||
|
||||
@Binding private var selectedInstruction: ConfigurationFileInstructions?
|
||||
|
||||
init(selectedInstruction: Binding<ConfigurationFileInstructions?>) {
|
||||
_selectedInstruction = selectedInstruction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let selectedInstruction {
|
||||
switch selectedInstruction.id {
|
||||
case .gettingStarted:
|
||||
GettingStartedView(selectedInstruction: $selectedInstruction)
|
||||
case .tool:
|
||||
ToolConfigurationView(selectedInstruction: selectedInstruction)
|
||||
case .otherShell:
|
||||
Form {
|
||||
Section {
|
||||
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/shells")!)
|
||||
} header: {
|
||||
Text(.integrationsCommunityShellListDescription)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
case .otherApp:
|
||||
Form {
|
||||
Section {
|
||||
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/apps")!)
|
||||
} header: {
|
||||
Text(.integrationsCommunityAppsListDescription)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
IntegrationsView()
|
||||
.frame(height: 500)
|
||||
}
|
||||
187
Sources/Secretive/Views/Configuration/SetupView.swift
Normal file
187
Sources/Secretive/Views/Configuration/SetupView.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SetupView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var setupComplete: Bool
|
||||
|
||||
@State var showingIntegrations = false
|
||||
@State var buttonWidth: CGFloat?
|
||||
|
||||
@State var installed = false
|
||||
@State var updates = false
|
||||
@State var integrations = false
|
||||
var allDone: Bool {
|
||||
installed && updates && integrations
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
StepView(
|
||||
title: .setupAgentTitle,
|
||||
description: .setupAgentDescription,
|
||||
systemImage: "lock.laptopcomputer",
|
||||
) {
|
||||
setupButton(
|
||||
.setupAgentInstallButton,
|
||||
complete: installed,
|
||||
width: buttonWidth
|
||||
) {
|
||||
installed = true
|
||||
Task {
|
||||
await LaunchAgentController().install()
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
StepView(
|
||||
title: .setupUpdatesTitle,
|
||||
description: .setupUpdatesDescription,
|
||||
systemImage: "network.badge.shield.half.filled",
|
||||
) {
|
||||
setupButton(
|
||||
.setupUpdatesOkButton,
|
||||
complete: updates,
|
||||
width: buttonWidth
|
||||
) {
|
||||
updates = true
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
StepView(
|
||||
title: .setupIntegrationsTitle,
|
||||
description: .setupIntegrationsDescription,
|
||||
systemImage: "firewall",
|
||||
) {
|
||||
setupButton(
|
||||
.setupIntegrationsButton,
|
||||
complete: integrations,
|
||||
width: buttonWidth
|
||||
) {
|
||||
showingIntegrations = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(setupButton.WidthKey.self) { width in
|
||||
buttonWidth = width
|
||||
}
|
||||
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
.frame(minWidth: 600, maxWidth: .infinity)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(.setupDoneButton) {
|
||||
setupComplete = true
|
||||
dismiss()
|
||||
}
|
||||
.disabled(!allDone)
|
||||
.primaryButton()
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
.padding()
|
||||
.sheet(isPresented: $showingIntegrations, onDismiss: {
|
||||
integrations = true
|
||||
}, content: {
|
||||
IntegrationsView()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct setupButton: View {
|
||||
|
||||
struct WidthKey: @MainActor PreferenceKey {
|
||||
@MainActor static var defaultValue: CGFloat? = nil
|
||||
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
||||
if let next = nextValue(), next > (value ?? -1) {
|
||||
value = next
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let label: LocalizedStringResource
|
||||
let complete: Bool
|
||||
let action: () -> Void
|
||||
let width: CGFloat?
|
||||
@State var currentWidth: CGFloat?
|
||||
|
||||
init(_ label: LocalizedStringResource, complete: Bool, width: CGFloat? = nil, action: @escaping () -> Void) {
|
||||
self.label = label
|
||||
self.complete = complete
|
||||
self.action = action
|
||||
self.width = width
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
if complete {
|
||||
Text(.setupStepCompleteButton)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
} else {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
.frame(width: width)
|
||||
.padding(.vertical, 2)
|
||||
.onGeometryChange(for: CGFloat.self) { proxy in
|
||||
proxy.size.width
|
||||
} action: { newValue in
|
||||
currentWidth = newValue
|
||||
}
|
||||
}
|
||||
.preference(key: WidthKey.self, value: currentWidth)
|
||||
.primaryButton()
|
||||
.disabled(complete)
|
||||
.tint(complete ? .green : nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct StepView<Content: View>: View {
|
||||
|
||||
let title: LocalizedStringResource
|
||||
let icon: Image
|
||||
let description: LocalizedStringResource
|
||||
let actions: Content
|
||||
|
||||
init(title: LocalizedStringResource, description: LocalizedStringResource, systemImage: String, actions: () -> Content) {
|
||||
self.title = title
|
||||
self.icon = Image(systemName: systemImage)
|
||||
self.description = description
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
icon
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24)
|
||||
Spacer()
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.bold()
|
||||
Text(description)
|
||||
}
|
||||
Spacer(minLength: 20)
|
||||
actions
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SetupView {
|
||||
|
||||
enum Constants {
|
||||
static let updaterFAQURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md#whats-this-network-request-to-github")!
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SetupView(setupComplete: .constant(false))
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct ToolConfigurationView: View {
|
||||
|
||||
private let instructions = Instructions()
|
||||
let selectedInstruction: ConfigurationFileInstructions
|
||||
|
||||
@Environment(\.secretStoreList) private var secretStoreList
|
||||
|
||||
@State var creating = false
|
||||
@State var selectedSecret: AnySecret?
|
||||
|
||||
init(selectedInstruction: ConfigurationFileInstructions) {
|
||||
self.selectedInstruction = selectedInstruction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if selectedInstruction.requiresSecret {
|
||||
if secretStoreList.allSecrets.isEmpty {
|
||||
Section {
|
||||
Text(.integrationsConfigureUsingSecretEmptyCreate)
|
||||
if let store = secretStoreList.modifiableStore {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(.createSecretTitle) {
|
||||
creating = true
|
||||
}
|
||||
.sheet(isPresented: $creating) {
|
||||
CreateSecretView(store: store) { created in
|
||||
selectedSecret = created
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Picker(.integrationsConfigureUsingSecretSecretTitle, selection: $selectedSecret) {
|
||||
if selectedSecret == nil {
|
||||
Text(.integrationsConfigureUsingSecretNoSecret)
|
||||
.tag(nil as (AnySecret?))
|
||||
}
|
||||
ForEach(secretStoreList.allSecrets) { secret in
|
||||
Text(secret.name)
|
||||
.tag(secret)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(.integrationsConfigureUsingSecretHeader)
|
||||
}
|
||||
.onAppear {
|
||||
selectedSecret = secretStoreList.allSecrets.first
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(selectedInstruction.steps) { stepGroup in
|
||||
Section {
|
||||
ConfigurationItemView(title: .integrationsPathTitle, value: stepGroup.path, action: .revealInFinder(stepGroup.path))
|
||||
ForEach(stepGroup.steps, id: \.self.key) { step in
|
||||
ConfigurationItemView(title: .integrationsAddThisTitle, action: .copy(String(localized: step))) {
|
||||
HStack {
|
||||
Text(placeholdersReplaced(text: String(localized: step)))
|
||||
.padding(8)
|
||||
.font(.system(.subheadline, design: .monospaced))
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.black.opacity(0.05))
|
||||
.stroke(.separator, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
if let note = stepGroup.note {
|
||||
Text(note)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let url = selectedInstruction.website {
|
||||
Section {
|
||||
Link(destination: url) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(.integrationsWebLink)
|
||||
.font(.headline)
|
||||
Text(url.absoluteString)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
}
|
||||
|
||||
func placeholdersReplaced(text: String) -> String {
|
||||
guard let selectedSecret else { return text }
|
||||
let writer = OpenSSHPublicKeyWriter()
|
||||
let fileController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
|
||||
return text
|
||||
.replacingOccurrences(of: Instructions.Constants.publicKeyPlaceholder, with: writer.openSSHString(secret: selectedSecret))
|
||||
.replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: fileController.publicKeyPath(for: selectedSecret))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
|
||||
@State var store: StoreType
|
||||
@Binding var showing: Bool
|
||||
|
||||
@State private var name = ""
|
||||
@State private var requiresAuthentication = true
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("create_secret_title")
|
||||
.font(.largeTitle)
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Text("create_secret_name_label")
|
||||
TextField("create_secret_name_placeholder", text: $name)
|
||||
.focusable()
|
||||
}
|
||||
ThumbnailPickerView(items: [
|
||||
ThumbnailPickerView.Item(value: true, name: "create_secret_require_authentication_title", description: "create_secret_require_authentication_description", thumbnail: AuthenticationView()),
|
||||
ThumbnailPickerView.Item(value: false, name: "create_secret_notify_title",
|
||||
description: "create_secret_notify_description",
|
||||
thumbnail: NotificationView())
|
||||
], selection: $requiresAuthentication)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("create_secret_cancel_button") {
|
||||
showing = false
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("create_secret_create_button", action: save)
|
||||
.disabled(name.isEmpty)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
|
||||
func save() {
|
||||
Task {
|
||||
try! await store.create(name: name, requiresAuthentication: requiresAuthentication)
|
||||
showing = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ThumbnailPickerView<ValueType: Hashable>: View {
|
||||
|
||||
private let items: [Item<ValueType>]
|
||||
@Binding var selection: ValueType
|
||||
|
||||
init(items: [ThumbnailPickerView<ValueType>.Item<ValueType>], selection: Binding<ValueType>) {
|
||||
self.items = items
|
||||
_selection = selection
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
ForEach(items) { item in
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
item.thumbnail
|
||||
.frame(height: 200)
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(lineWidth: item.value == selection ? 15 : 0))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.foregroundColor(.accentColor)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(item.name)
|
||||
.bold()
|
||||
Text(item.description)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(width: 250)
|
||||
.onTapGesture {
|
||||
withAnimation(.spring()) {
|
||||
selection = item.value
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ThumbnailPickerView {
|
||||
|
||||
struct Item<InnerValueType: Hashable>: Identifiable {
|
||||
let id = UUID()
|
||||
let value: InnerValueType
|
||||
let name: LocalizedStringKey
|
||||
let description: LocalizedStringKey
|
||||
let thumbnail: AnyView
|
||||
|
||||
init<ViewType: View>(value: InnerValueType, name: LocalizedStringKey, description: LocalizedStringKey, thumbnail: ViewType) {
|
||||
self.value = value
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.thumbnail = AnyView(thumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@MainActor @Observable class SystemBackground {
|
||||
|
||||
static let shared = SystemBackground()
|
||||
var image: NSImage?
|
||||
|
||||
private init() {
|
||||
if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) {
|
||||
image = NSImage(contentsOf: imageURL)
|
||||
} else {
|
||||
image = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SystemBackgroundView: View {
|
||||
|
||||
let anchor: UnitPoint
|
||||
|
||||
var body: some View {
|
||||
if let image = SystemBackground.shared.image {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaleEffect(3, anchor: anchor)
|
||||
.clipped()
|
||||
.allowsHitTesting(false)
|
||||
} else {
|
||||
Rectangle()
|
||||
.foregroundColor(Color(.systemPurple))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthenticationView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
SystemBackgroundView(anchor: .center)
|
||||
GeometryReader { geometry in
|
||||
VStack {
|
||||
Image(systemName: "touchid")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(Color(.systemRed))
|
||||
Text(verbatim: "Touch ID Prompt")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.redacted(reason: .placeholder)
|
||||
VStack {
|
||||
Text(verbatim: "Touch ID Detail prompt.Detail two.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.primary)
|
||||
Text(verbatim: "Touch ID Detail prompt.Detail two.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.redacted(reason: .placeholder)
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.frame(width: geometry.size.width, height: 20, alignment: .center)
|
||||
.foregroundColor(.accentColor)
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.frame(width: geometry.size.width, height: 20, alignment: .center)
|
||||
.foregroundColor(Color(.unemphasizedSelectedContentBackgroundColor))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 150)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.foregroundStyle(.ultraThickMaterial)
|
||||
)
|
||||
.padding()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct NotificationView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
SystemBackgroundView(anchor: .topTrailing)
|
||||
VStack {
|
||||
Rectangle()
|
||||
.background(Color.clear)
|
||||
.foregroundStyle(.thinMaterial)
|
||||
.frame(height: 35)
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Image(nsImage: NSApplication.shared.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundColor(.primary)
|
||||
VStack(alignment: .leading) {
|
||||
Text(verbatim: "Secretive")
|
||||
.font(.title)
|
||||
.foregroundColor(.primary)
|
||||
Text(verbatim: "Secretive wants to sign")
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}.padding()
|
||||
.redacted(reason: .placeholder)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.foregroundStyle(.ultraThickMaterial)
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct CreateSecretView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
|
||||
AuthenticationView().environment(\.colorScheme, .dark)
|
||||
AuthenticationView().environment(\.colorScheme, .light)
|
||||
NotificationView().environment(\.colorScheme, .dark)
|
||||
NotificationView().environment(\.colorScheme, .light)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,58 +0,0 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
|
||||
@State var store: StoreType
|
||||
let secret: StoreType.SecretType
|
||||
var dismissalBlock: (Bool) -> ()
|
||||
|
||||
@State private var confirm = ""
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Image(nsImage: NSApplication.shared.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 64, height: 64)
|
||||
.padding()
|
||||
VStack {
|
||||
HStack {
|
||||
Text("delete_confirmation_title_\(secret.name)").bold()
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Text("delete_confirmation_description_\(secret.name)_\(secret.name)")
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Text("delete_confirmation_confirm_name_label")
|
||||
TextField(secret.name, text: $confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("delete_confirmation_delete_button", action: delete)
|
||||
.disabled(confirm != secret.name)
|
||||
Button("delete_confirmation_cancel_button") {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 400)
|
||||
.onExitCommand {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
}
|
||||
|
||||
func delete() {
|
||||
Task {
|
||||
try! await store.delete(secret: secret)
|
||||
dismissalBlock(true)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NoStoresView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("no_secure_storage_title")
|
||||
.bold()
|
||||
Text("no_secure_storage_description")
|
||||
Link("no_secure_storage_yubico_link", destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!)
|
||||
}.padding()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct NoStoresView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NoStoresView()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,52 +0,0 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
|
||||
@State var store: StoreType
|
||||
let secret: StoreType.SecretType
|
||||
var dismissalBlock: (_ renamed: Bool) -> ()
|
||||
|
||||
@State private var newName = ""
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Image(nsImage: NSApplication.shared.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 64, height: 64)
|
||||
.padding()
|
||||
VStack {
|
||||
HStack {
|
||||
Text("rename_title_\(secret.name)")
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
TextField(secret.name, text: $newName).focusable()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("rename_rename_button", action: rename)
|
||||
.disabled(newName.count == 0)
|
||||
.keyboardShortcut(.return)
|
||||
Button("rename_cancel_button") {
|
||||
dismissalBlock(false)
|
||||
}.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 400)
|
||||
.onExitCommand {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
}
|
||||
|
||||
func rename() {
|
||||
Task {
|
||||
try? await store.update(secret: secret, name: newName)
|
||||
dismissalBlock(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct SecretDetailView<SecretType: Secret>: View {
|
||||
|
||||
let secret: SecretType
|
||||
|
||||
private let keyWriter = OpenSSHKeyWriter()
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID))
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Form {
|
||||
Section {
|
||||
CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: "secret_detail_md5_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: "secret_detail_public_key_label", image: Image(systemName: "key"), text: keyString)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: "secret_detail_public_key_path_label", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minHeight: 200, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
var dashedKeyName: String {
|
||||
secret.name.replacingOccurrences(of: " ", with: "-")
|
||||
}
|
||||
|
||||
var dashedHostName: String {
|
||||
["secretive", Host.current().localizedName, "local"]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: ".")
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
}
|
||||
|
||||
var keyString: String {
|
||||
keyWriter.openSSHString(secret: secret, comment: "\(dashedKeyName)@\(dashedHostName)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct SecretDetailView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SecretDetailView(secret: Preview.Store(numberOfRandomSecrets: 1).secrets[0])
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
151
Sources/Secretive/Views/Secrets/CreateSecretView.swift
Normal file
151
Sources/Secretive/Views/Secrets/CreateSecretView.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
|
||||
@State var store: StoreType
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var createdSecret: (AnySecret?) -> Void
|
||||
|
||||
@State private var name = ""
|
||||
@State private var keyAttribution = ""
|
||||
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
|
||||
@State private var keyType: KeyType?
|
||||
@State var advanced = false
|
||||
@State var errorText: String?
|
||||
|
||||
private var authenticationOptions: [AuthenticationRequirement] {
|
||||
if advanced || authenticationRequirement == .biometryCurrent {
|
||||
[.presenceRequired, .notRequired, .biometryCurrent]
|
||||
} else {
|
||||
[.presenceRequired, .notRequired]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Picker(.createSecretRequireAuthenticationTitle, selection: $authenticationRequirement) {
|
||||
ForEach(authenticationOptions) { option in
|
||||
HStack {
|
||||
switch option {
|
||||
case .notRequired:
|
||||
Image(systemName: "bell")
|
||||
Text(.createSecretNotifyTitle)
|
||||
case .presenceRequired:
|
||||
Image(systemName: "lock")
|
||||
Text(.createSecretRequireAuthenticationTitle)
|
||||
case .biometryCurrent:
|
||||
Image(systemName: "lock.trianglebadge.exclamationmark.fill")
|
||||
Text(.createSecretRequireAuthenticationBiometricCurrentTitle)
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.tag(option)
|
||||
}
|
||||
}
|
||||
Group {
|
||||
switch authenticationRequirement {
|
||||
case .notRequired:
|
||||
Text(.createSecretNotifyDescription)
|
||||
case .presenceRequired:
|
||||
Text(.createSecretRequireAuthenticationDescription)
|
||||
case .biometryCurrent:
|
||||
Text(.createSecretRequireAuthenticationBiometricCurrentDescription)
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if authenticationRequirement == .biometryCurrent {
|
||||
Text(.createSecretBiometryCurrentWarning)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 3)
|
||||
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if advanced {
|
||||
Section {
|
||||
VStack {
|
||||
Picker(.createSecretKeyTypeLabel, selection: $keyType) {
|
||||
ForEach(store.supportedKeyTypes, id: \.self) { option in
|
||||
Text(String(describing: option))
|
||||
.tag(option)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if keyType?.algorithm == .mldsa {
|
||||
Text(.createSecretMldsaWarning)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 3)
|
||||
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
TextField(.createSecretKeyAttributionLabel, text: $keyAttribution, prompt: Text(verbatim: "test@example.com"))
|
||||
Text(.createSecretKeyAttributionDescription)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
} footer: {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
|
||||
.toggleStyle(.button)
|
||||
Spacer()
|
||||
Button(.createSecretCancelButton, role: .cancel) {
|
||||
dismiss()
|
||||
}
|
||||
Button(.createSecretCreateButton, action: save)
|
||||
.keyboardShortcut(.return)
|
||||
.primaryButton()
|
||||
.disabled(name.isEmpty)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
keyType = store.supportedKeyTypes.first
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
func save() {
|
||||
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
|
||||
Task {
|
||||
do {
|
||||
let new = try await store.create(
|
||||
name: name,
|
||||
attributes: .init(
|
||||
keyType: keyType!,
|
||||
authentication: authenticationRequirement,
|
||||
publicKeyAttribution: attribution
|
||||
)
|
||||
)
|
||||
createdSecret(AnySecret(new))
|
||||
dismiss()
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// CreateSecretView(store: Preview.StoreModifiable()) { _ in }
|
||||
//}
|
||||
60
Sources/Secretive/Views/Secrets/DeleteSecretView.swift
Normal file
60
Sources/Secretive/Views/Secrets/DeleteSecretView.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
extension View {
|
||||
|
||||
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ secret: AnySecret, _ store: AnySecretStoreModifiable?, dismissalBlock: @escaping (Bool) -> ()) -> some View {
|
||||
modifier(DeleteSecretConfirmationModifier(isPresented: isPresented, secret: secret, store: store, dismissalBlock: dismissalBlock))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DeleteSecretConfirmationModifier: ViewModifier {
|
||||
|
||||
var isPresented: Binding<Bool>
|
||||
var secret: AnySecret
|
||||
var store: AnySecretStoreModifiable?
|
||||
var dismissalBlock: (Bool) -> ()
|
||||
@State var confirmedSecretName = ""
|
||||
@State private var errorText: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.confirmationDialog(
|
||||
.deleteConfirmationTitle(secretName: secret.name),
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible,
|
||||
actions: {
|
||||
TextField(secret.name, text: $confirmedSecretName)
|
||||
if let errorText {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
Button(.deleteConfirmationDeleteButton, action: delete)
|
||||
.disabled(confirmedSecretName != secret.name)
|
||||
Button(.deleteConfirmationCancelButton, role: .cancel) {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
},
|
||||
message: {
|
||||
Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name))
|
||||
}
|
||||
)
|
||||
.dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill"))
|
||||
.onExitCommand {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
}
|
||||
|
||||
func delete() {
|
||||
Task {
|
||||
do {
|
||||
try await store!.delete(secret: secret)
|
||||
dismissalBlock(true)
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
67
Sources/Secretive/Views/Secrets/EditSecretView.swift
Normal file
67
Sources/Secretive/Views/Secrets/EditSecretView.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct EditSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
|
||||
let store: StoreType
|
||||
let secret: StoreType.SecretType
|
||||
let dismissalBlock: (_ renamed: Bool) -> ()
|
||||
|
||||
@State private var name: String
|
||||
@State private var publicKeyAttribution: String
|
||||
@State var errorText: String?
|
||||
|
||||
init(store: StoreType, secret: StoreType.SecretType, dismissalBlock: @escaping (Bool) -> ()) {
|
||||
self.store = store
|
||||
self.secret = secret
|
||||
self.dismissalBlock = dismissalBlock
|
||||
name = secret.name
|
||||
publicKeyAttribution = secret.publicKeyAttribution ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
|
||||
VStack(alignment: .leading) {
|
||||
TextField(.createSecretKeyAttributionLabel, text: $publicKeyAttribution, prompt: Text(verbatim: "test@example.com"))
|
||||
Text(.createSecretKeyAttributionDescription)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} footer: {
|
||||
if let errorText {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button(.editCancelButton) {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button(.editSaveButton, action: rename)
|
||||
.disabled(name.isEmpty)
|
||||
.keyboardShortcut(.return)
|
||||
.primaryButton()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
func rename() {
|
||||
var attributes = secret.attributes
|
||||
attributes.publicKeyAttribution = publicKeyAttribution.isEmpty ? nil : publicKeyAttribution
|
||||
Task {
|
||||
do {
|
||||
try await store.update(secret: secret, name: name, attributes: attributes)
|
||||
dismissalBlock(true)
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,9 @@ struct EmptyStoreImmutableView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("empty_store_nonmodifiable_title").bold()
|
||||
Text("empty_store_nonmodifiable_description")
|
||||
Text("empty_store_nonmodifiable_supported_key_types")
|
||||
Text(.emptyStoreNonmodifiableTitle).bold()
|
||||
Text(.emptyStoreNonmodifiableDescription)
|
||||
Text(.emptyStoreNonmodifiableSupportedKeyTypes)
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@@ -49,23 +49,18 @@ struct EmptyStoreModifiableView: View {
|
||||
path.addLine(to: CGPoint(x: g.size.width - 3, y: 0))
|
||||
}.fill()
|
||||
}.frame(height: (windowGeometry.size.height/2) - 20).padding()
|
||||
Text("empty_store_modifiable_click_here_title").bold()
|
||||
Text("empty_store_modifiable_click_here_description")
|
||||
Text(.emptyStoreModifiableClickHereTitle).bold()
|
||||
Text(.emptyStoreModifiableClickHereDescription)
|
||||
Spacer()
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct EmptyStoreModifiableView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
EmptyStoreImmutableView()
|
||||
EmptyStoreModifiableView()
|
||||
}
|
||||
}
|
||||
#Preview {
|
||||
EmptyStoreImmutableView()
|
||||
}
|
||||
#Preview {
|
||||
EmptyStoreModifiableView()
|
||||
}
|
||||
|
||||
#endif
|
||||
19
Sources/Secretive/Views/Secrets/NoStoresView.swift
Normal file
19
Sources/Secretive/Views/Secrets/NoStoresView.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NoStoresView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(.noSecureStorageTitle)
|
||||
.bold()
|
||||
Text(.noSecureStorageDescription)
|
||||
Link(.noSecureStorageYubicoLink, destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!)
|
||||
}.padding()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NoStoresView()
|
||||
}
|
||||
|
||||
42
Sources/Secretive/Views/Secrets/SecretDetailView.swift
Normal file
42
Sources/Secretive/Views/Secrets/SecretDetailView.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct SecretDetailView<SecretType: Secret>: View {
|
||||
|
||||
let secret: SecretType
|
||||
|
||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Form {
|
||||
Section {
|
||||
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: .secretDetailMd5FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: .secretDetailPublicKeyLabel, image: Image(systemName: "key"), text: keyString)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: .secretDetailPublicKeyPathLabel, image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minHeight: 200, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
|
||||
var keyString: String {
|
||||
keyWriter.openSSHString(secret: secret)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// SecretDetailView(secret: Preview.Secret(name: "Demonstration Secret"))
|
||||
//}
|
||||
@@ -12,21 +12,9 @@ struct SecretListItemView: View {
|
||||
var deletedSecret: (AnySecret) -> Void
|
||||
var renamedSecret: (AnySecret) -> Void
|
||||
|
||||
private var showingPopup: Binding<Bool> {
|
||||
Binding(
|
||||
get: { isDeleting || isRenaming },
|
||||
set: {
|
||||
if $0 == false {
|
||||
isDeleting = false
|
||||
isRenaming = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(value: secret) {
|
||||
if secret.requiresAuthentication {
|
||||
if secret.authenticationRequirement.required {
|
||||
HStack {
|
||||
Text(secret.name)
|
||||
Spacer()
|
||||
@@ -39,28 +27,26 @@ struct SecretListItemView: View {
|
||||
.contextMenu {
|
||||
if store is AnySecretStoreModifiable {
|
||||
Button(action: { isRenaming = true }) {
|
||||
Text("secret_list_rename_button")
|
||||
Image(systemName: "pencil")
|
||||
Text(.secretListEditButton)
|
||||
}
|
||||
Button(action: { isDeleting = true }) {
|
||||
Text("secret_list_delete_button")
|
||||
Image(systemName: "trash")
|
||||
Text(.secretListDeleteButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
.popover(isPresented: showingPopup) {
|
||||
.showingDeleteConfirmation(isPresented: $isDeleting, secret, store as? AnySecretStoreModifiable) { deleted in
|
||||
if deleted {
|
||||
deletedSecret(secret)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isRenaming) {
|
||||
if let modifiable = store as? AnySecretStoreModifiable {
|
||||
if isDeleting {
|
||||
DeleteSecretView(store: modifiable, secret: secret) { deleted in
|
||||
isDeleting = false
|
||||
if deleted {
|
||||
deletedSecret(secret)
|
||||
}
|
||||
}
|
||||
} else if isRenaming {
|
||||
RenameSecretView(store: modifiable, secret: secret) { renamed in
|
||||
isRenaming = false
|
||||
if renamed {
|
||||
renamedSecret(secret)
|
||||
}
|
||||
EditSecretView(store: modifiable, secret: secret) { renamed in
|
||||
isRenaming = false
|
||||
if renamed {
|
||||
renamedSecret(secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import SecretKit
|
||||
|
||||
struct StoreListView: View {
|
||||
@@ -13,6 +12,8 @@ struct StoreListView: View {
|
||||
}
|
||||
|
||||
private func secretRenamed(secret: AnySecret) {
|
||||
// Toggle so name updates in list.
|
||||
activeSecret = nil
|
||||
activeSecret = secret
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ struct StoreListView: View {
|
||||
extension StoreListView {
|
||||
|
||||
private var nextDefaultSecret: AnySecret? {
|
||||
return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
|
||||
return storeList.allSecrets.first
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SetupView: View {
|
||||
|
||||
@State var stepIndex = 0
|
||||
@Binding var visible: Bool
|
||||
@Binding var setupComplete: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
VStack {
|
||||
StepView(numberOfSteps: 3, currentStep: stepIndex, width: proxy.size.width)
|
||||
GeometryReader { _ in
|
||||
HStack(spacing: 0) {
|
||||
SecretAgentSetupView(buttonAction: advance)
|
||||
.frame(width: proxy.size.width)
|
||||
SSHAgentSetupView(buttonAction: advance)
|
||||
.frame(width: proxy.size.width)
|
||||
UpdaterExplainerView {
|
||||
visible = false
|
||||
setupComplete = true
|
||||
}
|
||||
.frame(width: proxy.size.width)
|
||||
}
|
||||
.offset(x: -proxy.size.width * Double(stepIndex), y: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 500, idealWidth: 500, minHeight: 500, idealHeight: 500)
|
||||
}
|
||||
|
||||
|
||||
func advance() {
|
||||
withAnimation(.spring()) {
|
||||
stepIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct StepView: View {
|
||||
|
||||
let numberOfSteps: Int
|
||||
let currentStep: Int
|
||||
|
||||
// Ideally we'd have a geometry reader inside this view doing this for us, but that crashes on 11.0b7
|
||||
let width: Double
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.foregroundColor(.blue)
|
||||
.frame(height: 5)
|
||||
Rectangle()
|
||||
.foregroundColor(.green)
|
||||
.frame(width: max(0, ((width - (Constants.padding * 2)) / Double(numberOfSteps - 1)) * Double(currentStep) - (Constants.circleWidth / 2)), height: 5)
|
||||
HStack {
|
||||
ForEach(Array(0..<numberOfSteps), id: \.self) { index in
|
||||
ZStack {
|
||||
if currentStep > index {
|
||||
Circle()
|
||||
.foregroundColor(.green)
|
||||
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
|
||||
Text("setup_step_complete_symbol")
|
||||
.foregroundColor(.white)
|
||||
.bold()
|
||||
} else {
|
||||
Circle()
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
|
||||
if currentStep == index {
|
||||
Circle()
|
||||
.strokeBorder(Color.white, lineWidth: 3)
|
||||
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
|
||||
}
|
||||
Text(String(describing: index + 1))
|
||||
.foregroundColor(.white)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
if index < numberOfSteps - 1 {
|
||||
Spacer(minLength: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.padding(Constants.padding)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StepView {
|
||||
|
||||
enum Constants {
|
||||
|
||||
static let padding: Double = 15
|
||||
static let circleWidth: Double = 30
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SetupStepView<Content> : View where Content : View {
|
||||
|
||||
let title: LocalizedStringKey
|
||||
let image: Image
|
||||
let bodyText: LocalizedStringKey
|
||||
let buttonTitle: LocalizedStringKey
|
||||
let buttonAction: () -> Void
|
||||
let content: Content
|
||||
|
||||
init(title: LocalizedStringKey, image: Image, bodyText: LocalizedStringKey, buttonTitle: LocalizedStringKey, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.image = image
|
||||
self.bodyText = bodyText
|
||||
self.buttonTitle = buttonTitle
|
||||
self.buttonAction = buttonAction
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(title)
|
||||
.font(.title)
|
||||
Spacer()
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 64)
|
||||
Spacer()
|
||||
Text(bodyText)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer()
|
||||
content
|
||||
Spacer()
|
||||
Button(buttonTitle) {
|
||||
buttonAction()
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SecretAgentSetupView: View {
|
||||
|
||||
let buttonAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
SetupStepView(title: "setup_agent_title",
|
||||
image: Image(nsImage: NSApplication.shared.applicationIconImage),
|
||||
bodyText: "setup_agent_description",
|
||||
buttonTitle: "setup_agent_install_button",
|
||||
buttonAction: install) {
|
||||
Text("setup_agent_activity_monitor_description")
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
func install() {
|
||||
Task {
|
||||
await LaunchAgentController().install()
|
||||
buttonAction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SSHAgentSetupView: View {
|
||||
|
||||
let buttonAction: () -> Void
|
||||
|
||||
private static let controller = ShellConfigurationController()
|
||||
@State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first!
|
||||
|
||||
var body: some View {
|
||||
SetupStepView(title: "setup_ssh_title",
|
||||
image: Image(systemName: "terminal"),
|
||||
bodyText: "setup_ssh_description",
|
||||
buttonTitle: "setup_ssh_added_manually_button",
|
||||
buttonAction: buttonAction) {
|
||||
Link("setup_third_party_faq_link", destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!)
|
||||
Picker(selection: $selectedShellInstruction, label: EmptyView()) {
|
||||
ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in
|
||||
Text(instruction.shell)
|
||||
.tag(instruction)
|
||||
.padding()
|
||||
}
|
||||
}.pickerStyle(SegmentedPickerStyle())
|
||||
CopyableView(title: "setup_ssh_add_to_config_button_\(selectedShellInstruction.shellConfigPath)", image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
|
||||
Button("setup_ssh_add_for_me_button") {
|
||||
let controller = ShellConfigurationController()
|
||||
if controller.addToShell(shellInstructions: selectedShellInstruction) {
|
||||
buttonAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Delegate: NSObject, NSOpenSavePanelDelegate {
|
||||
|
||||
private let name: String
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
func panel(_ sender: Any, shouldEnable url: URL) -> Bool {
|
||||
return url.lastPathComponent == name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct UpdaterExplainerView: View {
|
||||
|
||||
let buttonAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
SetupStepView(title: "setup_updates_title",
|
||||
image: Image(systemName: "dot.radiowaves.left.and.right"),
|
||||
bodyText: "setup_updates_description",
|
||||
buttonTitle: "setup_updates_ok",
|
||||
buttonAction: buttonAction) {
|
||||
Link("setup_updates_readmore", destination: SetupView.Constants.updaterFAQURL)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SetupView {
|
||||
|
||||
enum Constants {
|
||||
static let updaterFAQURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md#whats-this-network-request-to-github")!
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ShellConfigInstruction: Identifiable, Hashable {
|
||||
|
||||
var shell: String
|
||||
var shellConfigDirectory: String
|
||||
var shellConfigFilename: String
|
||||
var text: String
|
||||
|
||||
var id: String {
|
||||
shell
|
||||
}
|
||||
|
||||
var shellConfigPath: String {
|
||||
return (shellConfigDirectory as NSString).appendingPathComponent(shellConfigFilename)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct SetupView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
SetupView(visible: .constant(true), setupComplete: .constant(false))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SecretAgentSetupView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
SecretAgentSetupView(buttonAction: {})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SSHAgentSetupView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
SSHAgentSetupView(buttonAction: {})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct UpdaterExplainerView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
UpdaterExplainerView(buttonAction: {})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
94
Sources/Secretive/Views/Styles/ActionButtonStyle.swift
Normal file
94
Sources/Secretive/Views/Styles/ActionButtonStyle.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PrimaryButtonModifier: ViewModifier {
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.isEnabled) var isEnabled
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// Tinted glass prominent is really hard to read on 26.0.
|
||||
if #available(macOS 26.0, *), colorScheme == .dark, isEnabled {
|
||||
content.buttonStyle(.glassProminent)
|
||||
} else {
|
||||
content.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func primaryButton() -> some View {
|
||||
modifier(PrimaryButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct MenuButtonModifier: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content
|
||||
.glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle)
|
||||
} else {
|
||||
content
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func menuButton() -> some View {
|
||||
modifier(MenuButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct NormalButtonModifier: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content.buttonStyle(.glass)
|
||||
} else {
|
||||
content.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func normalButton() -> some View {
|
||||
modifier(NormalButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DangerButtonModifier: ViewModifier {
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// Tinted glass prominent is really hard to read on 26.0.
|
||||
if #available(macOS 26.0, *), colorScheme == .dark {
|
||||
content.buttonStyle(.glassProminent)
|
||||
.tint(.red)
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
content.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func danger() -> some View {
|
||||
modifier(DangerButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
19
Sources/Secretive/Views/Styles/ErrorStyle.swift
Normal file
19
Sources/Secretive/Views/Styles/ErrorStyle.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ErrorStyleModifier: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.foregroundStyle(.red)
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func errorStyle() -> some View {
|
||||
modifier(ErrorStyleModifier())
|
||||
}
|
||||
|
||||
}
|
||||
153
Sources/Secretive/Views/Views/AgentStatusView.swift
Normal file
153
Sources/Secretive/Views/Views/AgentStatusView.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AgentStatusView: View {
|
||||
|
||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||
|
||||
var body: some View {
|
||||
if agentStatusChecker.running {
|
||||
AgentRunningView()
|
||||
} else {
|
||||
AgentNotRunningView()
|
||||
}
|
||||
}
|
||||
}
|
||||
struct AgentRunningView: View {
|
||||
|
||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
if let process = agentStatusChecker.process {
|
||||
ConfigurationItemView(
|
||||
title: .agentDetailsLocationTitle,
|
||||
value: process.bundleURL!.path(),
|
||||
action: .revealInFinder(process.bundleURL!.path()),
|
||||
)
|
||||
ConfigurationItemView(
|
||||
title: .agentDetailsSocketPathTitle,
|
||||
value: URL.socketPath,
|
||||
action: .copy(URL.socketPath),
|
||||
)
|
||||
ConfigurationItemView(
|
||||
title: .agentDetailsVersionTitle,
|
||||
value: Bundle(url: process.bundleURL!)!.infoDictionary!["CFBundleShortVersionString"] as! String
|
||||
)
|
||||
if let launchDate = process.launchDate {
|
||||
ConfigurationItemView(
|
||||
title: .agentDetailsRunningSinceTitle,
|
||||
value: launchDate.formatted()
|
||||
)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(.agentRunningNoticeDetailTitle)
|
||||
.font(.headline)
|
||||
.padding(.top)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(.agentRunningNoticeDetailDescription)
|
||||
HStack {
|
||||
Spacer()
|
||||
Menu(.agentDetailsRestartAgentButton) {
|
||||
Button(.agentDetailsDisableAgentButton) {
|
||||
Task {
|
||||
_ = await LaunchAgentController()
|
||||
.uninstall()
|
||||
agentStatusChecker.check()
|
||||
}
|
||||
}
|
||||
} primaryAction: {
|
||||
Task {
|
||||
let controller = LaunchAgentController()
|
||||
let installed = await controller.install()
|
||||
if !installed {
|
||||
_ = await controller.forceLaunch()
|
||||
}
|
||||
agentStatusChecker.check()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 400)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct AgentNotRunningView: View {
|
||||
|
||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||
@State var triedRestart = false
|
||||
@State var loading = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
} header: {
|
||||
Text(.agentNotRunningNoticeTitle)
|
||||
.font(.headline)
|
||||
.padding(.top)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(.agentNotRunningNoticeDetailDescription)
|
||||
HStack {
|
||||
if !triedRestart {
|
||||
Spacer()
|
||||
Button {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
Task {
|
||||
let controller = LaunchAgentController()
|
||||
let installed = await controller.install()
|
||||
if !installed {
|
||||
_ = await controller.forceLaunch()
|
||||
}
|
||||
agentStatusChecker.check()
|
||||
loading = false
|
||||
|
||||
if !agentStatusChecker.running {
|
||||
triedRestart = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if !loading {
|
||||
Text(.agentDetailsStartAgentButton)
|
||||
} else {
|
||||
HStack {
|
||||
Text(.agentDetailsStartAgentButtonStarting)
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
}
|
||||
.primaryButton()
|
||||
} else {
|
||||
Text(.agentDetailsCouldNotStartError)
|
||||
.bold()
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 400)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// AgentStatusView()
|
||||
// .environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: false))
|
||||
//}
|
||||
//#Preview {
|
||||
// AgentStatusView()
|
||||
// .environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: true, process: .current))
|
||||
//}
|
||||
@@ -30,13 +30,13 @@ struct ContentView: View {
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 320)
|
||||
.toolbar {
|
||||
// toolbarItem(updateNoticeView, id: "update")
|
||||
toolbarItem(updateNoticeView, id: "update")
|
||||
toolbarItem(runningOrRunSetupView, id: "setup")
|
||||
toolbarItem(appPathNoticeView, id: "appPath")
|
||||
toolbarItem(newItemView, id: "new")
|
||||
}
|
||||
.sheet(isPresented: $runningSetup) {
|
||||
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
|
||||
SetupView(setupComplete: $hasRunSetup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ extension ContentView {
|
||||
}
|
||||
|
||||
var needsSetup: Bool {
|
||||
(runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild
|
||||
runningSetup || !hasRunSetup
|
||||
}
|
||||
|
||||
/// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message
|
||||
@@ -66,19 +66,19 @@ extension ContentView {
|
||||
if needsSetup {
|
||||
setupNoticeView
|
||||
} else {
|
||||
runningNoticeView
|
||||
agentStatusToolbarView
|
||||
}
|
||||
}
|
||||
|
||||
var updateNoticeContent: (LocalizedStringKey, Color)? {
|
||||
var updateNoticeContent: (LocalizedStringResource, Color)? {
|
||||
guard let update = updater.update else { return nil }
|
||||
if update.critical {
|
||||
return ("update_critical_notice_title", .red)
|
||||
return (.updateCriticalNoticeTitle, .red)
|
||||
} else {
|
||||
if updater.testBuild {
|
||||
return ("update_test_notice_title", .blue)
|
||||
return (.updateTestNoticeTitle, .blue)
|
||||
} else {
|
||||
return ("update_normal_notice_title", .orange)
|
||||
return (.updateNormalNoticeTitle, .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ extension ContentView {
|
||||
.foregroundColor(.white)
|
||||
})
|
||||
.buttonStyle(ToolbarButtonStyle(color: color))
|
||||
.popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in
|
||||
.sheet(item: $selectedUpdate) { update in
|
||||
UpdateDetailView(update: update)
|
||||
}
|
||||
}
|
||||
@@ -103,18 +103,17 @@ extension ContentView {
|
||||
@ViewBuilder
|
||||
var newItemView: some View {
|
||||
if storeList.modifiableStore?.isAvailable ?? false {
|
||||
Button(action: {
|
||||
Button(.appMenuNewSecretButton, systemImage: "plus") {
|
||||
showingCreation = true
|
||||
}, label: {
|
||||
Image(systemName: "plus")
|
||||
})
|
||||
}
|
||||
.menuButton()
|
||||
.sheet(isPresented: $showingCreation) {
|
||||
if let modifiable = storeList.modifiableStore {
|
||||
CreateSecretView(store: modifiable, showing: $showingCreation)
|
||||
.onDisappear {
|
||||
guard let newest = modifiable.secrets.last else { return }
|
||||
activeSecret = newest
|
||||
CreateSecretView(store: modifiable) { created in
|
||||
if let created {
|
||||
activeSecret = created
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,43 +124,44 @@ extension ContentView {
|
||||
Button(action: {
|
||||
runningSetup = true
|
||||
}, label: {
|
||||
Group {
|
||||
if hasRunSetup && !agentStatusChecker.running {
|
||||
Text("agent_not_running_notice_title")
|
||||
} else {
|
||||
Text("agent_setup_notice_title")
|
||||
}
|
||||
if !hasRunSetup {
|
||||
Text(.agentSetupNoticeTitle)
|
||||
.font(.headline)
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
})
|
||||
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var runningNoticeView: some View {
|
||||
var agentStatusToolbarView: some View {
|
||||
Button(action: {
|
||||
showingAgentInfo = true
|
||||
}, label: {
|
||||
HStack {
|
||||
Text("agent_running_notice_title")
|
||||
.font(.headline)
|
||||
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
|
||||
Circle()
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(Color.green)
|
||||
if agentStatusChecker.running {
|
||||
Text(.agentRunningNoticeTitle)
|
||||
.font(.headline)
|
||||
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
|
||||
Circle()
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(Color.green)
|
||||
} else {
|
||||
Text(.agentNotRunningNoticeTitle)
|
||||
.font(.headline)
|
||||
Circle()
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
}
|
||||
})
|
||||
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
|
||||
.buttonStyle(
|
||||
ToolbarButtonStyle(
|
||||
lightColor: agentStatusChecker.running ? .black.opacity(0.05) : .red.opacity(0.75),
|
||||
darkColor: agentStatusChecker.running ? .white.opacity(0.05) : .red.opacity(0.5),
|
||||
)
|
||||
)
|
||||
.popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
|
||||
VStack {
|
||||
Text("agent_running_notice_detail_title")
|
||||
.font(.title)
|
||||
.padding(5)
|
||||
Text("agent_running_notice_detail_description")
|
||||
.frame(width: 300)
|
||||
}
|
||||
.padding()
|
||||
AgentStatusView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ extension ContentView {
|
||||
showingAppPathNotice = true
|
||||
}, label: {
|
||||
Group {
|
||||
Text("app_not_in_applications_notice_title")
|
||||
Text(.appNotInApplicationsNoticeTitle)
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
@@ -184,7 +184,7 @@ extension ContentView {
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 64)
|
||||
Text("app_not_in_applications_notice_detail_description")
|
||||
Text(.appNotInApplicationsNoticeDetailDescription)
|
||||
.frame(maxWidth: 300)
|
||||
}
|
||||
.padding()
|
||||
@@ -193,31 +193,22 @@ extension ContentView {
|
||||
}
|
||||
|
||||
var attachmentAnchor: PopoverAttachmentAnchor {
|
||||
// Ideally .point(.bottom), but broken on Sonoma (FB12726503)
|
||||
.rect(.bounds)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
// Empty on modifiable and nonmodifiable
|
||||
ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
|
||||
.environment(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
|
||||
.environment(PreviewUpdater())
|
||||
|
||||
// 5 items on modifiable and nonmodifiable
|
||||
ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
|
||||
.environment(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
|
||||
.environment(PreviewUpdater())
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
//#Preview {
|
||||
// // Empty on modifiable and nonmodifiable
|
||||
// ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
|
||||
// .environment(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
|
||||
// .environment(PreviewUpdater())
|
||||
//}
|
||||
//
|
||||
//#Preview {
|
||||
// // 5 items on modifiable and nonmodifiable
|
||||
// ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
|
||||
// .environment(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
|
||||
// .environment(PreviewUpdater())
|
||||
//}
|
||||
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
|
||||
|
||||
struct CopyableView: View {
|
||||
|
||||
var title: LocalizedStringKey
|
||||
var title: LocalizedStringResource
|
||||
var image: Image
|
||||
var text: String
|
||||
|
||||
@@ -76,10 +76,10 @@ struct CopyableView: View {
|
||||
switch interactionState {
|
||||
case .hovering:
|
||||
Image(systemName: "document.on.document")
|
||||
.accessibilityLabel(String(localized: "copyable_click_to_copy_button"))
|
||||
.accessibilityLabel(String(localized: .copyableClickToCopyButton))
|
||||
case .clicking:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.accessibilityLabel(String(localized: "copyable_copied"))
|
||||
.accessibilityLabel(String(localized: .copyableCopied))
|
||||
case .normal, .dragging:
|
||||
EmptyView()
|
||||
}
|
||||
@@ -125,7 +125,8 @@ extension View {
|
||||
fileprivate struct BackgroundViewModifier: ViewModifier {
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@Environment(\.appearsActive) private var appearsActive
|
||||
|
||||
let interactionState: InteractionState
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
@@ -148,6 +149,7 @@ fileprivate struct BackgroundViewModifier: ViewModifier {
|
||||
}
|
||||
|
||||
func backgroundColor(interactionState: InteractionState) -> Color {
|
||||
guard appearsActive else { return Color.clear }
|
||||
switch interactionState {
|
||||
case .normal:
|
||||
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
|
||||
@@ -161,17 +163,12 @@ fileprivate struct BackgroundViewModifier: ViewModifier {
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct CopyableView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "figure.wave"), text: "Hello world.")
|
||||
.padding()
|
||||
CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "figure.wave"), text: "Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. ")
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
#Preview {
|
||||
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "figure.wave"), text: "Hello world.")
|
||||
.padding()
|
||||
}
|
||||
|
||||
#endif
|
||||
#Preview {
|
||||
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "figure.wave"), text: "Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. ")
|
||||
.padding()
|
||||
}
|
||||
@@ -9,22 +9,22 @@ struct UpdateDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("update_version_name_\(update.name)").font(.title)
|
||||
GroupBox(label: Text("update_release_notes_title")) {
|
||||
Text(.updateVersionName(updateName: update.name)).font(.title)
|
||||
GroupBox(label: Text(.updateReleaseNotesTitle)) {
|
||||
ScrollView {
|
||||
attributedBody
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
if !update.critical {
|
||||
Button("update_ignore_button") {
|
||||
Button(.updateIgnoreButton) {
|
||||
Task {
|
||||
await updater.ignore(release: update)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Button("update_update_button") {
|
||||
Button(.updateUpdateButton) {
|
||||
NSWorkspace.shared.open(update.html_url)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
Reference in New Issue
Block a user