Compare commits

..

1 Commits

Author SHA1 Message Date
Max Goedjen
b2c294211a Symlink to Package.swift 2025-08-23 15:50:38 -07:00
57 changed files with 1688 additions and 1712 deletions

16
.github/workflows/add-to-project.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
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 }}

View File

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

View File

@@ -23,7 +23,7 @@ jobs:
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
- name: Test
run: swift test --build-system swiftbuild --package-path Sources/Packages
run: swift build --build-system swiftbuild --package-path Sources/Packages
build:
# runs-on: macOS-latest
runs-on: macos-15

View File

@@ -10,7 +10,5 @@ jobs:
- uses: actions/checkout@v5
- name: Set Environment
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
- name: Test
run: swift build --build-system swiftbuild --package-path Sources/Packages

View File

@@ -1,3 +1,125 @@
# App Configuration
# Setting up Third Party Apps FAQ
Instructions for setting up apps and shells has moved to [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions)!
## 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.

2
FAQ.md
View File

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

View File

@@ -1,69 +0,0 @@
// 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("../../Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {
[
.swiftLanguageMode(.v6),
// This freaks out Xcode in a dependency context.
// .treatAllWarnings(as: .error),
]
}

1
Package.swift Symbolic link
View File

@@ -0,0 +1 @@
Sources/Packages/Package.swift

View File

@@ -1,4 +1,4 @@
# Secretive [![Test](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg)
# Secretive ![Test](https://github.com/maxgoedjen/secretive/workflows/Test/badge.svg) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg)
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
@@ -61,4 +61,4 @@ Because secrets in the Secure Enclave are not exportable, they are not able to b
## 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)
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."

View File

@@ -1,27 +1,9 @@
# 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
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)
If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com with the subject containing "SECRETIVE SECURITY."

View File

@@ -927,6 +927,84 @@
}
}
},
"auth_context_request_decrypt_description" : {
"comment" : "When the user performs a decryption action using a secret, they are shown a prompt to approve the action. This is the description, showing which secret will be used. The placeholder is the name of the secret. NOTE: This is currently not exposed in UI.",
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "desencripta dades usant el secret \"%1$(secretName)@\" "
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Daten mit dem Secret \"%1$(secretName)@\" entschlüsseln"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "decrypt data using secret \"%1$(secretName)@“"
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "pura salaus käyttäen salaisuutta \"%1$(secretName)@\""
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "déchiffrer les données en utilisant le secret \"%1$(secretName)@\"."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "decifra i dati usando il Segreto \"%1$(secretName)@\""
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "シークレット“%1$(secretName)@”を使って復号化します"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "비밀 \"%1$(secretName)@\"를 사용해서 데이터 복호화"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "odszyfruj dane używając sekretu “%1$(secretName)@”"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "decriptar o dado utilizando segredo \"%1$(secretName)@\""
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "расшифровать данные используя секрет \"%1$(secretName)@\""
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用密钥串 \"%1$(secretName)@\" 解密数据"
}
}
}
},
"auth_context_request_deny_button" : {
"comment" : "When the user chooses to perform an action that requires Touch ID/password authentication, they are shown a prompt to approve the action. This is the deny button for that prompt.",
"extractionState" : "manual",
@@ -1005,6 +1083,84 @@
}
}
},
"auth_context_request_encrypt_description" : {
"comment" : "When the user performs an encryption action using a secret, they are shown a prompt to approve the action. This is the description, showing which secret will be used. The placeholder is the name of the secret. NOTE: This is currently not exposed in UI.",
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "encripta dades usant el secret \"%1$(secretName)@\""
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Daten mit dem Secret \"%1$(secretName)@\" verschlüsseln"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "encrypt data using secret \"%1$(secretName)@“"
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "salaa käyttäen salaisuutta \"%1$(secretName)@\""
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "chiffrer les données en utilisant le secret \"%1$(secretName)@\""
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "cifra i dati usando il Segreto \"%1$(secretName)@\""
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "シークレット“%1$(secretName)@”を使って暗号化します"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "비밀 \"%1$(secretName)@\"를 사용해서 데이터 암호화"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "zaszyfruj dane używając sekretu “%1$(secretName)@”"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "encriptar dado utilizando o segredo \"%1$(secretName)@\""
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "зашифровать данные используя секрет \"%1$(secretName)@\""
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用密钥串 \"%1$(secretName)@\" 加密数据"
}
}
}
},
"auth_context_request_signature_description" : {
"comment" : "When the user performs a signature action using a secret, they are shown a prompt to approve the action. This is the description, showing which secret will be used, and where the request is coming from. The first placeholder is the name of the app requesting the operation. The second placeholder is the name of the secret.",
"extractionState" : "manual",
@@ -1315,28 +1471,6 @@
}
}
},
"create_secret_advanced_label" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Advanced"
}
}
}
},
"create_secret_biometry_current_warning" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "If you change your biometric settings in _any way_, including adding a new fingerprint, this key will no longer be accessible."
}
}
}
},
"create_secret_cancel_button" : {
"extractionState" : "manual",
"localizations" : {
@@ -1491,122 +1625,78 @@
}
}
},
"create_secret_key_attribution_description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This shows at the end of your public key."
}
}
}
},
"create_secret_key_attribution_label" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Key Attribution"
}
}
}
},
"create_secret_key_type_label" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Key Type"
}
}
}
},
"create_secret_mldsa_warning" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Warning: ML-DSA keys are very new, and not supported by many servers yet. Please verify the server you'll be using this key for accepts ML-DSA keys."
}
}
}
},
"create_secret_name_label" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nom"
"value" : "Nom:"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Name"
"value" : "Name:"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Name"
"value" : "Name:"
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nimi"
"value" : "Nimi:"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nom"
"value" : "Nom :"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nome"
"value" : "Nome:"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "名前"
"value" : "名前:"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이름"
"value" : "이름:"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nazwa"
"value" : "Nazwa:"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nome"
"value" : "Nome:"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Название"
"value" : "Название:"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "名称"
}
}
@@ -1843,28 +1933,6 @@
}
}
},
"create_secret_require_authentication_biometric_current_description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Require authentication with current set of biometrics."
}
}
}
},
"create_secret_require_authentication_biometric_current_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Current Biometrics"
}
}
}
},
"create_secret_require_authentication_description" : {
"extractionState" : "manual",
"localizations" : {
@@ -2179,72 +2247,72 @@
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Confirma el nom"
"value" : "Confirma el nom:"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Name bestätigen"
"value" : "Name bestätigen:"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Confirm Name"
"value" : "Confirm Name:"
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vahvista nimi"
"value" : "Vahvista nimi:"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Confirmer le nom"
"value" : "Confirmer le nom :"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conferma nome"
"value" : "Conferma nome:"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "名前の確認"
"value" : "名前の確認:"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "확인 이름"
"value" : "확인 이름:"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Powtórz nazwę"
"value" : "Powtórz nazwę:"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Confirmar Nome"
"value" : "Confirmar Nome:"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Подтвердить название"
"value" : "Подтвердить название:"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "确认名称"
}
}
@@ -2481,148 +2549,6 @@
}
}
},
"edit_cancel_button" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel·la"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abbrechen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Annuler"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Annulla"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "キャンセル"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "취소"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Anuluj"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancelar"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Отменить"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "返回"
}
}
}
},
"edit_save_button" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Canvia el nom"
}
},
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Umbenennen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Save"
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Renommer"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Rinomina"
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "名前の変更"
}
},
"ko" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "이름 변경"
}
},
"pl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Zmień nazwę"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Renomear"
}
},
"ru" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Переименовать"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "重命名"
}
}
}
},
"empty_store_modifiable_click_here_description" : {
"extractionState" : "manual",
"localizations" : {
@@ -3395,6 +3321,219 @@
}
}
},
"rename_cancel_button" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel·la"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abbrechen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Annuler"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Annulla"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "キャンセル"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "취소"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Anuluj"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancelar"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Отменить"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "返回"
}
}
}
},
"rename_rename_button" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Canvia el nom"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Umbenennen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rename"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Renommer"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rinomina"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "名前の変更"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이름 변경"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zmień nazwę"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Renomear"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Переименовать"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "重命名"
}
}
}
},
"rename_title" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Escriu el nou nom per a %1$(secretName)@ baix."
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Gib einen neuen Namen für %1$(secretName)@ ein."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Type your new name for %1$(secretName)@ below."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Saisissez votre nouveau nom pour %1$(secretName)@ ci-dessous."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Digita qui sotto il nuovo nome per %1$(secretName)@."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$(secretName)@の新しい名前を入力してください。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "아래에 %1$(secretName)@의 새 이름을 입력하세요."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Wprowadź nową nazwę dla %1$(secretName)@ poniżej."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Digite o novo nome para %1$(secretName)@ abaixo."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Введите новое название для \"%1$(secretName)@\" ниже."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在此输入密钥串 %1$(secretName)@ 的新名字。"
}
}
}
},
"secret_detail_md5_fingerprint_label" : {
"extractionState" : "manual",
"localizations" : {
@@ -3750,72 +3889,72 @@
}
}
},
"secret_list_edit_button" : {
"secret_list_rename_button" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Canvia el nom"
}
},
"de" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Umbenennen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Edit"
"value" : "Rename"
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Renommer"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Rinomina"
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "名前を変更"
}
},
"ko" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "이름 변경"
}
},
"pl" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Zmień nazwę"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Renomear"
}
},
"ru" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Переименовать"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "重命名"
}
}

View File

@@ -36,47 +36,47 @@ let package = Package(
name: "SecretKit",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
swiftSettings: swiftSettings
),
.testTarget(
name: "SecretKitTests",
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
swiftSettings: swiftSettings,
swiftSettings: swiftSettings
),
.target(
name: "SecureEnclaveSecretKit",
dependencies: ["SecretKit"],
resources: [localization],
swiftSettings: swiftSettings,
swiftSettings: swiftSettings
),
.target(
name: "SmartCardSecretKit",
dependencies: ["SecretKit"],
resources: [localization],
swiftSettings: swiftSettings,
swiftSettings: swiftSettings
),
.target(
name: "SecretAgentKit",
dependencies: ["SecretKit", "SecretAgentKitHeaders"],
resources: [localization],
swiftSettings: swiftSettings,
swiftSettings: swiftSettings
),
.systemLibrary(
name: "SecretAgentKitHeaders",
name: "SecretAgentKitHeaders"
),
.testTarget(
name: "SecretAgentKitTests",
dependencies: ["SecretAgentKit"],
),
dependencies: ["SecretAgentKit"])
,
.target(
name: "Brief",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
swiftSettings: swiftSettings
),
.testTarget(
name: "BriefTests",
dependencies: ["Brief"],
dependencies: ["Brief"]
),
]
)

View File

@@ -9,8 +9,8 @@ public final class Agent: Sendable {
private let storeList: SecretStoreList
private let witness: SigningWitness?
private let publicKeyWriter = OpenSSHPublicKeyWriter()
private let signatureWriter = OpenSSHSignatureWriter()
private let writer = OpenSSHKeyWriter()
private let requestTracer = SigningRequestTracer()
private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
@@ -33,26 +33,28 @@ extension Agent {
/// Handles an incoming request.
/// - Parameters:
/// - data: The data to handle.
/// - provenance: The origin of the request.
/// - Returns: A response data payload.
public func handle(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
/// - reader: A ``FileHandleReader`` to read the content of the request.
/// - writer: A ``FileHandleWriter`` to write the response to.
/// - Return value:
/// - Boolean if data could be read
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) async -> Bool {
logger.debug("Agent handling new data")
guard data.count > 4 else {
throw InvalidDataProvidedError()
}
let data = Data(reader.availableData)
guard data.count > 4 else { return false}
let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return SSHAgent.ResponseType.agentFailure.data.lengthAndData
return true
}
logger.debug("Agent handling request of type \(requestType.debugDescription)")
let subData = Data(data[5...])
let response = await handle(requestType: requestType, data: subData, provenance: provenance)
return response
let response = await handle(requestType: requestType, data: subData, reader: reader)
writer.write(response)
return true
}
private func handle(requestType: SSHAgent.RequestType, data: Data, provenance: SigningRequestProvenance) async -> Data {
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) 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()
@@ -63,6 +65,7 @@ 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)")
@@ -72,7 +75,8 @@ extension Agent {
response.append(SSHAgent.ResponseType.agentFailure.data)
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
}
return response.lengthAndData
let full = OpenSSHKeyWriter().lengthAndData(of: response)
return full
}
}
@@ -84,18 +88,18 @@ extension Agent {
func identities() async -> Data {
let secrets = await storeList.allSecrets
await certificateHandler.reloadCertificates(for: secrets)
var count = 0
var count = secrets.count
var keyData = Data()
for secret in secrets {
let keyBlob = publicKeyWriter.data(secret: secret)
keyData.append(keyBlob.lengthAndData)
keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
count += 1
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))
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
keyData.append(certificateData.lengthAndData)
keyData.append(name.lengthAndData)
keyData.append(writer.lengthAndData(of: certificateData))
keyData.append(writer.lengthAndData(of: name))
count += 1
}
}
@@ -114,7 +118,6 @@ 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
@@ -122,18 +125,60 @@ extension Agent {
hash = payloadHash
}
guard let (secret, store) = await secret(matching: hash) else {
guard let (store, secret) = await secret(matching: hash) else {
logger.debug("Agent did not have a key matching \(hash as NSData)")
throw NoMatchingKeyError()
throw AgentError.noMatchingKey
}
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
if let witness = witness {
try await witness.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
}
let dataToSign = reader.readNextChunk()
let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
let signed = try await store.sign(data: dataToSign, with: secret, for: provenance)
let derSignature = signed
try await witness?.witness(accessTo: secret, from: store, by: provenance)
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)
}
logger.debug("Agent signed request")
@@ -158,10 +203,16 @@ 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 -> (AnySecret, AnySecretStore)? {
await storeList.allSecretsWithStores.first {
hash == publicKeyWriter.data(secret: $0.0)
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)
}
}
return nil
}
}
@@ -169,8 +220,13 @@ extension Agent {
extension Agent {
struct InvalidDataProvidedError: Error {}
struct NoMatchingKeyError: Error {}
/// An error involving agent operations..
enum AgentError: Error {
case unhandledType
case noMatchingKey
case unsupportedKeyType
case notOpenSSHCertificate
}
}

View File

@@ -1,32 +1,23 @@
import Foundation
import OSLog
import SecretKit
/// A controller that manages socket configuration and request dispatching.
public struct SocketController {
public final class SocketController {
/// 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.
/// 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.
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")
@@ -34,104 +25,25 @@ public struct SocketController {
let exists = FileManager.default.fileExists(atPath: path)
assert(!exists)
logger.debug("Socket controller path is clear")
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])
port = socketPort(at: path)
configureSocket(at: path)
logger.debug("Socket listening at \(path)")
}
}
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()
}
/// 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])
}
}
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) {
/// Creates a SocketPort for a path.
/// - Parameter path: The path to use as a socket.
/// - Returns: A configured SocketPort.
func socketPort(at path: String) -> SocketPort {
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
@@ -149,7 +61,51 @@ private extension SocketPort {
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
}
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
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 = logger] in
if((await handler?(new, new)) == true) {
logger.debug("Socket controller handled data, wait for more data")
await new.waitForDataInBackgroundAndNotifyOnMainActor()
} else {
logger.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)
}
}

View File

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

View File

@@ -3,28 +3,34 @@ import Foundation
/// Type eraser for Secret.
public struct AnySecret: Secret, @unchecked Sendable {
public let base: any Secret
let base: Any
private let hashable: AnyHashable
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
base = secret as Any
self.hashable = 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 }
}
}
@@ -36,20 +42,28 @@ 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._eq(rhs)
lhs.hashable == rhs.hashable
}
public func hash(into hasher: inout Hasher) {
id.hash(into: &hasher)
hashable.hash(into: &hasher)
}
}

View File

@@ -1,9 +1,10 @@
import Foundation
import Combine
/// Type eraser for SecretStore.
open class AnySecretStore: SecretStore, @unchecked Sendable {
public class AnySecretStore: SecretStore, @unchecked Sendable {
let base: any SecretStore
let base: any Sendable
private let _isAvailable: @MainActor @Sendable () -> Bool
private let _id: @Sendable () -> UUID
private let _name: @MainActor @Sendable () -> String
@@ -61,34 +62,27 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
private let _create: @Sendable (String, Attributes) async throws -> AnySecret
private let _create: @Sendable (String, Bool) async throws -> Void
private let _delete: @Sendable (AnySecret) async throws -> Void
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
private let _supportedKeyTypes: @Sendable () -> [KeyType]
private let _update: @Sendable (AnySecret, String) async throws -> Void
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { try await secretStore.create(name: $0, requiresAuthentication: $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, attributes: $2) }
_supportedKeyTypes = { secretStore.supportedKeyTypes }
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1) }
super.init(secretStore)
}
@discardableResult
public func create(name: String, attributes: Attributes) async throws -> SecretType {
try await _create(name, attributes)
public func create(name: String, requiresAuthentication: Bool) async throws {
try await _create(name, requiresAuthentication)
}
public func delete(secret: AnySecret) async throws {
try await _delete(secret)
}
public func update(secret: AnySecret, name: String, attributes: Attributes) async throws {
try await _update(secret, name, attributes)
}
public var supportedKeyTypes: [KeyType] {
_supportedKeyTypes()
public func update(secret: AnySecret, name: String) async throws {
try await _update(secret, name)
}
}

View File

@@ -51,17 +51,19 @@ 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) -> SecKeyAlgorithm? {
switch secret.keyType {
case .ecdsa256:
.ecdsaSignatureMessageX962SHA256
case .ecdsa384:
.ecdsaSignatureMessageX962SHA384
case .rsa2048:
.rsaSignatureMessagePKCS1v15SHA512
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
default:
nil
fatalError()
}
}

View File

@@ -1,23 +0,0 @@
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
}
}

View File

@@ -6,7 +6,7 @@ public actor OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHPublicKeyWriter()
private let writer = OpenSSHKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler.
@@ -31,6 +31,7 @@ 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",
@@ -39,10 +40,10 @@ public actor OpenSSHCertificateHandler: Sendable {
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
return openSSHIdentifier.lengthAndData +
curveIdentifier.lengthAndData +
publicKey.lengthAndData
let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)!
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
default:
return nil
}
@@ -77,13 +78,14 @@ public actor OpenSSHCertificateHandler: Sendable {
throw OpenSSHCertificateError.parsingFailed
}
if certElements.count >= 3 {
let certName = Data(certElements[2].utf8)
if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) {
return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) {
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
let certName = Data(secret.name.utf8)
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
}
}

View File

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

View File

@@ -1,114 +0,0 @@
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
}
}

View File

@@ -17,7 +17,9 @@ public final class OpenSSHReader {
let lengthRange = 0..<(UInt32.bitWidth/8)
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)
let littleEndianLength = lengthChunk.bytes.unsafeLoad(as: UInt32.self)
let littleEndianLength = lengthChunk.withUnsafeBytes { pointer in
return pointer.load(as: UInt32.self)
}
let length = Int(littleEndianLength.bigEndian)
let dataRange = 0..<length
let ret = Data(remaining[dataRange])

View File

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

View File

@@ -6,7 +6,7 @@ public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let directory: String
private let keyWriter = OpenSSHPublicKeyWriter()
private let keyWriter = OpenSSHKeyWriter()
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: String) {
@@ -32,7 +32,7 @@ public final class PublicKeyFileStoreController: Sendable {
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
for secret in secrets {
let path = publicKeyPath(for: secret)
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
}
logger.log("Finished writing public keys")

View File

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

View File

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

View File

@@ -5,81 +5,43 @@ 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 }
}
public extension Secret {
/// The type of algorithm the Secret uses. Currently, only elliptic curve algorithms are supported.
public enum Algorithm: Hashable, Sendable {
/// 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
}
case ellipticCurve
case rsa
/// Initializes the Algorithm with a secAttr representation of an algorithm.
/// - Parameter secAttr: the secAttr, represented as an NSNumber.
public init?(secAttr: NSNumber, size: Int) {
public init(secAttr: NSNumber) {
let secAttrString = secAttr.stringValue as CFString
switch secAttrString {
case kSecAttrKeyTypeEC:
algorithm = .ecdsa
self = .ellipticCurve
case kSecAttrKeyTypeRSA:
algorithm = .rsa
self = .rsa
default:
return nil
fatalError()
}
self.size = size
}
public var secAttrKeyType: CFString? {
switch algorithm {
case .ecdsa:
kSecAttrKeyTypeEC
public var secAttrKeyType: CFString {
switch self {
case .ellipticCurve:
return kSecAttrKeyTypeEC
case .rsa:
kSecAttrKeyTypeRSA
case .mldsa:
nil
return kSecAttrKeyTypeRSA
}
}
public var description: String {
"\(algorithm)-\(size)"
}
}

View File

@@ -1,7 +1,8 @@
import Foundation
import Combine
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore<SecretType>: Identifiable, Sendable {
public protocol SecretStore: Identifiable, Sendable {
associatedtype SecretType: Secret
@@ -41,14 +42,13 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
}
/// A SecretStore that the Secretive admin app can modify.
public protocol SecretStoreModifiable<SecretType>: SecretStore {
public protocol SecretStoreModifiable: SecretStore {
/// Creates a new ``Secret`` in the store.
/// - Parameters:
/// - name: The user-facing name for the ``Secret``.
/// - attributes: A struct describing the options for creating the key.'
@discardableResult
func create(name: String, attributes: Attributes) async throws -> SecretType
/// - 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
/// Deletes a Secret in the store.
/// - Parameters:
@@ -59,10 +59,7 @@ public protocol SecretStoreModifiable<SecretType>: SecretStore {
/// - Parameters:
/// - secret: The ``Secret`` to update.
/// - name: The new name for the Secret.
/// - attributes: The new attributes for the secret.
func update(secret: SecretType, name: String, attributes: Attributes) async throws
var supportedKeyTypes: [KeyType] { get }
func update(secret: SecretType, name: String) async throws
}

View File

@@ -1,99 +0,0 @@
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 migrated = 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
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)
migrated = true
}
if migrated {
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
}
}

View File

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

View File

@@ -2,13 +2,12 @@ import Foundation
import Observation
import Security
import CryptoKit
@preconcurrency import LocalAuthentication
import LocalAuthentication
import SecretKit
import os
extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave using CryptoKit API.
/// An implementation of Store backed by the Secure Enclave.
@Observable public final class Store: SecretStoreModifiable {
@MainActor public var secrets: [Secret] = []
@@ -23,69 +22,118 @@ extension SecureEnclave {
@MainActor public init() {
loadSecrets()
Task {
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.
return
}
reloadSecrets()
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false)
}
}
}
// MARK: - Public API
// MARK: SecretStore
// 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()
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
var context: LAContext
let context: LAContext
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = existing.context
} else {
let newContext = LAContext()
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: secret.id,
kSecReturnAttributes: true,
kSecReturnData: true,
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: 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
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(queryAttributes, &untyped)
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped as? [CFString: Any] else {
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
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)
let key = untypedSafe as! SecKey
var signError: SecurityError?
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()
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
@@ -96,197 +144,114 @@ extension SecureEnclave {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
@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)
}
public func reloadSecrets() async {
await reloadSecretsInternal(notifyAgent: false)
}
// 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 queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecReturnData: true,
let publicAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
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
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: .unnamedSecret)
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
}
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
}
secrets.append(contentsOf: wrapped)
}
/// Saves a public key.
/// - Parameters:
/// - key: The data representation key to save.
/// - publicKey: The public key to save.
/// - name: A user-facing name for the key.
/// - 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)
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)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
return id
}
}
extension SecureEnclave.Store {
extension SecureEnclave {
enum Constants {
static let keyClass = kSecClassGenericPassword as String
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
static let notificationToken = UUID().uuidString
static let keyType = kSecAttrKeyTypeECSECPrimeRandom as String
static let unauthenticatedThreshold: TimeInterval = 0.05
}
struct UnsupportedAlgorithmError: Error {}
struct MissingAttributesError: Error {}
}

View File

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

View File

@@ -1,7 +1,7 @@
import Foundation
import Observation
import Security
@preconcurrency import CryptoTokenKit
import CryptoTokenKit
import LocalAuthentication
import SecretKit
@@ -23,9 +23,6 @@ extension SmartCard {
public var isAvailable: Bool {
state.isAvailable
}
@MainActor public var smartcardTokenID: String? {
state.tokenID
}
public let id = UUID()
@MainActor public var name: String {
@@ -37,18 +34,17 @@ extension SmartCard {
/// Initializes a Store.
public init() {
Task {
await MainActor.run {
if let tokenID = smartcardTokenID {
state.isAvailable = true
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
}
loadSecrets()
Task { @MainActor in
if let tokenID = state.tokenID {
state.isAvailable = true
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
}
// 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
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.
Task {
await self.smartcardInserted(for: id)
self.smartcardInserted(for: id)
}
}
}
@@ -56,6 +52,14 @@ 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()
@@ -79,8 +83,7 @@ extension SmartCard {
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
guard let signature = SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
@@ -117,13 +120,12 @@ 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
reloadSecretsInternal()
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
}
/// Resets the token ID and reloads secrets.
@@ -154,25 +156,104 @@ extension SmartCard.Store {
var untyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped: [SecretType] = typed.compactMap {
let wrapped = typed.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithmSecAttr = $0[kSecAttrKeyType] as! NSNumber
let algorithm = Algorithm(secAttr: $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
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
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
}
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: .authContextRequestEncryptDescription(secretName: secret.name))
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
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: .authContextRequestDecryptDescription(secretName: secret.name))
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
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.
@@ -181,9 +262,3 @@ extension TKTokenWatcher {
}
}
extension SmartCard {
public struct UnsupportKeyType: Error {}
}

View File

@@ -6,77 +6,81 @@ import CryptoKit
@Suite struct AgentTests {
let stubWriter = StubFileHandleWriter()
// MARK: Identity Listing
// let testProvenance = SigningRequestProvenance(root: .init(pid: 0, processName: "Test", appName: "Test", iconURL: nil, path: /, validSignature: true, parentPID: nil))
@Test func emptyStores() async throws {
@Test func emptyStores() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
let agent = Agent(storeList: SecretStoreList())
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
#expect(response == Constants.Responses.requestIdentitiesEmpty)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestIdentitiesEmpty)
}
@Test func identitiesList() async throws {
@Test func identitiesList() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
#expect(response == Constants.Responses.requestIdentitiesMultiple)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
}
// MARK: Signatures
@Test func noMatchingIdentities() async throws {
@Test func noMatchingIdentities() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
let response = try await agent.handle(data: Constants.Requests.requestSignatureWithNoneMatching, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure)
}
// @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))
// }
@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)
// Correct signature
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
.isValidSignature(signature, for: dataToSign))
}
// MARK: Witness protocol
@Test func witnessObjectionStopsRequest() async throws {
@Test func witnessObjectionStopsRequest() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let witness = StubWitness(speakNow: { _,_ in
return true
}, witness: { _, _ in })
let agent = Agent(storeList: list, witness: witness)
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure)
}
@Test func witnessSignature() async throws {
@Test func witnessSignature() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
nonisolated(unsafe) var witnessed = false
let witness = StubWitness(speakNow: { _, trace in
@@ -85,11 +89,12 @@ import CryptoKit
witnessed = true
})
let agent = Agent(storeList: list, witness: witness)
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(witnessed)
}
@Test func requestTracing() async throws {
@Test func requestTracing() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
@@ -100,38 +105,36 @@ import CryptoKit
witnessTrace = trace
})
let agent = Agent(storeList: list, witness: witness)
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(witnessTrace == speakNowTrace)
#expect(witnessTrace == .test)
#expect(witnessTrace?.origin.displayName == "Finder")
#expect(witnessTrace?.origin.validSignature == true)
#expect(witnessTrace?.origin.parentPID == 1)
}
// MARK: Exception Handling
@Test func signatureException() async throws {
@Test func signatureException() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
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)
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure)
}
// MARK: Unsupported
@Test func unhandledAdd() async throws {
@Test func unhandledAdd() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.addIdentity)
let agent = Agent(storeList: SecretStoreList())
let response = try await agent.handle(data: Constants.Requests.addIdentity, provenance: .test)
#expect(response == Constants.Responses.requestFailure)
await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == 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 {

View File

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

View File

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

View File

@@ -45,15 +45,20 @@ extension Stub {
let privateData = (privateAttributes[kSecValueData] as! Data)
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
print(secret)
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
print("Public Key OpenSSH: \(OpenSSHKeyWriter().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 = try CryptoKit.P256.Signing.PrivateKey(x963Representation: secret.privateKey)
return try privateKey.signature(for: data).rawRepresentation
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 existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
@@ -74,22 +79,24 @@ extension Stub {
struct Secret: SecretKit.Secret, CustomDebugStringConvertible {
let id = Data(UUID().uuidString.utf8)
let id = UUID().uuidString.data(using: .utf8)!
let name = UUID().uuidString
let attributes: Attributes
let algorithm = Algorithm.ellipticCurve
let keySize: Int
let publicKey: Data
let requiresAuthentication = false
let privateKey: Data
init(keySize: Int, publicKey: Data, privateKey: Data) {
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired)
self.keySize = keySize
self.publicKey = publicKey
self.privateKey = privateKey
}
var debugDescription: String {
"""
Key Size \(attributes.keyType.size)
Key Size \(keySize)
Private: \(privateKey.base64EncodedString())
Public: \(publicKey.base64EncodedString())
"""

View File

@@ -4,16 +4,15 @@ import Testing
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct AnySecretTests {
@Test func eraser() {
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 secret = SmartCard.Secret(id: UUID().uuidString.data(using: .utf8)!, name: "Name", algorithm: .ellipticCurve, keySize: 256, publicKey: UUID().uuidString.data(using: .utf8)!)
let erased = AnySecret(secret)
#expect(erased.id == secret.id as AnyHashable)
#expect(erased.name == secret.name)
#expect(erased.keyType == secret.keyType)
#expect(erased.algorithm == secret.algorithm)
#expect(erased.keySize == secret.keySize)
#expect(erased.publicKey == secret.publicKey)
}

View File

@@ -4,9 +4,9 @@ import Testing
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHPublicKeyWriterTests {
@Suite struct OpenSSHWriterTests {
let writer = OpenSSHPublicKeyWriter()
let writer = OpenSSHKeyWriter()
@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= test@example.com")
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")
}
@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== test@example.com")
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")
}
@Test func ecdsa384Hash() {
@@ -44,11 +44,11 @@ import Testing
}
extension OpenSSHPublicKeyWriterTests {
extension OpenSSHWriterTests {
enum Constants {
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let 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==")!)
}

View File

@@ -1,5 +1,6 @@
import Cocoa
import OSLog
import Combine
import SecretKit
import SecureEnclaveSecretKit
import SmartCardSecretKit
@@ -12,10 +13,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor private let storeList: SecretStoreList = {
let list = SecretStoreList()
let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
@@ -29,22 +27,14 @@ 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 {
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 { @MainActor in
socketController.handler = { [agent] reader, writer in
await agent.handle(reader: reader, writer: writer)
}
}
Task {
@@ -58,7 +48,6 @@ 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)
}

View File

@@ -69,7 +69,7 @@ final class Notifier: Sendable {
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.authenticationRequirement.required {
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.requiresAuthentication {
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
}
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {

View File

@@ -7,7 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */; };
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.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 */; };
@@ -52,7 +52,6 @@
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -99,7 +98,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditSecretView.swift; sourceTree = "<group>"; };
2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameSecretView.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>"; };
@@ -141,7 +140,6 @@
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>"; };
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 */
@@ -243,13 +241,12 @@
children = (
50617D8423FCE48E0099B055 /* ContentView.swift */,
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */,
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
50153E21250DECA300525160 /* SecretListItemView.swift */,
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */,
50B8550C24138C4F009958AC /* DeleteSecretView.swift */,
2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */,
2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */,
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */,
506772C82425BB8500034DED /* NoStoresView.swift */,
50153E1F250AFCB200525160 /* UpdateView.swift */,
@@ -433,12 +430,11 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */,
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */,
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */,
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */,

View File

@@ -1,3 +1,4 @@
import Cocoa
import SwiftUI
import SecretKit
import SecureEnclaveSecretKit
@@ -9,10 +10,7 @@ extension EnvironmentValues {
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
let list = SecretStoreList()
let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()

View File

@@ -1,4 +1,5 @@
import Foundation
import Combine
import AppKit
import SecretKit
import Observation

View File

@@ -1,4 +1,5 @@
import Foundation
import Combine
import AppKit
protocol JustUpdatedCheckerProtocol: Observable {

View File

@@ -56,7 +56,7 @@ struct ShellConfigurationController {
} catch {
return false
}
handle.write(Data("\n# Secretive Config\n\(shellInstructions.text)\n".utf8))
handle.write("\n# Secretive Config\n\(shellInstructions.text)\n".data(using: .utf8)!)
return true
}

View File

@@ -1,4 +1,5 @@
import Foundation
import Combine
class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {

View File

@@ -9,13 +9,11 @@ extension Preview {
let id = UUID().uuidString
let name: String
let publicKey = Data(UUID().uuidString.utf8)
var attributes: Attributes {
Attributes(
keyType: .init(algorithm: .ecdsa, size: 256),
authentication: .presenceRequired,
)
}
let algorithm = Algorithm.ellipticCurve
let keySize = 256
let requiresAuthentication: Bool = false
let publicKey = UUID().uuidString.data(using: .utf8)!
}
}
@@ -60,18 +58,7 @@ 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
}
@@ -96,14 +83,13 @@ extension Preview {
}
func create(name: String, attributes: Attributes) throws -> Secret {
fatalError()
func create(name: String, requiresAuthentication: Bool) throws {
}
func delete(secret: Preview.Secret) throws {
}
func update(secret: Preview.Secret, name: String, attributes: Attributes) throws {
func update(secret: Preview.Secret, name: String) throws {
}
}
}

View File

@@ -1,24 +0,0 @@
import SwiftUI
struct PrimaryButtonModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme
func body(content: Content) -> some View {
// Tinted glass prominent is really hard to read on 26.0.
if #available(macOS 26.0, *), colorScheme == .dark {
content.buttonStyle(.glassProminent)
} else {
content.buttonStyle(.borderedProminent)
}
}
}
extension View {
func primary() -> some View {
modifier(PrimaryButtonModifier())
}
}

View File

@@ -30,7 +30,7 @@ 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")

View File

@@ -7,130 +7,244 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
@Binding var showing: Bool
@State private var name = ""
@State private var keyAttribution = ""
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
@State private var keyType: KeyType?
@State var advanced = false
private var authenticationOptions: [AuthenticationRequirement] {
if advanced || authenticationRequirement == .biometryCurrent {
[.presenceRequired, .notRequired, .biometryCurrent]
} else {
[.presenceRequired, .notRequired]
}
}
@State private var requiresAuthentication = true
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))
}
VStack {
HStack {
VStack {
HStack {
Text(.createSecretTitle)
.font(.largeTitle)
Spacer()
}
}
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)
}
HStack {
Text(.createSecretNameLabel)
TextField(String(localized: .createSecretNamePlaceholder), text: $name)
.focusable()
}
ThumbnailPickerView(items: [
ThumbnailPickerView.Item(value: true, name: .createSecretRequireAuthenticationTitle, description: .createSecretRequireAuthenticationDescription, thumbnail: AuthenticationView()),
ThumbnailPickerView.Item(value: false, name: .createSecretNotifyTitle,
description: .createSecretNotifyDescription,
thumbnail: NotificationView())
], selection: $requiresAuthentication)
}
}
HStack {
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
.toggleStyle(.button)
Spacer()
Button(.createSecretCancelButton, role: .cancel) {
Button(.createSecretCancelButton) {
showing = false
}
.keyboardShortcut(.cancelAction)
Button(.createSecretCreateButton, action: save)
.primary()
.disabled(name.isEmpty)
.keyboardShortcut(.defaultAction)
}
.padding()
}
.onAppear {
keyType = store.supportedKeyTypes.first
}
.formStyle(.grouped)
}.padding()
}
func save() {
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
Task {
try! await store.create(
name: name,
attributes: .init(
keyType: keyType!,
authentication: authenticationRequirement,
publicKeyAttribution: attribution
)
)
try! await store.create(name: name, requiresAuthentication: requiresAuthentication)
showing = false
}
}
}
#Preview {
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
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: LocalizedStringResource
let description: LocalizedStringResource
let thumbnail: AnyView
init<ViewType: View>(value: InnerValueType, name: LocalizedStringResource, description: LocalizedStringResource, 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

View File

@@ -1,60 +1,57 @@
import SwiftUI
import SecretKit
extension View {
struct DeleteSecretView<StoreType: SecretStoreModifiable>: 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?
@State var store: StoreType
let secret: StoreType.SecretType
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)
.foregroundStyle(.red)
.font(.callout)
@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(.deleteConfirmationTitle(secretName: secret.name)).bold()
Spacer()
}
Button(.deleteConfirmationDeleteButton, action: delete)
.disabled(confirmedSecretName != secret.name)
Button(.deleteConfirmationCancelButton, role: .cancel) {
dismissalBlock(false)
HStack {
Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name))
Spacer()
}
HStack {
Text(.deleteConfirmationConfirmNameLabel)
TextField(secret.name, text: $confirm)
}
},
message: {
Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name))
}
)
.dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill"))
.onExitCommand {
dismissalBlock(false)
}
HStack {
Spacer()
Button(.deleteConfirmationDeleteButton, action: delete)
.disabled(confirm != secret.name)
Button(.deleteConfirmationCancelButton) {
dismissalBlock(false)
}
.keyboardShortcut(.cancelAction)
}
}
.padding()
.frame(minWidth: 400)
.onExitCommand {
dismissalBlock(false)
}
}
func delete() {
Task {
do {
try await store!.delete(secret: secret)
dismissalBlock(true)
} catch {
errorText = error.localizedDescription
}
try! await store.delete(secret: secret)
dismissalBlock(true)
}
}

View File

@@ -1,66 +0,0 @@
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)
}
}
if let errorText {
Text(verbatim: errorText)
.foregroundStyle(.red)
.font(.callout)
}
}
HStack {
Button(.editSaveButton, action: rename)
.disabled(name.isEmpty)
.keyboardShortcut(.return)
Button(.editCancelButton) {
dismissalBlock(false)
}
.keyboardShortcut(.cancelAction)
}
.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
}
}
}
}

View File

@@ -0,0 +1,52 @@
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(.renameTitle(secretName: secret.name))
Spacer()
}
HStack {
TextField(secret.name, text: $newName).focusable()
}
}
}
HStack {
Spacer()
Button(.renameRenameButton, action: rename)
.disabled(newName.count == 0)
.keyboardShortcut(.return)
Button(.renameCancelButton) {
dismissalBlock(false)
}.keyboardShortcut(.cancelAction)
}
}
.padding()
.frame(minWidth: 400)
.onExitCommand {
dismissalBlock(false)
}
}
func rename() {
Task {
try? await store.update(secret: secret, name: newName)
dismissalBlock(true)
}
}
}

View File

@@ -5,7 +5,7 @@ struct SecretDetailView<SecretType: Secret>: View {
let secret: SecretType
private let keyWriter = OpenSSHPublicKeyWriter()
private let keyWriter = OpenSSHKeyWriter()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID))
var body: some View {
@@ -30,9 +30,19 @@ struct SecretDetailView<SecretType: Secret>: View {
.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)
keyWriter.openSSHString(secret: secret, comment: "\(dashedKeyName)@\(dashedHostName)")
}
}

View File

@@ -12,9 +12,21 @@ 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.authenticationRequirement.required {
if secret.requiresAuthentication {
HStack {
Text(secret.name)
Spacer()
@@ -27,26 +39,28 @@ struct SecretListItemView: View {
.contextMenu {
if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) {
Image(systemName: "pencil")
Text(.secretListEditButton)
Text(.secretListRenameButton)
}
Button(action: { isDeleting = true }) {
Image(systemName: "trash")
Text(.secretListDeleteButton)
}
}
}
.showingDeleteConfirmation(isPresented: $isDeleting, secret, store as? AnySecretStoreModifiable) { deleted in
if deleted {
deletedSecret(secret)
}
}
.sheet(isPresented: $isRenaming) {
.popover(isPresented: showingPopup) {
if let modifiable = store as? AnySecretStoreModifiable {
EditSecretView(store: modifiable, secret: secret) { renamed in
isRenaming = false
if renamed {
renamedSecret(secret)
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)
}
}
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI
import Combine
import SecretKit
struct StoreListView: View {
@@ -12,8 +13,6 @@ struct StoreListView: View {
}
private func secretRenamed(secret: AnySecret) {
// Toggle so name updates in list.
activeSecret = nil
activeSecret = secret
}
@@ -58,7 +57,7 @@ struct StoreListView: View {
extension StoreListView {
private var nextDefaultSecret: AnySecret? {
return storeList.allSecrets.first
return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
}
}