diff --git a/.github/readme/app-dark.png b/.github/readme/app-dark.png index c7f5e8d..68bf4eb 100644 Binary files a/.github/readme/app-dark.png and b/.github/readme/app-dark.png differ diff --git a/.github/readme/app-light.png b/.github/readme/app-light.png index d0ecb89..d231aaa 100644 Binary files a/.github/readme/app-light.png and b/.github/readme/app-light.png differ diff --git a/.github/readme/notification.png b/.github/readme/notification.png index 069ee07..47d53fc 100644 Binary files a/.github/readme/notification.png and b/.github/readme/notification.png differ diff --git a/.github/templates/release.md b/.github/templates/release.md new file mode 100644 index 0000000..c406f69 --- /dev/null +++ b/.github/templates/release.md @@ -0,0 +1,16 @@ +Update description + +## Features + + +## Fixes + + +## Minimum macOS Version + + +## Build +https://github.com/maxgoedjen/secretive/actions/runs/RUN_ID + +## Attestation +https://github.com/maxgoedjen/secretive/attestations/ATTESTATION_ID diff --git a/.github/workflows/add-to-project.yml b/.github/workflows/add-to-project.yml deleted file mode 100644 index 9bfc6e0..0000000 --- a/.github/workflows/add-to-project.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Add bugs to bugs project - -on: - issues: - types: - - opened - -jobs: - add-to-project: - name: Add issue to project - runs-on: ubuntu-latest - steps: - - uses: actions/add-to-project@v1.0.1 - with: - project-url: https://github.com/users/maxgoedjen/projects/1 - github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..d5fb7f3 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,47 @@ +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '26 15 * * 3' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + security-events: write + packages: read + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + # Disable this until CodeQL supports Xcode 26 builds. + # - language: swift + # build-mode: manual + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - if: matrix.build-mode == 'manual' + name: "Select Xcode" + run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app + - if: matrix.build-mode == 'manual' + name: "Build" + run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ab92a74..2ffe90a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -3,13 +3,18 @@ name: Nightly on: schedule: - cron: "0 8 * * *" + workflow_dispatch: + jobs: build: -# runs-on: macOS-latest runs-on: macos-15 + permissions: + id-token: write + contents: write + attestations: write timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Signing env: SIGNING_DATA: ${{ secrets.SIGNING_DATA }} @@ -20,35 +25,34 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} run: ./.github/scripts/signing.sh - name: Set Environment - run: sudo xcrun xcode-select -s /Applications/Xcode_16.4.app + run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app - name: Update Build Number env: RUN_ID: ${{ github.run_id }} run: | - sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig + DATE=$(date "+%Y-%m-%d") + sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0_nightly-$DATE/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf - name: Build run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive - - name: Create ZIPs + - name: Create ZIP run: | ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip - ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip - name: Notarize env: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip - - name: Document SHAs - run: | - echo "sha-512:" - shasum -a 512 Secretive.zip - shasum -a 512 Archive.zip - echo "sha-256:" - shasum -a 256 Secretive.zip - shasum -a 256 Archive.zip - name: Upload App to Artifacts + id: upload uses: actions/upload-artifact@v4 with: name: Secretive.zip path: Secretive.zip + - name: Attest + id: attest + uses: actions/attest-build-provenance@v2 + with: + subject-name: "Secretive.zip" + subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be7e6d0..66cf202 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,11 +6,12 @@ on: - '*' jobs: test: -# runs-on: macOS-latest + permissions: + contents: read runs-on: macos-15 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Signing env: SIGNING_DATA: ${{ secrets.SIGNING_DATA }} @@ -21,18 +22,20 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} run: ./.github/scripts/signing.sh - name: Set Environment - run: sudo xcrun xcode-select -s /Applications/Xcode_16.4.app + run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app - name: Test - run: | - pushd Sources/Packages - swift test - popd + run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test + # SPM doesn't seem to pick up on the tests currently? + # run: swift test --build-system swiftbuild --package-path Sources/Packages build: -# runs-on: macOS-latest + permissions: + id-token: write + contents: write + attestations: write runs-on: macos-15 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Signing env: SIGNING_DATA: ${{ secrets.SIGNING_DATA }} @@ -43,7 +46,7 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} run: ./.github/scripts/signing.sh - name: Set Environment - run: sudo xcrun xcode-select -s /Applications/Xcode_16.4.app + run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app - name: Update Build Number env: TAG_NAME: ${{ github.ref }} @@ -55,64 +58,34 @@ jobs: sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf - name: Build run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive - - name: Create ZIPs + - name: Create ZIP run: | ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip - ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip - name: Notarize env: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip - - name: Document SHAs - run: | - echo "sha-512:" - shasum -a 512 Secretive.zip - shasum -a 512 Archive.zip - echo "sha-256:" - shasum -a 256 Secretive.zip - shasum -a 256 Archive.zip - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - body: | - Update description - - ## Features - - - ## Fixes - - - ## Minimum macOS Version - - - ## Build - https://github.com/maxgoedjen/secretive/actions/runs/${{ github.run_id }} - draft: true - prerelease: false - - name: Upload App to Release - id: upload-release-asset-app - uses: actions/upload-release-asset@v1.0.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./Secretive.zip - asset_name: Secretive.zip - asset_content_type: application/zip - name: Upload App to Artifacts + id: upload uses: actions/upload-artifact@v4 with: name: Secretive.zip path: Secretive.zip - - name: Upload Archive to Artifacts - uses: actions/upload-artifact@v4 + - name: Attest + id: attest + uses: actions/attest-build-provenance@v2 with: - name: Xcode_Archive.zip - path: Archive.zip + subject-name: "Secretive.zip" + subject-digest: ${{ steps.upload.outputs.artifact-digest }} + - name: Create Release + run: | + sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md + sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md + gh release create $TAG_NAME -d -F .github/templates/release.md + gh release upload Secretive.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref }} + RUN_ID: ${{ github.run_id }} + ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da42eb3..0e2f077 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,15 +3,17 @@ name: Test on: [push, pull_request] jobs: test: -# runs-on: macOS-latest + permissions: + contents: read runs-on: macos-15 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set Environment - run: sudo xcrun xcode-select -s /Applications/Xcode_16.4.app - - name: Test - run: | - pushd Sources/Packages - swift test - popd + run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app + - name: Test Main Packages + run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test + # SPM doesn't seem to pick up on the tests currently? + # run: swift test --build-system swiftbuild --package-path Sources/Packages + - name: Test SecretKit Packages + run: swift test --build-system swiftbuild diff --git a/APP_CONFIG.md b/APP_CONFIG.md index 863177a..448d9c5 100644 --- a/APP_CONFIG.md +++ b/APP_CONFIG.md @@ -1,116 +1,3 @@ -# Setting up Third Party Apps FAQ +# App Configuration -## Tower - -Tower provides [instructions](https://www.git-tower.com/help/mac/integration/environment). - -## GitHub Desktop - -Should just work, no configuration needed - -## Fork - -Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow). - -``` -Host * - IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh -``` - -## VS Code - -Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow). - -``` -Host * - IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh -``` - -## nushell - -Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow). - -``` -Host * - IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh -``` - -## Cyberduck - -Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist` - -``` - - - - - Label - link-ssh-auth-sock - ProgramArguments - - /bin/sh - -c - /bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK - - RunAtLoad - - - -``` - -Log out and log in again before launching Cyberduck. - -## Mountain Duck - -Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist` - -``` - - - - - Label - link-ssh-auth-sock - ProgramArguments - - /bin/sh - -c - /bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK - - RunAtLoad - - - -``` - -Log out and log in again before launching Mountain Duck. - -## GitKraken - -Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist` - -``` - - - - - Label - link-ssh-auth-sock - ProgramArguments - - /bin/sh - -c - /bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK - - RunAtLoad - - - -``` - -Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH) - -# The app I use isn't listed here! - -If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome. -If you're not able to get it working, please file a [GitHub issue](https://github.com/maxgoedjen/secretive/issues/new) for it. No guarantees we'll be able to get it working, but chances are someone else in the community might be able to. +Instructions for setting up apps and shells has moved to [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions)! diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8503c33..902fddc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ If you'd like to contribute a translation, please see [Localizing](LOCALIZING.md ## Credits -If you make a material contribution to the app, please add yourself to the end of the [credits](https://github.com/maxgoedjen/secretive/blob/main/Secretive/Credits.rtf). +If you make a material contribution to the app, please add yourself to the end of the [credits](https://github.com/maxgoedjen/secretive/blob/main/Sources/Secretive/Credits.rtf). ## Collaborator Status diff --git a/DESIGN.md b/Design/DESIGN.md similarity index 100% rename from DESIGN.md rename to Design/DESIGN.md diff --git a/Design/Icon.icon/Assets/Icon 7.png b/Design/Icon.icon/Assets/Icon 7.png new file mode 100644 index 0000000..2bfab03 Binary files /dev/null and b/Design/Icon.icon/Assets/Icon 7.png differ diff --git a/Design/Icon.icon/Assets/Rectangle 2 8.png b/Design/Icon.icon/Assets/Rectangle 2 8.png new file mode 100644 index 0000000..7ed1192 Binary files /dev/null and b/Design/Icon.icon/Assets/Rectangle 2 8.png differ diff --git a/Design/Icon.icon/Assets/Rectangle Copy 10.png b/Design/Icon.icon/Assets/Rectangle Copy 10.png new file mode 100644 index 0000000..7b118b6 Binary files /dev/null and b/Design/Icon.icon/Assets/Rectangle Copy 10.png differ diff --git a/Design/Icon.icon/icon.json b/Design/Icon.icon/icon.json new file mode 100644 index 0000000..8c665bb --- /dev/null +++ b/Design/Icon.icon/icon.json @@ -0,0 +1,59 @@ +{ + "fill" : { + "solid" : "srgb:0.00000,0.53333,1.00000,0.00000" + }, + "groups" : [ + { + "blur-material" : 0.5, + "layers" : [ + { + "image-name" : "Icon 7.png", + "name" : "Signature", + "position" : { + "scale" : 1, + "translation-in-points" : [ + 64.00083178971097, + -58.21801551632592 + ] + } + }, + { + "image-name" : "Rectangle Copy 10.png", + "name" : "Border" + }, + { + "fill-specializations" : [ + { + "appearance" : "tinted", + "value" : { + "solid" : "display-p3:0.00000,0.00000,0.00000,0.50000" + } + } + ], + "image-name" : "Rectangle 2 8.png", + "name" : "Backing", + "opacity-specializations" : [ + { + "appearance" : "tinted", + "value" : 1 + } + ] + } + ], + "shadow" : { + "kind" : "layer-color", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : [ + "macOS" + ] + } +} \ No newline at end of file diff --git a/FAQ.md b/FAQ.md index 09f6ee5..2abce27 100644 --- a/FAQ.md +++ b/FAQ.md @@ -6,7 +6,7 @@ The secure enclave doesn't allow import or export of private keys. For any new c ### Secretive doesn't work with my git client/app -Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [App Config FAQ](APP_CONFIG.md). +Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions) repo. ### Secretive isn't working for me diff --git a/LOCALIZING.md b/LOCALIZING.md index b064af7..f17fadd 100644 --- a/LOCALIZING.md +++ b/LOCALIZING.md @@ -2,36 +2,35 @@ If you speak another language, and would like to help translate Secretive to support that language, we'd love your help! -## Getting Started +## Crowdin -### Download Xcode +[Secretive uses Crowdin for localization](https://crowdin.com/project/secretive/). Open the link and select your language to translate! -Download the latest version of Xcode (at minimum, Xcode 15) from [Apple](http://developer.apple.com/download/applications/). +### Manual Translation -### Clone Secretive - -Clone Secretive using [these instructions from GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository). - -### Open Secretive - -Open [Sources/Secretive.xcodeproj](Sources/Secretive.xcodeproj) in Xcode. - -### Translate - -Navigate to [Secretive/Localizable](Sources/Secretive/Localizable.xcstrings). - -Screenshot of Xcode navigating to the Localizable file - -If your language already has an in-progress localization, select it from the list. If it isn't there, hit the "+" button and choose your language from the list. - -Screenshot of Xcode adding a new language - -Start translating! You'll see a list of english phrases, and a space to add a translation of your language. - -### Create a Pull Request - -Push your changes and open a pull request. +Crowdin is the easiest way to translate Secretive, but I'm happy to accept Pull Requests directly as well. ### Questions Please open an issue if you have a question about translating the app. I'm more than happy to clarify any terms that are ambiguous or confusing. Thanks for contributing! + +### Thank You + +Thanks to all the folks who have contributed localizations so far! + +- @mtardy for the French localization +- @GravityRyu for the Chinese localization +- @Saeger for the Portuguese (Brazil) localization +- @moritzsternemann for the German localization +- @RoboRich00A16 for the Italian localization +- @akx for the Finnish localization +- @mog422 for the Korean localization +- @niw for the Japanese localization +- @truita for the Catalan localization +- @Adimac93 for the Polish localization +- @alongotv for the Russian localization + + + + +A special thanks to [Crowdin](https://crowdin.com) for their [generous support of open source projects](https://crowdin.com/page/open-source-project-setup-request). diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..2ba06ef --- /dev/null +++ b/Package.swift @@ -0,0 +1,69 @@ +// swift-tools-version:6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// This is basically the same package as `Sources/Packages/Package.swift`, but thinned slightly. +// Ideally this would be the same package, but SPM requires it to be at the root of the project, +// and Xcode does _not_ like that, so they're separate. +let package = Package( + name: "SecretKit", + defaultLocalization: "en", + platforms: [ + .macOS(.v14) + ], + products: [ + .library( + name: "SecretKit", + targets: ["SecretKit"]), + .library( + name: "SecureEnclaveSecretKit", + targets: ["SecureEnclaveSecretKit"]), + .library( + name: "SmartCardSecretKit", + targets: ["SmartCardSecretKit"]), + ], + dependencies: [ + ], + targets: [ + .target( + name: "SecretKit", + dependencies: [], + path: "Sources/Packages/Sources/SecretKit", + resources: [localization], + swiftSettings: swiftSettings + ), + .testTarget( + name: "SecretKitTests", + dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"], + path: "Sources/Packages/Tests/SecretKitTests", + swiftSettings: swiftSettings + ), + .target( + name: "SecureEnclaveSecretKit", + dependencies: ["SecretKit"], + path: "Sources/Packages/Sources/SecureEnclaveSecretKit", + resources: [localization], + swiftSettings: swiftSettings + ), + .target( + name: "SmartCardSecretKit", + dependencies: ["SecretKit"], + path: "Sources/Packages/Sources/SmartCardSecretKit", + resources: [localization], + swiftSettings: swiftSettings + ), + ] +) + +var localization: Resource { + .process("../../Resources/Localizable.xcstrings") +} + +var swiftSettings: [PackageDescription.SwiftSetting] { + [ + .swiftLanguageMode(.v6), + // This freaks out Xcode in a dependency context. + // .treatAllWarnings(as: .error), + ] +} diff --git a/README.md b/README.md index 50c32b2..66d2b04 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Secretive ![Test](https://github.com/maxgoedjen/secretive/workflows/Test/badge.svg) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg) +# Secretive [![Test](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg) Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app. @@ -49,7 +49,7 @@ There's a [FAQ here](FAQ.md). ### Auditable Build Process -Builds are produced by GitHub Actions with an auditable build and release generation process. Each build has a "Document SHAs" step, which will output SHA checksums for the build produced by the GitHub Action, so you can verify that the source code for a given build corresponds to any given release. +Builds are produced by GitHub Actions with an auditable build and release generation process. Starting with Secretive 3.0, builds are attested using [GitHub Artifact Attestation](https://docs.github.com/en/actions/concepts/security/artifact-attestations). Attestations are viewable in the build log for a build, and also on the [main attestation page](https://github.com/maxgoedjen/secretive/attestations). ### A Note Around Code Signing and Keychains @@ -61,4 +61,4 @@ Because secrets in the Secure Enclave are not exportable, they are not able to b ## Security -If you discover any vulnerabilities in this project, please notify [max.goedjen@gmail.com](mailto:max.goedjen@gmail.com) with the subject containing "SECRETIVE SECURITY." +Secretive's security policy is detailed in [SECURITY.md](SECURITY.md). To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) diff --git a/SECURITY.md b/SECURITY.md index 5541d19..63412c6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,9 +1,27 @@ # Security Policy +## Security Principles + +Secretive is designed with a few general tenets in mind: + +### It's Hard to Leak a Key Secretive Can't Read The Key Material + +Secretive only operates on hardware-backed keys. In general terms, this means that it should be _very_ hard for Secretive to have any sort of bug that causes a key to be shared, because Secretive can't access private key data even if it wants to. + +### Simplicity and Auditability + +Secretive won't expand to have every feature it could possibly have. Part of the goal of the app is that it is possible for consumers to reasonably audit the code, and that often means not implementing features that might be cool, but which would significantly inflate the size of the codebase. + +### Dependencies + +Both in support of the previous principle and to rule out supply chain attacks, Secretive does not rely on any third party dependencies. + +There are limited exceptions to this, particularly in the build process, but the app itself does not depend on any third party code. + ## Supported Versions The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version. ## Reporting a Vulnerability -If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com with the subject containing "SECRETIVE SECURITY." +To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) diff --git a/Sources/Config/Secretive.xctestplan b/Sources/Config/Secretive.xctestplan index fb2bac9..61b77d0 100644 --- a/Sources/Config/Secretive.xctestplan +++ b/Sources/Config/Secretive.xctestplan @@ -13,12 +13,24 @@ }, "testTargets" : [ { - "enabled" : false, - "parallelizable" : true, "target" : { - "containerPath" : "container:Secretive.xcodeproj", - "identifier" : "50617D9323FCE48E0099B055", - "name" : "SecretiveTests" + "containerPath" : "container:Packages", + "identifier" : "BriefTests", + "name" : "BriefTests" + } + }, + { + "target" : { + "containerPath" : "container:Packages", + "identifier" : "SecretKitTests", + "name" : "SecretKitTests" + } + }, + { + "target" : { + "containerPath" : "container:Packages", + "identifier" : "SecretAgentKitTests", + "name" : "SecretAgentKitTests" } } ], diff --git a/Sources/Packages/Package.swift b/Sources/Packages/Package.swift index 9fa196a..4dcb725 100644 --- a/Sources/Packages/Package.swift +++ b/Sources/Packages/Package.swift @@ -1,12 +1,13 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "SecretivePackages", + defaultLocalization: "en", platforms: [ - .macOS(.v12) + .macOS(.v14) ], products: [ .library( @@ -20,13 +21,13 @@ let package = Package( targets: ["SmartCardSecretKit"]), .library( name: "SecretAgentKit", - targets: ["SecretAgentKit"]), - .library( - name: "SecretAgentKitHeaders", - targets: ["SecretAgentKitHeaders"]), + targets: ["SecretAgentKit", "XPCWrappers"]), .library( name: "Brief", targets: ["Brief"]), + .library( + name: "XPCWrappers", + targets: ["XPCWrappers"]), ], dependencies: [ ], @@ -34,42 +35,61 @@ let package = Package( .target( name: "SecretKit", dependencies: [], - swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] + resources: [localization], + swiftSettings: swiftSettings, ), .testTarget( name: "SecretKitTests", dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"], - swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] + swiftSettings: swiftSettings, ), .target( name: "SecureEnclaveSecretKit", dependencies: ["SecretKit"], - swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] + resources: [localization], + swiftSettings: swiftSettings, ), .target( name: "SmartCardSecretKit", dependencies: ["SecretKit"], - swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] + resources: [localization], + swiftSettings: swiftSettings, ), .target( name: "SecretAgentKit", - dependencies: ["SecretKit", "SecretAgentKitHeaders"], - swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] - ), - .systemLibrary( - name: "SecretAgentKitHeaders" + dependencies: ["SecretKit"], + resources: [localization], + swiftSettings: swiftSettings, ), .testTarget( name: "SecretAgentKitTests", - dependencies: ["SecretAgentKit"]) - , + dependencies: ["SecretAgentKit"], + ), .target( name: "Brief", - dependencies: [] + dependencies: ["XPCWrappers"], + resources: [localization], + swiftSettings: swiftSettings, ), .testTarget( name: "BriefTests", - dependencies: ["Brief"] + dependencies: ["Brief"], + ), + .target( + name: "XPCWrappers", + swiftSettings: swiftSettings, ), ] ) + +var localization: Resource { + .process("../../Resources/Localizable.xcstrings") +} + +var swiftSettings: [PackageDescription.SwiftSetting] { + [ + .swiftLanguageMode(.v6), + .treatAllWarnings(as: .error), + .strictMemorySafety() + ] +} diff --git a/Sources/Secretive/Localizable.xcstrings b/Sources/Packages/Resources/Localizable.xcstrings similarity index 80% rename from Sources/Secretive/Localizable.xcstrings rename to Sources/Packages/Resources/Localizable.xcstrings index a642ab0..38e0bbd 100644 --- a/Sources/Secretive/Localizable.xcstrings +++ b/Sources/Packages/Resources/Localizable.xcstrings @@ -1,7 +1,110 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, + "agent_details_could_not_start_error" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secretive was unable to get SecretAgent to launch. Please try restarting your Mac, and if that doesn't work, file an issue on GitHub." + } + } + } + }, + "agent_details_disable_agent_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disable Agent" + } + } + } + }, + "agent_details_restart_agent_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restart Agent" + } + } + } + }, + "agent_details_running_since_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Running Since" + } + } + } + }, + "agent_details_socket_path_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Socket Path" + } + } + } + }, + "agent_details_start_agent_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Agent" + } + } + } + }, + "agent_details_start_agent_button_starting" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starting Agent" + } + } + } + }, + "agent_details_version_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + } + } + }, + "agent_not_running_notice_detail_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SecretAgent is a process that runs in the background to sign requests, so you don't need to keep Secretive open all the time.\n\n**Secretive will not be able to function properly unless the agent is installed and running.**" + } + } + } + }, "agent_not_running_notice_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -63,6 +166,12 @@ "value" : "Agent não está rodando" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Агент не запущен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -72,6 +181,7 @@ } }, "agent_running_notice_detail_description" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -133,6 +243,12 @@ "value" : "SecretAgent é um processo que roda em background para assinar requisições para que você não precise manter o Secretive aberto a todo momento.\n\n**Você pode fechar o Secretive e tudo continuará funcionando.**" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SecretAgent это процесс, который работает в фоне чтобы подписывать запросы – так Вам не нужно держать Secretive открытым все время.\n\n**Вы можете закрыть Secretive, и все продолжит работать штатно.**" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -142,6 +258,7 @@ } }, "agent_running_notice_detail_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -203,6 +320,12 @@ "value" : "Secret Agent está rodando" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SecretAgent запущен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -212,6 +335,7 @@ } }, "agent_running_notice_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -273,6 +397,12 @@ "value" : "Agent está rodando" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Агент запущен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -281,77 +411,19 @@ } } }, - "agent_setup_notice_title" : { + "agentDetailsLocationTitle" : { + "extractionState" : "manual", "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inicialitza Secretive" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Secretive Einrichten" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Setup Secretive" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Asenna Secretive" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configurer Secretive" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Imposta Secretive" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Secretiveをセットアップ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Secretive 설치" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Konfiguracja Secretive" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configurar Secretive" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "设置 Secretive" + "value" : "Secret Agent Location" } } } }, "app_menu_help_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -413,6 +485,12 @@ "value" : "Ajuda" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Помощь" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -422,6 +500,7 @@ } }, "app_menu_new_secret_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -483,6 +562,12 @@ "value" : "Novo Segredo" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новый секрет" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -491,77 +576,8 @@ } } }, - "app_menu_setup_button" : { - "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inicialitza Secretive" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Secretive Einrichten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Setup Secretive" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Asenna Secretive" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configurer Secretive" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Imposta Secretive" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "セットアップ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Secretive 설치" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skonfiguruj Scretive" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configurar Secretive" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "设置 Secretive" - } - } - } - }, "app_not_in_applications_notice_detail_description" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -623,6 +639,12 @@ "value" : "Secretive necessita estar no seu diretório de Aplicações para funcionar corretamente. Por favor mova-o e abra novamente." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secretive должен находиться в папке Applications чтобы работать правильно. Пожалуйста, переместите его и перезапустите приложение." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -632,6 +654,7 @@ } }, "app_not_in_applications_notice_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -693,6 +716,12 @@ "value" : "Secretive não está no diretório de Aplicações" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secretive находится не в папке Applications" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -701,218 +730,80 @@ } } }, - "auth_context_persist_for_duration_%@_%@" : { + "auth_context_persist_for_duration" : { "comment" : "When the user clicks the notification to leave a secret unlocked, they are shown a prompt to approve the action. This is the description, showing which secret will used. The first placeholder is the name of the secret. The second placeholder is a localized description of the time period it will remain unlocked for (eg: \"five minutes\")", "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { "state" : "translated", - "value" : "desbloqueja el secret \"%1$@\" per a %2$@" + "value" : "desbloqueja el secret \"%1$(secretName)@\" per a %2$(duration)@" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Secret \"%1$@\" für %2$@ entsperren" + "value" : "Secret \"%1$(secretName)@\" für %2$(duration)@ entsperren" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "unlock secret \"%1$@\" for %2$@" + "value" : "unlock secret \"%1$(secretName)@“ for %2$(duration)@" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "avaa salaisuuden \"%1$@\" lukitus ajaksi %2$@" + "value" : "avaa salaisuuden \"%1$(secretName)@\" lukitus ajaksi %2$(duration)@" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "déverrouiller le secret \"%1$@\" pendant %2$@" + "value" : "déverrouiller le secret \"%1$(secretName)@\" pendant %2$(duration)@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "sblocca il Segreto \"%1$@\" per %2$@" + "value" : "sblocca il Segreto \"%1$(secretName)@\" per %2$(duration)@" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "シークレット“%1$@”のロックを解除します (%2$@間)" + "value" : "シークレット“%1$(secretName)@”のロックを解除します (%2$(duration)@間)" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "비밀 \"%1$@\"를 %2$@ 동안 잠금 해제" + "value" : "비밀 \"%1$(secretName)@\"를 %2$(duration)@ 동안 잠금 해제" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "odblokuj sekret “%1$@” dla %2$@" + "value" : "odblokuj sekret “%1$(secretName)@” dla %2$(duration)@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "destravar segredo \"%1$@\" for %2$@" + "value" : "destravar segredo \"%1$(secretName)@\" for %2$(duration)@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "разблокировать секрет \"%1$(secretName)@\" на %2$(duration)@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "解锁密钥串 \"%1$@\" 给 %2$@" - } - } - } - }, - "auth_context_persist_for_duration_unknown_%@" : { - "comment" : "When the user clicks the notification to leave a secret unlocked, they are shown a prompt to approve the action. This is the description, showing which secret will used. The placeholder is the name of the secret. This is a fallback used when a duration is unable to be specified.", - "extractionState" : "manual", - "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "desbloqueja el secret \"%1$@\"" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Secret \"%1$@\" entsperren" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unlock secret \"%1$@\"" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "avaa salaisuuden \"%1$@\" lukitus" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "déverrouiller le secret \"%1$@\"" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "sblocca il Segreto \"%1$@\"" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "シークレット“%1$@”のロックを解除します" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "비밀 \"%1$@\" 잠금 해제" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "odblokuj sekret “%1$@”" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "destravar secreto \"%1$@\"" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "解锁密钥串 \"%1$@\"" - } - } - } - }, - "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$@\" " - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Daten mit dem Secret \"%1$@\" entschlüsseln" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "decrypt data using secret \"%1$@\"" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "pura salaus käyttäen salaisuutta \"%1$@\"" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "déchiffrer les données en utilisant le secret \"%1$@\"." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "decifra i dati usando il Segreto \"%1$@\"" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "シークレット“%1$@”を使って復号化します" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "비밀 \"%1$@\"를 사용해서 데이터 복호화" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "odszyfruj dane używając sekretu “%1$@”" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "decriptar o dado utilizando segredo \"%1$@\"" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用密钥串 \"%1$@\" 解密数据" + "value" : "解锁密钥串 \"%1$(secretName)@\" 给 %2$(duration)@" } } } @@ -981,6 +872,12 @@ "value" : "Negar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отказать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -989,223 +886,86 @@ } } }, - "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$@\"" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Daten mit dem Secret \"%1$@\" verschlüsseln" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "encrypt data using secret \"%1$@\"" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "salaa käyttäen salaisuutta \"%1$@\"" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "chiffrer les données en utilisant le secret \"%1$@\"" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "cifra i dati usando il Segreto \"%1$@\"" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "シークレット“%1$@”を使って暗号化します" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "비밀 \"%1$@\"를 사용해서 데이터 암호화" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zaszyfruj dane używając sekretu “%1$@”" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "encriptar dado utilizando o segredo \"%1$@\"" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用密钥串 \"%1$@\" 加密数据" - } - } - } - }, - "auth_context_request_signature_description_%@_%@" : { + "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", "localizations" : { "ca" : { "stringUnit" : { "state" : "translated", - "value" : "signa una petición de \"%1$@\" usant el secret \"%2$@\"" + "value" : "signa una petición de \"%1$(appName)@\" usant el secret \"%2$(secretName)@\"" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "eine Anfrage von \"%1$@\" mit dem Secret \"%2$@\" signieren" + "value" : "eine Anfrage von \"%1$(appName)@\" mit dem Secret \"%2$(secretName)@\" signieren" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "sign a request from \"%1$@\" using secret \"%2$@\"" + "value" : "sign a request from \"%1$(appName)@“ using secret \"%2$(secretName)@“" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "allekirjoita pyyntö lähteestä \"%1$@\" käyttäen salaisuutta \"%2$@\"" + "value" : "allekirjoita pyyntö lähteestä \"%1$(appName)@\" käyttäen salaisuutta \"%2$(secretName)@\"" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "signer une requête de \"%1$@\" en utilisant le secret \"%2$@\"" + "value" : "signer une requête de \"%1$(appName)@\" en utilisant le secret \"%2$(secretName)@\"" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "firma la richiesta di \"%1$@\" usando il Segreto \"%2$@\"" + "value" : "firma la richiesta di \"%1$(appName)@\" usando il Segreto \"%2$(secretName)@\"" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "シークレット“%2$@”を使って“%1$@”の要求に署名します" + "value" : "シークレット“%2$(secretName)@”を使って“%1$(appName)@”の要求に署名します" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "비밀 \"%2$@\"를 사용해서 \"%1$@\"의 요청에 서명" + "value" : "비밀 \"%2$(secretName)@\"를 사용해서 \"%1$(appName)@\"의 요청에 서명" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "podpisz zapytanie od “%1$@\" za pomocą sekretu “%2$@”" + "value" : "podpisz zapytanie od “%1$(appName)@\" za pomocą sekretu “%2$(secretName)@”" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "assinar requisição a partir do \"%1$@\" utilizando o segredo \"%2$@\"" + "value" : "assinar requisição a partir do \"%1$(appName)@\" utilizando o segredo \"%2$(secretName)@\"" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "подписать запрос от \"%1$(appName)@\" используя секрет \"%2$(secretName)@\"" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用密钥串 \"%2$@\" 认证 \"%1$@\" " - } - } - } - }, - "auth_context_request_verify_description_%@" : { - "comment" : "When the user performs a signature verification 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" : "verifica una signatura usant el secret \"%1$@\"" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "eine Signatur mit dem Secret \"%1$@\" verifizieren" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "verify a signature using secret \"%1$@\"" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "varmista allekirjoitus käyttäen salaisuutta \"%1$@\"" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "vérifier une signature en utilisant le secret \"%1$@\"" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "verifica una firma usando il segreto \"%1$@\"" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "シークレット“%2$@”を使って署名を検証します" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "비밀 \"%1$@\"를 사용하여 서명 검증" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zweryfikuj sygnaturę za pomocą sekretu “%1$@”" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "verificar a assinatura utilizando o segredo \"%1$@\"" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用密钥串 \"%1$@\" 认证" + "value" : "使用密钥串 \"%2$(secretName)@\" 认证 \"%1$(appName)@\" " } } } }, "copyable_click_to_copy_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -1267,6 +1027,12 @@ "value" : "Clique para Copiar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кликните чтобы скопировать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1276,6 +1042,7 @@ } }, "copyable_copied" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -1337,6 +1104,12 @@ "value" : "Copiado" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировано" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1345,7 +1118,30 @@ } } }, + "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" : { "ca" : { "stringUnit" : { @@ -1407,6 +1203,12 @@ "value" : "Cancelar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1416,6 +1218,7 @@ } }, "create_secret_create_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -1477,6 +1280,12 @@ "value" : "Criar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1485,77 +1294,129 @@ } } }, + "create_secret_key_attribution_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This shows at the end of your public key. It’s usually an email address." + } + } + } + }, + "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" : "Название" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "名称" } } } }, "create_secret_name_placeholder" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -1617,6 +1478,12 @@ "value" : "Shhhhh" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тссссс" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1626,6 +1493,7 @@ } }, "create_secret_notify_description" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -1687,6 +1555,12 @@ "value" : "Autenticação não é requerida enquanto seu Mac estiver destravado, mas você será notificado quando um segredo for utilizado." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Аутентификация не потребуется пока Ваш Mac разблокирован, но Вы получите уведомление, если секрет был использован." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1696,6 +1570,7 @@ } }, "create_secret_notify_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -1757,6 +1632,12 @@ "value" : "Notificar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1765,7 +1646,41 @@ } } }, + "create_secret_protection_level_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protection Level" + } + } + } + }, + "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" : { "ca" : { "stringUnit" : { @@ -1827,6 +1742,12 @@ "value" : "Você será requerido a autenticar utilizando Touch ID, Apple Watch ou senha antes de cada uso." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вам потребуется аутентифицироваться при помощи Touch ID, Apple Watch или пароля перед каждым использованием." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1836,6 +1757,7 @@ } }, "create_secret_require_authentication_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -1897,6 +1819,12 @@ "value" : "Requer Autenticação" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требовать аутентификацию" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1906,6 +1834,7 @@ } }, "create_secret_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -1967,6 +1896,12 @@ "value" : "Criar um Novo Segredo" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать новый секрет" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1976,6 +1911,7 @@ } }, "delete_confirmation_cancel_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -2037,6 +1973,12 @@ "value" : "Não Apagar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалять" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2045,77 +1987,8 @@ } } }, - "delete_confirmation_confirm_name_label" : { - "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirma el nom:" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Name bestätigen:" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirm Name:" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vahvista nimi:" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmer le nom :" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Conferma nome:" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "名前の確認:" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "확인 이름:" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Powtórz nazwę:" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmar Nome:" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "确认名称" - } - } - } - }, "delete_confirmation_delete_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -2177,6 +2050,12 @@ "value" : "Apagar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2185,147 +2064,304 @@ } } }, - "delete_confirmation_description_%@_%@" : { + "delete_confirmation_description" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Si esborres %1$@, no podràs recuperar-la. Escriu \"%2$@\" per a confirmar." + "value" : "Si esborres %1$(secretName)@, no podràs recuperar-la. Escriu \"%2$(confirmSecretName)@\" per a confirmar." } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Wenn du %1$@ löschst, kannst du es nicht wiederherstellen. Gib zur Bestätigung \"%2$@\" ein." + "value" : "Wenn du %1$(secretName)@ löschst, kannst du es nicht wiederherstellen. Gib zur Bestätigung \"%2$(confirmSecretName)@\" ein." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "If you delete %1$@, you will not be able to recover it. Type \"%2$@\" to confirm." + "value" : "If you delete %1$(secretName)@, you will not be able to recover it. Type “%2$(confirmSecretName)@“ to confirm." } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Jos poistat kohteen %1$@, sitä ei pysty palauttamaan. Kirjoita \"%2$@\" vahvistaaksesi poiston." + "value" : "Jos poistat kohteen %1$(secretName)@, sitä ei pysty palauttamaan. Kirjoita \"%2$(confirmSecretName)@\" vahvistaaksesi poiston." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Si vous effacez %1$@, vous ne pourrez pas le récupérer. Tapez \"%2$@\" pour confirmer." + "value" : "Si vous effacez %1$(secretName)@, vous ne pourrez pas le récupérer. Tapez \"%2$(confirmSecretName)@\" pour confirmer." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Se elimini %1$@, non sarai più in grado di recuperarlo. Digita “%1$@” per confermare." + "value" : "Se elimini %1$(secretName)@, non sarai più in grado di recuperarlo. Digita “%1$(secretName)@” per confermare." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "一旦%1$@を削除すると二度と元には戻せません。“%2$@”と入力して確認してください。" + "value" : "一旦%1$(secretName)@を削除すると二度と元には戻せません。“%2$(confirmSecretName)@”と入力して確認してください。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@를 삭제하면 복구할 수 없습니다. 확인하려면 \"%2$@\"를 입력하세요." + "value" : "%1$(secretName)@를 삭제하면 복구할 수 없습니다. 확인하려면 \"%2$(confirmSecretName)@\"를 입력하세요." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Jeżeli usuniesz %1$@, nie będziesz w stanie go odzyskać. Napisz “%2$@” aby potwierdzić." + "value" : "Jeżeli usuniesz %1$(secretName)@, nie będziesz w stanie go odzyskać. Napisz “%2$(confirmSecretName)@” aby potwierdzić." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Se você deletar %1$@, você não será permitido recuperá-lo. Digite \"%2$@\" para confirmar." + "value" : "Se você deletar %1$(secretName)@, você não será permitido recuperá-lo. Digite \"%2$(confirmSecretName)@\" para confirmar." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если Вы удалите %1$(secretName)@, вы не сможете его восстановить. Введите \"%2$(confirmSecretName)@\" для подтверждения." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "如果您删除 %1$@ ,您将没有任何方式恢复它。输入 \"%2$@\" 以确认。" + "value" : "如果您删除 %1$(secretName)@ ,您将没有任何方式恢复它。输入 \"%2$(confirmSecretName)@\" 以确认。" } } } }, - "delete_confirmation_title_%@" : { + "delete_confirmation_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Esborrar %1$@?" + "value" : "Esborrar %1$(secretName)@?" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ Löschen?" + "value" : "%1$(secretName)@ Löschen?" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Delete %1$@?" + "value" : "Delete %1$(secretName)@?" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Poista %1$@?" + "value" : "Poista %1$(secretName)@?" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Supprimer %1$@?" + "value" : "Supprimer %1$(secretName)@?" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Eliminare %1$@?" + "value" : "Eliminare %1$(secretName)@?" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@を削除しますか?" + "value" : "%1$(secretName)@を削除しますか?" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@를 지우겠습니까?" + "value" : "%1$(secretName)@를 지우겠습니까?" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Usunąć %1$@?" + "value" : "Usunąć %1$(secretName)@?" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Deletar %1$@?" + "value" : "Deletar %1$(secretName)@?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить %1$(secretName)@?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除 %1$@ 吗?" + "value" : "删除 %1$(secretName)@ 吗?" + } + } + } + }, + "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" : { "ca" : { "stringUnit" : { @@ -2387,6 +2423,12 @@ "value" : "Criar um novo clicando aqui." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создайте новый, кликнув сюда." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2396,6 +2438,7 @@ } }, "empty_store_modifiable_click_here_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -2457,6 +2500,12 @@ "value" : "Sem Segredos" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет секретов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2465,77 +2514,30 @@ } } }, - "empty_store_modifiable_title" : { + "empty_store_modifiable_empty_os_warning_description" : { + "extractionState" : "manual", "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sense secrets" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Secrets" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "No Secrets" + "value" : "It looks like you may have recently updated macOS. Sometimes this puts the Secure Enclave into a weird state, and you might need to reboot your Mac before things start working again." } - }, - "fi" : { + } + } + }, + "empty_store_modifiable_empty_os_warning_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ei salaisuuksia" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun secret" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nessun Segreto" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "シークレットがありません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "비밀이 없음" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Brak sekretów" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sem Segredos" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "没有密钥串" + "value" : "Missing Secrets?" } } } }, "empty_store_nonmodifiable_description" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -2597,6 +2599,12 @@ "value" : "Utilize sua ferramenta de gestão de Smart Card para criar um segredo." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для создания секрета воспользуйтесь инструментом управления Вашей смарт-картой." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2606,76 +2614,84 @@ } }, "empty_store_nonmodifiable_supported_key_types" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Secretive suporta claus EC256, EC384, RSA1024 i RSA2048." } }, "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Secretive unterstützt EC256, EC384, RSA1024 und RSA2048 Schlüssel." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive supports EC256, EC384, RSA1024, and RSA2048 keys." + "value" : "Secretive supports EC256, EC384, and RSA2048 keys." } }, "fi" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Secretive tukee EC256-, EC384-, RSA1024- ja RSA2048-avaimia." } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Secretive prend en charge les clés EC256, EC384, RSA1024 et RSA2048." } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Secretive supporta la cifratura EC256, EC384, RSA1024 e RSA2048." } }, "ja" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "SecretiveはEC256、EC384、RSA1024、またはRSA2048の鍵に対応しています。" } }, "ko" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Secretive는 EC256, EC384, RSA1024 및 RSA2048 키를 지원합니다." } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Secretive wspiera klucze EC256, EC384, RSA1024 i RSA2048." } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Secretive suporta chaves EC256, EC384, RSA1024 e RSA2048." } }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Secretive поддерживает ключи EC256, EC384, RSA1024, и RSA2048." + } + }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Secretive 支持 EC256, EC384, RSA1024, 和RSA2048." } } } }, "empty_store_nonmodifiable_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -2737,6 +2753,12 @@ "value" : "Sem Segredos" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет секретов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2745,7 +2767,404 @@ } } }, + "export SSH_AUTH_SOCK=%@" : { + "shouldTranslate" : false + }, + "Host *\n\tIdentityAgent %@" : { + "shouldTranslate" : false + }, + "integrations_add_this_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add This:" + } + } + } + }, + "integrations_apps_row_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps" + } + } + } + }, + "integrations_community_apps_list_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There's a community-maintained list of instructions for apps on GitHub. If the app you're looking for isn't supported, create an issue and the community may be able to help." + } + } + } + }, + "integrations_community_shell_list_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There's a community-maintained list of shell instructions on GitHub. If the shell you're looking for isn't supported, create an issue and the community may be able to help." + } + } + } + }, + "integrations_configure_using_secret_empty_create" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You'll need to create a Secret before configuring this action." + } + } + } + }, + "integrations_configure_using_secret_header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configure Using Secret" + } + } + } + }, + "integrations_configure_using_secret_no_secret" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Secret" + } + } + } + }, + "integrations_configure_using_secret_secret_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret" + } + } + } + }, + "integrations_getting_started_multiple_config" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can configure more than one tool, they generally won't interfere with each other." + } + } + } + }, + "integrations_getting_started_row_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Getting Started" + } + } + } + }, + "integrations_getting_started_section_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integrations" + } + } + } + }, + "integrations_getting_started_suggestion_git" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you're trying to sign your git commits, set up Git Signing." + } + } + } + }, + "integrations_getting_started_suggestion_shell" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you're trying to configure anything your command line runs to use Secretive, configure your shell." + } + } + } + }, + "integrations_getting_started_suggestion_shell_default" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you don't known what shell you use and haven't changed it, you're probably using `%(shellName)@`." + } + } + } + }, + "integrations_getting_started_suggestion_ssh" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you're trying to authenticate with an SSH server or authenticating with a service like GitHub over SSH, configure your SSH client." + } + } + } + }, + "integrations_getting_started_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuring Tools for Secretive" + } + } + } + }, + "integrations_getting_started_title_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Most tools will try and look for SSH keys on disk in `~/.ssh`. To use Secretive, we need to configure those tools to talk to Secretive instead." + } + } + } + }, + "integrations_getting_started_what_should_i_configure_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What Should I Configure?" + } + } + } + }, + "integrations_git_step_gitallowedsigners_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "~/.gitallowedsigners probably does not exist. You'll need to create it." + } + } + } + }, + "integrations_git_step_gitconfig_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "[user]\n signingkey = %1$(publicKeyPathPlaceholder)@\n[commit]\n gpgsign = true\n[gpg]\n format = ssh\n[gpg \"ssh\"]\n allowedSignersFile = ~/.gitallowedsigners" + } + } + }, + "shouldTranslate" : false + }, + "integrations_menu_bar_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integrations…" + } + } + } + }, + "integrations_other_section_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other" + } + } + } + }, + "integrations_other_shell_row_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "other" + } + } + } + }, + "integrations_path_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration File" + } + } + } + }, + "integrations_shell_section_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell" + } + } + } + }, + "integrations_ssh_specific_key_note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can tell SSH to use a specific key for a given host. See the web documentation for more details." + } + } + } + }, + "integrations_system_section_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + } + } + }, + "integrations_tool_name_bash" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bash" + } + } + }, + "shouldTranslate" : false + }, + "integrations_tool_name_fish" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "fish" + } + } + }, + "shouldTranslate" : false + }, + "integrations_tool_name_git_signing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Git Signing" + } + } + } + }, + "integrations_tool_name_ssh" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH" + } + } + }, + "shouldTranslate" : false + }, + "integrations_tool_name_zsh" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "zsh" + } + } + }, + "shouldTranslate" : false + }, + "integrations_view_other_github_link" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View on GitHub" + } + } + } + }, + "integrations_web_link" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View Documentation on Web" + } + } + } + }, + "integrationsGitStepGitconfigSectionNote" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If any section (like [user]) already exists, just add the entries in the existing section." + } + } + } + }, "no_secure_storage_description" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -2807,6 +3226,12 @@ "value" : "Seu Mac não possui o Secure Enclave e não há um Smart Card compatível inserido." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш Mac не поддерживает Secure Enclave, и не обнаружено совместимой смарт-карты." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2816,6 +3241,7 @@ } }, "no_secure_storage_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -2877,6 +3303,12 @@ "value" : "Sem Armazenamento Seguro Disponível" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Защищенное хранилище недоступно" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2886,6 +3318,7 @@ } }, "no_secure_storage_yubico_link" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -2947,6 +3380,12 @@ "value" : "Se você está buscando adicionar um para seu Mac, o YubiKey 5 Series é muito bom." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если Вы собираетесь добавить его к Вашему Mac, YubiKey 5 серии - отличный выбор." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3019,6 +3458,12 @@ "value" : "Deixar Destrancado" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оставить разблокированным" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3091,6 +3536,12 @@ "value" : "Não Destravar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не разблокировывать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3099,199 +3550,19 @@ } } }, - "rename_cancel_button" : { + "reveal_in_finder_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" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "返回" - } - } - } - }, - "rename_rename_button" : { - "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" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "重命名" - } - } - } - }, - "rename_title_%@" : { - "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escriu el nou nom per a %1$@ baix." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gib einen neuen Namen für %1$@ ein." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Type your new name for %1$@ below." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Saisissez votre nouveau nom pour %1$@ ci-dessous." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Digita qui sotto il nuovo nome per %1$@." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@の新しい名前を入力してください。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "아래에 %1$@의 새 이름을 입력하세요." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wprowadź nową nazwę dla %1$@ poniżej." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Digite o novo nome para %1$@ abaixo." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在此输入密钥串 %1$@ 的新名字。" + "value" : "Reveal in Finder" } } } }, "secret_detail_md5_fingerprint_label" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -3347,6 +3618,12 @@ "value" : "MD5 Fingerprint" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отпечаток MD5" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3356,6 +3633,7 @@ } }, "secret_detail_public_key_label" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -3411,6 +3689,12 @@ "value" : "Chave Pública" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Публичный ключ" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3420,6 +3704,7 @@ } }, "secret_detail_public_key_path_label" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -3475,6 +3760,12 @@ "value" : "Caminho da Chave Pública" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Путь к публичному ключу" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3484,6 +3775,7 @@ } }, "secret_detail_sha256_fingerprint_label" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -3539,6 +3831,12 @@ "value" : "SHA256 Fingerprint" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отпечаток SHA256" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3548,6 +3846,7 @@ } }, "secret_list_delete_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -3603,6 +3902,12 @@ "value" : "Apagar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3611,65 +3916,72 @@ } } }, - "secret_list_rename_button" : { + "secret_list_edit_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Canvia el nom" } }, "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Umbenennen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Rename" + "value" : "Edit" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Renommer" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Rinomina" } }, "ja" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "名前を変更" } }, "ko" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "이름 변경" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Zmień nazwę" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Renomear" } }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Переименовать" + } + }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "重命名" } } @@ -3732,6 +4044,12 @@ "value" : "Secure Enclave" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secure Enclave" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3740,7 +4058,11 @@ } } }, + "set -x SSH_AUTH_SOCK %@" : { + "shouldTranslate" : false + }, "setup_agent_activity_monitor_description" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -3796,6 +4118,12 @@ "value" : "Este aplicativo de ajuda é chamado **Secret Agent** e você pode vê-lo no Monitor de Atividades de tempo em tempo." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это вспомогательное приложение назвается **Secret Agent**, Вы можете наблюдать его в Activity Monitor время от времени." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3805,6 +4133,7 @@ } }, "setup_agent_description" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -3860,6 +4189,12 @@ "value" : "Secretive precisa configurar um aplicativo de ajuda para funcionar corretamente. Isso irá assinar requisições de clientes SSH no plano de fundo para que você não precise manter o aplicativo Secretive aberto." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secretive требуется настроить вспомогательное приложение, чтобы работать правильно. Оно будет подписывать запросы от SSH клиентов в фоне, так Вам не придется держать основное приложение Secretive открытым." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3869,6 +4204,7 @@ } }, "setup_agent_install_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -3924,6 +4260,12 @@ "value" : "Instalar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3933,6 +4275,7 @@ } }, "setup_agent_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -3988,6 +4331,12 @@ "value" : "Configurar Agent" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настроить агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3996,455 +4345,52 @@ } } }, - "setup_ssh_add_for_me_button" : { + "setup_done_button" : { + "extractionState" : "manual", "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afegeix-ho per mi" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Für Mich Einfügen" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Add it For Me" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajoutez-le pour moi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aggiungila per me" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "自動で追加する" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "나를 위해 추가해주세요" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodaj za mnie" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adicionar para mim" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "为我添加" + "value" : "Done" } } } }, - "setup_ssh_add_to_config_button_%@" : { + "setup_integrations_button" : { + "extractionState" : "manual", "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afegeix a %1$@" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "In %1$@ einfügen" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Add to %1$@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajouter à %1$@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aggiungi a %1$@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@に追加" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@에 추가" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodaj do %1$@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adicionar para %1$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "添加到 %1$@" + "value" : "Configure" } } } }, - "setup_ssh_added_manually_button" : { + "setup_integrations_description" : { + "extractionState" : "manual", "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "L'he afegida manualment" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ich habe es Manuell Eingefügt" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "I Added it Manually" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je l'ai ajouté manuellement" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "L’ho aggiunta manualmente" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "手動で追加する" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "내가 수동으로 추가했습니다" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodałem to samodzielnie" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eu adicionei manualmente" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "我自行手动添加" + "value" : "Tell the tools you use how to talk to Secretive." } } } }, - "setup_ssh_description" : { + "setup_integrations_title" : { + "extractionState" : "manual", "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afegeix aquesta línia a la teua configuració del shell per que SSH es comunique amb Secretive quan vulga autenticar. Secretive pot fer aquest procediment automàticament, o pots copiar i pegar açò al teu fitxer de configuració." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Füge diese Zeile in deine Shell-Konfiguration ein, damit SSH zur Authentifizierung mit dem Secret Agent kommuniziert. Secretive kann dies automatisch tun, oder du kopierst diese Zeile in deine Konfigurationsdatei." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Add this line to your shell config telling SSH to talk to Secret Agent when it wants to authenticate. Secretive can either do this for you automatically, or you can copy and paste this into your config file." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajoutez cette ligne à votre configuration shell pour indiquer à SSH de communiquer à Secret Agent quand il veut s'authentifier. Secretive peut le faire automatiquement pour vous, ou vous pouvez copier et coller cette ligne dans votre fichier de configuration." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aggiungi questa riga alla configurazione del Terminale per dire a SSH di parlare con Secret Agent quando vuole autenticarsi. Secretive può farlo automaticamente per te, oppure puoi copiare e incollare questa riga nel file di configurazione." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "以下の行をシェルの設定に追加してSSHが認証の際にSecretAgentを利用できるようにしてください。Secretiveが自動で追加するか、手動でコピーして設定に追加することもできます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "SSH가 인증을 원할 때 Secret Agent와 통신하도록 지시하는 이 줄을 쉘 구성에 추가하세요. Secretive는 이 작업을 자동으로 수행하거나 사용자가 이를 복사하여 구성 파일에 붙여넣을 수 있습니다." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodaj tą linijkę to pliku konfiguracyjnego SSH, aby nawiązać połączenie z Secret Agent kiedy potrzebna jest autoryzacja. Secretive może ustawić to automatycznie lub możesz to zrobić samodzielnie kopiując to do pliku konfiguracyjnego." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adicione esta linha nas configurações do seu shell para dizer ao SSH para falar com o Secret Agent quando ele necessitar de autenticação. Secretive pode fazer isto para você automaticamente ou você pode copiar e colar isso no seu arquivo de configuração." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "将以下文本添加到您的SSH 配置中以使用Secret Agent. Secretive 无法自动帮您完成该过程,或者您可以选择拷贝并粘贴该文本到您的配置文件中" - } - } - } - }, - "setup_ssh_title" : { - "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configura el teu agent SSH" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Konfiguriere deinen SSH Agent" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configure your SSH Agent" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configurer votre Agent SSH" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configura il tuo Agente SSH" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "SSHエージェントを設定" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "SSH Agent 설정" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skonfiguruj twojego klienta SSH" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configurar seu agente SSH" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "设置您的SSH 代理" - } - } - } - }, - "setup_step_complete_symbol" : { - "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓" - } - } - } - }, - "setup_third_party_faq_link" : { - "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Si tractes de configurar una aplicació de tercers, comprova el FAQ." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Schaue dir die FAQs an, um eine Drittanbieter-App einzurichten." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "If you're trying to set up a third party app, check out the FAQ." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Si vous essayez de configurer une application tierce, consultez la FAQ." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Se stai cercando di impostare un’app di terze parti, dai un'occhiata alla FAQ." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "その他のアプリから使う場合はよくある質問をご覧ください。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "타사 앱을 설정하려는 경우 FAQ를 확인하세요." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jeżeli próbujesz ustawić aplikacje stron trzecich, sprawdź FAQ." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Se você estiver tentando configurar um aplicativo de terceiros, verifique o FAQ." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "如果您想设置第三方APP,请阅读 FAQ。" + "value" : "Configure Integrations" } } } }, "setup_updates_description" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -4500,6 +4446,12 @@ "value" : "Secretive irá periodicamente verificar com o GitHub para verificar se existe uma nova versão. Se você ver alguma requisição de rede para o GitHub, este é o porque." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secretive будет проверять обновления на GitHub. Если Вы видите сетевые запросы к GitHub, вот их причина." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4508,7 +4460,8 @@ } } }, - "setup_updates_ok" : { + "setup_updates_ok_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -4564,6 +4517,12 @@ "value" : "OK" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4572,71 +4531,8 @@ } } }, - "setup_updates_readmore" : { - "localizations" : { - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Llegiu més ací." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lies hier mehr darüber." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Read more about this here." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pour en savoir plus, cliquez ici." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Leggi di più a riguardo qui." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "詳細はこちら" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이에 대한 자세한 내용은 여기를 참조하세요." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Przeczytaj więcej tutaj." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Leia mais sobre isto aqui." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在此处查看详情。" - } - } - } - }, "setup_updates_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -4692,6 +4588,12 @@ "value" : "Atualizações" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновления" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4700,134 +4602,169 @@ } } }, - "signed_notification_description_%@" : { + "setupStepCompleteButton" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "signed_notification_description" : { "comment" : "When the user performs an action using a secret, they're shown a notification describing what happened. This is the description, showing which secret was used. The placeholder is the name of the secret.", "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Usant secret %1$@" + "value" : "Usant secret %1$(secretName)@" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Benutze Secret %1$@" + "value" : "Benutze Secret %1$(secretName)@" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Using secret %1$@" + "value" : "Using secret %1$(secretName)@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Using secret %1$(secretName)@" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Utilisation du secret %1$@" + "value" : "Utilisation du secret %1$(secretName)@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Usato il Segreto %1$@" + "value" : "Usato il Segreto %1$(secretName)@" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@を使用中" + "value" : "%1$(secretName)@を使用中" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "비밀 %1$@ 사용됨" + "value" : "비밀 %1$(secretName)@ 사용됨" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Użyto sekretu %1$@" + "value" : "Użyto sekretu %1$(secretName)@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Utilizando o segredo %1$@" + "value" : "Utilizando o segredo %1$(secretName)@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используя секрет %1$(secretName)@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用密钥串 %1$@" + "value" : "使用密钥串 %1$(secretName)@" } } } }, - "signed_notification_title_%@" : { + "signed_notification_title" : { "comment" : "When the user performs an action using a secret, they're shown a notification describing what happened. This is the title, showing which app requested the action. The placeholder is the name of the app.", "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Petició signada de %1$@" + "value" : "Petició signada de %1$(appName)@" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Signierte Anfrage von %1$@" + "value" : "Signierte Anfrage von %1$(appName)@" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Signed Request from %1$@" + "value" : "Signed Request from %1$(appName)@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signed Request from %1$(appName)@" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Requête signée de %1$@" + "value" : "Requête signée de %1$(appName)@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Firmata la richiesta da %1$@" + "value" : "Firmata la richiesta da %1$(appName)@" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@の要求に署名しました" + "value" : "%1$(appName)@の要求に署名しました" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@에서 서명 요청" + "value" : "%1$(appName)@에서 서명 요청" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Podpisano żądanie od %1$@" + "value" : "Podpisano żądanie od %1$(appName)@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Requisição Assinada fr %1$@" + "value" : "Requisição Assinada fr %1$(appName)@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подписан запрос от %1$(appName)@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已认证来自 %1$@ 的请求" + "value" : "已认证来自 %1$(appName)@ 的请求" } } } @@ -4889,6 +4826,12 @@ "value" : "Smart Card" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Смарт-карта" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4954,6 +4897,12 @@ "value" : "Sem Nome" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Без названия" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4963,6 +4912,7 @@ } }, "update_critical_notice_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -5018,6 +4968,12 @@ "value" : "Atualização Crítica de Segurança Requerida" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется критическое обновление безопасности" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5027,6 +4983,7 @@ } }, "update_ignore_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -5082,6 +5039,12 @@ "value" : "Ignorar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пропустить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5091,6 +5054,7 @@ } }, "update_normal_notice_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -5146,6 +5110,12 @@ "value" : "Atualização Disponível" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступно обновление" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5212,6 +5182,12 @@ "value" : "Ignorar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пропустить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5278,6 +5254,12 @@ "value" : "Atualizar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5286,68 +5268,80 @@ } } }, - "update_notification_update_critical_title_%@" : { + "update_notification_update_critical_title" : { "comment" : "When an update is available, a notification is shown. This is the title for a very high priority update with security fixes. The placeholder is for the application version, eg \"Critical Security Update - 2.0\"", "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Actualització de seguretat crítica - %1$@" + "value" : "Actualització de seguretat crítica - %1$(updateName)@" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Kritisches Sicherheitsupdate - %1$@" + "value" : "Kritisches Sicherheitsupdate - %1$(updateName)@" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Critical Security Update - %1$@" + "value" : "Critical Security Update - %1$(updateName)@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Critical Security Update - %1$(updateName)@" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mise à jour critique de sécurité - %1$@" + "value" : "Mise à jour critique de sécurité - %1$(updateName)@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Aggiornamento di sicurezza critico - %1$@" + "value" : "Aggiornamento di sicurezza critico - %1$(updateName)@" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "重要なセキュリティアップデート - %1$@" + "value" : "重要なセキュリティアップデート - %1$(updateName)@" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "치명적 보안 업데이트 - %1$@" + "value" : "치명적 보안 업데이트 - %1$(updateName)@" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Krytyczna aktualizacja bezpieczeństwa - %1$@" + "value" : "Krytyczna aktualizacja bezpieczeństwa - %1$(updateName)@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Atualização de Segurança Crítica - %1$@" + "value" : "Atualização de Segurança Crítica - %1$(updateName)@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Критическое обновление безопасности - %1$(updateName)@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "重要安全更新 - %1$@" + "value" : "重要安全更新 - %1$(updateName)@" } } } @@ -5410,6 +5404,12 @@ "value" : "Clique para Atualizar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кликните чтобы обновить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5418,73 +5418,86 @@ } } }, - "update_notification_update_normal_title_%@" : { + "update_notification_update_normal_title" : { "comment" : "When an update is available, a notification is shown. This is the title for a normal priority update. The placeholder is for the application version, eg \"Update Available - 2.0\"", "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Actualizació disponible - %1$@" + "value" : "Actualizació disponible - %1$(updateName)@" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Update Verfügbar - %1$@" + "value" : "Update Verfügbar - %1$(updateName)@" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Update Available - %1$@" + "value" : "Update Available - %1$(updateName)@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Available - %1$(updateName)@" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mise à jour disponible - %1$@" + "value" : "Mise à jour disponible - %1$(updateName)@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Aggiornamento disponibile - %1$@" + "value" : "Aggiornamento disponibile - %1$(updateName)@" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "アップデートがあります - %1$@" + "value" : "アップデートがあります - %1$(updateName)@" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "사용 가능한 업데이트 - %1$@" + "value" : "사용 가능한 업데이트 - %1$(updateName)@" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Aktualizacja dostępna - %1$@" + "value" : "Aktualizacja dostępna - %1$(updateName)@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Atualização disponível - %1$@" + "value" : "Atualização disponível - %1$(updateName)@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступно обновление - %1$(updateName)@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "更新可用 - %1$@" + "value" : "更新可用 - %1$(updateName)@" } } } }, "update_release_notes_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -5540,6 +5553,12 @@ "value" : "Notas de Mudanças" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Список изменений" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5549,6 +5568,7 @@ } }, "update_test_notice_title" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -5604,6 +5624,12 @@ "value" : "Versão de Teste" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тестовая сборка" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5613,6 +5639,7 @@ } }, "update_update_button" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { @@ -5668,6 +5695,12 @@ "value" : "Atualizar" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5676,66 +5709,90 @@ } } }, - "update_version_name_%@" : { + "update_version_name" : { + "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secretive %1$(updateName)@" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secretive %1$(updateName)@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Secretive %1$@" + "value" : "Secretive %1$(updateName)@" + } + } + } + }, + "updater_download_latest_nightly_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download Latest Nightly Build" } } } diff --git a/Sources/Packages/Sources/Brief/Release.swift b/Sources/Packages/Sources/Brief/Release.swift index 847dffe..ffc3293 100644 --- a/Sources/Packages/Sources/Brief/Release.swift +++ b/Sources/Packages/Sources/Brief/Release.swift @@ -1,7 +1,7 @@ import Foundation /// A release is a representation of a downloadable update. -public struct Release: Codable { +public struct Release: Codable, Sendable { /// The user-facing name of the release. Typically "Secretive 1.2.3" public let name: String diff --git a/Sources/Packages/Sources/Brief/SemVer.swift b/Sources/Packages/Sources/Brief/SemVer.swift index 8308521..472cd0e 100644 --- a/Sources/Packages/Sources/Brief/SemVer.swift +++ b/Sources/Packages/Sources/Brief/SemVer.swift @@ -1,16 +1,24 @@ import Foundation /// A representation of a Semantic Version. -public struct SemVer { +public struct SemVer: Sendable { /// The SemVer broken into an array of integers. let versionNumbers: [Int] + public let previewDescription: String? + + public var isTestBuild: Bool { + versionNumbers == [0, 0, 0] + } /// Initializes a SemVer from a string representation. /// - Parameter version: A string representation of the SemVer, formatted as "major.minor.patch". public init(_ version: String) { // Betas have the format 1.2.3_beta1 - let strippedBeta = version.split(separator: "_").first! + // Nightlies have the format 0.0.0_nightly-2025-09-03 + let splitFull = version.split(separator: "_") + let strippedBeta = splitFull.first! + previewDescription = splitFull.count > 1 ? String(splitFull[1]) : nil var split = strippedBeta.split(separator: ".").compactMap { Int($0) } while split.count < 3 { split.append(0) @@ -22,6 +30,7 @@ public struct SemVer { /// - Parameter version: An `OperatingSystemVersion` representation of the SemVer. public init(_ version: OperatingSystemVersion) { versionNumbers = [version.majorVersion, version.minorVersion, version.patchVersion] + previewDescription = nil } } diff --git a/Sources/Packages/Sources/Brief/Updater.swift b/Sources/Packages/Sources/Brief/Updater.swift index 6c88d82..12be1ee 100644 --- a/Sources/Packages/Sources/Brief/Updater.swift +++ b/Sources/Packages/Sources/Brief/Updater.swift @@ -1,16 +1,24 @@ import Foundation -import Combine +import Observation +import XPCWrappers /// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version. -public final class Updater: ObservableObject, UpdaterProtocol { +@Observable public final class Updater: UpdaterProtocol, Sendable { - @Published public var update: Release? - public let testBuild: Bool + private let state = State() + @MainActor @Observable public final class State { + var update: Release? = nil + nonisolated init() {} + } + public var update: Release? { + state.update + } + + /// The current version of the app that is running. + public let currentVersion: SemVer /// The current OS version. private let osVersion: SemVer - /// The current version of the app that is running. - private let currentVersion: SemVer /// Initializes an Updater. /// - Parameters: @@ -18,36 +26,40 @@ public final class Updater: ObservableObject, UpdaterProtocol { /// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour. /// - osVersion: The current OS version. /// - currentVersion: The current version of the app that is running. - public init(checkOnLaunch: Bool, checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value, osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion), currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")) { + public init( + checkOnLaunch: Bool, + checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value, + osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion), + currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0") + ) { self.osVersion = osVersion self.currentVersion = currentVersion - testBuild = currentVersion == SemVer("0.0.0") - if checkOnLaunch { - // Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet. - checkForUpdates() + Task { + if checkOnLaunch { + try await checkForUpdates() + } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(Int(checkFrequency))) + try await checkForUpdates() + } } - let timer = Timer.scheduledTimer(withTimeInterval: checkFrequency, repeats: true) { _ in - self.checkForUpdates() - } - timer.tolerance = 60*60 } /// Manually trigger an update check. - public func checkForUpdates() { - URLSession.shared.dataTask(with: Constants.updateURL) { data, _, _ in - guard let data = data else { return } - guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return } - self.evaluate(releases: releases) - }.resume() + public func checkForUpdates() async throws { + let session = try await XPCTypedSession<[Release], Never>(serviceName: "com.maxgoedjen.Secretive.SecretiveUpdater") + await evaluate(releases: try await session.send()) + session.complete() } + /// Ignores a specified release. `update` will be nil if the user has ignored the latest available release. /// - Parameter release: The release to ignore. - public func ignore(release: Release) { + public func ignore(release: Release) async { guard !release.critical else { return } defaults.set(true, forKey: release.name) - DispatchQueue.main.async { - self.update = nil + await MainActor.run { + state.update = nil } } @@ -57,7 +69,7 @@ extension Updater { /// Evaluates the available downloadable releases, and selects the newest non-prerelease release that the user is able to run. /// - Parameter releases: An array of ``Release`` objects. - func evaluate(releases: [Release]) { + func evaluate(releases: [Release]) async { guard let release = releases .sorted() .reversed() @@ -67,8 +79,8 @@ extension Updater { guard !release.prerelease else { return } let latestVersion = SemVer(release.name) if latestVersion > currentVersion { - DispatchQueue.main.async { - self.update = release + await MainActor.run { + state.update = release } } } @@ -87,11 +99,3 @@ extension Updater { } } - -extension Updater { - - enum Constants { - static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")! - } - -} diff --git a/Sources/Packages/Sources/Brief/UpdaterProtocol.swift b/Sources/Packages/Sources/Brief/UpdaterProtocol.swift index a5c5edc..06c248e 100644 --- a/Sources/Packages/Sources/Brief/UpdaterProtocol.swift +++ b/Sources/Packages/Sources/Brief/UpdaterProtocol.swift @@ -1,13 +1,13 @@ import Foundation -import Combine /// A protocol for retreiving the latest available version of an app. -public protocol UpdaterProtocol: ObservableObject { +public protocol UpdaterProtocol: Observable, Sendable { /// The latest update - var update: Release? { get } - /// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build) - var testBuild: Bool { get } + @MainActor var update: Release? { get } + var currentVersion: SemVer { get } + + func ignore(release: Release) async } diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index 7209635..fbd739e 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -5,12 +5,12 @@ import SecretKit import AppKit /// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores. -public final class Agent { +public final class Agent: Sendable { private let storeList: SecretStoreList private let witness: SigningWitness? - private let writer = OpenSSHKeyWriter() - private let requestTracer = SigningRequestTracer() + private let publicKeyWriter = OpenSSHPublicKeyWriter() + private let signatureWriter = OpenSSHSignatureWriter() private let certificateHandler = OpenSSHCertificateHandler() private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") @@ -22,59 +22,40 @@ public final class Agent { logger.debug("Agent is running") self.storeList = storeList self.witness = witness - certificateHandler.reloadCertificates(for: storeList.allSecrets) + Task { @MainActor in + await certificateHandler.reloadCertificates(for: storeList.allSecrets) + } } } extension Agent { - /// Handles an incoming request. - /// - Parameters: - /// - reader: A ``FileHandleReader`` to read the content of the request. - /// - writer: A ``FileHandleWriter`` to write the response to. - /// - Return value: - /// - Boolean if data could be read - @discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) async -> Bool { - logger.debug("Agent handling new data") - let data = Data(reader.availableData) - guard data.count > 4 else { return false} - let requestTypeInt = data[4] - guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else { - writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data)) - logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)") - return true - } - logger.debug("Agent handling request of type \(requestType.debugDescription)") - let subData = Data(data[5...]) - let response = await handle(requestType: requestType, data: subData, reader: reader) - writer.write(response) - return true - } - - func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) async -> Data { + public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data { // Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting - reloadSecretsIfNeccessary() + await reloadSecretsIfNeccessary() var response = Data() do { - switch requestType { + switch request { case .requestIdentities: - response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data) - response.append(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 sign(data: data, provenance: provenance)) - logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)") + response.append(SSHAgent.Response.agentIdentitiesAnswer.data) + response.append(await identities()) + logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)") + case .signRequest(let context): + response.append(SSHAgent.Response.agentSignResponse.data) + response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance)) + logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)") + case .unknown(let value): + logger.error("Agent received unknown request of type \(value).") + default: + logger.debug("Agent received valid request of type \(request.debugDescription), but not currently supported.") + throw UnhandledRequestError() } } catch { - response.removeAll() - response.append(SSHAgent.ResponseType.agentFailure.data) - logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)") + response = SSHAgent.Response.agentFailure.data + logger.debug("Agent returned \(SSHAgent.Response.agentFailure.debugDescription)") } - let full = OpenSSHKeyWriter().lengthAndData(of: response) - return full + return response.lengthAndData } } @@ -83,27 +64,27 @@ extension Agent { /// Lists the identities available for signing operations /// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations. - func identities() -> Data { - let secrets = storeList.allSecrets - certificateHandler.reloadCertificates(for: secrets) - var count = secrets.count + func identities() async -> Data { + let secrets = await storeList.allSecrets + await certificateHandler.reloadCertificates(for: secrets) + var count = 0 var keyData = Data() for secret in secrets { - let keyBlob = writer.data(secret: secret) - let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)! - keyData.append(writer.lengthAndData(of: keyBlob)) - keyData.append(writer.lengthAndData(of: curveData)) - - if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) { - keyData.append(writer.lengthAndData(of: certificateData)) - keyData.append(writer.lengthAndData(of: name)) + let keyBlob = publicKeyWriter.data(secret: secret) + keyData.append(keyBlob.lengthAndData) + keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData) + count += 1 + + if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) { + keyData.append(certificateData.lengthAndData) + keyData.append(name.lengthAndData) count += 1 } } logger.log("Agent enumerated \(count) identities") var countBigEndian = UInt32(count).bigEndian - let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8) + let countData = unsafe Data(bytes: &countBigEndian, count: MemoryLayout.size) return countData + keyData } @@ -112,71 +93,19 @@ extension Agent { /// - data: The data to sign. /// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request. /// - Returns: An OpenSSH formatted Data payload containing the signed data response. - func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data { - let reader = OpenSSHReader(data: data) - let payloadHash = reader.readNextChunk() - let hash: Data - // Check if hash is actually an openssh certificate and reconstruct the public key if it is - if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) { - hash = certificatePublicKey - } else { - hash = payloadHash - } - - guard let (store, secret) = secret(matching: hash) else { - logger.debug("Agent did not have a key matching \(hash as NSData)") - throw AgentError.noMatchingKey + func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data { + guard let (secret, store) = await secret(matching: keyBlob) else { + let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined() + logger.debug("Agent did not have a key matching \(keyBlobHex)") + throw NoMatchingKeyError() } - if let witness = witness { - try witness.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance) - } + try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance) - let dataToSign = reader.readNextChunk() - let signed = try store.sign(data: dataToSign, with: secret, for: provenance) - let derSignature = signed + let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance) + let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) - let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)! - - // Convert from DER formatted rep to raw (r||s) - - let rawRepresentation: Data - switch (secret.algorithm, secret.keySize) { - case (.ellipticCurve, 256): - rawRepresentation = try CryptoKit.P256.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation - case (.ellipticCurve, 384): - rawRepresentation = try CryptoKit.P384.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation - default: - throw AgentError.unsupportedKeyType - } - - - let rawLength = rawRepresentation.count/2 - // Check if we need to pad with 0x00 to prevent certain - // ssh servers from thinking r or s is negative - let paddingRange: ClosedRange = 0x80...0xFF - var r = Data(rawRepresentation[0.. (AnySecretStore, AnySecret)? { - storeList.stores.compactMap { store -> (AnySecretStore, AnySecret)? in - let allMatching = store.secrets.filter { secret in - hash == writer.data(secret: secret) - } - if let matching = allMatching.first { - return (store, matching) - } - return nil - }.first + func secret(matching hash: Data) async -> (AnySecret, AnySecretStore)? { + await storeList.allSecretsWithStores.first { + hash == publicKeyWriter.data(secret: $0.0) + } } } @@ -217,21 +141,16 @@ extension Agent { extension Agent { - /// An error involving agent operations.. - enum AgentError: Error { - case unhandledType - case noMatchingKey - case unsupportedKeyType - case notOpenSSHCertificate - } + struct NoMatchingKeyError: Error {} + struct UnhandledRequestError: Error {} } -extension SSHAgent.ResponseType { +extension SSHAgent.Response { var data: Data { var raw = self.rawValue - return Data(bytes: &raw, count: UInt8.bitWidth/8) + return unsafe Data(bytes: &raw, count: MemoryLayout.size) } } diff --git a/Sources/Packages/Sources/SecretAgentKit/FileHandleProtocols.swift b/Sources/Packages/Sources/SecretAgentKit/FileHandleProtocols.swift index 40a2840..8baedd6 100644 --- a/Sources/Packages/Sources/SecretAgentKit/FileHandleProtocols.swift +++ b/Sources/Packages/Sources/SecretAgentKit/FileHandleProtocols.swift @@ -1,32 +1,12 @@ import Foundation -/// Protocol abstraction of the reading aspects of FileHandle. -public protocol FileHandleReader: Sendable { - - /// Gets data that is available for reading. - var availableData: Data { get } - /// A file descriptor of the handle. - var fileDescriptor: Int32 { get } - /// The process ID of the process coonnected to the other end of the FileHandle. - var pidOfConnectedProcess: Int32 { get } - -} - -/// Protocol abstraction of the writing aspects of FileHandle. -public protocol FileHandleWriter: Sendable { - - /// Writes data to the handle. - func write(_ data: Data) - -} - -extension FileHandle: FileHandleReader, FileHandleWriter { +extension FileHandle { public var pidOfConnectedProcess: Int32 { - let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1) + let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: MemoryLayout.size, alignment: 1) var len = socklen_t(MemoryLayout.size) - getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len) - return pidPointer.load(as: Int32.self) + unsafe getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len) + return unsafe pidPointer.load(as: Int32.self) } } diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift b/Sources/Packages/Sources/SecretAgentKit/OpenSSHCertificateHandler.swift similarity index 59% rename from Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift rename to Sources/Packages/Sources/SecretAgentKit/OpenSSHCertificateHandler.swift index 5066545..5451e49 100644 --- a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift +++ b/Sources/Packages/Sources/SecretAgentKit/OpenSSHCertificateHandler.swift @@ -1,12 +1,13 @@ import Foundation import OSLog +import SecretKit /// Manages storage and lookup for OpenSSH certificates. -public final class OpenSSHCertificateHandler { +public actor OpenSSHCertificateHandler: Sendable { - private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) + private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory) private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler") - private let writer = OpenSSHKeyWriter() + private let writer = OpenSSHPublicKeyWriter() private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:] /// Initializes an OpenSSHCertificateHandler. @@ -25,38 +26,6 @@ public final class OpenSSHCertificateHandler { } } - /// Whether or not the certificate handler has a certifiicate associated with a given secret. - /// - Parameter secret: The secret to check for a certificate. - /// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret - public func hasCertificate(for secret: SecretType) -> Bool { - keyBlobsAndNames[AnySecret(secret)] != nil - } - - - /// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported - /// - Parameter certBlock: The openssh certificate to extract the public key from - /// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil. - public func publicKeyHash(from hash: Data) -> Data? { - let reader = OpenSSHReader(data: hash) - let certType = String(decoding: reader.readNextChunk(), as: UTF8.self) - - switch certType { - case "ecdsa-sha2-nistp256-cert-v01@openssh.com", - "ecdsa-sha2-nistp384-cert-v01@openssh.com", - "ecdsa-sha2-nistp521-cert-v01@openssh.com": - _ = reader.readNextChunk() // nonce - let curveIdentifier = reader.readNextChunk() - let publicKey = reader.readNextChunk() - - let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)! - return writer.lengthAndData(of: curveType) + - writer.lengthAndData(of: curveIdentifier) + - writer.lengthAndData(of: publicKey) - default: - return nil - } - } - /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` /// - Parameter secret: The secret to search for a certificate with /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. @@ -86,14 +55,13 @@ public final class OpenSSHCertificateHandler { throw OpenSSHCertificateError.parsingFailed } - if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) { + if certElements.count >= 3 { + let certName = Data(certElements[2].utf8) return (certDecoded, certName) - } else if let certName = secret.name.data(using: .utf8) { - logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead") - return (certDecoded, certName) - } else { - throw OpenSSHCertificateError.parsingFailed } + let certName = Data(secret.name.utf8) + logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead") + return (certDecoded, certName) } } diff --git a/Sources/Packages/Sources/SecretAgentKit/OpenSSHReader.swift b/Sources/Packages/Sources/SecretAgentKit/OpenSSHReader.swift new file mode 100644 index 0000000..a3508e3 --- /dev/null +++ b/Sources/Packages/Sources/SecretAgentKit/OpenSSHReader.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Reads OpenSSH protocol data. +final class OpenSSHReader { + + var remaining: Data + + /// Initialize the reader with an OpenSSH data payload. + /// - Parameter data: The data to read. + init(data: Data) { + remaining = Data(data) + } + + /// Reads the next chunk of data from the playload. + /// - Returns: The next chunk of data. + func readNextChunk(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> Data { + let littleEndianLength = try readNextBytes(as: UInt32.self) + let length = convertEndianness ? Int(littleEndianLength.bigEndian) : Int(littleEndianLength) + guard remaining.count >= length else { throw .beyondBounds } + let dataRange = 0..(as: T.Type) throws(OpenSSHReaderError) -> T { + let size = MemoryLayout.size + guard remaining.count >= size else { throw .beyondBounds } + let lengthRange = 0.. String { + try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self) + } + + func readNextChunkAsSubReader(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> OpenSSHReader { + OpenSSHReader(data: try readNextChunk(convertEndianness: convertEndianness)) + } + +} + +public enum OpenSSHReaderError: Error, Codable { + case beyondBounds +} diff --git a/Sources/Packages/Sources/SecretAgentKit/SSHAgentInputParser.swift b/Sources/Packages/Sources/SecretAgentKit/SSHAgentInputParser.swift new file mode 100644 index 0000000..6e9a2ee --- /dev/null +++ b/Sources/Packages/Sources/SecretAgentKit/SSHAgentInputParser.swift @@ -0,0 +1,109 @@ +import Foundation +import OSLog +import SecretKit + +public protocol SSHAgentInputParserProtocol { + + func parse(data: Data) async throws -> SSHAgent.Request + +} + +public struct SSHAgentInputParser: SSHAgentInputParserProtocol { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser") + + public init() { + + } + + public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request { + logger.debug("Parsing new data") + guard data.count > 4 else { + throw .invalidData + } + let specifiedLength = unsafe (data[0..<4].bytes.unsafeLoad(as: UInt32.self).bigEndian) + 4 + let rawRequestInt = data[4] + let remainingDataRange = 5.. SSHAgent.Request.SignatureRequestContext { + let reader = OpenSSHReader(data: data) + let rawKeyBlob = try reader.readNextChunk() + let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob + let dataToSign = try reader.readNextChunk() + return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign) + } + + func certificatePublicKeyBlob(from hash: Data) -> Data? { + let reader = OpenSSHReader(data: hash) + do { + let certType = String(decoding: try reader.readNextChunk(), as: UTF8.self) + switch certType { + case "ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ecdsa-sha2-nistp384-cert-v01@openssh.com", + "ecdsa-sha2-nistp521-cert-v01@openssh.com": + _ = try reader.readNextChunk() // nonce + let curveIdentifier = try reader.readNextChunk() + let publicKey = try reader.readNextChunk() + let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "") + return openSSHIdentifier.lengthAndData + + curveIdentifier.lengthAndData + + publicKey.lengthAndData + default: + return nil + } + } catch { + return nil + } + } + +} + + +extension SSHAgentInputParser { + + public enum AgentParsingError: Error, Codable { + case unknownRequest + case unhandledRequest + case invalidData + case openSSHReader(OpenSSHReaderError) + } + +} diff --git a/Sources/Packages/Sources/SecretAgentKit/SSHAgentProtocol.swift b/Sources/Packages/Sources/SecretAgentKit/SSHAgentProtocol.swift index 4c45616..0007989 100644 --- a/Sources/Packages/Sources/SecretAgentKit/SSHAgentProtocol.swift +++ b/Sources/Packages/Sources/SecretAgentKit/SSHAgentProtocol.swift @@ -6,39 +6,92 @@ public enum SSHAgent {} extension SSHAgent { /// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1 - public enum RequestType: UInt8, CustomDebugStringConvertible { + public enum Request: CustomDebugStringConvertible, Codable, Sendable { - case requestIdentities = 11 - case signRequest = 13 + case requestIdentities + case signRequest(SignatureRequestContext) + case addIdentity + case removeIdentity + case removeAllIdentities + case addIDConstrained + case addSmartcardKey + case removeSmartcardKey + case lock + case unlock + case addSmartcardKeyConstrained + case protocolExtension + case unknown(UInt8) + + public var protocolID: UInt8 { + switch self { + case .requestIdentities: 11 + case .signRequest: 13 + case .addIdentity: 17 + case .removeIdentity: 18 + case .removeAllIdentities: 19 + case .addIDConstrained: 25 + case .addSmartcardKey: 20 + case .removeSmartcardKey: 21 + case .lock: 22 + case .unlock: 23 + case .addSmartcardKeyConstrained: 26 + case .protocolExtension: 27 + case .unknown(let value): value + } + } public var debugDescription: String { switch self { - case .requestIdentities: - return "RequestIdentities" - case .signRequest: - return "SignRequest" + case .requestIdentities: "SSH_AGENTC_REQUEST_IDENTITIES" + case .signRequest: "SSH_AGENTC_SIGN_REQUEST" + case .addIdentity: "SSH_AGENTC_ADD_IDENTITY" + case .removeIdentity: "SSH_AGENTC_REMOVE_IDENTITY" + case .removeAllIdentities: "SSH_AGENTC_REMOVE_ALL_IDENTITIES" + case .addIDConstrained: "SSH_AGENTC_ADD_ID_CONSTRAINED" + case .addSmartcardKey: "SSH_AGENTC_ADD_SMARTCARD_KEY" + case .removeSmartcardKey: "SSH_AGENTC_REMOVE_SMARTCARD_KEY" + case .lock: "SSH_AGENTC_LOCK" + case .unlock: "SSH_AGENTC_UNLOCK" + case .addSmartcardKeyConstrained: "SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED" + case .protocolExtension: "SSH_AGENTC_EXTENSION" + case .unknown: "UNKNOWN_MESSAGE" } } + + public struct SignatureRequestContext: Sendable, Codable { + public let keyBlob: Data + public let dataToSign: Data + + public init(keyBlob: Data, dataToSign: Data) { + self.keyBlob = keyBlob + self.dataToSign = dataToSign + } + + public static var empty: SignatureRequestContext { + SignatureRequestContext(keyBlob: Data(), dataToSign: Data()) + } + } + } /// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1 - public enum ResponseType: UInt8, CustomDebugStringConvertible { + public enum Response: UInt8, CustomDebugStringConvertible { case agentFailure = 5 case agentSuccess = 6 case agentIdentitiesAnswer = 12 case agentSignResponse = 14 + case agentExtensionFailure = 28 + case agentExtensionResponse = 29 public var debugDescription: String { switch self { - case .agentFailure: - return "AgentFailure" - case .agentSuccess: - return "AgentSuccess" - case .agentIdentitiesAnswer: - return "AgentIdentitiesAnswer" - case .agentSignResponse: - return "AgentSignResponse" + case .agentFailure: "SSH_AGENT_FAILURE" + case .agentSuccess: "SSH_AGENT_SUCCESS" + case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER" + case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE" + case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE" + case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE" } } } diff --git a/Sources/Packages/Sources/SecretAgentKit/Sendability.swift b/Sources/Packages/Sources/SecretAgentKit/Sendability.swift deleted file mode 100644 index 5338464..0000000 --- a/Sources/Packages/Sources/SecretAgentKit/Sendability.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -struct UncheckedSendable: @unchecked Sendable { - - let value: T - - init(_ value: T) { - self.value = value - } - -} diff --git a/Sources/Packages/Sources/SecretAgentKit/SigningRequestTracer.swift b/Sources/Packages/Sources/SecretAgentKit/SigningRequestTracer.swift index aa8e295..8801d6f 100644 --- a/Sources/Packages/Sources/SecretAgentKit/SigningRequestTracer.swift +++ b/Sources/Packages/Sources/SecretAgentKit/SigningRequestTracer.swift @@ -2,7 +2,6 @@ import Foundation import AppKit import Security import SecretKit -import SecretAgentKitHeaders /// An object responsible for generating ``SecretKit.SigningRequestProvenance`` objects. struct SigningRequestTracer { @@ -10,12 +9,11 @@ struct SigningRequestTracer { extension SigningRequestTracer { - /// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandleReader``. - /// - Parameter fileHandleReader: The reader involved in processing the request. + /// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandle``. + /// - Parameter fileHandle: The reader involved in processing the request. /// - Returns: A ``SecretKit.SigningRequestProvenance`` describing the origin of the request. - func provenance(from fileHandleReader: FileHandleReader) -> SigningRequestProvenance { - let firstInfo = process(from: fileHandleReader.pidOfConnectedProcess) - + func provenance(from fileHandle: FileHandle) -> SigningRequestProvenance { + let firstInfo = process(from: fileHandle.pidOfConnectedProcess) var provenance = SigningRequestProvenance(root: firstInfo) while NSRunningApplication(processIdentifier: provenance.origin.pid) == nil && provenance.origin.parentPID != nil { provenance.chain.append(process(from: provenance.origin.parentPID!)) @@ -27,11 +25,11 @@ extension SigningRequestTracer { /// - Parameter pid: The process ID to look up. /// - Returns: a `kinfo_proc` struct describing the process ID. func pidAndNameInfo(from pid: Int32) -> kinfo_proc { - var len = MemoryLayout.size + var len = unsafe MemoryLayout.size let infoPointer = UnsafeMutableRawPointer.allocate(byteCount: len, alignment: 1) var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] - sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0) - return infoPointer.load(as: kinfo_proc.self) + unsafe sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0) + return unsafe infoPointer.load(as: kinfo_proc.self) } /// Generates a ``SecretKit.SigningRequestProvenance.Process`` from a provided process ID. @@ -39,18 +37,18 @@ extension SigningRequestTracer { /// - Returns: A ``SecretKit.SigningRequestProvenance.Process`` describing the process. func process(from pid: Int32) -> SigningRequestProvenance.Process { var pidAndNameInfo = self.pidAndNameInfo(from: pid) - let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil - let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in - String(cString: pointer) + let ppid = unsafe pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil + let procName = unsafe withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in + unsafe String(cString: pointer) } let pathPointer = UnsafeMutablePointer.allocate(capacity: Int(MAXPATHLEN)) - _ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN)) - let path = String(cString: pathPointer) + _ = unsafe proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN)) + let path = unsafe String(cString: pathPointer) var secCode: Unmanaged! let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks] - SecCodeCreateWithPID(pid, SecCSFlags(), &secCode) - let valid = SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess + unsafe SecCodeCreateWithPID(pid, SecCSFlags(), &secCode) + let valid = unsafe SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid) } @@ -81,3 +79,11 @@ extension SigningRequestTracer { } } + +// from libproc.h +@_silgen_name("proc_pidpath") +@discardableResult func proc_pidpath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer!, _ buffersize: UInt32) -> Int32 + +//// from SecTask.h +@_silgen_name("SecCodeCreateWithPID") +@discardableResult func SecCodeCreateWithPID(_: Int32, _: SecCSFlags, _: UnsafeMutablePointer?>!) -> OSStatus diff --git a/Sources/Packages/Sources/SecretAgentKit/SigningWitness.swift b/Sources/Packages/Sources/SecretAgentKit/SigningWitness.swift index b090bd3..2e6ab49 100644 --- a/Sources/Packages/Sources/SecretAgentKit/SigningWitness.swift +++ b/Sources/Packages/Sources/SecretAgentKit/SigningWitness.swift @@ -2,7 +2,7 @@ import Foundation import SecretKit /// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access. -public protocol SigningWitness { +public protocol SigningWitness: Sendable { /// A ridiculously named method that notifies the callee that a signing operation is about to be performed using a secret. The callee may `throw` an `Error` to prevent access from occurring. /// - Parameters: @@ -10,13 +10,13 @@ public protocol SigningWitness { /// - store: The `Store` being asked to sign the request.. /// - provenance: A `SigningRequestProvenance` object describing the origin of the request. /// - Note: This method being called does not imply that the requst has been authorized. If a secret requires authentication, authentication will still need to be performed by the user before the request will be performed. If the user declines or fails to authenticate, the request will fail. - func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws + func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws /// Notifies the callee that a signing operation has been performed for a given secret. /// - Parameters: /// - secret: The `Secret` that will was used to sign the request. /// - store: The `Store` that signed the request.. /// - provenance: A `SigningRequestProvenance` object describing the origin of the request. - func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws + func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws } diff --git a/Sources/Packages/Sources/SecretAgentKit/SocketController.swift b/Sources/Packages/Sources/SecretAgentKit/SocketController.swift index a51951f..9de2564 100644 --- a/Sources/Packages/Sources/SecretAgentKit/SocketController.swift +++ b/Sources/Packages/Sources/SecretAgentKit/SocketController.swift @@ -1,23 +1,32 @@ import Foundation import OSLog +import SecretKit /// A controller that manages socket configuration and request dispatching. -public final class SocketController { +public struct SocketController { - /// The active FileHandle. - private var fileHandle: FileHandle? - /// The active SocketPort. - private var port: SocketPort? - /// A handler that will be notified when a new read/write handle is available. - /// False if no data could be read - public var handler: (@Sendable (FileHandleReader, FileHandleWriter) async -> Bool)? - /// Logger. + /// A stream of Sessions. Each session represents one connection to a class communicating with the socket. Multiple Sessions may be active simultaneously. + public let sessions: AsyncStream + + /// A continuation to create new sessions. + private let sessionsContinuation: AsyncStream.Continuation + + /// The active SocketPort. Must be retained to be kept valid. + private let port: SocketPort + + /// The FileHandle for the main socket. + private let fileHandle: FileHandle + + /// Logger for the socket controller. private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "SocketController") + /// Tracer which determines who originates a socket connection. + private let requestTracer = SigningRequestTracer() /// Initializes a socket controller with a specified path. /// - Parameter path: The path to use as a socket. public init(path: String) { + (sessions, sessionsContinuation) = AsyncStream.makeStream() logger.debug("Socket controller setting up at \(path)") if let _ = try? FileManager.default.removeItem(atPath: path) { logger.debug("Socket controller removed existing socket") @@ -25,77 +34,87 @@ public final class SocketController { let exists = FileManager.default.fileExists(atPath: path) assert(!exists) logger.debug("Socket controller path is clear") - port = socketPort(at: path) - configureSocket(at: path) - logger.debug("Socket listening at \(path)") - } - - /// Configures the socket and a corresponding FileHandle. - /// - Parameter path: The path to use as a socket. - func configureSocket(at path: String) { - guard let port = port else { return } + port = SocketPort(path: path) fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true) - NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionAccept(notification:)), name: .NSFileHandleConnectionAccepted, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionDataAvailable(notification:)), name: .NSFileHandleDataAvailable, object: nil) - fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common]) - } - - /// Creates a SocketPort for a path. - /// - Parameter path: The path to use as a socket. - /// - Returns: A configured SocketPort. - func socketPort(at path: String) -> SocketPort { - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - - var len: Int = 0 - withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in - path.withCString { cstring in - len = strlen(cstring) - strncpy(pointer, cstring, len) - } - } - addr.sun_len = UInt8(len+2) - - var data: Data! - withUnsafePointer(to: &addr) { pointer in - data = Data(bytes: pointer, count: MemoryLayout.size) - } - - return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)! - } - - /// Handles a new connection being accepted, invokes the handler, and prepares to accept new connections. - /// - Parameter notification: A `Notification` that triggered the call. - @objc func handleConnectionAccept(notification: Notification) { - logger.debug("Socket controller accepted connection") - guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return } - Task { [handler, fileHandle] in - _ = await handler?(new, new) - await new.waitForDataInBackgroundAndNotifyOnMainActor() - await fileHandle?.acceptConnectionInBackgroundAndNotifyOnMainActor() - } - } - - /// Handles a new connection providing data and invokes the handler callback. - /// - Parameter notification: A `Notification` that triggered the call. - @objc func handleConnectionDataAvailable(notification: Notification) { - logger.debug("Socket controller has new data available") - guard let new = notification.object as? FileHandle else { return } - logger.debug("Socket controller received new file handle") - Task { [handler, logger = UncheckedSendable(logger)] in - if((await handler?(new, new)) == true) { - logger.value.debug("Socket controller handled data, wait for more data") - await new.waitForDataInBackgroundAndNotifyOnMainActor() - } else { - logger.value.debug("Socket controller called with empty data, socked closed") + Task { [fileHandle, sessionsContinuation, logger] in + for await notification in NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted) { + logger.debug("Socket controller accepted connection") + guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue } + let session = Session(fileHandle: new) + sessionsContinuation.yield(session) + await fileHandle.acceptConnectionInBackgroundAndNotifyOnMainActor() } } + fileHandle.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common]) + logger.debug("Socket listening at \(path)") } } -extension FileHandle { +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 + + /// 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.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() + } + + } + +} + +private extension FileHandle { + /// Ensures waitForDataInBackgroundAndNotify will be called on the main actor. @MainActor func waitForDataInBackgroundAndNotifyOnMainActor() { waitForDataInBackgroundAndNotify() @@ -109,3 +128,27 @@ extension FileHandle { } } + +private extension SocketPort { + + convenience init(path: String) { + var addr = sockaddr_un() + + let length = unsafe withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in + unsafe path.withCString { cstring in + let len = unsafe strlen(cstring) + unsafe strncpy(pointer, cstring, len) + return len + } + } + // This doesn't seem to be _strictly_ neccessary with SocketPort. + // but just for good form. + addr.sun_family = sa_family_t(AF_UNIX) + // This mirrors the SUN_LEN macro format. + addr.sun_len = UInt8(MemoryLayout.size - MemoryLayout.size(ofValue: addr.sun_path) + length) + + let data = unsafe Data(bytes: &addr, count: MemoryLayout.size) + self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)! + } + +} diff --git a/Sources/Packages/Sources/SecretAgentKitHeaders/Stub.swift b/Sources/Packages/Sources/SecretAgentKitHeaders/Stub.swift deleted file mode 100644 index 8b13789..0000000 --- a/Sources/Packages/Sources/SecretAgentKitHeaders/Stub.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Sources/Packages/Sources/SecretAgentKitHeaders/include/SecretAgentKit.h b/Sources/Packages/Sources/SecretAgentKitHeaders/include/SecretAgentKit.h deleted file mode 100644 index e9c7150..0000000 --- a/Sources/Packages/Sources/SecretAgentKitHeaders/include/SecretAgentKit.h +++ /dev/null @@ -1,19 +0,0 @@ -#import -#import - - -// Forward declarations - -// from libproc.h -int proc_pidpath(int pid, void * buffer, uint32_t buffersize); - -// from SecTask.h -OSStatus SecCodeCreateWithPID(int32_t, SecCSFlags, SecCodeRef *); - -//! Project version number for SecretAgentKit. -FOUNDATION_EXPORT double SecretAgentKitVersionNumber; - -//! Project version string for SecretAgentKit. -FOUNDATION_EXPORT const unsigned char SecretAgentKitVersionString[]; - - diff --git a/Sources/Packages/Sources/SecretAgentKitHeaders/module.modulemap b/Sources/Packages/Sources/SecretAgentKitHeaders/module.modulemap deleted file mode 100644 index 7fceeab..0000000 --- a/Sources/Packages/Sources/SecretAgentKitHeaders/module.modulemap +++ /dev/null @@ -1,4 +0,0 @@ -module SecretAgentKitHeaders [system] { - header "include/SecretAgentKit.h" - export * -} diff --git a/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md b/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md index a7fed06..8798ca6 100644 --- a/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md +++ b/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md @@ -22,7 +22,7 @@ SecretKit is a collection of protocols describing secrets and stores. ### OpenSSH -- ``OpenSSHKeyWriter`` +- ``OpenSSHPublicKeyWriter`` - ``OpenSSHReader`` ### Signing Process diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift index f6e8bef..17ba732 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift @@ -1,36 +1,30 @@ import Foundation /// Type eraser for Secret. -public struct AnySecret: Secret { +public struct AnySecret: Secret, @unchecked Sendable { - let base: Any - private let hashable: AnyHashable + public let base: any Secret private let _id: () -> AnyHashable private let _name: () -> String - private let _algorithm: () -> Algorithm - private let _keySize: () -> Int - private let _requiresAuthentication: () -> Bool private let _publicKey: () -> Data + private let _attributes: () -> Attributes + private let _eq: (AnySecret) -> Bool public init(_ secret: T) where T: Secret { if let secret = secret as? AnySecret { base = secret.base - hashable = secret.hashable _id = secret._id _name = secret._name - _algorithm = secret._algorithm - _keySize = secret._keySize - _requiresAuthentication = secret._requiresAuthentication _publicKey = secret._publicKey + _attributes = secret._attributes + _eq = secret._eq } else { - base = secret as Any - self.hashable = secret + base = secret _id = { secret.id as AnyHashable } _name = { secret.name } - _algorithm = { secret.algorithm } - _keySize = { secret.keySize } - _requiresAuthentication = { secret.requiresAuthentication } _publicKey = { secret.publicKey } + _attributes = { secret.attributes } + _eq = { secret == $0.base as? T } } } @@ -42,28 +36,20 @@ public struct AnySecret: Secret { _name() } - public var algorithm: Algorithm { - _algorithm() - } - - public var keySize: Int { - _keySize() - } - - public var requiresAuthentication: Bool { - _requiresAuthentication() - } - public var publicKey: Data { _publicKey() } + + public var attributes: Attributes { + _attributes() + } public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool { - lhs.hashable == rhs.hashable + lhs._eq(rhs) } public func hash(into hasher: inout Hasher) { - hashable.hash(into: &hasher) + id.hash(into: &hasher) } } diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift index bf5a74d..08123a1 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift @@ -1,21 +1,17 @@ import Foundation -import Combine /// Type eraser for SecretStore. -public class AnySecretStore: SecretStore { +open class AnySecretStore: SecretStore, @unchecked Sendable { - let base: Any - private let _isAvailable: () -> Bool - private let _id: () -> UUID - private let _name: () -> String - private let _secrets: () -> [AnySecret] - private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data - private let _verify: (Data, Data, AnySecret) throws -> Bool - private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext? - private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void - private let _reloadSecrets: () -> Void - - private var sink: AnyCancellable? + let base: any SecretStore + private let _isAvailable: @MainActor @Sendable () -> Bool + private let _id: @Sendable () -> UUID + private let _name: @MainActor @Sendable () -> String + private let _secrets: @MainActor @Sendable () -> [AnySecret] + private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data + private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext? + private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void + private let _reloadSecrets: @Sendable () async -> Void public init(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore { base = secretStore @@ -23,17 +19,13 @@ public class AnySecretStore: SecretStore { _name = { secretStore.name } _id = { secretStore.id } _secrets = { secretStore.secrets.map { AnySecret($0) } } - _sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) } - _verify = { try secretStore.verify(signature: $0, for: $1, with: $2.base as! SecretStoreType.SecretType) } - _existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) } - _persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) } - _reloadSecrets = { secretStore.reloadSecrets() } - sink = secretStore.objectWillChange.sink { _ in - self.objectWillChange.send() - } + _sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) } + _existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) } + _persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) } + _reloadSecrets = { await secretStore.reloadSecrets() } } - public var isAvailable: Bool { + @MainActor public var isAvailable: Bool { return _isAvailable() } @@ -41,59 +33,62 @@ public class AnySecretStore: SecretStore { return _id() } - public var name: String { + @MainActor public var name: String { return _name() } - public var secrets: [AnySecret] { + @MainActor public var secrets: [AnySecret] { return _secrets() } - public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) throws -> Data { - try _sign(data, secret, provenance) + public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) async throws -> Data { + try await _sign(data, secret, provenance) } - public func verify(signature: Data, for data: Data, with secret: AnySecret) throws -> Bool { - try _verify(signature, data, secret) + public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? { + await _existingPersistedAuthenticationContext(secret) } - public func existingPersistedAuthenticationContext(secret: AnySecret) -> PersistedAuthenticationContext? { - _existingPersistedAuthenticationContext(secret) + public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws { + try await _persistAuthentication(secret, duration) } - public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) throws { - try _persistAuthentication(secret, duration) - } - - public func reloadSecrets() { - _reloadSecrets() + public func reloadSecrets() async { + await _reloadSecrets() } } -public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable { +public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable { - private let _create: (String, Bool) throws -> Void - private let _delete: (AnySecret) throws -> Void - private let _update: (AnySecret, String) throws -> Void + private let _create: @Sendable (String, Attributes) async throws -> AnySecret + private let _delete: @Sendable (AnySecret) async throws -> Void + private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void + private let _supportedKeyTypes: @Sendable () -> [KeyType] - public init(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable { - _create = { try secretStore.create(name: $0, requiresAuthentication: $1) } - _delete = { try secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) } - _update = { try secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1) } + public init(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable { + _create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) } + _delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) } + _update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) } + _supportedKeyTypes = { secretStore.supportedKeyTypes } super.init(secretStore) } - public func create(name: String, requiresAuthentication: Bool) throws { - try _create(name, requiresAuthentication) + @discardableResult + public func create(name: String, attributes: Attributes) async throws -> SecretType { + try await _create(name, attributes) } - public func delete(secret: AnySecret) throws { - try _delete(secret) + public func delete(secret: AnySecret) async throws { + try await _delete(secret) } - public func update(secret: AnySecret, name: String) throws { - try _update(secret, name) + public func update(secret: AnySecret, name: String, attributes: Attributes) async throws { + try await _update(secret, name, attributes) + } + + public var supportedKeyTypes: [KeyType] { + _supportedKeyTypes() } } diff --git a/Sources/Packages/Sources/SecretKit/KeychainTypes.swift b/Sources/Packages/Sources/SecretKit/KeychainTypes.swift index cfea466..5574f65 100644 --- a/Sources/Packages/Sources/SecretKit/KeychainTypes.swift +++ b/Sources/Packages/Sources/SecretKit/KeychainTypes.swift @@ -36,12 +36,12 @@ public struct KeychainError: Error { /// A signing-related error. public struct SigningError: Error { /// The underlying error reported by the API, if one was returned. - public let error: SecurityError? + public let error: CFError? /// Initializes a SigningError with an optional SecurityError. /// - Parameter statusCode: The SecurityError, if one is applicable. public init(error: SecurityError?) { - self.error = error + self.error = unsafe error?.takeRetainedValue() } } @@ -51,19 +51,17 @@ public extension SecretStore { /// Returns the appropriate keychian signature algorithm to use for a given secret. /// - Parameters: /// - secret: The secret which will be used for signing. - /// - allowRSA: Whether or not RSA key types should be permited. /// - Returns: The appropriate algorithm. - func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm { - switch (secret.algorithm, secret.keySize) { - case (.ellipticCurve, 256): - return .ecdsaSignatureMessageX962SHA256 - case (.ellipticCurve, 384): - return .ecdsaSignatureMessageX962SHA384 - case (.rsa, 1024), (.rsa, 2048): - guard allowRSA else { fatalError() } - return .rsaSignatureMessagePKCS1v15SHA512 + func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? { + switch secret.keyType { + case .ecdsa256: + .ecdsaSignatureMessageX962SHA256 + case .ecdsa384: + .ecdsaSignatureMessageX962SHA384 + case .rsa2048: + .rsaSignatureMessagePKCS1v15SHA512 default: - fatalError() + nil } } diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/LengthAndData.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/LengthAndData.swift new file mode 100644 index 0000000..9b29a85 --- /dev/null +++ b/Sources/Packages/Sources/SecretKit/OpenSSH/LengthAndData.swift @@ -0,0 +1,23 @@ +import Foundation + +extension Data { + + /// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload. + /// - Returns: OpenSSH data. + package var lengthAndData: Data { + let rawLength = UInt32(count) + var endian = rawLength.bigEndian + return unsafe Data(bytes: &endian, count: MemoryLayout.size) + self + } + +} + +extension String { + + /// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload. + /// - Returns: OpenSSH data. + package var lengthAndData: Data { + Data(utf8).lengthAndData + } + +} diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift deleted file mode 100644 index da8c4b1..0000000 --- a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift +++ /dev/null @@ -1,89 +0,0 @@ -import Foundation -import CryptoKit - -/// Generates OpenSSH representations of Secrets. -public struct OpenSSHKeyWriter { - - /// Initializes the writer. - public init() { - } - - /// Generates an OpenSSH data payload identifying the secret. - /// - Returns: OpenSSH data payload identifying the secret. - public func data(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(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(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)..(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" - } - } - -} diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHPublicKeyWriter.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHPublicKeyWriter.swift new file mode 100644 index 0000000..30249e0 --- /dev/null +++ b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHPublicKeyWriter.swift @@ -0,0 +1,114 @@ +import Foundation +import CryptoKit + +/// Generates OpenSSH representations of the public key sof secrets. +public struct OpenSSHPublicKeyWriter: Sendable { + + /// Initializes the writer. + public init() { + } + + /// Generates an OpenSSH data payload identifying the secret. + /// - Returns: OpenSSH data payload identifying the secret. + public func data(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(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(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)..(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(secret: SecretType) -> String { + if let comment = secret.publicKeyAttribution { + return comment + } else { + let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-") + let dashedHostName = ["secretive", Host.current().localizedName, "local"] + .compactMap { $0 } + .joined(separator: ".") + .replacingOccurrences(of: " ", with: "-") + return "\(dashedKeyName)@\(dashedHostName)" + } + + } +} + +extension OpenSSHPublicKeyWriter { + + /// The fully qualified OpenSSH identifier for the algorithm. + /// - Parameters: + /// - algorithm: The algorithm to identify. + /// - length: The key length of the algorithm. + /// - Returns: The OpenSSH identifier for the algorithm. + public func openSSHIdentifier(for keyType: KeyType) -> String { + switch keyType { + case .ecdsa256: + "ecdsa-sha2-nistp256" + case .ecdsa384: + "ecdsa-sha2-nistp384" + case .mldsa65: + "ssh-mldsa-65" + case .mldsa87: + "ssh-mldsa-87" + case .rsa2048: + "ssh-rsa" + default: + "unknown" + } + } + +} + +extension OpenSSHPublicKeyWriter { + + func rsaPublicKeyBlob(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 + } + +} diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHReader.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHReader.swift deleted file mode 100644 index 6b7bc08..0000000 --- a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHReader.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -/// Reads OpenSSH protocol data. -public final class OpenSSHReader { - - var remaining: Data - - /// Initialize the reader with an OpenSSH data payload. - /// - Parameter data: The data to read. - public init(data: Data) { - remaining = Data(data) - } - - /// Reads the next chunk of data from the playload. - /// - Returns: The next chunk of data. - public func readNextChunk() -> Data { - let lengthRange = 0..<(UInt32.bitWidth/8) - let lengthChunk = remaining[lengthRange] - remaining.removeSubrange(lengthRange) - let littleEndianLength = lengthChunk.withUnsafeBytes { pointer in - return pointer.load(as: UInt32.self) - } - let length = Int(littleEndianLength.bigEndian) - let dataRange = 0..(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 = 0x80...0xFF + var r = Data(rawRepresentation[0.. 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 + } + +} diff --git a/Sources/Packages/Sources/SecretKit/PublicKeyStandinFileController.swift b/Sources/Packages/Sources/SecretKit/PublicKeyStandinFileController.swift index 7c3f8da..49983d2 100644 --- a/Sources/Packages/Sources/SecretKit/PublicKeyStandinFileController.swift +++ b/Sources/Packages/Sources/SecretKit/PublicKeyStandinFileController.swift @@ -2,15 +2,15 @@ import Foundation import OSLog /// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts. -public final class PublicKeyFileStoreController { +public final class PublicKeyFileStoreController: Sendable { private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController") - private let directory: String - private let keyWriter = OpenSSHKeyWriter() + private let directory: URL + private let keyWriter = OpenSSHPublicKeyWriter() /// Initializes a PublicKeyFileStoreController. - public init(homeDirectory: String) { - directory = homeDirectory.appending("/PublicKeys") + public init(homeDirectory: URL) { + directory = homeDirectory.appending(component: "PublicKeys") } /// Writes out the keys specified to disk. @@ -20,19 +20,20 @@ public final class PublicKeyFileStoreController { logger.log("Writing public keys to disk") if clear { let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) })) - let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? [] + let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? [] let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" } let untracked = Set(fullPathContents) .subtracting(validPaths) for path in untracked { - try? FileManager.default.removeItem(at: URL(fileURLWithPath: path)) + // string instead of fileURLWithPath since we're already using fileURL format. + try? FileManager.default.removeItem(at: URL(string: path)!) } } - try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil) for secret in secrets { let path = publicKeyPath(for: secret) - guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue } + let data = Data(keyWriter.openSSHString(secret: secret).utf8) FileManager.default.createFile(atPath: path, contents: data, attributes: nil) } logger.log("Finished writing public keys") @@ -44,14 +45,14 @@ public final class PublicKeyFileStoreController { /// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to. public func publicKeyPath(for secret: SecretType) -> String { let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "") - return directory.appending("/").appending("\(minimalHex).pub") + return directory.appending(component: "\(minimalHex).pub").path() } /// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory. public var hasAnyCertificates: Bool { do { return try FileManager.default - .contentsOfDirectory(atPath: directory) + .contentsOfDirectory(atPath: directory.path()) .filter { $0.hasSuffix("-cert.pub") } .isEmpty == false } catch { @@ -65,7 +66,7 @@ public final class PublicKeyFileStoreController { /// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be. public func sshCertificatePath(for secret: SecretType) -> String { let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "") - return directory.appending("/").appending("\(minimalHex)-cert.pub") + return directory.appending(component: "\(minimalHex)-cert.pub").path() } } diff --git a/Sources/Packages/Sources/SecretKit/SecretStoreList.swift b/Sources/Packages/Sources/SecretKit/SecretStoreList.swift index eb8456f..fb42e7e 100644 --- a/Sources/Packages/Sources/SecretKit/SecretStoreList.swift +++ b/Sources/Packages/Sources/SecretKit/SecretStoreList.swift @@ -1,49 +1,47 @@ import Foundation -import Combine +import Observation /// A "Store Store," which holds a list of type-erased stores. -public final class SecretStoreList: ObservableObject { +@Observable @MainActor public final class SecretStoreList: Sendable { /// The Stores managed by the SecretStoreList. - @Published public var stores: [AnySecretStore] = [] + public var stores: [AnySecretStore] = [] /// A modifiable store, if one is available. - @Published public var modifiableStore: AnySecretStoreModifiable? - private var cancellables: Set = [] + public var modifiableStore: AnySecretStoreModifiable? = nil /// Initializes a SecretStoreList. - public init() { + public nonisolated init() { } /// Adds a non-type-erased SecretStore to the list. public func add(store: SecretStoreType) { - addInternal(store: AnySecretStore(store)) + stores.append(AnySecretStore(store)) } /// Adds a non-type-erased modifiable SecretStore. public func add(store: SecretStoreType) { - let modifiable = AnySecretStoreModifiable(modifiable: store) - modifiableStore = modifiable - addInternal(store: modifiable) + let modifiable = AnySecretStoreModifiable(store) + if modifiableStore == nil { + modifiableStore = modifiable + } + stores.append(modifiable) } /// A boolean describing whether there are any Stores available. public var anyAvailable: Bool { - stores.reduce(false, { $0 || $1.isAvailable }) + stores.contains(where: \.isAvailable) } public var allSecrets: [AnySecret] { stores.flatMap(\.secrets) } -} - -extension SecretStoreList { - - private func addInternal(store: AnySecretStore) { - stores.append(store) - store.objectWillChange.sink { - self.objectWillChange.send() - }.store(in: &cancellables) + public var allSecretsWithStores: [(AnySecret, AnySecretStore)] { + stores.flatMap { store in + store.secrets.map { secret in + (secret, store) + } + } } } diff --git a/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift b/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift new file mode 100644 index 0000000..99ab8f3 --- /dev/null +++ b/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct Attributes: Sendable, Codable, Hashable { + + /// The type of key involved. + public let keyType: KeyType + + /// The authentication requirements for the key. This is simply a description of the option recorded at creation – modifying it doers not modify the key's authentication requirements. + public let authentication: AuthenticationRequirement + + /// The string appended to the end of the SSH Public Key. + /// If nil, a default value will be used. + public var publicKeyAttribution: String? + + public init( + keyType: KeyType, + authentication: AuthenticationRequirement, + publicKeyAttribution: String? = nil + ) { + self.keyType = keyType + self.authentication = authentication + self.publicKeyAttribution = publicKeyAttribution + } + + public struct UnsupportedOptionError: Error { + package init() {} + } + +} + +/// The option specified +public enum AuthenticationRequirement: String, Hashable, Sendable, Codable, Identifiable { + + /// Authentication is not required for usage. + case notRequired + + /// The user needs to authenticate, using either a biometric option, a connected authorized watch, or password entry.. + case presenceRequired + + /// ONLY the current set of biometric data, as matching at time of creation, is accepted. + /// - Warning: This is a dangerous option prone to data loss. The user should be warned before configuring this key that if they modify their enrolled biometry INCLUDING by simply adding a new entry (ie, adding another fingeprting), the key will no longer be able to be accessed. This cannot be overridden with a password. + case biometryCurrent + + /// The authentication requirement was not recorded at creation, and is unknown. + case unknown + + /// Whether or not the key is known to require authentication. + public var required: Bool { + self == .presenceRequired || self == .biometryCurrent + } + + public var id: AuthenticationRequirement { + self + } +} diff --git a/Sources/Packages/Sources/SecretKit/Types/PersistedAuthenticationContext.swift b/Sources/Packages/Sources/SecretKit/Types/PersistedAuthenticationContext.swift index 65ceaf8..edd6dea 100644 --- a/Sources/Packages/Sources/SecretKit/Types/PersistedAuthenticationContext.swift +++ b/Sources/Packages/Sources/SecretKit/Types/PersistedAuthenticationContext.swift @@ -1,7 +1,7 @@ import Foundation /// Protocol describing a persisted authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time. -public protocol PersistedAuthenticationContext { +public protocol PersistedAuthenticationContext: Sendable { /// Whether the context remains valid. var valid: Bool { get } /// The date at which the authorization expires and the context becomes invalid. diff --git a/Sources/Packages/Sources/SecretKit/Types/Secret.swift b/Sources/Packages/Sources/SecretKit/Types/Secret.swift index 8f9656c..6b952f6 100644 --- a/Sources/Packages/Sources/SecretKit/Types/Secret.swift +++ b/Sources/Packages/Sources/SecretKit/Types/Secret.swift @@ -1,47 +1,85 @@ import Foundation /// The base protocol for describing a Secret -public protocol Secret: Identifiable, Hashable { +public protocol Secret: Identifiable, Hashable, Sendable { /// A user-facing string identifying the Secret. var name: String { get } - /// The algorithm this secret uses. - var algorithm: Algorithm { get } - /// The key size for the secret. - var keySize: Int { get } - /// Whether the secret requires authentication before use. - var requiresAuthentication: Bool { get } /// The public key data for the secret. var publicKey: Data { get } + /// The attributes of the key. + var attributes: Attributes { get } } -/// The type of algorithm the Secret uses. Currently, only elliptic curve algorithms are supported. -public enum Algorithm: Hashable { +public extension Secret { - case ellipticCurve - case rsa + /// The algorithm and key size this secret uses. + var keyType: KeyType { + attributes.keyType + } + + /// Whether the secret requires authentication before use. + var authenticationRequirement: AuthenticationRequirement { + attributes.authentication + } + /// An attribution string to apply to the generated public key. + var publicKeyAttribution: String? { + attributes.publicKeyAttribution + } + +} + +/// The type of algorithm the Secret uses. +public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible { + + public static let ecdsa256 = KeyType(algorithm: .ecdsa, size: 256) + public static let ecdsa384 = KeyType(algorithm: .ecdsa, size: 384) + public static let mldsa65 = KeyType(algorithm: .mldsa, size: 65) + public static let mldsa87 = KeyType(algorithm: .mldsa, size: 87) + public static let rsa2048 = KeyType(algorithm: .rsa, size: 2048) + + public enum Algorithm: Hashable, Sendable, Codable { + case ecdsa + case mldsa + case rsa + } + + public var algorithm: Algorithm + public var size: Int + + public init(algorithm: Algorithm, size: Int) { + self.algorithm = algorithm + self.size = size + } /// Initializes the Algorithm with a secAttr representation of an algorithm. /// - Parameter secAttr: the secAttr, represented as an NSNumber. - public init(secAttr: NSNumber) { + public init?(secAttr: NSNumber, size: Int) { let secAttrString = secAttr.stringValue as CFString switch secAttrString { case kSecAttrKeyTypeEC: - self = .ellipticCurve + algorithm = .ecdsa case kSecAttrKeyTypeRSA: - self = .rsa + algorithm = .rsa default: - fatalError() + return nil + } + self.size = size + } + + public var secAttrKeyType: CFString? { + switch algorithm { + case .ecdsa: + kSecAttrKeyTypeEC + case .rsa: + kSecAttrKeyTypeRSA + case .mldsa: + nil } } - public var secAttrKeyType: CFString { - switch self { - case .ellipticCurve: - return kSecAttrKeyTypeEC - case .rsa: - return kSecAttrKeyTypeRSA - } + public var description: String { + "\(algorithm)-\(size)" } } diff --git a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift index f780201..14abc9f 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift @@ -1,19 +1,18 @@ import Foundation -import Combine /// Manages access to Secrets, and performs signature operations on data using those Secrets. -public protocol SecretStore: ObservableObject, Identifiable { +public protocol SecretStore: Identifiable, Sendable { associatedtype SecretType: Secret /// A boolean indicating whether or not the store is available. - var isAvailable: Bool { get } + @MainActor var isAvailable: Bool { get } /// A unique identifier for the store. var id: UUID { get } /// A user-facing name for the store. - var name: String { get } + @MainActor var name: String { get } /// The secrets the store manages. - var secrets: [SecretType] { get } + @MainActor var secrets: [SecretType] { get } /// Signs a data payload with a specified Secret. /// - Parameters: @@ -21,53 +20,49 @@ public protocol SecretStore: ObservableObject, Identifiable { /// - secret: The ``Secret`` to sign with. /// - provenance: A ``SigningRequestProvenance`` describing where the request came from. /// - Returns: The signed data. - func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data - - /// Verifies that a signature is valid over a specified payload. - /// - Parameters: - /// - signature: The signature over the data. - /// - data: The data to verify the signature of. - /// - secret: The secret whose signature to verify. - /// - Returns: Whether the signature was verified. - func verify(signature: Data, for data: Data, with secret: SecretType) throws -> Bool + func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data /// Checks to see if there is currently a valid persisted authentication for a given secret. /// - Parameters: /// - secret: The ``Secret`` to check if there is a persisted authentication for. /// - Returns: A persisted authentication context, if a valid one exists. - func existingPersistedAuthenticationContext(secret: SecretType) -> PersistedAuthenticationContext? + func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext? /// Persists user authorization for access to a secret. /// - Parameters: /// - secret: The ``Secret`` to persist the authorization for. /// - duration: The duration that the authorization should persist for. /// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret. - func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) throws + func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws /// Requests that the store reload secrets from any backing store, if neccessary. - func reloadSecrets() + func reloadSecrets() async } /// A SecretStore that the Secretive admin app can modify. -public protocol SecretStoreModifiable: SecretStore { +public protocol SecretStoreModifiable: SecretStore { /// Creates a new ``Secret`` in the store. /// - Parameters: /// - name: The user-facing name for the ``Secret``. - /// - requiresAuthentication: A boolean indicating whether or not the user will be required to authenticate before performing signature operations with the secret. - func create(name: String, requiresAuthentication: Bool) throws + /// - attributes: A struct describing the options for creating the key.' + @discardableResult + func create(name: String, attributes: Attributes) async throws -> SecretType /// Deletes a Secret in the store. /// - Parameters: /// - secret: The ``Secret`` to delete. - func delete(secret: SecretType) throws + func delete(secret: SecretType) async throws /// Updates the name of a Secret in the store. /// - Parameters: /// - secret: The ``Secret`` to update. /// - name: The new name for the Secret. - func update(secret: SecretType, name: String) throws + /// - attributes: The new attributes for the secret. + func update(secret: SecretType, name: String, attributes: Attributes) async throws + + var supportedKeyTypes: [KeyType] { get } } diff --git a/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift b/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift index a1095fd..2216f45 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift @@ -2,7 +2,7 @@ import Foundation import AppKit /// Describes the chain of applications that requested a signature operation. -public struct SigningRequestProvenance: Equatable { +public struct SigningRequestProvenance: Equatable, Sendable { /// A list of processes involved in the request. /// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app` @@ -30,7 +30,7 @@ extension SigningRequestProvenance { extension SigningRequestProvenance { /// Describes a process in a `SigningRequestProvenance` chain. - public struct Process: Equatable { + public struct Process: Equatable, Sendable { /// The pid of the process. public let pid: Int32 diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift new file mode 100644 index 0000000..cf6b3c0 --- /dev/null +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift @@ -0,0 +1,103 @@ +import Foundation +import Security +import CryptoTokenKit +import CryptoKit +import SecretKit +import os + +extension SecureEnclave { + + public struct CryptoKitMigrator { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CryptoKitMigrator") + + public init() { + } + + /// Keys prior to 3.0 were created and stored directly using the keychain as kSecClassKey items. CryptoKit operates a little differently, in that it creates a key on your behalf which you can persist using an opaque data blob to a generic keychain item. Keychain created keys _also_ use this blob under the hood, but it's stored in the "toid" attribute. This migrates the old keys from kSecClassKey to generic items, copying the "toid" to be the main stored data. If the key is migrated successfully, the old key's identifier is renamed to indicate it's been migrated. + /// - Note: Migration is non-destructive – users can still see and use their keys in older versions of Secretive. + @MainActor public func migrate(to store: Store) throws { + let privateAttributes = KeychainDictionary([ + kSecClass: kSecClassKey, + kSecAttrKeyType: Constants.oldKeyType, + kSecAttrApplicationTag: SecureEnclave.Store.Constants.keyTag, + kSecAttrKeyClass: kSecAttrKeyClassPrivate, + kSecReturnRef: true, + kSecMatchLimit: kSecMatchLimitAll, + kSecReturnAttributes: true + ]) + var privateUntyped: CFTypeRef? + unsafe SecItemCopyMatching(privateAttributes, &privateUntyped) + guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return } + let migratedPublicKeys = Set(store.secrets.map(\.publicKey)) + var migratedAny = false + for key in privateTyped { + let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret) + let id = key[kSecAttrApplicationLabel] as! Data + guard !id.contains(Constants.migrationMagicNumber) else { + logger.log("Skipping \(name), already migrated.") + continue + } + let ref = key[kSecValueRef] as! SecKey + let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any] + let tokenObjectID = unsafe attributes[Constants.tokenObjectID] as! Data + let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl + // Best guess. + let auth: AuthenticationRequirement = String(describing: accessControl) + .contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown + do { + let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID) + let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth)) + guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else { + logger.log("Skipping \(name), public key already present. Marking as migrated.") + try markMigrated(secret: secret, oldID: id) + continue + } + logger.log("Migrating \(name).") + try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes) + logger.log("Migrated \(name).") + try markMigrated(secret: secret, oldID: id) + migratedAny = true + } catch { + logger.error("Failed to migrate \(name): \(error).") + } + } + if migratedAny { + store.reloadSecrets() + } + } + + + + public func markMigrated(secret: Secret, oldID: Data) throws { + let updateQuery = KeychainDictionary([ + kSecClass: kSecClassKey, + kSecAttrApplicationLabel: secret.id + ]) + + let newID = oldID + Constants.migrationMagicNumber + let updatedAttributes = KeychainDictionary([ + kSecAttrApplicationLabel: newID as CFData + ]) + + let status = SecItemUpdate(updateQuery, updatedAttributes) + if status != errSecSuccess { + throw KeychainError(statusCode: status) + } + } + + + } + +} + +extension SecureEnclave.CryptoKitMigrator { + + enum Constants { + public static let oldKeyType = kSecAttrKeyTypeECSECPrimeRandom as String + public static let migrationMagicNumber = Data("_cryptokit_1".utf8) + // https://github.com/apple-opensource/Security/blob/5e9101b3bd1fb096bae4f40e79d50426ba1db8e9/OSX/sec/Security/SecItemConstants.c#L111 + public static nonisolated(unsafe) let tokenObjectID = "toid" as CFString + } + +} diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/PersistentAuthenticationHandler.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/PersistentAuthenticationHandler.swift new file mode 100644 index 0000000..4934c77 --- /dev/null +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/PersistentAuthenticationHandler.swift @@ -0,0 +1,70 @@ +import LocalAuthentication +import SecretKit + +extension SecureEnclave { + + /// A context describing a persisted authentication. + final class PersistentAuthenticationContext: PersistedAuthenticationContext { + + /// The Secret to persist authentication for. + let secret: Secret + /// The LAContext used to authorize the persistent context. + nonisolated(unsafe) let context: LAContext + /// An expiration date for the context. + /// - Note - Monotonic time instead of Date() to prevent people setting the clock back. + let monotonicExpiration: UInt64 + + /// Initializes a context. + /// - Parameters: + /// - secret: The Secret to persist authentication for. + /// - context: The LAContext used to authorize the persistent context. + /// - duration: The duration of the authorization context, in seconds. + init(secret: Secret, context: LAContext, duration: TimeInterval) { + self.secret = secret + unsafe self.context = context + let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value + self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds) + } + + /// A boolean describing whether or not the context is still valid. + var valid: Bool { + clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration + } + + var expiration: Date { + let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC) + let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value + return Date(timeIntervalSinceNow: remainingInSeconds) + } + } + + actor PersistentAuthenticationHandler: Sendable { + + private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:] + + func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? { + guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil } + return persisted + } + + func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { + let newContext = LAContext() + newContext.touchIDAuthenticationAllowableReuseDuration = duration + newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) + + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .spellOut + formatter.allowedUnits = [.hour, .minute, .day] + + + let durationString = formatter.string(from: duration)! + newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString)) + let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) + guard success else { return } + let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration) + persistedAuthenticationContexts[secret] = context + } + + } + +} diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift index 530d01e..7a53d5a 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift @@ -1,5 +1,4 @@ import Foundation -import Combine import SecretKit extension SecureEnclave { @@ -7,12 +6,26 @@ extension SecureEnclave { /// An implementation of Secret backed by the Secure Enclave. public struct Secret: SecretKit.Secret { - public let id: Data + public let id: String public let name: String - public let algorithm = Algorithm.ellipticCurve - public let keySize = 256 - public let requiresAuthentication: Bool public let publicKey: Data + public let attributes: Attributes + + init( + id: String, + name: String, + publicKey: Data, + attributes: Attributes + ) { + self.id = id + self.name = name + self.publicKey = publicKey + self.attributes = attributes + } + + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } } diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift index 19b6168..5156900 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift @@ -1,351 +1,292 @@ import Foundation -import Combine +import Observation import Security -import CryptoTokenKit +import CryptoKit import LocalAuthentication import SecretKit +import os extension SecureEnclave { - /// An implementation of Store backed by the Secure Enclave. - public final class Store: SecretStoreModifiable { + /// An implementation of Store backed by the Secure Enclave using CryptoKit API. + @Observable public final class Store: SecretStoreModifiable { + @MainActor public var secrets: [Secret] = [] public var isAvailable: Bool { - // For some reason, as of build time, CryptoKit.SecureEnclave.isAvailable always returns false - // error msg "Received error sending GET UNIQUE DEVICE command" - // Verify it with TKTokenWatcher manually. - TKTokenWatcher().tokenIDs.contains("com.apple.setoken") + CryptoKit.SecureEnclave.isAvailable } public let id = UUID() - public let name = String(localized: "secure_enclave") - @Published public private(set) var secrets: [Secret] = [] - - private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:] + public let name = String(localized: .secureEnclave) + private let persistentAuthenticationHandler = PersistentAuthenticationHandler() /// Initializes a Store. - public init() { - DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { [reload = reloadSecretsInternal(notifyAgent:)] _ in - reload(false) - } + @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. + continue + } + reloadSecrets() + } + } } - // MARK: Public API - - public func create(name: String, requiresAuthentication: Bool) throws { - var accessError: SecurityError? - let flags: SecAccessControlCreateFlags - if requiresAuthentication { - flags = [.privateKeyUsage, .userPresence] + // MARK: - Public API + + // MARK: SecretStore + + public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { + var context: LAContext + if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { + context = unsafe existing.context } else { - flags = .privateKeyUsage + 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, + ]) + var untyped: CFTypeRef? + let status = unsafe SecItemCopyMatching(queryAttributes, &untyped) + if status != errSecSuccess { + throw KeychainError(statusCode: status) + } + guard let untypedSafe = untyped as? [CFString: Any] 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) + + 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() + } + + } + + public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { + await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) + } + + public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { + 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) + } + } + + // 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, + unsafe SecAccessControlCreateWithFlags(kCFAllocatorDefault, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, - &accessError) as Any - if let error = accessError { - throw error.takeRetainedValue() as Error + &accessError) + if let error = unsafe accessError { + throw unsafe 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 + 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() } - guard let keypair = keypair, let publicKey = SecKeyCopyPublicKey(keypair) else { - throw KeychainError(statusCode: nil) - } - try savePublicKey(publicKey, name: name) - reloadSecretsInternal() + 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) throws { + public func delete(secret: Secret) async throws { let deleteAttributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrApplicationLabel: secret.id as CFData + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecAttrAccount: secret.id, ]) let status = SecItemDelete(deleteAttributes) if status != errSecSuccess { throw KeychainError(statusCode: status) } - reloadSecretsInternal() + await reloadSecrets() } - public func update(secret: Secret, name: String) throws { + public func update(secret: Secret, name: String, attributes: Attributes) async throws { let updateQuery = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrApplicationLabel: secret.id as CFData + 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) } - reloadSecretsInternal() + await reloadSecrets() } - public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { - let context: LAContext - if let existing = persistedAuthenticationContexts[secret], existing.valid { - context = existing.context + public var supportedKeyTypes: [KeyType] { + if #available(macOS 26, *) { + [ + .ecdsa256, + .mldsa65, + .mldsa87, + ] } else { - let newContext = LAContext() - newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button") - context = newContext - } - context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)") - let attributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyClass: kSecAttrKeyClassPrivate, - kSecAttrApplicationLabel: secret.id as CFData, - kSecAttrKeyType: Constants.keyType, - kSecAttrTokenID: kSecAttrTokenIDSecureEnclave, - kSecAttrApplicationTag: Constants.keyTag, - kSecUseAuthenticationContext: context, - kSecReturnRef: true - ]) - 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 signError: SecurityError? - - guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else { - throw SigningError(error: signError) - } - return signature as Data - } - - public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool { - let context = LAContext() - context.localizedReason = String(localized: "auth_context_request_verify_description_\(secret.name)") - context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") - let attributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyClass: kSecAttrKeyClassPrivate, - kSecAttrApplicationLabel: secret.id as CFData, - kSecAttrKeyType: Constants.keyType, - kSecAttrTokenID: kSecAttrTokenIDSecureEnclave, - kSecAttrApplicationTag: Constants.keyTag, - kSecUseAuthenticationContext: context, - kSecReturnRef: true - ]) - var verifyError: SecurityError? - var untyped: CFTypeRef? - let status = SecItemCopyMatching(attributes, &untyped) - if status != errSecSuccess { - throw KeychainError(statusCode: status) - } - guard let untypedSafe = untyped else { - throw KeychainError(statusCode: errSecSuccess) - } - let key = untypedSafe as! SecKey - let verified = SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, signature as CFData, &verifyError) - if !verified, let verifyError { - if verifyError.takeUnretainedValue() ~= .verifyError { - return false - } else { - throw SigningError(error: verifyError) - } - } - return verified - } - - public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { - guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil } - return persisted - } - - public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) throws { - let newContext = LAContext() - newContext.touchIDAuthenticationAllowableReuseDuration = duration - newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button") - - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .spellOut - formatter.allowedUnits = [.hour, .minute, .day] - - if let durationString = formatter.string(from: duration) { - newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)") - } else { - newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)") - } - newContext.evaluatePolicy(LAPolicy.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) { [weak self] success, _ in - guard success else { return } - let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration) - self?.persistedAuthenticationContexts[secret] = context + [.ecdsa256] } } - - public func reloadSecrets() { - reloadSecretsInternal(notifyAgent: false) - } - } } 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. - private func reloadSecretsInternal(notifyAgent: Bool = true) { - 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. - private func loadSecrets() { - let publicAttributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyType: SecureEnclave.Constants.keyType, - kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, - kSecAttrKeyClass: kSecAttrKeyClassPublic, - kSecReturnRef: true, + @MainActor private func loadSecrets() { + let queryAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecReturnData: true, kSecMatchLimit: kSecMatchLimitAll, kSecReturnAttributes: true ]) - var publicUntyped: CFTypeRef? - SecItemCopyMatching(publicAttributes, &publicUntyped) - guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return } - let privateAttributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyType: SecureEnclave.Constants.keyType, - kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, - kSecAttrKeyClass: kSecAttrKeyClassPrivate, - kSecReturnRef: true, - kSecMatchLimit: kSecMatchLimitAll, - kSecReturnAttributes: true - ]) - var privateUntyped: CFTypeRef? - SecItemCopyMatching(privateAttributes, &privateUntyped) - guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return } - let privateMapped = privateTyped.reduce(into: [:] as [Data: [CFString: Any]]) { partialResult, next in - let id = next[kSecAttrApplicationLabel] as! Data - partialResult[id] = next - } - let authNotRequiredAccessControl: SecAccessControl = - SecAccessControlCreateWithFlags(kCFAllocatorDefault, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - [.privateKeyUsage], - nil)! - - let wrapped: [SecureEnclave.Secret] = publicTyped.map { - let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") - let id = $0[kSecAttrApplicationLabel] as! Data - let publicKeyRef = $0[kSecValueRef] as! SecKey - let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any] - let publicKey = publicKeyAttributes[kSecValueData] as! Data - let privateKey = privateMapped[id] - let requiresAuth: Bool - if let authRequirements = privateKey?[kSecAttrAccessControl] { - // Unfortunately we can't inspect the access control object directly, but it does behave predicatable with equality. - requiresAuth = authRequirements as! SecAccessControl != authNotRequiredAccessControl - } else { - requiresAuth = false + var untyped: CFTypeRef? + unsafe SecItemCopyMatching(queryAttributes, &untyped) + guard let typed = untyped as? [[CFString: Any]] else { return } + let wrapped: [SecureEnclave.Secret] = typed.compactMap { + do { + let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") + guard let attributesData = $0[kSecAttrGeneric] as? Data, + let id = $0[kSecAttrAccount] as? String else { + throw MissingAttributesError() + } + let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData) + let keyData = $0[kSecValueData] as! Data + let publicKey: Data + switch attributes.keyType { + case .ecdsa256: + let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData) + publicKey = key.publicKey.x963Representation + case .mldsa65: + guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } + let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData) + publicKey = key.publicKey.rawRepresentation + case .mldsa87: + guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } + let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData) + publicKey = key.publicKey.rawRepresentation + default: + throw UnsupportedAlgorithmError() + } + return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey, attributes: attributes) + } catch { + return nil } - return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey) } secrets.append(contentsOf: wrapped) } /// Saves a public key. /// - Parameters: - /// - publicKey: The public key to save. + /// - key: The data representation key to save. /// - name: A user-facing name for the key. - private func savePublicKey(_ publicKey: SecKey, name: String) throws { - let attributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyType: SecureEnclave.Constants.keyType, - kSecAttrKeyClass: kSecAttrKeyClassPublic, - kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, - kSecValueRef: publicKey, - kSecAttrIsPermanent: true, - kSecReturnData: true, - kSecAttrLabel: name - ]) - let status = SecItemAdd(attributes, nil) + /// - attributes: Attributes of the key. + /// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate. + @discardableResult + func saveKey(_ key: Data, name: String, attributes: Attributes) throws -> String { + let attributes = try JSONEncoder().encode(attributes) + let id = UUID().uuidString + let keychainAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAttrAccount: id, + kSecValueData: key, + kSecAttrLabel: name, + kSecAttrGeneric: attributes + ]) + let status = SecItemAdd(keychainAttributes, nil) if status != errSecSuccess { throw KeychainError(statusCode: status) } + return id } - + } -extension SecureEnclave { +extension SecureEnclave.Store { enum Constants { - static let keyTag = "com.maxgoedjen.secretive.secureenclave.key".data(using: .utf8)! as CFData - static let keyType = kSecAttrKeyTypeECSECPrimeRandom - static let unauthenticatedThreshold: TimeInterval = 0.05 - } - -} - -extension SecureEnclave { - - /// A context describing a persisted authentication. - private struct PersistentAuthenticationContext: PersistedAuthenticationContext { - - /// The Secret to persist authentication for. - let secret: Secret - /// The LAContext used to authorize the persistent context. - let context: LAContext - /// An expiration date for the context. - /// - Note - Monotonic time instead of Date() to prevent people setting the clock back. - let monotonicExpiration: UInt64 - - /// Initializes a context. - /// - Parameters: - /// - secret: The Secret to persist authentication for. - /// - context: The LAContext used to authorize the persistent context. - /// - duration: The duration of the authorization context, in seconds. - init(secret: Secret, context: LAContext, duration: TimeInterval) { - self.secret = secret - self.context = context - let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value - self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds) - } - - /// A boolean describing whether or not the context is still valid. - var valid: Bool { - clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration - } - - var expiration: Date { - let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC) - let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value - return Date(timeIntervalSinceNow: remainingInSeconds) - } + static let keyClass = kSecClassGenericPassword as String + static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8) + static let notificationToken = UUID().uuidString } + + struct UnsupportedAlgorithmError: Error {} + struct MissingAttributesError: Error {} } diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift index 655214f..977355e 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift @@ -1,5 +1,4 @@ import Foundation -import Combine import SecretKit extension SmartCard { @@ -9,10 +8,8 @@ extension SmartCard { public let id: Data public let name: String - public let algorithm: Algorithm - public let keySize: Int - public let requiresAuthentication: Bool = false public let publicKey: Data + public var attributes: Attributes } diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift index c8c3281..a636fde 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift @@ -1,57 +1,66 @@ import Foundation -import Combine +import Observation import Security -import CryptoTokenKit +@unsafe @preconcurrency import CryptoTokenKit import LocalAuthentication import SecretKit extension SmartCard { + + @MainActor @Observable fileprivate final class State { + var isAvailable = false + var name = String(localized: .smartCard) + var secrets: [Secret] = [] + let watcher = TKTokenWatcher() + var tokenID: String? = nil + nonisolated init() {} + } /// An implementation of Store backed by a Smart Card. - public final class Store: SecretStore { + @Observable public final class Store: SecretStore { + + private let state = State() + public var isAvailable: Bool { + state.isAvailable + } + @MainActor public var smartcardTokenID: String? { + state.tokenID + } - @Published public var isAvailable: Bool = false public let id = UUID() - public private(set) var name = String(localized: "smart_card") - @Published public private(set) var secrets: [Secret] = [] - private let watcher = TKTokenWatcher() - private var tokenID: String? + @MainActor public var name: String { + state.name + } + public var secrets: [Secret] { + state.secrets + } /// Initializes a Store. public init() { - tokenID = watcher.nonSecureEnclaveTokens.first - watcher.setInsertionHandler { [reload = reloadSecretsInternal] string in - guard self.tokenID == nil else { return } - guard !string.contains("setoken") else { return } - - self.tokenID = string - DispatchQueue.main.async { - reload() + Task { + await MainActor.run { + if let tokenID = smartcardTokenID { + state.isAvailable = true + state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID) + } + loadSecrets() + } + // Doing this inside a regular mainactor handler casues thread assertions in CryptoTokenKit to blow up when the handler executes. + await state.watcher.setInsertionHandler { id in + Task { + await self.smartcardInserted(for: id) + } } - self.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string) } - if let tokenID = tokenID { - self.isAvailable = true - self.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID) - } - loadSecrets() } // 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) throws -> Data { - guard let tokenID = tokenID else { fatalError() } + public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { + guard let tokenID = await state.tokenID else { fatalError() } let context = LAContext() - context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)") - context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") + context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name)) + context.localizedCancelTitle = String(localized: .authContextRequestDenyButton) let attributes = KeychainDictionary([ kSecClass: kSecClassKey, kSecAttrKeyClass: kSecAttrKeyClassPrivate, @@ -61,7 +70,7 @@ extension SmartCard { kSecReturnRef: true ]) var untyped: CFTypeRef? - let status = SecItemCopyMatching(attributes, &untyped) + let status = unsafe SecItemCopyMatching(attributes, &untyped) if status != errSecSuccess { throw KeychainError(statusCode: status) } @@ -70,35 +79,13 @@ extension SmartCard { } let key = untypedSafe as! SecKey var signError: SecurityError? - guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else { - throw SigningError(error: signError) + guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() } + guard let signature = unsafe SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else { + throw unsafe SigningError(error: signError) } return signature as Data } - public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool { - let attributes = KeychainDictionary([ - kSecAttrKeyType: secret.algorithm.secAttrKeyType, - kSecAttrKeySizeInBits: secret.keySize, - kSecAttrKeyClass: kSecAttrKeyClassPublic - ]) - var verifyError: SecurityError? - let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError) - guard let untypedSafe = untyped else { - throw KeychainError(statusCode: errSecSuccess) - } - let key = untypedSafe as! SecKey - let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, signature as CFData, &verifyError) - if !verified, let verifyError { - if verifyError.takeUnretainedValue() ~= .verifyError { - return false - } else { - throw SigningError(error: verifyError) - } - } - return verified - } - public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { nil } @@ -107,7 +94,7 @@ extension SmartCard { } /// Reloads all secrets from the store. - public func reloadSecrets() { + @MainActor public func reloadSecrets() { reloadSecretsInternal() } @@ -117,32 +104,44 @@ extension SmartCard { extension SmartCard.Store { - private func reloadSecretsInternal() { - self.isAvailable = self.tokenID != nil - let before = self.secrets - self.secrets.removeAll() - self.loadSecrets() + @MainActor private func reloadSecretsInternal() { + let before = state.secrets + state.isAvailable = state.tokenID != nil + state.secrets.removeAll() + loadSecrets() if self.secrets != before { NotificationCenter.default.post(name: .secretStoreReloaded, object: self) } } + /// 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() + } + /// Resets the token ID and reloads secrets. /// - Parameter tokenID: The ID of the token that was removed. - private func smartcardRemoved(for tokenID: String? = nil) { - self.tokenID = nil + @MainActor private func smartcardRemoved(for tokenID: String? = nil) { + state.tokenID = nil reloadSecrets() } /// Loads all secrets from the store. - private func loadSecrets() { - guard let tokenID = tokenID else { return } + @MainActor private func loadSecrets() { + guard let tokenID = state.tokenID else { return } - let fallbackName = String(localized: "smart_card") - if let driverName = watcher.tokenInfo(forTokenID: tokenID)?.driverName { - name = driverName + let fallbackName = String(localized: .smartCard) + if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName { + state.name = driverName } else { - name = fallbackName + state.name = fallbackName } let attributes = KeychainDictionary([ @@ -153,102 +152,23 @@ extension SmartCard.Store { kSecReturnAttributes: true ]) var untyped: CFTypeRef? - SecItemCopyMatching(attributes, &untyped) + unsafe SecItemCopyMatching(attributes, &untyped) guard let typed = untyped as? [[CFString: Any]] else { return } - let wrapped = typed.map { - let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") + let wrapped: [SecretType] = typed.compactMap { + let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret) let tokenID = $0[kSecAttrApplicationLabel] as! Data - let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber) + let algorithmSecAttr = $0[kSecAttrKeyType] as! NSNumber let keySize = $0[kSecAttrKeySizeInBits] as! Int let publicKeyRef = $0[kSecValueRef] as! SecKey let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)! let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any] let publicKey = publicKeyAttributes[kSecValueData] as! Data - return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey) - } - secrets.append(contentsOf: wrapped) - } - -} - - -// MARK: Smart Card specific encryption/decryption/verification -extension SmartCard.Store { - - /// Encrypts a payload with a specified key. - /// - Parameters: - /// - data: The payload to encrypt. - /// - secret: The secret to encrypt with. - /// - Returns: The encrypted data. - /// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged. - public func encrypt(data: Data, with secret: SecretType) throws -> Data { - let context = LAContext() - context.localizedReason = String(localized: "auth_context_request_encrypt_description_\(secret.name)") - context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") - let attributes = KeychainDictionary([ - kSecAttrKeyType: secret.algorithm.secAttrKeyType, - kSecAttrKeySizeInBits: secret.keySize, - kSecAttrKeyClass: kSecAttrKeyClassPublic, - kSecUseAuthenticationContext: context - ]) - var encryptError: SecurityError? - let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &encryptError) - guard let untypedSafe = untyped else { - throw KeychainError(statusCode: errSecSuccess) - } - let key = untypedSafe as! SecKey - guard let signature = SecKeyCreateEncryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else { - throw SigningError(error: encryptError) - } - return signature as Data - } - - /// Decrypts a payload with a specified key. - /// - Parameters: - /// - data: The payload to decrypt. - /// - secret: The secret to decrypt with. - /// - Returns: The decrypted data. - /// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged. - public func decrypt(data: Data, with secret: SecretType) throws -> Data { - guard let tokenID = tokenID else { fatalError() } - let context = LAContext() - context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)") - context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") - let attributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyClass: kSecAttrKeyClassPrivate, - kSecAttrApplicationLabel: secret.id as CFData, - kSecAttrTokenID: tokenID, - kSecUseAuthenticationContext: context, - kSecReturnRef: true - ]) - var untyped: CFTypeRef? - let status = SecItemCopyMatching(attributes, &untyped) - if status != errSecSuccess { - throw KeychainError(statusCode: status) - } - guard let untypedSafe = untyped else { - throw KeychainError(statusCode: errSecSuccess) - } - let key = untypedSafe as! SecKey - var encryptError: SecurityError? - guard let signature = SecKeyCreateDecryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else { - throw SigningError(error: encryptError) - } - return signature as Data - } - - private func encryptionAlgorithm(for secret: SecretType) -> SecKeyAlgorithm { - switch (secret.algorithm, secret.keySize) { - case (.ellipticCurve, 256): - return .eciesEncryptionCofactorVariableIVX963SHA256AESGCM - case (.ellipticCurve, 384): - return .eciesEncryptionCofactorVariableIVX963SHA384AESGCM - case (.rsa, 1024), (.rsa, 2048): - return .rsaEncryptionOAEPSHA512AESGCM - default: - fatalError() + let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown) + let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes) + guard signatureAlgorithm(for: secret) != nil else { return nil } + return secret } + state.secrets.append(contentsOf: wrapped) } } @@ -261,3 +181,9 @@ extension TKTokenWatcher { } } + +extension SmartCard { + + public struct UnsupportKeyType: Error {} + +} diff --git a/Sources/Packages/Sources/XPCWrappers/XPCProtocol.swift b/Sources/Packages/Sources/XPCWrappers/XPCProtocol.swift new file mode 100644 index 0000000..9691c31 --- /dev/null +++ b/Sources/Packages/Sources/XPCWrappers/XPCProtocol.swift @@ -0,0 +1,14 @@ +import Foundation + +@objc protocol _XPCProtocol: Sendable { + func process(_ data: Data, with reply: @Sendable @escaping (Data?, Error?) -> Void) +} + +public protocol XPCProtocol: Sendable { + + associatedtype Input: Codable + associatedtype Output: Codable + + func process(_ data: Input) async throws -> Output + +} diff --git a/Sources/Packages/Sources/XPCWrappers/XPCServiceDelegate.swift b/Sources/Packages/Sources/XPCWrappers/XPCServiceDelegate.swift new file mode 100644 index 0000000..5108ed2 --- /dev/null +++ b/Sources/Packages/Sources/XPCWrappers/XPCServiceDelegate.swift @@ -0,0 +1,70 @@ +import Foundation + +public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate { + + private let exportedObject: ErasedXPCProtocol + + public init(exportedObject: XPCProtocolType) { + self.exportedObject = ErasedXPCProtocol(exportedObject) + } + + public func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self) + let exportedObject = exportedObject + newConnection.exportedObject = exportedObject + newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6") + newConnection.resume() + return true + } +} + +@objc private final class ErasedXPCProtocol: NSObject, _XPCProtocol { + + let _process: @Sendable (Data, @Sendable @escaping (Data?, (any Error)?) -> Void) -> Void + + public init(_ exportedObject: XPCProtocolType) { + _process = { data, reply in + Task { [reply] in + do { + let decoded = try JSONDecoder().decode(XPCProtocolType.Input.self, from: data) + let result = try await exportedObject.process(decoded) + let encoded = try JSONEncoder().encode(result) + reply(encoded, nil) + } catch { + if let error = error as? Codable & Error { + reply(nil, NSError(error)) + } else { + reply(nil, error) + } + } + } + } + } + + func process(_ data: Data, with reply: @Sendable @escaping (Data?, (any Error)?) -> Void) { + _process(data, reply) + } + + +} + +extension NSError { + + private enum Constants { + static let domain = "com.maxgoedjen.secretive.xpcwrappers" + static let code = -1 + static let dataKey = "underlying" + } + + @nonobjc convenience init(_ error: ErrorType) { + let encoded = try? JSONEncoder().encode(error) + self.init(domain: Constants.domain, code: Constants.code, userInfo: [Constants.dataKey: encoded as Any]) + } + + @nonobjc public func underlying(as errorType: ErrorType.Type) -> ErrorType? { + guard domain == Constants.domain && code == Constants.code, let data = userInfo[Constants.dataKey] as? Data else { return nil } + return try? JSONDecoder().decode(ErrorType.self, from: data) + } + +} + diff --git a/Sources/Packages/Sources/XPCWrappers/XPCTypedSession.swift b/Sources/Packages/Sources/XPCWrappers/XPCTypedSession.swift new file mode 100644 index 0000000..fc73a34 --- /dev/null +++ b/Sources/Packages/Sources/XPCWrappers/XPCTypedSession.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct XPCTypedSession: ~Copyable { + + private let connection: NSXPCConnection + private let proxy: _XPCProtocol + + public init(serviceName: String, warmup: Bool = false) async throws { + let connection = NSXPCConnection(serviceName: serviceName) + connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self) + connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6") + connection.resume() + guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() } + self.connection = connection + self.proxy = proxy + if warmup { + _ = try? await send() + } + } + + public func send(_ message: some Encodable = Data()) async throws -> ResponseType { + let encoded = try JSONEncoder().encode(message) + return try await withCheckedThrowingContinuation { continuation in + proxy.process(encoded) { data, error in + do { + if let error { + throw error + } + guard let data else { + throw NoDataError() + } + let decoded = try JSONDecoder().decode(ResponseType.self, from: data) + continuation.resume(returning: decoded) + } catch { + if let typed = (error as NSError).underlying(as: ErrorType.self) { + continuation.resume(throwing: typed) + } else { + continuation.resume(throwing: error) + } + } + } + } + } + + + public func complete() { + connection.invalidate() + } + + public struct NoDataError: Error {} + +} + diff --git a/Sources/Packages/Tests/BriefTests/ReleaseParsingTests.swift b/Sources/Packages/Tests/BriefTests/ReleaseParsingTests.swift index 74af9ee..a75c2ad 100644 --- a/Sources/Packages/Tests/BriefTests/ReleaseParsingTests.swift +++ b/Sources/Packages/Tests/BriefTests/ReleaseParsingTests.swift @@ -1,54 +1,65 @@ -import XCTest +import Testing +import Foundation @testable import Brief -class ReleaseParsingTests: XCTestCase { +@Suite struct ReleaseParsingTests { - func testNonCritical() { + @Test + func nonCritical() { let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release") - XCTAssert(release.critical == false) + #expect(release.critical == false) } - func testCritical() { + @Test + func critical() { let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update") - XCTAssert(release.critical == true) + #expect(release.critical == true) } - func testOSMissing() { + @Test + func osMissing() { let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update") - XCTAssert(release.minimumOSVersion == SemVer("11.0.0")) + #expect(release.minimumOSVersion == SemVer("11.0.0")) } - func testOSPresentWithContentBelow() { + @Test + func osPresentWithContentBelow() { let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update ##Minimum macOS Version\n1.2.3\nBuild info") - XCTAssert(release.minimumOSVersion == SemVer("1.2.3")) + #expect(release.minimumOSVersion == SemVer("1.2.3")) } - func testOSPresentAtEnd() { + @Test + func osPresentAtEnd() { let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3") - XCTAssert(release.minimumOSVersion == SemVer("1.2.3")) + #expect(release.minimumOSVersion == SemVer("1.2.3")) } - func testOSWithMacOSPrefix() { + @Test + func osWithMacOSPrefix() { let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: macOS 1.2.3") - XCTAssert(release.minimumOSVersion == SemVer("1.2.3")) + #expect(release.minimumOSVersion == SemVer("1.2.3")) } - func testOSGreaterThanMinimum() { + @Test + func osGreaterThanMinimum() { let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3") - XCTAssert(release.minimumOSVersion < SemVer("11.0.0")) + #expect(release.minimumOSVersion < SemVer("11.0.0")) } - func testOSEqualToMinimum() { + @Test + func osEqualToMinimum() { let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 11.2.3") - XCTAssert(release.minimumOSVersion <= SemVer("11.2.3")) + #expect(release.minimumOSVersion <= SemVer("11.2.3")) } - func testOSLessThanMinimum() { + @Test + func osLessThanMinimum() { let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3") - XCTAssert(release.minimumOSVersion > SemVer("1.0.0")) + #expect(release.minimumOSVersion > SemVer("1.0.0")) } - func testGreatestSelectedIfOldPatchIsPublishedLater() { + @Test + @MainActor func greatestSelectedIfOldPatchIsPublishedLater() async throws { // If 2.x.x series has been published, and a patch for 1.x.x is issued // 2.x.x should still be selected if user can run it. let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0")) @@ -60,16 +71,13 @@ class ReleaseParsingTests: XCTestCase { Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"), ] - let expectation = XCTestExpectation() - updater.evaluate(releases: releases) - DispatchQueue.main.async { - XCTAssert(updater.update == two) - expectation.fulfill() - } - wait(for: [expectation], timeout: 1) + await updater.evaluate(releases: releases) + try await Task.sleep(nanoseconds: 1) + #expect(updater.update == two) } - func testLatestVersionIsRunnable() { + @Test + @MainActor func latestVersionIsRunnable() async throws { // If the 2.x.x series has been published but the user can't run it // the last version the user can run should be selected. let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0")) @@ -80,16 +88,13 @@ class ReleaseParsingTests: XCTestCase { Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available! Minimum macOS Version: 2.2.3"), Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"), ] - let expectation = XCTestExpectation() - updater.evaluate(releases: releases) - DispatchQueue.main.async { - XCTAssert(updater.update == oneOhTwo) - expectation.fulfill() - } - wait(for: [expectation], timeout: 1) + await updater.evaluate(releases: releases) + try await Task.sleep(nanoseconds: 1) + #expect(updater.update == oneOhTwo) } - func testSorting() { + @Test + func sorting() { let two = Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available!") let releases = [ Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release"), @@ -98,7 +103,7 @@ class ReleaseParsingTests: XCTestCase { Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch!"), ] let sorted = releases.sorted().reversed().first - XCTAssert(sorted == two) + #expect(sorted == two) } } diff --git a/Sources/Packages/Tests/BriefTests/SemVerTests.swift b/Sources/Packages/Tests/BriefTests/SemVerTests.swift index f7ee332..5cd5738 100644 --- a/Sources/Packages/Tests/BriefTests/SemVerTests.swift +++ b/Sources/Packages/Tests/BriefTests/SemVerTests.swift @@ -1,51 +1,52 @@ -import XCTest +import Testing +import Foundation @testable import Brief -class SemVerTests: XCTestCase { +@Suite struct SemVerTests { - func testEqual() { + @Test func equal() { let current = SemVer("1.0.2") let old = SemVer("1.0.2") - XCTAssert(!(current > old)) + #expect(!(current > old)) } - func testPatchGreaterButMinorLess() { + @Test func patchGreaterButMinorLess() { let current = SemVer("1.1.0") let old = SemVer("1.0.2") - XCTAssert(current > old) + #expect(current > old) } - func testMajorSameMinorGreater() { + @Test func majorSameMinorGreater() { let current = SemVer("1.0.2") let new = SemVer("1.0.3") - XCTAssert(current < new) + #expect(current < new) } - func testMajorGreaterMinorLesser() { + @Test func majorGreaterMinorLesser() { let current = SemVer("1.0.2") let new = SemVer("2.0.0") - XCTAssert(current < new) + #expect(current < new) } - func testRegularParsing() { + @Test func regularParsing() { let current = SemVer("1.0.2") - XCTAssert(current.versionNumbers == [1, 0, 2]) + #expect(current.versionNumbers == [1, 0, 2]) } - func testNoPatch() { + @Test func noPatch() { let current = SemVer("1.1") - XCTAssert(current.versionNumbers == [1, 1, 0]) + #expect(current.versionNumbers == [1, 1, 0]) } - func testGarbage() { + @Test func garbage() { let current = SemVer("Test") - XCTAssert(current.versionNumbers == [0, 0, 0]) + #expect(current.versionNumbers == [0, 0, 0]) } - func testBeta() { + @Test func beta() { let current = SemVer("1.0.2") let new = SemVer("1.1.0_beta1") - XCTAssert(current < new) + #expect(current < new) } } diff --git a/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift b/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift index d5ffd37..bbef669 100644 --- a/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift +++ b/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift @@ -1,56 +1,59 @@ import Foundation -import XCTest +import Testing import CryptoKit @testable import SecretKit @testable import SecretAgentKit -class AgentTests: XCTestCase { - - let stubWriter = StubFileHandleWriter() +@Suite struct AgentTests { // MARK: Identity Listing - func testEmptyStores() async { - let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities) + @Test func emptyStores() async throws { let agent = Agent(storeList: SecretStoreList()) - await agent.handle(reader: stubReader, writer: stubWriter) - XCTAssertEqual(stubWriter.data, Constants.Responses.requestIdentitiesEmpty) + let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities) + let response = await agent.handle(request: request, provenance: .test) + #expect(response == Constants.Responses.requestIdentitiesEmpty) } - func testIdentitiesList() async { - let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities) - let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) + @Test func identitiesList() async throws { + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let agent = Agent(storeList: list) - await agent.handle(reader: stubReader, writer: stubWriter) - XCTAssertEqual(stubWriter.data, Constants.Responses.requestIdentitiesMultiple) + let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities) + let response = await agent.handle(request: request, provenance: .test) + + let actual = OpenSSHReader(data: response) + let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple) + print(actual, expected) + #expect(response == Constants.Responses.requestIdentitiesMultiple) } // MARK: Signatures - func testNoMatchingIdentities() async { - let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching) - let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) + @Test func noMatchingIdentities() async throws { + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let agent = Agent(storeList: list) - await agent.handle(reader: stubReader, writer: stubWriter) -// XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure) + let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching) + let response = await agent.handle(request: request, provenance: .test) + #expect(response == Constants.Responses.requestFailure) } - func testSignature() async { - let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) - let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...]) - _ = requestReader.readNextChunk() - let dataToSign = requestReader.readNextChunk() - let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) + @Test func ecdsaSignature() async throws { + let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) + guard case SSHAgent.Request.signRequest(let context) = request else { return } + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let agent = Agent(storeList: list) - await agent.handle(reader: stubReader, writer: stubWriter) - let outer = OpenSSHReader(data: stubWriter.data[5...]) - let payload = outer.readNextChunk() - let inner = OpenSSHReader(data: payload) - _ = inner.readNextChunk() - let signedData = inner.readNextChunk() - let rsData = OpenSSHReader(data: signedData) - var r = rsData.readNextChunk() - var s = rsData.readNextChunk() + let response = await agent.handle(request: request, provenance: .test) + let responseReader = OpenSSHReader(data: response) + let length = try responseReader.readNextBytes(as: UInt32.self).bigEndian + let type = try responseReader.readNextBytes(as: UInt8.self).bigEndian + #expect(length == response.count - MemoryLayout.size) + #expect(type == SSHAgent.Response.agentSignResponse.rawValue) + let outer = OpenSSHReader(data: responseReader.remaining) + let inner = try outer.readNextChunkAsSubReader() + _ = try inner.readNextChunk() + let rsData = try inner.readNextChunkAsSubReader() + var r = try rsData.readNextChunk() + var s = try rsData.readNextChunk() // This is fine IRL, but it freaks out CryptoKit if r[0] == 0 { r.removeFirst() @@ -60,52 +63,42 @@ class AgentTests: XCTestCase { } var rs = r rs.append(s) - let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs) - let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign) - let store = list.stores.first! - let derVerifies = try! store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret)) - let invalidRandomSignature = try? store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret)) - let invalidRandomData = try? store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret)) - let invalidWrongKey = try? store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa384Secret)) - XCTAssertTrue(referenceValid) - XCTAssertTrue(derVerifies) - XCTAssert(invalidRandomSignature == false) - XCTAssert(invalidRandomData == false) - XCTAssert(invalidWrongKey == false) + let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs) + // Correct signature + #expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey) + .isValidSignature(signature, for: context.dataToSign)) } // MARK: Witness protocol - func testWitnessObjectionStopsRequest() async { - let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) - let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) + @Test func witnessObjectionStopsRequest() async throws { + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret]) let witness = StubWitness(speakNow: { _,_ in return true }, witness: { _, _ in }) let agent = Agent(storeList: list, witness: witness) - await agent.handle(reader: stubReader, writer: stubWriter) - XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure) + let response = await agent.handle(request: .signRequest(.empty), provenance: .test) + #expect(response == Constants.Responses.requestFailure) } - func testWitnessSignature() async { - let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) - let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) - var witnessed = false + @Test func witnessSignature() async throws { + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret]) + nonisolated(unsafe) var witnessed = false let witness = StubWitness(speakNow: { _, trace in return false }, witness: { _, trace in witnessed = true }) let agent = Agent(storeList: list, witness: witness) - await agent.handle(reader: stubReader, writer: stubWriter) - XCTAssertTrue(witnessed) + let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) + _ = await agent.handle(request: request, provenance: .test) + #expect(witnessed) } - func testRequestTracing() async { - let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) - let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) - var speakNowTrace: SigningRequestProvenance! = nil - var witnessTrace: SigningRequestProvenance! = nil + @Test func requestTracing() async throws { + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret]) + nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance? + nonisolated(unsafe) var witnessTrace: SigningRequestProvenance? let witness = StubWitness(speakNow: { _, trace in speakNowTrace = trace return false @@ -113,39 +106,43 @@ class AgentTests: XCTestCase { witnessTrace = trace }) let agent = Agent(storeList: list, witness: witness) - await agent.handle(reader: stubReader, writer: stubWriter) - XCTAssertEqual(witnessTrace, speakNowTrace) - XCTAssertEqual(witnessTrace.origin.displayName, "Finder") - XCTAssertEqual(witnessTrace.origin.validSignature, true) - XCTAssertEqual(witnessTrace.origin.parentPID, 1) + let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) + _ = await agent.handle(request: request, provenance: .test) + #expect(witnessTrace == speakNowTrace) + #expect(witnessTrace == .test) } // MARK: Exception Handling - func testSignatureException() async { - let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) - let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) - let store = list.stores.first?.base as! Stub.Store + @Test func signatureException() async throws { + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) + let store = await list.stores.first?.base as! Stub.Store store.shouldThrow = true let agent = Agent(storeList: list) - await agent.handle(reader: stubReader, writer: stubWriter) - XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure) + let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) + let response = await agent.handle(request: request, provenance: .test) + #expect(response == Constants.Responses.requestFailure) } // MARK: Unsupported - func testUnhandledAdd() async { - let stubReader = StubFileHandleReader(availableData: Constants.Requests.addIdentity) + @Test func unhandledAdd() async throws { let agent = Agent(storeList: SecretStoreList()) - await agent.handle(reader: stubReader, writer: stubWriter) - XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure) + let response = await agent.handle(request: .addIdentity, provenance: .test) + #expect(response == Constants.Responses.requestFailure) } } +extension SigningRequestProvenance { + + static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0)) + +} + extension AgentTests { - func storeList(with secrets: [Stub.Secret]) -> SecretStoreList { + @MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList { let store = Stub.Store() store.secrets.append(contentsOf: secrets) let storeList = SecretStoreList() @@ -157,14 +154,13 @@ extension AgentTests { enum Requests { static let requestIdentities = Data(base64Encoded: "AAAAAQs=")! - static let addIdentity = Data(base64Encoded: "AAAAARE=")! static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")! static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")! } enum Responses { static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")! - static let requestIdentitiesMultiple = Data(base64Encoded: "AAABKwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0")! + static let requestIdentitiesMultiple = Data(base64Encoded: "AAABLwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAFWVjZHNhLTI1NkBleGFtcGxlLmNvbQAAAIgAAAATZWNkc2Etc2hhMi1uaXN0cDM4NAAAAAhuaXN0cDM4NAAAAGEEspLMDmreMJverQkqKC9zF9ZUasn5uSWkbRlz1jNTCtuyH1KKm+VImL6wdAj47SbzwM6lEEC24AdfrR64P9i/bnS2i83v/4wQVtcZn+Et13QGgWlZst8lxCPzTookaVwMAAAAFWVjZHNhLTM4NEBleGFtcGxlLmNvbQ==")! static let requestFailure = Data(base64Encoded: "AAAAAQU=")! } diff --git a/Sources/Packages/Tests/SecretAgentKitTests/OpenSSHReaderTests.swift b/Sources/Packages/Tests/SecretAgentKitTests/OpenSSHReaderTests.swift new file mode 100644 index 0000000..34201c6 --- /dev/null +++ b/Sources/Packages/Tests/SecretAgentKitTests/OpenSSHReaderTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import SecretAgentKit +@testable import SecureEnclaveSecretKit +@testable import SmartCardSecretKit + +@Suite struct OpenSSHReaderTests { + + @Test func signatureRequest() throws { + let reader = OpenSSHReader(data: Constants.signatureRequest) + let hash = try reader.readNextChunk() + #expect(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ==")) + let dataToSign = try reader.readNextChunk() + #expect(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU=")) + let empty = try reader.readNextChunk() + #expect(empty.isEmpty) + } + +} + +extension OpenSSHReaderTests { + + enum Constants { + static let signatureRequest = Data(base64Encoded: "AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAADvAAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAAAA")! + } + +} diff --git a/Sources/Packages/Tests/SecretAgentKitTests/StubFileHandleReader.swift b/Sources/Packages/Tests/SecretAgentKitTests/StubFileHandleReader.swift deleted file mode 100644 index a9bf274..0000000 --- a/Sources/Packages/Tests/SecretAgentKitTests/StubFileHandleReader.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SecretAgentKit -import AppKit - -struct StubFileHandleReader: FileHandleReader { - - let availableData: Data - var fileDescriptor: Int32 { - NSWorkspace.shared.runningApplications.filter({ $0.localizedName == "Finder" }).first!.processIdentifier - } - var pidOfConnectedProcess: Int32 { - fileDescriptor - } - -} diff --git a/Sources/Packages/Tests/SecretAgentKitTests/StubFileHandleWriter.swift b/Sources/Packages/Tests/SecretAgentKitTests/StubFileHandleWriter.swift deleted file mode 100644 index 5b35aef..0000000 --- a/Sources/Packages/Tests/SecretAgentKitTests/StubFileHandleWriter.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation -import SecretAgentKit - -class StubFileHandleWriter: FileHandleWriter { - - var data = Data() - - func write(_ data: Data) { - self.data.append(data) - } - -} diff --git a/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift b/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift index f990f97..c3a01d7 100644 --- a/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift +++ b/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift @@ -6,7 +6,7 @@ struct Stub {} extension Stub { - public final class Store: SecretStore { + public final class Store: SecretStore, @unchecked Sendable { public let isAvailable = true public let id = UUID() @@ -45,43 +45,15 @@ extension Stub { let privateData = (privateAttributes[kSecValueData] as! Data) let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData) print(secret) - print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))") + print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))") } public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { guard !shouldThrow else { throw NSError(domain: "test", code: 0, userInfo: nil) } - let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([ - kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, - kSecAttrKeySizeInBits: secret.keySize, - kSecAttrKeyClass: kSecAttrKeyClassPrivate - ]) - , nil)! - return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data - } - - public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool { - let attributes = KeychainDictionary([ - kSecAttrKeyType: secret.algorithm.secAttrKeyType, - kSecAttrKeySizeInBits: secret.keySize, - kSecAttrKeyClass: kSecAttrKeyClassPublic - ]) - var verifyError: Unmanaged? - let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError) - guard let untypedSafe = untyped else { - throw NSError(domain: "test", code: 0, userInfo: nil) - } - let key = untypedSafe as! SecKey - let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret), data as CFData, signature as CFData, &verifyError) - if let verifyError { - if verifyError.takeUnretainedValue() ~= .verifyError { - return false - } else { - throw NSError(domain: "test", code: 0, userInfo: nil) - } - } - return verified + let privateKey = try CryptoKit.P256.Signing.PrivateKey(x963Representation: secret.privateKey) + return try privateKey.signature(for: data).rawRepresentation } public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? { @@ -102,24 +74,22 @@ extension Stub { struct Secret: SecretKit.Secret, CustomDebugStringConvertible { - let id = UUID().uuidString.data(using: .utf8)! + let id = Data(UUID().uuidString.utf8) let name = UUID().uuidString - let algorithm = Algorithm.ellipticCurve - - let keySize: Int + let attributes: Attributes let publicKey: Data let requiresAuthentication = false let privateKey: Data init(keySize: Int, publicKey: Data, privateKey: Data) { - self.keySize = keySize + self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired, publicKeyAttribution: "ecdsa-\(keySize)@example.com") self.publicKey = publicKey self.privateKey = privateKey } var debugDescription: String { """ - Key Size \(keySize) + Key Size \(attributes.keyType.size) Private: \(privateKey.base64EncodedString()) Public: \(publicKey.base64EncodedString()) """ diff --git a/Sources/Packages/Tests/SecretAgentKitTests/StubWitness.swift b/Sources/Packages/Tests/SecretAgentKitTests/StubWitness.swift index 87e0fd9..2fa1af8 100644 --- a/Sources/Packages/Tests/SecretAgentKitTests/StubWitness.swift +++ b/Sources/Packages/Tests/SecretAgentKitTests/StubWitness.swift @@ -3,8 +3,8 @@ import SecretAgentKit struct StubWitness { - let speakNow: (AnySecret, SigningRequestProvenance) -> Bool - let witness: (AnySecret, SigningRequestProvenance) -> () + let speakNow: @Sendable (AnySecret, SigningRequestProvenance) -> Bool + let witness: @Sendable (AnySecret, SigningRequestProvenance) -> () } diff --git a/Sources/Packages/Tests/SecretKitTests/AnySecretTests.swift b/Sources/Packages/Tests/SecretKitTests/AnySecretTests.swift index ee2646b..b8e4b2b 100644 --- a/Sources/Packages/Tests/SecretKitTests/AnySecretTests.swift +++ b/Sources/Packages/Tests/SecretKitTests/AnySecretTests.swift @@ -1,19 +1,20 @@ import Foundation -import XCTest +import Testing @testable import SecretKit @testable import SecureEnclaveSecretKit @testable import SmartCardSecretKit -class AnySecretTests: XCTestCase { - func testEraser() { - let secret = SmartCard.Secret(id: UUID().uuidString.data(using: .utf8)!, name: "Name", algorithm: .ellipticCurve, keySize: 256, publicKey: UUID().uuidString.data(using: .utf8)!) +@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 erased = AnySecret(secret) - XCTAssert(erased.id == secret.id as AnyHashable) - XCTAssert(erased.name == secret.name) - XCTAssert(erased.algorithm == secret.algorithm) - XCTAssert(erased.keySize == secret.keySize) - XCTAssert(erased.publicKey == secret.publicKey) + #expect(erased.id == secret.id as AnyHashable) + #expect(erased.name == secret.name) + #expect(erased.keyType == secret.keyType) + #expect(erased.publicKey == secret.publicKey) } } diff --git a/Sources/Packages/Tests/SecretKitTests/OpenSSHPublicKeyWriterTests.swift b/Sources/Packages/Tests/SecretKitTests/OpenSSHPublicKeyWriterTests.swift new file mode 100644 index 0000000..92c3132 --- /dev/null +++ b/Sources/Packages/Tests/SecretKitTests/OpenSSHPublicKeyWriterTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import SecretKit +@testable import SecureEnclaveSecretKit +@testable import SmartCardSecretKit + +@Suite struct OpenSSHPublicKeyWriterTests { + + let writer = OpenSSHPublicKeyWriter() + + @Test func ecdsa256MD5Fingerprint() { + #expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5") + } + + @Test func ecdsa256SHA256Fingerprint() { + #expect(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa256Secret) == "SHA256:/VQFeGyM8qKA8rB6WGMuZZxZLJln2UgXLk3F0uTF650") + } + + @Test func ecdsa256PublicKey() { + #expect(writer.openSSHString(secret: Constants.ecdsa256Secret) == + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo= test@example.com") + } + + @Test func ecdsa256Hash() { + #expect(writer.data(secret: Constants.ecdsa256Secret) == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")) + } + + @Test func ecdsa384MD5Fingerprint() { + #expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa384Secret) == "66:e0:66:d7:41:ed:19:8e:e2:20:df:ce:ac:7e:2b:6e") + } + + @Test func ecdsa384SHA256Fingerprint() { + #expect(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa384Secret) == "SHA256:GJUEymQNL9ymaMRRJCMGY4rWIJHu/Lm8Yhao/PAiz1I") + } + + @Test func ecdsa384PublicKey() { + #expect(writer.openSSHString(secret: Constants.ecdsa384Secret) == + "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ== test@example.com") + } + + @Test func ecdsa384Hash() { + #expect(writer.data(secret: Constants.ecdsa384Secret) == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")) + } + +} + +extension OpenSSHPublicKeyWriterTests { + + 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")) + + } + +} diff --git a/Sources/Packages/Tests/SecretKitTests/OpenSSHReaderTests.swift b/Sources/Packages/Tests/SecretKitTests/OpenSSHReaderTests.swift deleted file mode 100644 index 5ad7494..0000000 --- a/Sources/Packages/Tests/SecretKitTests/OpenSSHReaderTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import XCTest -@testable import SecretKit -@testable import SecureEnclaveSecretKit -@testable import SmartCardSecretKit - -class OpenSSHReaderTests: XCTestCase { - - func testSignatureRequest() { - let reader = OpenSSHReader(data: Constants.signatureRequest) - let hash = reader.readNextChunk() - XCTAssert(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ==")) - let dataToSign = reader.readNextChunk() - XCTAssert(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU=")) - let empty = reader.readNextChunk() - XCTAssert(empty.isEmpty) - } - -} - -extension OpenSSHReaderTests { - - enum Constants { - static let signatureRequest = Data(base64Encoded: "AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAADvAAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAAAA")! - } - -} diff --git a/Sources/Packages/Tests/SecretKitTests/OpenSSHWriterTests.swift b/Sources/Packages/Tests/SecretKitTests/OpenSSHWriterTests.swift deleted file mode 100644 index 6e9718f..0000000 --- a/Sources/Packages/Tests/SecretKitTests/OpenSSHWriterTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import XCTest -@testable import SecretKit -@testable import SecureEnclaveSecretKit -@testable import SmartCardSecretKit - -class OpenSSHWriterTests: XCTestCase { - - let writer = OpenSSHKeyWriter() - - func testECDSA256MD5Fingerprint() { - XCTAssertEqual(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret), "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5") - } - - func testECDSA256SHA256Fingerprint() { - XCTAssertEqual(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa256Secret), "SHA256:/VQFeGyM8qKA8rB6WGMuZZxZLJln2UgXLk3F0uTF650") - } - - func testECDSA256PublicKey() { - XCTAssertEqual(writer.openSSHString(secret: Constants.ecdsa256Secret), - "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=") - } - - func testECDSA256Hash() { - XCTAssertEqual(writer.data(secret: Constants.ecdsa256Secret), Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")) - } - - func testECDSA384MD5Fingerprint() { - XCTAssertEqual(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa384Secret), "66:e0:66:d7:41:ed:19:8e:e2:20:df:ce:ac:7e:2b:6e") - } - - func testECDSA384SHA256Fingerprint() { - XCTAssertEqual(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa384Secret), "SHA256:GJUEymQNL9ymaMRRJCMGY4rWIJHu/Lm8Yhao/PAiz1I") - } - - func testECDSA384PublicKey() { - XCTAssertEqual(writer.openSSHString(secret: Constants.ecdsa384Secret), - "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==") - } - - func testECDSA384Hash() { - XCTAssertEqual(writer.data(secret: Constants.ecdsa384Secret), Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")) - } - -} - -extension OpenSSHWriterTests { - - enum Constants { - static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", algorithm: .ellipticCurve, keySize: 256, publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!) - static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", algorithm: .ellipticCurve, keySize: 384, publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!) - - } - -} diff --git a/Sources/SecretAgent/AppDelegate.swift b/Sources/SecretAgent/AppDelegate.swift index ab6a3cd..28bef7e 100644 --- a/Sources/SecretAgent/AppDelegate.swift +++ b/Sources/SecretAgent/AppDelegate.swift @@ -1,24 +1,27 @@ import Cocoa import OSLog -import Combine import SecretKit import SecureEnclaveSecretKit import SmartCardSecretKit import SecretAgentKit import Brief +import Observation -@NSApplicationMain +@main class AppDelegate: NSObject, NSApplicationDelegate { - private let storeList: SecretStoreList = { + @MainActor private let storeList: SecretStoreList = { let list = SecretStoreList() - list.add(store: SecureEnclave.Store()) + let cryptoKit = SecureEnclave.Store() + let migrator = SecureEnclave.CryptoKitMigrator() + try? migrator.migrate(to: cryptoKit) + list.add(store: cryptoKit) list.add(store: SmartCard.Store()) return list }() - private let updater = Updater(checkOnLaunch: false) + private let updater = Updater(checkOnLaunch: true) private let notifier = Notifier() - private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) + private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory) private lazy var agent: Agent = { Agent(storeList: storeList, witness: notifier) }() @@ -26,22 +29,42 @@ 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") - DispatchQueue.main.async { - self.socketController.handler = self.agent.handle(reader:writer:) + Task { + let inputParser = try await XPCAgentInputParser() + for await session in socketController.sessions { + Task { + do { + for await message in session.messages { + let request = try await inputParser.parse(data: message) + let agentResponse = await agent.handle(request: request, provenance: session.provenance) + try await session.write(agentResponse) + } + } catch { + try session.close() + } + } + } } - NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in - try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) + Task { + for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) { + try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) + } } try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) notifier.prompt() - updateSink = updater.$update.sink { update in - guard let update = update else { return } - self.notifier.notify(update: update, ignore: self.updater.ignore(release:)) + _ = withObservationTracking { + updater.update + } onChange: { [updater, notifier] in + Task { + guard !updater.currentVersion.isTestBuild else { return } + await notifier.notify(update: updater.update!) { release in + await updater.ignore(release: release) + } + } } } diff --git a/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index c14ca73..0000000 --- a/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "images" : [ - { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "Mac Icon.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "Mac Icon@0.25x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon.png b/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon.png deleted file mode 100644 index 99a172b..0000000 Binary files a/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon.png and /dev/null differ diff --git a/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png b/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png deleted file mode 100644 index 8b7b7ae..0000000 Binary files a/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png and /dev/null differ diff --git a/Sources/SecretAgent/Assets.xcassets/Contents.json b/Sources/SecretAgent/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/Sources/SecretAgent/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Sources/SecretAgent/InternetAccessPolicy.plist b/Sources/SecretAgent/InternetAccessPolicy.plist index 5ef4c38..32b9e35 100644 --- a/Sources/SecretAgent/InternetAccessPolicy.plist +++ b/Sources/SecretAgent/InternetAccessPolicy.plist @@ -9,22 +9,7 @@ Website https://github.com/maxgoedjen/secretive Connections - - - IsIncoming - - Host - api.github.com - NetworkProtocol - TCP - Port - 443 - Purpose - Secretive checks GitHub for new versions and security updates. - DenyConsequences - If you deny these connections, you will not be notified about new versions and critical security updates. - - + Services diff --git a/Sources/SecretAgent/Notifier.swift b/Sources/SecretAgent/Notifier.swift index 69b29bb..fa48cdd 100644 --- a/Sources/SecretAgent/Notifier.swift +++ b/Sources/SecretAgent/Notifier.swift @@ -5,13 +5,13 @@ import SecretKit import SecretAgentKit import Brief -class Notifier { +final class Notifier: Sendable { private let notificationDelegate = NotificationDelegate() init() { - let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: "update_notification_update_button"), options: []) - let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: "update_notification_ignore_button"), options: []) + let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: .updateNotificationUpdateButton), options: []) + let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: .updateNotificationIgnoreButton), options: []) let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: []) let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: []) @@ -22,32 +22,35 @@ class Notifier { Measurement(value: 24, unit: UnitDuration.hours) ] - let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: "persist_authentication_decline_button"), options: []) + let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: []) var allPersistenceActions = [doNotPersistAction] let formatter = DateComponentsFormatter() formatter.unitsStyle = .spellOut formatter.allowedUnits = [.hour, .minute, .day] + var identifiers: [String: TimeInterval] = [:] for duration in rawDurations { let seconds = duration.converted(to: .seconds).value guard let string = formatter.string(from: seconds)?.capitalized else { continue } let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)") let action = UNNotificationAction(identifier: identifier, title: string, options: []) - notificationDelegate.persistOptions[identifier] = seconds + identifiers[identifier] = seconds allPersistenceActions.append(action) } let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: []) if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) { - persistAuthenticationCategory.setValue(String(localized: "persist_authentication_accept_button"), forKey: "_actionsMenuTitle") + persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle") } UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory]) UNUserNotificationCenter.current().delegate = notificationDelegate - notificationDelegate.persistAuthentication = { secret, store, duration in - guard let duration = duration else { return } - try? store.persistAuthentication(secret: secret, forDuration: duration) + Task { + await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in + guard let duration = duration else { return } + try? await store.persistAuthentication(secret: secret, forDuration: duration) + } } } @@ -57,53 +60,51 @@ class Notifier { notificationCenter.requestAuthorization(options: .alert) { _, _ in } } - func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) { - notificationDelegate.pendingPersistableSecrets[secret.id.description] = secret - notificationDelegate.pendingPersistableStores[store.id.description] = store + func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async { + await notificationDelegate.state.setPending(secret: secret, store: store) let notificationCenter = UNUserNotificationCenter.current() let notificationContent = UNMutableNotificationContent() - notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)") - notificationContent.subtitle = String(localized: "signed_notification_description_\(secret.name)") + notificationContent.title = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName)) + notificationContent.subtitle = String(localized: .signedNotificationDescription(secretName: secret.name)) notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description notificationContent.interruptionLevel = .timeSensitive - if secret.requiresAuthentication && store.existingPersistedAuthenticationContext(secret: secret) == nil { + if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required { notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier } if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { notificationContent.attachments = [attachment] } let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil) - notificationCenter.add(request, withCompletionHandler: nil) + try? await notificationCenter.add(request) } - func notify(update: Release, ignore: ((Release) -> Void)?) { - notificationDelegate.release = update - notificationDelegate.ignore = ignore + func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async { + await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore) let notificationCenter = UNUserNotificationCenter.current() let notificationContent = UNMutableNotificationContent() if update.critical { notificationContent.interruptionLevel = .critical - notificationContent.title = String(localized: "update_notification_update_critical_title_\(update.name)") + notificationContent.title = String(localized: .updateNotificationUpdateCriticalTitle(updateName: update.name)) } else { - notificationContent.title = String(localized: "update_notification_update_normal_title_\(update.name)") + notificationContent.title = String(localized: .updateNotificationUpdateNormalTitle(updateName: update.name)) } - notificationContent.subtitle = String(localized: "update_notification_update_description") + notificationContent.subtitle = String(localized: .updateNotificationUpdateDescription) notificationContent.body = update.body notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil) - notificationCenter.add(request, withCompletionHandler: nil) + try? await notificationCenter.add(request) } } extension Notifier: SigningWitness { - func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws { + func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws { } - func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws { - notify(accessTo: secret, from: store, by: provenance) + func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws { + await notify(accessTo: secret, from: store, by: provenance) } } @@ -129,55 +130,91 @@ extension Notifier { } -class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { +final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable { - fileprivate var release: Release? - fileprivate var ignore: ((Release) -> Void)? - fileprivate var persistAuthentication: ((AnySecret, AnySecretStore, TimeInterval?) -> Void)? - fileprivate var persistOptions: [String: TimeInterval] = [:] - fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:] - fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:] + fileprivate actor State { + typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void) + typealias IgnoreAction = (@Sendable (Release) async -> Void) + fileprivate var release: Release? + fileprivate var ignoreAction: IgnoreAction? + fileprivate var persistAction: PersistAction? + fileprivate var persistOptions: [String: TimeInterval] = [:] + fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:] + fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:] + + func setPending(secret: AnySecret, store: AnySecretStore) { + pendingPersistableSecrets[secret.id.description] = secret + pendingPersistableStores[store.id.description] = store + } + + func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? { + guard let secret = pendingPersistableSecrets[secretID], + let store = pendingPersistableStores[storeID], + let options = persistOptions[optionID] else { + return nil + } + pendingPersistableSecrets.removeValue(forKey: secretID) + return (secret, store, options) + } + + func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) { + self.persistOptions = options + self.persistAction = action + } + + func prepareForNotification(release: Release, ignoreAction: IgnoreAction?) { + self.release = release + self.ignoreAction = ignoreAction + } + + + } + + fileprivate let state = State() func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { } - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { let category = response.notification.request.content.categoryIdentifier switch category { case Notifier.Constants.updateCategoryIdentitifier: - handleUpdateResponse(response: response) + await handleUpdateResponse(response: response) case Notifier.Constants.persistAuthenticationCategoryIdentitifier: - handlePersistAuthenticationResponse(response: response) + await handlePersistAuthenticationResponse(response: response) default: break } - - completionHandler() } - func handleUpdateResponse(response: UNNotificationResponse) { - guard let update = release else { return } - switch response.actionIdentifier { + func handleUpdateResponse(response: UNNotificationResponse) async { + let id = response.actionIdentifier + guard let update = await state.release else { return } + switch id { case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier: NSWorkspace.shared.open(update.html_url) case Notifier.Constants.ignoreActionIdentitifier: - ignore?(update) + await state.ignoreAction?(update) default: fatalError() } } - func handlePersistAuthenticationResponse(response: UNNotificationResponse) { - guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String, let secret = pendingPersistableSecrets[secretID], - let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String, let store = pendingPersistableStores[storeID] - else { return } - pendingPersistableSecrets[secretID] = nil - persistAuthentication?(secret, store, persistOptions[response.actionIdentifier]) + func handlePersistAuthenticationResponse(response: UNNotificationResponse) async { + guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String, + let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else { + return + } + let optionID = response.actionIdentifier + guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return } + await state.persistAction?(secret, store, persistOptions) } - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler([.list, .banner]) + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + [.list, .banner] } } + diff --git a/Sources/SecretAgent/SecretAgent.entitlements b/Sources/SecretAgent/SecretAgent.entitlements index 895fc77..c9423c4 100644 --- a/Sources/SecretAgent/SecretAgent.entitlements +++ b/Sources/SecretAgent/SecretAgent.entitlements @@ -2,10 +2,6 @@ - com.apple.security.app-sandbox - - com.apple.security.network.client - com.apple.security.smartcard keychain-access-groups diff --git a/Sources/SecretAgent/XPCInputParser.swift b/Sources/SecretAgent/XPCInputParser.swift new file mode 100644 index 0000000..33d179f --- /dev/null +++ b/Sources/SecretAgent/XPCInputParser.swift @@ -0,0 +1,23 @@ +import Foundation +import SecretAgentKit +import Brief +import XPCWrappers + +/// Delegates all agent input parsing to an XPC service which wraps OpenSSH +public final class XPCAgentInputParser: SSHAgentInputParserProtocol { + + private let session: XPCTypedSession + + public init() async throws { + session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretAgentInputParser", warmup: true) + } + + public func parse(data: Data) async throws -> SSHAgent.Request { + try await session.send(data) + } + + deinit { + session.complete() + } + +} diff --git a/Sources/SecretAgentInputParser/Info.plist b/Sources/SecretAgentInputParser/Info.plist new file mode 100644 index 0000000..c123a5d --- /dev/null +++ b/Sources/SecretAgentInputParser/Info.plist @@ -0,0 +1,11 @@ + + + + + XPCService + + ServiceType + Application + + + diff --git a/Sources/SecretAgentInputParser/SecretAgentInputParser.swift b/Sources/SecretAgentInputParser/SecretAgentInputParser.swift new file mode 100644 index 0000000..cc0c8fd --- /dev/null +++ b/Sources/SecretAgentInputParser/SecretAgentInputParser.swift @@ -0,0 +1,17 @@ +import Foundation +import OSLog +import XPCWrappers +import SecretAgentKit + +final class SecretAgentInputParser: NSObject, XPCProtocol { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive.SecretAgentInputParser", category: "SecretAgentInputParser") + + func process(_ data: Data) async throws -> SSHAgent.Request { + let parser = SSHAgentInputParser() + let result = try parser.parse(data: data) + logger.log("Parser parsed message as type \(result.debugDescription)") + return result + } + +} diff --git a/Sources/SecretAgentInputParser/main.swift b/Sources/SecretAgentInputParser/main.swift new file mode 100644 index 0000000..5e039ca --- /dev/null +++ b/Sources/SecretAgentInputParser/main.swift @@ -0,0 +1,7 @@ +import Foundation +import XPCWrappers + +let delegate = XPCServiceDelegate(exportedObject: SecretAgentInputParser()) +let listener = NSXPCListener.service() +listener.delegate = delegate +listener.resume() diff --git a/Sources/Secretive.xcodeproj/project.pbxproj b/Sources/Secretive.xcodeproj/project.pbxproj index d7c5e73..ab820fa 100644 --- a/Sources/Secretive.xcodeproj/project.pbxproj +++ b/Sources/Secretive.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */; }; + 2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */; }; 50020BB024064869003D4025 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50020BAF24064869003D4025 /* AppDelegate.swift */; }; 50033AC327813F1700253856 /* BundleIDs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50033AC227813F1700253856 /* BundleIDs.swift */; }; 5003EF3B278005E800DF2006 /* SecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF3A278005E800DF2006 /* SecretKit */; }; @@ -18,26 +18,43 @@ 5003EF612780081600DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF602780081600DF2006 /* SmartCardSecretKit */; }; 5003EF632780081B00DF2006 /* SecureEnclaveSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF622780081B00DF2006 /* SecureEnclaveSecretKit */; }; 5003EF652780081B00DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF642780081B00DF2006 /* SmartCardSecretKit */; }; - 500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; }; + 5008C23E2E525D8900507AC2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */; }; + 5008C2402E52792400507AC2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8623FCE48E0099B055 /* Assets.xcassets */; }; + 5008C2412E52D18700507AC2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */; }; 501421622781262300BBAA70 /* Brief in Frameworks */ = {isa = PBXBuildFile; productRef = 501421612781262300BBAA70 /* Brief */; }; 501421652781268000BBAA70 /* SecretAgent.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 50A3B78A24026B7500D209EA /* SecretAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 50153E20250AFCB200525160 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E1F250AFCB200525160 /* UpdateView.swift */; }; 50153E22250DECA300525160 /* SecretListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E21250DECA300525160 /* SecretListItemView.swift */; }; + 501578132E6C0479004A37D0 /* XPCInputParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501578122E6C0479004A37D0 /* XPCInputParser.swift */; }; 5018F54F24064786002EB505 /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018F54E24064786002EB505 /* Notifier.swift */; }; + 504788EC2E680DC800B4556F /* URLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788EB2E680DC400B4556F /* URLs.swift */; }; + 504788F22E681F3A00B4556F /* Instructions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F12E681F3A00B4556F /* Instructions.swift */; }; + 504788F42E681F6900B4556F /* ToolConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F32E681F6900B4556F /* ToolConfigurationView.swift */; }; + 504788F62E68206F00B4556F /* GettingStartedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F52E68206F00B4556F /* GettingStartedView.swift */; }; + 504789232E697DD300B4556F /* BoxBackgroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504789222E697DD300B4556F /* BoxBackgroundStyle.swift */; }; 50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */; }; 50571E0524393D1500F76F6C /* LaunchAgentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50571E0424393D1500F76F6C /* LaunchAgentController.swift */; }; 50617D8323FCE48E0099B055 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D8223FCE48E0099B055 /* App.swift */; }; 50617D8523FCE48E0099B055 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D8423FCE48E0099B055 /* ContentView.swift */; }; 50617D8723FCE48E0099B055 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8623FCE48E0099B055 /* Assets.xcassets */; }; 50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */; }; - 50617D9923FCE48E0099B055 /* SecretiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D9823FCE48E0099B055 /* SecretiveTests.swift */; }; 50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617DD123FCEFA90099B055 /* PreviewStore.swift */; }; 5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5065E312295517C500E16645 /* ToolbarButtonStyle.swift */; }; 5066A6C22516F303004B5A36 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C12516F303004B5A36 /* SetupView.swift */; }; 5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; }; - 5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */; }; 506772C72424784600034DED /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 506772C62424784600034DED /* Credits.rtf */; }; 506772C92425BB8500034DED /* NoStoresView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506772C82425BB8500034DED /* NoStoresView.swift */; }; + 50692D1D2E6FDB880043C7BB /* SecretiveUpdater.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 50692D122E6FDB880043C7BB /* SecretiveUpdater.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 50692D282E6FDB8D0043C7BB /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50692D242E6FDB8D0043C7BB /* main.swift */; }; + 50692D2D2E6FDC000043C7BB /* XPCWrappers in Frameworks */ = {isa = PBXBuildFile; productRef = 50692D2C2E6FDC000043C7BB /* XPCWrappers */; }; + 50692D2F2E6FDC2B0043C7BB /* SecretiveUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50692D2E2E6FDC290043C7BB /* SecretiveUpdater.swift */; }; + 50692D312E6FDC390043C7BB /* Brief in Frameworks */ = {isa = PBXBuildFile; productRef = 50692D302E6FDC390043C7BB /* Brief */; }; + 50692E5B2E6FF9D20043C7BB /* SecretAgentInputParser.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 50692E502E6FF9D20043C7BB /* SecretAgentInputParser.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 50692E682E6FF9E20043C7BB /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50692E632E6FF9E20043C7BB /* main.swift */; }; + 50692E692E6FF9E20043C7BB /* SecretAgentInputParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50692E642E6FF9E20043C7BB /* SecretAgentInputParser.swift */; }; + 50692E6C2E6FFA510043C7BB /* SecretAgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = 50692E6B2E6FFA510043C7BB /* SecretAgentKit */; }; + 50692E6D2E6FFA5F0043C7BB /* SecretiveUpdater.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 50692D122E6FDB880043C7BB /* SecretiveUpdater.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 50692E702E6FFA6E0043C7BB /* SecretAgentInputParser.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 50692E502E6FF9D20043C7BB /* SecretAgentInputParser.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5079BA0E250F29BF00EA86F4 /* StoreListView.swift */; }; 508A58AA241E06B40069DC07 /* PreviewUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508A58A9241E06B40069DC07 /* PreviewUpdater.swift */; }; 508A58B3241ED2180069DC07 /* AgentStatusChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508A58B2241ED2180069DC07 /* AgentStatusChecker.swift */; }; @@ -46,13 +63,16 @@ 508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */ = {isa = PBXBuildFile; fileRef = 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */; }; 5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */; }; 5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; }; - 50A3B79124026B7600D209EA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79024026B7600D209EA /* Assets.xcassets */; }; 50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79324026B7600D209EA /* Preview Assets.xcassets */; }; 50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; }; + 50AE97002E5C1A420018C710 /* IntegrationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */; }; 50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; }; 50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; }; + 50BDCB722E63BAF20072D2E7 /* AgentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */; }; + 50BDCB742E6436CA0072D2E7 /* ErrorStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */; }; + 50BDCB762E6450950072D2E7 /* ConfigurationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */; }; 50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; }; - 50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; }; + 50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,16 +83,68 @@ remoteGlobalIDString = 50A3B78924026B7500D209EA; remoteInfo = SecretAgent; }; - 50617D9523FCE48E0099B055 /* PBXContainerItemProxy */ = { + 501577D32E6BC5DD004A37D0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 50617D7723FCE48D0099B055 /* Project object */; proxyType = 1; - remoteGlobalIDString = 50617D7E23FCE48D0099B055; - remoteInfo = Secretive; + remoteGlobalIDString = 501577BC2E6BC5B4004A37D0; + remoteInfo = ReleasesDownloader; + }; + 50692D1B2E6FDB880043C7BB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50617D7723FCE48D0099B055 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 50692D112E6FDB880043C7BB; + remoteInfo = SecretiveUpdater; + }; + 50692E592E6FF9D20043C7BB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50617D7723FCE48D0099B055 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 50692E4F2E6FF9D20043C7BB; + remoteInfo = SecretAgentInputParser; + }; + 50692E6E2E6FFA5F0043C7BB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50617D7723FCE48D0099B055 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 50692D112E6FDB880043C7BB; + remoteInfo = SecretiveUpdater; + }; + 50692E712E6FFA6E0043C7BB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50617D7723FCE48D0099B055 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 50692E4F2E6FF9D20043C7BB; + remoteInfo = SecretAgentInputParser; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 501577C92E6BC5B4004A37D0 /* Embed XPC Services */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/XPCServices"; + dstSubfolderSpec = 16; + files = ( + 50692D1D2E6FDB880043C7BB /* SecretiveUpdater.xpc in Embed XPC Services */, + 50692E5B2E6FF9D20043C7BB /* SecretAgentInputParser.xpc in Embed XPC Services */, + ); + name = "Embed XPC Services"; + runOnlyForDeploymentPostprocessing = 0; + }; + 501577D22E6BC5D4004A37D0 /* Embed XPC Services */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/XPCServices"; + dstSubfolderSpec = 16; + files = ( + 50692E6D2E6FFA5F0043C7BB /* SecretiveUpdater.xpc in Embed XPC Services */, + 50692E702E6FFA6E0043C7BB /* SecretAgentInputParser.xpc in Embed XPC Services */, + ); + name = "Embed XPC Services"; + runOnlyForDeploymentPostprocessing = 0; + }; 50617DBF23FCE4AB0099B055 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -106,14 +178,20 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameSecretView.swift; sourceTree = ""; }; + 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditSecretView.swift; sourceTree = ""; }; 50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = ""; }; 5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = ""; }; - 500B93C22B478D8400E157DE /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = Packages/Resources/Localizable.xcstrings; sourceTree = SOURCE_ROOT; }; 50153E1F250AFCB200525160 /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = ""; }; 50153E21250DECA300525160 /* SecretListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretListItemView.swift; sourceTree = ""; }; + 501578122E6C0479004A37D0 /* XPCInputParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCInputParser.swift; sourceTree = ""; }; 5018F54E24064786002EB505 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = ""; }; + 504788EB2E680DC400B4556F /* URLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLs.swift; sourceTree = ""; }; + 504788F12E681F3A00B4556F /* Instructions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instructions.swift; sourceTree = ""; }; + 504788F32E681F6900B4556F /* ToolConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolConfigurationView.swift; sourceTree = ""; }; + 504788F52E68206F00B4556F /* GettingStartedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GettingStartedView.swift; sourceTree = ""; }; + 504789222E697DD300B4556F /* BoxBackgroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxBackgroundStyle.swift; sourceTree = ""; }; 50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustUpdatedChecker.swift; sourceTree = ""; }; 50571E0424393D1500F76F6C /* LaunchAgentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAgentController.swift; sourceTree = ""; }; 50617D7F23FCE48E0099B055 /* Secretive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Secretive.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -123,16 +201,21 @@ 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 50617D8E23FCE48E0099B055 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50617D8F23FCE48E0099B055 /* Secretive.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Secretive.entitlements; sourceTree = ""; }; - 50617D9423FCE48E0099B055 /* SecretiveTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SecretiveTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 50617D9823FCE48E0099B055 /* SecretiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretiveTests.swift; sourceTree = ""; }; - 50617D9A23FCE48E0099B055 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50617DD123FCEFA90099B055 /* PreviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewStore.swift; sourceTree = ""; }; 5065E312295517C500E16645 /* ToolbarButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarButtonStyle.swift; sourceTree = ""; }; 5066A6C12516F303004B5A36 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; 5066A6C72516FE6E004B5A36 /* CopyableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableView.swift; sourceTree = ""; }; - 5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellConfigurationController.swift; sourceTree = ""; }; 506772C62424784600034DED /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 506772C82425BB8500034DED /* NoStoresView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoStoresView.swift; sourceTree = ""; }; + 50692BA52E6D5CC90043C7BB /* InternetAccessPolicy.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = InternetAccessPolicy.plist; sourceTree = ""; }; + 50692D122E6FDB880043C7BB /* SecretiveUpdater.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = SecretiveUpdater.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; + 50692D232E6FDB8D0043C7BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50692D242E6FDB8D0043C7BB /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 50692D2E2E6FDC290043C7BB /* SecretiveUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretiveUpdater.swift; sourceTree = ""; }; + 50692E502E6FF9D20043C7BB /* SecretAgentInputParser.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = SecretAgentInputParser.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; + 50692E622E6FF9E20043C7BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50692E632E6FF9E20043C7BB /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 50692E642E6FF9E20043C7BB /* SecretAgentInputParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretAgentInputParser.swift; sourceTree = ""; }; 5079BA0E250F29BF00EA86F4 /* StoreListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListView.swift; sourceTree = ""; }; 508A58A9241E06B40069DC07 /* PreviewUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewUpdater.swift; sourceTree = ""; }; 508A58AB241E121B0069DC07 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; @@ -144,14 +227,18 @@ 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDirectoryController.swift; sourceTree = ""; }; 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSecretView.swift; sourceTree = ""; }; 50A3B78A24026B7500D209EA /* SecretAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SecretAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 50A3B79024026B7600D209EA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 50A3B79324026B7600D209EA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 50A3B79624026B7600D209EA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 50A3B79824026B7600D209EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50A3B79924026B7600D209EA /* SecretAgent.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SecretAgent.entitlements; sourceTree = ""; }; + 50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationsView.swift; sourceTree = ""; }; 50B8550C24138C4F009958AC /* DeleteSecretView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteSecretView.swift; sourceTree = ""; }; 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStoreView.swift; sourceTree = ""; }; + 50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentStatusView.swift; sourceTree = ""; }; + 50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorStyle.swift; sourceTree = ""; }; + 50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationItemView.swift; sourceTree = ""; }; 50C385A42407A76D00AF2719 /* SecretDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretDetailView.swift; sourceTree = ""; }; + 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonStyle.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -166,10 +253,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 50617D9123FCE48E0099B055 /* Frameworks */ = { + 50692D0F2E6FDB880043C7BB /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 50692D2D2E6FDC000043C7BB /* XPCWrappers in Frameworks */, + 50692D312E6FDC390043C7BB /* Brief in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 50692E4D2E6FF9D20043C7BB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50692E6C2E6FFA510043C7BB /* SecretAgentKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -196,14 +293,65 @@ path = Helpers; sourceTree = ""; }; + 504788ED2E681EB200B4556F /* Styles */ = { + isa = PBXGroup; + children = ( + 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */, + 50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */, + 504789222E697DD300B4556F /* BoxBackgroundStyle.swift */, + 5065E312295517C500E16645 /* ToolbarButtonStyle.swift */, + ); + path = Styles; + sourceTree = ""; + }; + 504788EE2E681EC300B4556F /* Secrets */ = { + isa = PBXGroup; + children = ( + 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */, + 50B8550C24138C4F009958AC /* DeleteSecretView.swift */, + 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */, + 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */, + 506772C82425BB8500034DED /* NoStoresView.swift */, + 50C385A42407A76D00AF2719 /* SecretDetailView.swift */, + 50153E21250DECA300525160 /* SecretListItemView.swift */, + 5079BA0E250F29BF00EA86F4 /* StoreListView.swift */, + ); + path = Secrets; + sourceTree = ""; + }; + 504788EF2E681ED700B4556F /* Configuration */ = { + isa = PBXGroup; + children = ( + 50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */, + 50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */, + 504788F12E681F3A00B4556F /* Instructions.swift */, + 504788F32E681F6900B4556F /* ToolConfigurationView.swift */, + 5066A6C12516F303004B5A36 /* SetupView.swift */, + 504788F52E68206F00B4556F /* GettingStartedView.swift */, + ); + path = Configuration; + sourceTree = ""; + }; + 504788F02E681F0100B4556F /* Views */ = { + isa = PBXGroup; + children = ( + 50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */, + 50617D8423FCE48E0099B055 /* ContentView.swift */, + 5066A6C72516FE6E004B5A36 /* CopyableView.swift */, + 50153E1F250AFCB200525160 /* UpdateView.swift */, + ); + path = Views; + sourceTree = ""; + }; 50617D7623FCE48D0099B055 = { isa = PBXGroup; children = ( 5003EF39278005C800DF2006 /* Packages */, 50617D8123FCE48E0099B055 /* Secretive */, - 50617D9723FCE48E0099B055 /* SecretiveTests */, 50A3B78B24026B7500D209EA /* SecretAgent */, 508A58AF241E144C0069DC07 /* Config */, + 50692D272E6FDB8D0043C7BB /* SecretiveUpdater */, + 50692E662E6FF9E20043C7BB /* SecretAgentInputParser */, 50617D8023FCE48E0099B055 /* Products */, 5099A08B240243730062B6F2 /* Frameworks */, ); @@ -213,8 +361,9 @@ isa = PBXGroup; children = ( 50617D7F23FCE48E0099B055 /* Secretive.app */, - 50617D9423FCE48E0099B055 /* SecretiveTests.xctest */, 50A3B78A24026B7500D209EA /* SecretAgent.app */, + 50692D122E6FDB880043C7BB /* SecretiveUpdater.xpc */, + 50692E502E6FF9D20043C7BB /* SecretAgentInputParser.xpc */, ); name = Products; sourceTree = ""; @@ -231,7 +380,7 @@ 508BF28D25B4F005009EFB7E /* InternetAccessPolicy.plist */, 50617D8F23FCE48E0099B055 /* Secretive.entitlements */, 506772C62424784600034DED /* Credits.rtf */, - 500B93C22B478D8400E157DE /* Localizable.xcstrings */, + 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */, 50617D8823FCE48E0099B055 /* Preview Content */, ); path = Secretive; @@ -248,13 +397,25 @@ path = "Preview Content"; sourceTree = ""; }; - 50617D9723FCE48E0099B055 /* SecretiveTests */ = { + 50692D272E6FDB8D0043C7BB /* SecretiveUpdater */ = { isa = PBXGroup; children = ( - 50617D9823FCE48E0099B055 /* SecretiveTests.swift */, - 50617D9A23FCE48E0099B055 /* Info.plist */, + 50692D232E6FDB8D0043C7BB /* Info.plist */, + 50692BA52E6D5CC90043C7BB /* InternetAccessPolicy.plist */, + 50692D242E6FDB8D0043C7BB /* main.swift */, + 50692D2E2E6FDC290043C7BB /* SecretiveUpdater.swift */, ); - path = SecretiveTests; + path = SecretiveUpdater; + sourceTree = ""; + }; + 50692E662E6FF9E20043C7BB /* SecretAgentInputParser */ = { + isa = PBXGroup; + children = ( + 50692E622E6FF9E20043C7BB /* Info.plist */, + 50692E632E6FF9E20043C7BB /* main.swift */, + 50692E642E6FF9E20043C7BB /* SecretAgentInputParser.swift */, + ); + path = SecretAgentInputParser; sourceTree = ""; }; 508A58AF241E144C0069DC07 /* Config */ = { @@ -269,19 +430,10 @@ 508A58B0241ED1C40069DC07 /* Views */ = { isa = PBXGroup; children = ( - 50617D8423FCE48E0099B055 /* ContentView.swift */, - 5065E312295517C500E16645 /* ToolbarButtonStyle.swift */, - 5079BA0E250F29BF00EA86F4 /* StoreListView.swift */, - 50153E21250DECA300525160 /* SecretListItemView.swift */, - 50C385A42407A76D00AF2719 /* SecretDetailView.swift */, - 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */, - 50B8550C24138C4F009958AC /* DeleteSecretView.swift */, - 2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */, - 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */, - 506772C82425BB8500034DED /* NoStoresView.swift */, - 50153E1F250AFCB200525160 /* UpdateView.swift */, - 5066A6C12516F303004B5A36 /* SetupView.swift */, - 5066A6C72516FE6E004B5A36 /* CopyableView.swift */, + 504788EF2E681ED700B4556F /* Configuration */, + 504788EE2E681EC300B4556F /* Secrets */, + 504788ED2E681EB200B4556F /* Styles */, + 504788F02E681F0100B4556F /* Views */, ); path = Views; sourceTree = ""; @@ -289,11 +441,11 @@ 508A58B1241ED1EA0069DC07 /* Controllers */ = { isa = PBXGroup; children = ( + 504788EB2E680DC400B4556F /* URLs.swift */, 508A58B2241ED2180069DC07 /* AgentStatusChecker.swift */, 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */, 50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */, 50571E0424393D1500F76F6C /* LaunchAgentController.swift */, - 5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */, ); path = Controllers; sourceTree = ""; @@ -310,7 +462,7 @@ children = ( 50020BAF24064869003D4025 /* AppDelegate.swift */, 5018F54E24064786002EB505 /* Notifier.swift */, - 50A3B79024026B7600D209EA /* Assets.xcassets */, + 501578122E6C0479004A37D0 /* XPCInputParser.swift */, 50A3B79524026B7600D209EA /* Main.storyboard */, 50A3B79824026B7600D209EA /* Info.plist */, 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */, @@ -340,11 +492,14 @@ 50617D7D23FCE48D0099B055 /* Resources */, 50617DBF23FCE4AB0099B055 /* Embed Frameworks */, 50C385AF240E438B00AF2719 /* CopyFiles */, + 501577C92E6BC5B4004A37D0 /* Embed XPC Services */, ); buildRules = ( ); dependencies = ( 50142167278126B500BBAA70 /* PBXTargetDependency */, + 50692D1C2E6FDB880043C7BB /* PBXTargetDependency */, + 50692E5A2E6FF9D20043C7BB /* PBXTargetDependency */, ); name = Secretive; packageProductDependencies = ( @@ -357,23 +512,46 @@ productReference = 50617D7F23FCE48E0099B055 /* Secretive.app */; productType = "com.apple.product-type.application"; }; - 50617D9323FCE48E0099B055 /* SecretiveTests */ = { + 50692D112E6FDB880043C7BB /* SecretiveUpdater */ = { isa = PBXNativeTarget; - buildConfigurationList = 50617DA023FCE48E0099B055 /* Build configuration list for PBXNativeTarget "SecretiveTests" */; + buildConfigurationList = 50692D1F2E6FDB880043C7BB /* Build configuration list for PBXNativeTarget "SecretiveUpdater" */; buildPhases = ( - 50617D9023FCE48E0099B055 /* Sources */, - 50617D9123FCE48E0099B055 /* Frameworks */, - 50617D9223FCE48E0099B055 /* Resources */, + 50692D0E2E6FDB880043C7BB /* Sources */, + 50692D0F2E6FDB880043C7BB /* Frameworks */, + 50692D102E6FDB880043C7BB /* Resources */, ); buildRules = ( ); dependencies = ( - 50617D9623FCE48E0099B055 /* PBXTargetDependency */, ); - name = SecretiveTests; - productName = SecretiveTests; - productReference = 50617D9423FCE48E0099B055 /* SecretiveTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; + name = SecretiveUpdater; + packageProductDependencies = ( + 50692D2C2E6FDC000043C7BB /* XPCWrappers */, + 50692D302E6FDC390043C7BB /* Brief */, + ); + productName = SecretiveUpdater; + productReference = 50692D122E6FDB880043C7BB /* SecretiveUpdater.xpc */; + productType = "com.apple.product-type.xpc-service"; + }; + 50692E4F2E6FF9D20043C7BB /* SecretAgentInputParser */ = { + isa = PBXNativeTarget; + buildConfigurationList = 50692E5D2E6FF9D20043C7BB /* Build configuration list for PBXNativeTarget "SecretAgentInputParser" */; + buildPhases = ( + 50692E4C2E6FF9D20043C7BB /* Sources */, + 50692E4D2E6FF9D20043C7BB /* Frameworks */, + 50692E4E2E6FF9D20043C7BB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SecretAgentInputParser; + packageProductDependencies = ( + 50692E6B2E6FFA510043C7BB /* SecretAgentKit */, + ); + productName = SecretAgentInputParser; + productReference = 50692E502E6FF9D20043C7BB /* SecretAgentInputParser.xpc */; + productType = "com.apple.product-type.xpc-service"; }; 50A3B78924026B7500D209EA /* SecretAgent */ = { isa = PBXNativeTarget; @@ -383,10 +561,14 @@ 50A3B78724026B7500D209EA /* Frameworks */, 50A3B78824026B7500D209EA /* Resources */, 50A5C18E240E4B4B00E2996C /* Embed Frameworks */, + 501577D22E6BC5D4004A37D0 /* Embed XPC Services */, ); buildRules = ( ); dependencies = ( + 501577D42E6BC5DD004A37D0 /* PBXTargetDependency */, + 50692E6F2E6FFA5F0043C7BB /* PBXTargetDependency */, + 50692E722E6FFA6E0043C7BB /* PBXTargetDependency */, ); name = SecretAgent; packageProductDependencies = ( @@ -406,16 +588,19 @@ 50617D7723FCE48D0099B055 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1220; - LastUpgradeCheck = 1320; + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; ORGANIZATIONNAME = "Max Goedjen"; TargetAttributes = { 50617D7E23FCE48D0099B055 = { CreatedOnToolsVersion = 11.3; }; - 50617D9323FCE48E0099B055 = { - CreatedOnToolsVersion = 11.3; - TestTargetID = 50617D7E23FCE48D0099B055; + 50692D112E6FDB880043C7BB = { + CreatedOnToolsVersion = 26.0; + }; + 50692E4F2E6FF9D20043C7BB = { + CreatedOnToolsVersion = 26.0; }; 50A3B78924026B7500D209EA = { CreatedOnToolsVersion = 11.4; @@ -436,6 +621,7 @@ fi, ko, ca, + ru, pl, ); mainGroup = 50617D7623FCE48D0099B055; @@ -444,8 +630,9 @@ projectRoot = ""; targets = ( 50617D7E23FCE48D0099B055 /* Secretive */, - 50617D9323FCE48E0099B055 /* SecretiveTests */, 50A3B78924026B7500D209EA /* SecretAgent */, + 50692D112E6FDB880043C7BB /* SecretiveUpdater */, + 50692E4F2E6FF9D20043C7BB /* SecretAgentInputParser */, ); }; /* End PBXProject section */ @@ -456,14 +643,21 @@ buildActionMask = 2147483647; files = ( 50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */, - 500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */, + 5008C23E2E525D8900507AC2 /* Localizable.xcstrings in Resources */, 50617D8723FCE48E0099B055 /* Assets.xcassets in Resources */, 506772C72424784600034DED /* Credits.rtf in Resources */, 508BF28E25B4F005009EFB7E /* InternetAccessPolicy.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 50617D9223FCE48E0099B055 /* Resources */ = { + 50692D102E6FDB880043C7BB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 50692E4E2E6FF9D20043C7BB /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -475,10 +669,10 @@ buildActionMask = 2147483647; files = ( 50A3B79724026B7600D209EA /* Main.storyboard in Resources */, - 50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */, + 5008C2412E52D18700507AC2 /* Localizable.xcstrings in Resources */, 50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */, - 50A3B79124026B7600D209EA /* Assets.xcassets in Resources */, 508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */, + 5008C2402E52792400507AC2 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -489,25 +683,34 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */, + 504788F22E681F3A00B4556F /* Instructions.swift in Sources */, + 50BDCB742E6436CA0072D2E7 /* ErrorStyle.swift in Sources */, + 2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */, 5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */, + 504788EC2E680DC800B4556F /* URLs.swift in Sources */, + 504789232E697DD300B4556F /* BoxBackgroundStyle.swift in Sources */, 5066A6C22516F303004B5A36 /* SetupView.swift in Sources */, 5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */, 50617D8523FCE48E0099B055 /* ContentView.swift in Sources */, + 504788F62E68206F00B4556F /* GettingStartedView.swift in Sources */, + 50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */, 50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */, 5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */, 50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */, - 5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */, 50033AC327813F1700253856 /* BundleIDs.swift in Sources */, + 50BDCB722E63BAF20072D2E7 /* AgentStatusView.swift in Sources */, 508A58B3241ED2180069DC07 /* AgentStatusChecker.swift in Sources */, 50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */, 5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */, + 50AE97002E5C1A420018C710 /* IntegrationsView.swift in Sources */, 50153E20250AFCB200525160 /* UpdateView.swift in Sources */, 50571E0524393D1500F76F6C /* LaunchAgentController.swift in Sources */, 5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */, 50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */, 50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */, + 50BDCB762E6450950072D2E7 /* ConfigurationItemView.swift in Sources */, 50617D8323FCE48E0099B055 /* App.swift in Sources */, + 504788F42E681F6900B4556F /* ToolConfigurationView.swift in Sources */, 506772C92425BB8500034DED /* NoStoresView.swift in Sources */, 50153E22250DECA300525160 /* SecretListItemView.swift in Sources */, 508A58B5241ED48F0069DC07 /* PreviewAgentStatusChecker.swift in Sources */, @@ -515,11 +718,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 50617D9023FCE48E0099B055 /* Sources */ = { + 50692D0E2E6FDB880043C7BB /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 50617D9923FCE48E0099B055 /* SecretiveTests.swift in Sources */, + 50692D2F2E6FDC2B0043C7BB /* SecretiveUpdater.swift in Sources */, + 50692D282E6FDB8D0043C7BB /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 50692E4C2E6FF9D20043C7BB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 50692E682E6FF9E20043C7BB /* main.swift in Sources */, + 50692E692E6FF9E20043C7BB /* SecretAgentInputParser.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -529,6 +742,7 @@ files = ( 50020BB024064869003D4025 /* AppDelegate.swift in Sources */, 5018F54F24064786002EB505 /* Notifier.swift in Sources */, + 501578132E6C0479004A37D0 /* XPCInputParser.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -540,10 +754,29 @@ target = 50A3B78924026B7500D209EA /* SecretAgent */; targetProxy = 50142166278126B500BBAA70 /* PBXContainerItemProxy */; }; - 50617D9623FCE48E0099B055 /* PBXTargetDependency */ = { + 501577D42E6BC5DD004A37D0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 50617D7E23FCE48D0099B055 /* Secretive */; - targetProxy = 50617D9523FCE48E0099B055 /* PBXContainerItemProxy */; + targetProxy = 501577D32E6BC5DD004A37D0 /* PBXContainerItemProxy */; + }; + 50692D1C2E6FDB880043C7BB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 50692D112E6FDB880043C7BB /* SecretiveUpdater */; + targetProxy = 50692D1B2E6FDB880043C7BB /* PBXContainerItemProxy */; + }; + 50692E5A2E6FF9D20043C7BB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 50692E4F2E6FF9D20043C7BB /* SecretAgentInputParser */; + targetProxy = 50692E592E6FF9D20043C7BB /* PBXContainerItemProxy */; + }; + 50692E6F2E6FFA5F0043C7BB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 50692D112E6FDB880043C7BB /* SecretiveUpdater */; + targetProxy = 50692E6E2E6FFA5F0043C7BB /* PBXContainerItemProxy */; + }; + 50692E722E6FFA6E0043C7BB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 50692E4F2E6FF9D20043C7BB /* SecretAgentInputParser */; + targetProxy = 50692E712E6FFA6E0043C7BB /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -564,6 +797,8 @@ baseConfigurationReference = 508A58AB241E121B0069DC07 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -594,9 +829,13 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_ENHANCED_SECURITY = YES; + ENABLE_POINTER_AUTHENTICATION = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -611,17 +850,21 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = ""; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; STRIP_INSTALLED_PRODUCT = NO; STRIP_SWIFT_SYMBOLS = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_MEMORY_SAFETY = YES; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -630,6 +873,8 @@ baseConfigurationReference = 508A58AB241E121B0069DC07 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -660,9 +905,13 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_ENHANCED_SECURITY = YES; ENABLE_NS_ASSERTIONS = NO; + ENABLE_POINTER_AUTHENTICATION = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -671,114 +920,319 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = ""; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; STRIP_INSTALLED_PRODUCT = NO; STRIP_SWIFT_SYMBOLS = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_MEMORY_SAFETY = YES; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 6.0; }; name = Release; }; 50617D9E23FCE48E0099B055 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Secretive/Secretive.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\""; DEVELOPMENT_TEAM = Z72PRUAWF6; + ENABLE_APP_SANDBOX = YES; + ENABLE_ENHANCED_SECURITY = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_POINTER_AUTHENTICATION = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; INFOPLIST_FILE = Secretive/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; }; name = Debug; }; 50617D9F23FCE48E0099B055 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Secretive/Secretive.entitlements; CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\""; DEVELOPMENT_TEAM = Z72PRUAWF6; + ENABLE_APP_SANDBOX = YES; + ENABLE_ENHANCED_SECURITY = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_POINTER_AUTHENTICATION = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; INFOPLIST_FILE = Secretive/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "Secretive - Host"; + }; + name = Release; + }; + 50692D202E6FDB880043C7BB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Z72PRUAWF6; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SecretiveUpdater/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SecretiveUpdater; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Max Goedjen. All rights reserved."; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretiveUpdater; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 50692D212E6FDB880043C7BB /* Test */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SecretiveUpdater/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SecretiveUpdater; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Max Goedjen. All rights reserved."; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretiveUpdater; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Test; + }; + 50692D222E6FDB880043C7BB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = Z72PRUAWF6; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SecretiveUpdater/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SecretiveUpdater; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Max Goedjen. All rights reserved."; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretiveUpdater; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; }; name = Release; }; - 50617DA123FCE48E0099B055 /* Debug */ = { + 50692E5E2E6FF9D20043C7BB /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = Z72PRUAWF6; - INFOPLIST_FILE = SecretiveTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@loader_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.SecretiveTests; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SecretAgentInputParser/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SecretAgentInputParser; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Max Goedjen. All rights reserved."; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgentInputParser; PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Secretive.app/Contents/MacOS/Secretive"; }; name = Debug; }; - 50617DA223FCE48E0099B055 /* Release */ = { + 50692E5F2E6FF9D20043C7BB /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = Z72PRUAWF6; - INFOPLIST_FILE = SecretiveTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@loader_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.SecretiveTests; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SecretAgentInputParser/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SecretAgentInputParser; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Max Goedjen. All rights reserved."; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgentInputParser; PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Test; + }; + 50692E602E6FF9D20043C7BB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = Z72PRUAWF6; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SecretAgentInputParser/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SecretAgentInputParser; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Max Goedjen. All rights reserved."; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgentInputParser; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Secretive.app/Contents/MacOS/Secretive"; }; name = Release; }; @@ -787,6 +1241,8 @@ baseConfigurationReference = 508A58AB241E121B0069DC07 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -817,9 +1273,13 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_ENHANCED_SECURITY = YES; + ENABLE_POINTER_AUTHENTICATION = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -834,63 +1294,58 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = ""; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; STRIP_INSTALLED_PRODUCT = NO; STRIP_SWIFT_SYMBOLS = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_MEMORY_SAFETY = YES; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 6.0; }; name = Test; }; 508A5915241EF1A00069DC07 /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Secretive/Secretive.entitlements; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\""; + ENABLE_APP_SANDBOX = YES; + ENABLE_ENHANCED_SECURITY = YES; ENABLE_HARDENED_RUNTIME = NO; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_POINTER_AUTHENTICATION = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; INFOPLIST_FILE = Secretive/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - }; - name = Test; - }; - 508A5916241EF1A00069DC07 /* Test */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = SecretiveTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@loader_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.SecretiveTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Secretive.app/Contents/MacOS/Secretive"; }; name = Test; }; @@ -900,19 +1355,30 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"SecretAgent/Preview Content\""; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; INFOPLIST_FILE = SecretAgent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; }; name = Test; }; @@ -923,20 +1389,31 @@ CODE_SIGN_ENTITLEMENTS = SecretAgent/SecretAgent.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"SecretAgent/Preview Content\""; DEVELOPMENT_TEAM = Z72PRUAWF6; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; INFOPLIST_FILE = SecretAgent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -948,21 +1425,32 @@ CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"SecretAgent/Preview Content\""; DEVELOPMENT_TEAM = Z72PRUAWF6; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; INFOPLIST_FILE = SecretAgent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "Secretive - Secret Agent"; - SWIFT_VERSION = 5.0; }; name = Release; }; @@ -989,12 +1477,22 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 50617DA023FCE48E0099B055 /* Build configuration list for PBXNativeTarget "SecretiveTests" */ = { + 50692D1F2E6FDB880043C7BB /* Build configuration list for PBXNativeTarget "SecretiveUpdater" */ = { isa = XCConfigurationList; buildConfigurations = ( - 50617DA123FCE48E0099B055 /* Debug */, - 508A5916241EF1A00069DC07 /* Test */, - 50617DA223FCE48E0099B055 /* Release */, + 50692D202E6FDB880043C7BB /* Debug */, + 50692D212E6FDB880043C7BB /* Test */, + 50692D222E6FDB880043C7BB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 50692E5D2E6FF9D20043C7BB /* Build configuration list for PBXNativeTarget "SecretAgentInputParser" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50692E5E2E6FF9D20043C7BB /* Debug */, + 50692E5F2E6FF9D20043C7BB /* Test */, + 50692E602E6FF9D20043C7BB /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -1048,6 +1546,18 @@ isa = XCSwiftPackageProductDependency; productName = Brief; }; + 50692D2C2E6FDC000043C7BB /* XPCWrappers */ = { + isa = XCSwiftPackageProductDependency; + productName = XPCWrappers; + }; + 50692D302E6FDC390043C7BB /* Brief */ = { + isa = XCSwiftPackageProductDependency; + productName = Brief; + }; + 50692E6B2E6FFA510043C7BB /* SecretAgentKit */ = { + isa = XCSwiftPackageProductDependency; + productName = SecretAgentKit; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 50617D7723FCE48D0099B055 /* Project object */; diff --git a/Sources/Secretive.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Sources/Secretive.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..561e394 --- /dev/null +++ b/Sources/Secretive.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + iOSPackagesShouldBuildARM64e + + + diff --git a/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/PackageTests.xcscheme b/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/PackageTests.xcscheme new file mode 100644 index 0000000..500661b --- /dev/null +++ b/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/PackageTests.xcscheme @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/SecretAgent.xcscheme b/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/SecretAgent.xcscheme index 647184f..4c9211b 100644 --- a/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/SecretAgent.xcscheme +++ b/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/SecretAgent.xcscheme @@ -1,6 +1,6 @@ (showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup) - .environmentObject(storeList) - .environmentObject(Updater(checkOnLaunch: hasRunSetup)) - .environmentObject(agentStatusChecker) + ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup) + .environment(EnvironmentValues._secretStoreList) .onAppear { if !hasRunSetup { showingSetup = true @@ -35,31 +55,34 @@ struct Secretive: App { .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in guard hasRunSetup else { return } agentStatusChecker.check() - if agentStatusChecker.running && justUpdatedChecker.justUpdated { + if agentStatusChecker.running && justUpdatedChecker.justUpdatedBuild { // Relaunch the agent, since it'll be running from earlier update still reinstallAgent() } else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild { forceLaunchAgent() } } + .sheet(isPresented: $showingIntegrations) { + IntegrationsView() + } } .commands { + CommandGroup(before: CommandGroupPlacement.appSettings) { + Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") { + showingIntegrations = true + } + } CommandGroup(after: CommandGroupPlacement.newItem) { - Button("app_menu_new_secret_button") { + Button(.appMenuNewSecretButton) { showingCreation = true } .keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift])) } CommandGroup(replacing: .help) { - Button("app_menu_help_button") { + Button(.appMenuHelpButton) { NSWorkspace.shared.open(Constants.helpURL) } } - CommandGroup(after: .help) { - Button("app_menu_setup_button") { - showingSetup = true - } - } SidebarCommands() } } @@ -69,14 +92,12 @@ struct Secretive: App { extension Secretive { private func reinstallAgent() { - justUpdatedChecker.check() - LaunchAgentController().install { - // Wait a second for launchd to kick in (next runloop isn't enough). - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - agentStatusChecker.check() - if !agentStatusChecker.running { - forceLaunchAgent() - } + Task { + _ = await LaunchAgentController().install() + try? await Task.sleep(for: .seconds(1)) + agentStatusChecker.check() + if !agentStatusChecker.running { + forceLaunchAgent() } } } @@ -84,7 +105,8 @@ extension Secretive { private func forceLaunchAgent() { // We've run setup, we didn't just update, launchd is just not doing it's thing. // Force a launch directly. - LaunchAgentController().forceLaunch { _ in + Task { + _ = await LaunchAgentController().forceLaunch() agentStatusChecker.check() } } diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Contents.json index c14ca73..a78196d 100644 --- a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,53 +1,61 @@ { "images" : [ { + "filename" : "Icon-macOS-ClearDark-16x16@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { + "filename" : "Icon-macOS-ClearDark-16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { + "filename" : "Icon-macOS-ClearDark-32x32@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { + "filename" : "Icon-macOS-ClearDark-32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { + "filename" : "Icon-macOS-ClearDark-128x128@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { + "filename" : "Icon-macOS-ClearDark-128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "Mac Icon.png", + "filename" : "Icon-macOS-ClearDark-256x256@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "Mac Icon@0.25x.png", + "filename" : "Icon-macOS-ClearDark-256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { + "filename" : "Icon-macOS-ClearDark-512x512@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { + "filename" : "Icon-macOS-ClearDark-1024x1024@1x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-1024x1024@1x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-1024x1024@1x.png new file mode 100644 index 0000000..d4a5a06 Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-1024x1024@1x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-128x128@1x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-128x128@1x.png new file mode 100644 index 0000000..639811c Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-128x128@1x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-128x128@2x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-128x128@2x.png new file mode 100644 index 0000000..68c79a1 Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-128x128@2x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-16x16@1x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-16x16@1x.png new file mode 100644 index 0000000..13e16f5 Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-16x16@1x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-16x16@2x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-16x16@2x.png new file mode 100644 index 0000000..0f9c355 Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-16x16@2x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-256x256@1x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-256x256@1x.png new file mode 100644 index 0000000..68c79a1 Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-256x256@1x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-256x256@2x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-256x256@2x.png new file mode 100644 index 0000000..e433fab Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-256x256@2x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-32x32@1x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-32x32@1x.png new file mode 100644 index 0000000..29ffd13 Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-32x32@1x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-32x32@2x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-32x32@2x.png new file mode 100644 index 0000000..8b67748 Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-32x32@2x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-512x512@1x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-512x512@1x.png new file mode 100644 index 0000000..e433fab Binary files /dev/null and b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Icon-macOS-ClearDark-512x512@1x.png differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon.png deleted file mode 100644 index 99a172b..0000000 Binary files a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon.png and /dev/null differ diff --git a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png b/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png deleted file mode 100644 index 8b7b7ae..0000000 Binary files a/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png and /dev/null differ diff --git a/Sources/Secretive/Assets.xcassets/Contents.json b/Sources/Secretive/Assets.xcassets/Contents.json index da4a164..73c0059 100644 --- a/Sources/Secretive/Assets.xcassets/Contents.json +++ b/Sources/Secretive/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Sources/Secretive/Controllers/AgentStatusChecker.swift b/Sources/Secretive/Controllers/AgentStatusChecker.swift index 8f0602e..b7327a6 100644 --- a/Sources/Secretive/Controllers/AgentStatusChecker.swift +++ b/Sources/Secretive/Controllers/AgentStatusChecker.swift @@ -1,48 +1,60 @@ import Foundation -import Combine import AppKit import SecretKit +import Observation -protocol AgentStatusCheckerProtocol: ObservableObject { +@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable { var running: Bool { get } var developmentBuild: Bool { get } + var process: NSRunningApplication? { get } + func check() } -class AgentStatusChecker: ObservableObject, AgentStatusCheckerProtocol { +@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol { - @Published var running: Bool = false + var running: Bool = false + var process: NSRunningApplication? = nil - init() { - check() + nonisolated init() { + Task { @MainActor in + check() + } } func check() { - running = instanceSecretAgentProcess != nil + process = instanceSecretAgentProcess + running = process != nil } // All processes, including ones from older versions, etc - var secretAgentProcesses: [NSRunningApplication] { - NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.agentBundleID) + var allSecretAgentProcesses: [NSRunningApplication] { + NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.agentBundleID) } // The process corresponding to this instance of Secretive var instanceSecretAgentProcess: NSRunningApplication? { - let agents = secretAgentProcesses + // FIXME: CHECK VERSION + let agents = allSecretAgentProcesses for agent in agents { guard let url = agent.bundleURL else { continue } - if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) { + if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) || (url.isXcodeURL && developmentBuild) { return agent } } return nil } - // Whether Secretive is being run in an Xcode environment. var developmentBuild: Bool { - Bundle.main.bundleURL.absoluteString.contains("/Library/Developer/Xcode") + Bundle.main.bundleURL.isXcodeURL } } +extension URL { + var isXcodeURL: Bool { + absoluteString.contains("/Library/Developer/Xcode") + } + +} diff --git a/Sources/Secretive/Controllers/JustUpdatedChecker.swift b/Sources/Secretive/Controllers/JustUpdatedChecker.swift index 4c86f68..75e9483 100644 --- a/Sources/Secretive/Controllers/JustUpdatedChecker.swift +++ b/Sources/Secretive/Controllers/JustUpdatedChecker.swift @@ -1,24 +1,33 @@ import Foundation -import Combine import AppKit -protocol JustUpdatedCheckerProtocol: ObservableObject { - var justUpdated: Bool { get } +@MainActor protocol JustUpdatedCheckerProtocol: Observable { + var justUpdatedBuild: Bool { get } + var justUpdatedOS: Bool { get } } -class JustUpdatedChecker: ObservableObject, JustUpdatedCheckerProtocol { +@Observable @MainActor class JustUpdatedChecker: JustUpdatedCheckerProtocol { - @Published var justUpdated: Bool = false + var justUpdatedBuild: Bool = false + var justUpdatedOS: Bool = false - init() { - check() + nonisolated init() { + Task { @MainActor in + check() + } } - func check() { - let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String ?? "None" + private func check() { + let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String + let lastOS = UserDefaults.standard.object(forKey: Constants.previousOSVersionUserDefaultsKey) as? String let currentBuild = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String + let osRaw = ProcessInfo.processInfo.operatingSystemVersion + let currentOS = "\(osRaw.majorVersion).\(osRaw.minorVersion).\(osRaw.patchVersion)" UserDefaults.standard.set(currentBuild, forKey: Constants.previousVersionUserDefaultsKey) - justUpdated = lastBuild != currentBuild + UserDefaults.standard.set(currentOS, forKey: Constants.previousOSVersionUserDefaultsKey) + justUpdatedBuild = lastBuild != currentBuild + // To prevent this showing on first lauch for every user, only show if lastBuild is non-nil. + justUpdatedOS = lastBuild != nil && lastOS != currentOS } @@ -29,6 +38,7 @@ extension JustUpdatedChecker { enum Constants { static let previousVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastBuild" + static let previousOSVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastOS" } } diff --git a/Sources/Secretive/Controllers/LaunchAgentController.swift b/Sources/Secretive/Controllers/LaunchAgentController.swift index 7f512aa..308c381 100644 --- a/Sources/Secretive/Controllers/LaunchAgentController.swift +++ b/Sources/Secretive/Controllers/LaunchAgentController.swift @@ -8,38 +8,58 @@ struct LaunchAgentController { private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController") - func install(completion: (() -> Void)? = nil) { + func install() async -> Bool { logger.debug("Installing agent") _ = setEnabled(false) // This is definitely a bit of a "seems to work better" thing but: // Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old // and start new? - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - _ = setEnabled(true) - completion?() + try? await Task.sleep(for: .seconds(1)) + let result = await MainActor.run { + setEnabled(true) } - + try? await Task.sleep(for: .seconds(1)) + return result } - func forceLaunch(completion: ((Bool) -> Void)?) { + func uninstall() async -> Bool { + logger.debug("Uninstalling agent") + try? await Task.sleep(for: .seconds(1)) + let result = await MainActor.run { + setEnabled(false) + } + try? await Task.sleep(for: .seconds(1)) + return result + } + + func forceLaunch() async -> Bool { logger.debug("Agent is not running, attempting to force launch") let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app") let config = NSWorkspace.OpenConfiguration() config.activates = false - NSWorkspace.shared.openApplication(at: url, configuration: config) { app, error in - DispatchQueue.main.async { - completion?(error == nil) - } - if let error = error { - logger.error("Error force launching \(error.localizedDescription)") - } else { - logger.debug("Agent force launched") - } + do { + try await NSWorkspace.shared.openApplication(at: url, configuration: config) + logger.debug("Agent force launched") + try? await Task.sleep(for: .seconds(1)) + return true + } catch { + logger.error("Error force launching \(error.localizedDescription)") + return false } } private func setEnabled(_ enabled: Bool) -> Bool { - SMLoginItemSetEnabled(Bundle.main.agentBundleID as CFString, enabled) + let service = SMAppService.loginItem(identifier: Bundle.agentBundleID) + do { + if enabled { + try service.register() + } else { + try service.unregister() + } + return true + } catch { + return false + } } } diff --git a/Sources/Secretive/Controllers/ShellConfigurationController.swift b/Sources/Secretive/Controllers/ShellConfigurationController.swift deleted file mode 100644 index 2f3e4c6..0000000 --- a/Sources/Secretive/Controllers/ShellConfigurationController.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -import Cocoa -import SecretKit - -struct ShellConfigurationController { - - let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String - - var shellInstructions: [ShellConfigInstruction] { - [ - ShellConfigInstruction(shell: "global", - shellConfigDirectory: "~/.ssh/", - shellConfigFilename: "config", - text: "Host *\n\tIdentityAgent \(socketPath)"), - ShellConfigInstruction(shell: "zsh", - shellConfigDirectory: "~/", - shellConfigFilename: ".zshrc", - text: "export SSH_AUTH_SOCK=\(socketPath)"), - ShellConfigInstruction(shell: "bash", - shellConfigDirectory: "~/", - shellConfigFilename: ".bashrc", - text: "export SSH_AUTH_SOCK=\(socketPath)"), - ShellConfigInstruction(shell: "fish", - shellConfigDirectory: "~/.config/fish", - shellConfigFilename: "config.fish", - text: "set -x SSH_AUTH_SOCK \(socketPath)"), - ] - - } - - - @MainActor func addToShell(shellInstructions: ShellConfigInstruction) -> Bool { - let openPanel = NSOpenPanel() - // This is sync, so no need to strongly retain - let delegate = Delegate(name: shellInstructions.shellConfigFilename) - openPanel.delegate = delegate - openPanel.message = "Select \(shellInstructions.shellConfigFilename) to let Secretive configure your shell automatically." - openPanel.prompt = "Add to \(shellInstructions.shellConfigFilename)" - openPanel.canChooseFiles = true - openPanel.canChooseDirectories = false - openPanel.showsHiddenFiles = true - openPanel.directoryURL = URL(fileURLWithPath: shellInstructions.shellConfigDirectory) - openPanel.nameFieldStringValue = shellInstructions.shellConfigFilename - openPanel.allowedContentTypes = [.symbolicLink, .data, .plainText] - openPanel.runModal() - guard let fileURL = openPanel.urls.first else { return false } - let handle: FileHandle - do { - handle = try FileHandle(forUpdating: fileURL) - guard let existing = try handle.readToEnd(), - let existingString = String(data: existing, encoding: .utf8) else { return false } - guard !existingString.contains(shellInstructions.text) else { - return true - } - try handle.seekToEnd() - } catch { - return false - } - handle.write("\n# Secretive Config\n\(shellInstructions.text)\n".data(using: .utf8)!) - return true - } - -} diff --git a/Sources/Secretive/Controllers/URLs.swift b/Sources/Secretive/Controllers/URLs.swift new file mode 100644 index 0000000..3ea1fe5 --- /dev/null +++ b/Sources/Secretive/Controllers/URLs.swift @@ -0,0 +1,25 @@ +import Foundation + +extension URL { + + static var agentHomeURL: URL { + URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID)) + } + + static var socketPath: String { + URL.agentHomeURL.appendingPathComponent("socket.ssh").path() + } + +} + +extension String { + + var normalizedPathAndFolder: (String, String) { + // All foundation-based normalization methods replace this with the container directly. + let processedPath = replacingOccurrences(of: "~", with: "/Users/\(NSUserName())") + let url = URL(filePath: processedPath) + let folder = url.deletingLastPathComponent().path() + return (processedPath, folder) + } + +} diff --git a/Sources/Secretive/Helpers/BundleIDs.swift b/Sources/Secretive/Helpers/BundleIDs.swift index de4967d..bc84add 100644 --- a/Sources/Secretive/Helpers/BundleIDs.swift +++ b/Sources/Secretive/Helpers/BundleIDs.swift @@ -1,7 +1,11 @@ import Foundation - extension Bundle { - public var agentBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "Host", with: "SecretAgent"))!} - public var hostBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "SecretAgent", with: "Host"))!} + public static var agentBundleID: String { + Bundle.main.bundleIdentifier!.replacingOccurrences(of: "Host", with: "SecretAgent") + } + + public static var hostBundleID: String { + Bundle.main.bundleIdentifier!.replacingOccurrences(of: "SecretAgent", with: "Host") + } } diff --git a/Sources/Secretive/InternetAccessPolicy.plist b/Sources/Secretive/InternetAccessPolicy.plist index 37307a7..e1602de 100644 --- a/Sources/Secretive/InternetAccessPolicy.plist +++ b/Sources/Secretive/InternetAccessPolicy.plist @@ -9,22 +9,7 @@ Website https://github.com/maxgoedjen/secretive Connections - - - IsIncoming - - Host - api.github.com - NetworkProtocol - TCP - Port - 443 - Purpose - Secretive checks GitHub for new versions and security updates. - DenyConsequences - If you deny these connections, you will not be notified about new versions and critical security updates. - - + Services diff --git a/Sources/Secretive/Preview Content/PreviewAgentStatusChecker.swift b/Sources/Secretive/Preview Content/PreviewAgentStatusChecker.swift index e893155..e9799e9 100644 --- a/Sources/Secretive/Preview Content/PreviewAgentStatusChecker.swift +++ b/Sources/Secretive/Preview Content/PreviewAgentStatusChecker.swift @@ -1,13 +1,18 @@ import Foundation -import Combine +import AppKit class PreviewAgentStatusChecker: AgentStatusCheckerProtocol { let running: Bool + let process: NSRunningApplication? let developmentBuild = false - init(running: Bool = true) { + init(running: Bool = true, process: NSRunningApplication? = nil) { self.running = running + self.process = process + } + + func check() { } } diff --git a/Sources/Secretive/Preview Content/PreviewStore.swift b/Sources/Secretive/Preview Content/PreviewStore.swift index 9480c88..8c65f80 100644 --- a/Sources/Secretive/Preview Content/PreviewStore.swift +++ b/Sources/Secretive/Preview Content/PreviewStore.swift @@ -9,41 +9,39 @@ extension Preview { let id = UUID().uuidString let name: String - let algorithm = Algorithm.ellipticCurve - let keySize = 256 - let requiresAuthentication: Bool = false - let publicKey = UUID().uuidString.data(using: .utf8)! - + let publicKey = Data(UUID().uuidString.utf8) + var attributes: Attributes { + Attributes( + keyType: .init(algorithm: .ecdsa, size: 256), + authentication: .presenceRequired, + ) + } } } extension Preview { - class Store: SecretStore, ObservableObject { + @Observable final class Store: SecretStore { let isAvailable = true let id = UUID() var name: String { "Preview Store" } - @Published var secrets: [Secret] = [] + let secrets: [Secret] init(secrets: [Secret]) { - self.secrets.append(contentsOf: secrets) + self.secrets = secrets } - init(numberOfRandomSecrets: Int = 5) { + convenience init(numberOfRandomSecrets: Int = 5) { let new = (0.. Data { return data } - func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool { - true - } - func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? { nil } @@ -56,23 +54,63 @@ extension Preview { } - class StoreModifiable: Store, SecretStoreModifiable { - override var name: String { "Modifiable Preview Store" } + final class StoreModifiable: SecretStoreModifiable { + + let isAvailable = true + 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 + } - func create(name: String, requiresAuthentication: Bool) throws { + convenience init(numberOfRandomSecrets: Int = 5) { + let new = (0.. Data { + return data + } + + func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? { + nil + } + + func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws { + } + + func reloadSecrets() { + } + + + func create(name: String, attributes: Attributes) throws -> Secret { + fatalError() } func delete(secret: Preview.Secret) throws { } - func update(secret: Preview.Secret, name: String) throws { + func update(secret: Preview.Secret, name: String, attributes: Attributes) throws { } } } extension Preview { - static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList { + @MainActor static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList { let list = SecretStoreList() for store in stores { list.add(store: store) diff --git a/Sources/Secretive/Preview Content/PreviewUpdater.swift b/Sources/Secretive/Preview Content/PreviewUpdater.swift index a993d87..b8bc29a 100644 --- a/Sources/Secretive/Preview Content/PreviewUpdater.swift +++ b/Sources/Secretive/Preview Content/PreviewUpdater.swift @@ -1,11 +1,12 @@ import Foundation -import Combine +import Observation import Brief -class PreviewUpdater: UpdaterProtocol { +@Observable @MainActor final class PreviewUpdater: UpdaterProtocol { - let update: Release? - let testBuild = false + var update: Release? = nil + + let currentVersion = SemVer("0.0.0_preview") init(update: Update = .none) { switch update { @@ -18,6 +19,9 @@ class PreviewUpdater: UpdaterProtocol { } } + func ignore(release: Release) async { + } + } extension PreviewUpdater { diff --git a/Sources/Secretive/Secretive.entitlements b/Sources/Secretive/Secretive.entitlements index c1bb5e0..ab2c42b 100644 --- a/Sources/Secretive/Secretive.entitlements +++ b/Sources/Secretive/Secretive.entitlements @@ -2,12 +2,16 @@ - com.apple.security.app-sandbox + com.apple.security.hardened-process - com.apple.security.files.user-selected.read-write + com.apple.security.hardened-process.dyld-ro - com.apple.security.network.client + com.apple.security.hardened-process.enhanced-security-version + 1 + com.apple.security.hardened-process.hardened-heap + com.apple.security.hardened-process.platform-restrictions + 2 com.apple.security.smartcard keychain-access-groups diff --git a/Sources/Secretive/Views/Configuration/ConfigurationItemView.swift b/Sources/Secretive/Views/Configuration/ConfigurationItemView.swift new file mode 100644 index 0000000..77e6a2e --- /dev/null +++ b/Sources/Secretive/Views/Configuration/ConfigurationItemView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct ConfigurationItemView: View { + + enum Action: Hashable { + case copy(String) + case revealInFinder(String) + } + + let title: LocalizedStringResource + let content: Content + let action: Action? + + init(title: LocalizedStringResource, value: String, action: Action? = nil) where Content == Text { + self.title = title + self.content = Text(value) + .font(.subheadline) + .foregroundStyle(.secondary) + self.action = action + } + + init(title: LocalizedStringResource, action: Action? = nil, content: () -> Content) { + self.title = title + self.content = content() + self.action = action + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(title) + Spacer() + switch action { + case .copy(let string): + Button(.copyableClickToCopyButton, systemImage: "document.on.document") { + NSPasteboard.general.declareTypes([.string], owner: nil) + NSPasteboard.general.setString(string, forType: .string) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + case .revealInFinder(let rawPath): + Button(.revealInFinderButton, systemImage: "folder") { + let (processedPath, folder) = rawPath.normalizedPathAndFolder + NSWorkspace.shared.selectFile(processedPath, inFileViewerRootedAtPath: folder) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + case nil: + EmptyView() + } + } + content + } + } +} + diff --git a/Sources/Secretive/Views/Configuration/GettingStartedView.swift b/Sources/Secretive/Views/Configuration/GettingStartedView.swift new file mode 100644 index 0000000..67c7b42 --- /dev/null +++ b/Sources/Secretive/Views/Configuration/GettingStartedView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct GettingStartedView: View { + + private let instructions = Instructions() + + @Binding var selectedInstruction: ConfigurationFileInstructions? + + init(selectedInstruction: Binding) { + _selectedInstruction = selectedInstruction + } + + var body: some View { + Form { + Section(.integrationsGettingStartedTitle) { + Text(.integrationsGettingStartedTitleDescription) + } + Section { + Group { + Text(.integrationsGettingStartedSuggestionSsh) + .onTapGesture { + self.selectedInstruction = instructions.ssh + } + VStack(alignment: .leading, spacing: 5) { + Text(.integrationsGettingStartedSuggestionShell) + Text(.integrationsGettingStartedSuggestionShellDefault(shellName: String(localized: instructions.defaultShell.tool))) + .font(.caption2) + } + .onTapGesture { + self.selectedInstruction = instructions.defaultShell + } + Text(.integrationsGettingStartedSuggestionGit) + .onTapGesture { + self.selectedInstruction = instructions.git + } + } + .foregroundStyle(.link) + + } header: { + Text(.integrationsGettingStartedWhatShouldIConfigureTitle) + } + footer: { + Text(.integrationsGettingStartedMultipleConfig) + } + } + .formStyle(.grouped) + } + +} diff --git a/Sources/Secretive/Views/Configuration/Instructions.swift b/Sources/Secretive/Views/Configuration/Instructions.swift new file mode 100644 index 0000000..bb92b86 --- /dev/null +++ b/Sources/Secretive/Views/Configuration/Instructions.swift @@ -0,0 +1,179 @@ +import Foundation + +struct Instructions { + + enum Constants { + static let publicKeyPathPlaceholder = "_PUBLIC_KEY_PATH_PLACEHOLDER_" + static let publicKeyPlaceholder = "_PUBLIC_KEY_PLACEHOLDER_" + } + + var defaultShell: ConfigurationFileInstructions { + zsh + } + + var gettingStarted: ConfigurationFileInstructions = ConfigurationFileInstructions(.integrationsGettingStartedRowTitle, id: .gettingStarted) + + var ssh: ConfigurationFileInstructions { + ConfigurationFileInstructions( + tool: LocalizedStringResource.integrationsToolNameSsh, + configPath: "~/.ssh/config", + configText: "Host *\n\tIdentityAgent \(URL.socketPath)", + website: URL(string: "https://man.openbsd.org/ssh_config.5")!, + note: .integrationsSshSpecificKeyNote, + ) + } + + var git: ConfigurationFileInstructions { + ConfigurationFileInstructions( + tool: .integrationsToolNameGitSigning, + steps: [ + .init(path: "~/.gitconfig", steps: [ + .integrationsGitStepGitconfigDescription(publicKeyPathPlaceholder: Constants.publicKeyPathPlaceholder) + ], + note: .integrationsGitStepGitconfigSectionNote + ), + .init( + path: "~/.gitallowedsigners", + steps: [ + LocalizedStringResource(stringLiteral: Constants.publicKeyPlaceholder) + ], + note: .integrationsGitStepGitallowedsignersDescription + ), + ], + website: URL(string: "https://git-scm.com/docs/git-config")!, + ) + } + + var zsh: ConfigurationFileInstructions { + ConfigurationFileInstructions( + tool: .integrationsToolNameZsh, + configPath: "~/.zshrc", + configText: "export SSH_AUTH_SOCK=\(URL.socketPath)" + ) + } + + var instructions: [ConfigurationGroup] { + [ + ConfigurationGroup(name: .integrationsGettingStartedSectionTitle, instructions: [ + gettingStarted + ]), + ConfigurationGroup( + name: .integrationsSystemSectionTitle, + instructions: [ + ssh, + git, + ] + ), + ConfigurationGroup(name: .integrationsShellSectionTitle, instructions: [ + zsh, + ConfigurationFileInstructions( + tool: .integrationsToolNameBash, + configPath: "~/.bashrc", + configText: "export SSH_AUTH_SOCK=\(URL.socketPath)" + ), + ConfigurationFileInstructions( + tool: .integrationsToolNameFish, + configPath: "~/.config/fish/config.fish", + configText: "set -x SSH_AUTH_SOCK \(URL.socketPath)" + ), + ConfigurationFileInstructions(.integrationsOtherShellRowTitle, id: .otherShell), + ]), + ConfigurationGroup(name: .integrationsOtherSectionTitle, instructions: [ + ConfigurationFileInstructions(.integrationsAppsRowTitle, id: .otherApp), + ]), + ] + } + +} + +struct ConfigurationGroup: Identifiable { + let id = UUID() + var name: LocalizedStringResource + var instructions: [ConfigurationFileInstructions] = [] +} + +struct ConfigurationFileInstructions: Hashable, Identifiable { + + struct StepGroup: Hashable, Identifiable { + let path: String + let steps: [LocalizedStringResource] + let note: LocalizedStringResource? + var id: String { path } + + init(path: String, steps: [LocalizedStringResource], note: LocalizedStringResource? = nil) { + self.path = path + self.steps = steps + self.note = note + } + + func hash(into hasher: inout Hasher) { + id.hash(into: &hasher) + } + } + + var id: ID + var tool: LocalizedStringResource + var steps: [StepGroup] + var requiresSecret: Bool + var website: URL? + + init( + tool: LocalizedStringResource, + configPath: String, + configText: LocalizedStringResource, + requiresSecret: Bool = false, + website: URL? = nil, + note: LocalizedStringResource? = nil + ) { + self.id = .tool(String(localized: tool)) + self.tool = tool + self.steps = [StepGroup(path: configPath, steps: [configText], note: note)] + self.requiresSecret = requiresSecret + self.website = website + } + + init( + tool: LocalizedStringResource, + steps: [StepGroup], + requiresSecret: Bool = false, + website: URL? = nil + ) { + self.id = .tool(String(localized: tool)) + self.tool = tool + self.steps = steps + self.requiresSecret = true + self.website = website + } + + init(_ name: LocalizedStringResource, id: ID) { + self.id = id + tool = name + steps = [] + requiresSecret = false + } + + func hash(into hasher: inout Hasher) { + id.hash(into: &hasher) + } + + enum ID: Identifiable, Hashable { + case gettingStarted + case tool(String) + case otherShell + case otherApp + + var id: String { + switch self { + case .gettingStarted: + "getting_started" + case .tool(let name): + name + case .otherShell: + "other_shell" + case .otherApp: + "other_app" + } + } + } + +} diff --git a/Sources/Secretive/Views/Configuration/IntegrationsView.swift b/Sources/Secretive/Views/Configuration/IntegrationsView.swift new file mode 100644 index 0000000..de6b8a0 --- /dev/null +++ b/Sources/Secretive/Views/Configuration/IntegrationsView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct IntegrationsView: View { + + @Environment(\.dismiss) private var dismiss + + @State private var selectedInstruction: ConfigurationFileInstructions? + private let instructions = Instructions() + + var body: some View { + NavigationSplitView { + List(selection: $selectedInstruction) { + ForEach(instructions.instructions) { group in + Section(group.name) { + ForEach(group.instructions) { instruction in + Text(instruction.tool) + .padding(.vertical, 8) + .tag(instruction) + } + } + } + } + } detail: { + IntegrationsDetailView(selectedInstruction: $selectedInstruction) + .fauxToolbar { + Button(.setupDoneButton) { + dismiss() + } + .normalButton() + } + } + .onAppear { + selectedInstruction = instructions.gettingStarted + } + .frame(minHeight: 500) + } + +} + +extension View { + + func fauxToolbar(content: () -> Content) -> some View { + modifier(FauxToolbarModifier(toolbarContent: content())) + } + +} + +struct FauxToolbarModifier: ViewModifier { + + var toolbarContent: ToolbarContent + + func body(content: Content) -> some View { + VStack(alignment: .leading, spacing: 0) { + content + Divider() + HStack { + Spacer() + toolbarContent + .padding(.top, 8) + .padding(.trailing, 16) + .padding(.bottom, 16) + } + } + + } + +} + +struct IntegrationsDetailView: View { + + @Binding private var selectedInstruction: ConfigurationFileInstructions? + + init(selectedInstruction: Binding) { + _selectedInstruction = selectedInstruction + } + + var body: some View { + if let selectedInstruction { + switch selectedInstruction.id { + case .gettingStarted: + GettingStartedView(selectedInstruction: $selectedInstruction) + case .tool: + ToolConfigurationView(selectedInstruction: selectedInstruction) + case .otherShell: + Form { + Section { + Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/shells")!) + } header: { + Text(.integrationsCommunityShellListDescription) + .font(.body) + } + } + .formStyle(.grouped) + + case .otherApp: + Form { + Section { + Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/apps")!) + } header: { + Text(.integrationsCommunityAppsListDescription) + .font(.body) + } + } + .formStyle(.grouped) + } + } + + } + +} + +#Preview { + IntegrationsView() + .frame(height: 500) +} diff --git a/Sources/Secretive/Views/Configuration/SetupView.swift b/Sources/Secretive/Views/Configuration/SetupView.swift new file mode 100644 index 0000000..2578c28 --- /dev/null +++ b/Sources/Secretive/Views/Configuration/SetupView.swift @@ -0,0 +1,201 @@ +import SwiftUI + +struct SetupView: View { + + @Environment(\.dismiss) private var dismiss + @Binding var setupComplete: Bool + + @State var showingIntegrations = false + @State var buttonWidth: CGFloat? + + @State var installed = false + @State var updates = false + @State var integrations = false + var allDone: Bool { + installed && updates && integrations + } + + var body: some View { + VStack { + VStack(alignment: .leading, spacing: 0) { + StepView( + title: .setupAgentTitle, + description: .setupAgentDescription, + detail: .setupAgentActivityMonitorDescription, + systemImage: "lock.laptopcomputer", + ) { + SetupButton( + .setupAgentInstallButton, + complete: installed, + width: buttonWidth + ) { + installed = true + Task { + await LaunchAgentController().install() + } + } + } + Divider() + StepView( + title: .setupUpdatesTitle, + description: .setupUpdatesDescription, + systemImage: "network.badge.shield.half.filled", + ) { + SetupButton( + .setupUpdatesOkButton, + complete: updates, + width: buttonWidth + ) { + updates = true + } + } + Divider() + StepView( + title: .setupIntegrationsTitle, + description: .setupIntegrationsDescription, + systemImage: "firewall", + ) { + SetupButton( + .setupIntegrationsButton, + complete: integrations, + width: buttonWidth + ) { + showingIntegrations = true + } + } + } + .onPreferenceChange(SetupButton.WidthKey.self) { width in + buttonWidth = width + } + .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) + .frame(minWidth: 600, maxWidth: .infinity) + HStack { + Spacer() + Button(.setupDoneButton) { + setupComplete = true + dismiss() + } + .disabled(!allDone) + .primaryButton() + } + } + .interactiveDismissDisabled() + .padding() + .sheet(isPresented: $showingIntegrations, onDismiss: { + integrations = true + }, content: { + IntegrationsView() + }) + } +} + +struct SetupButton: View { + + struct WidthKey: @MainActor PreferenceKey { + @MainActor static var defaultValue: CGFloat? = nil + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + if let next = nextValue(), next > (value ?? -1) { + value = next + } + } + + } + + let label: LocalizedStringResource + let complete: Bool + let action: () -> Void + let width: CGFloat? + @State var currentWidth: CGFloat? + + init(_ label: LocalizedStringResource, complete: Bool, width: CGFloat? = nil, action: @escaping () -> Void) { + self.label = label + self.complete = complete + self.action = action + self.width = width + } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + if complete { + Text(.setupStepCompleteButton) + Image(systemName: "checkmark.circle.fill") + } else { + Text(label) + } + } + .frame(width: width) + .padding(.vertical, 2) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.width + } action: { newValue in + currentWidth = newValue + } + } + .preference(key: WidthKey.self, value: currentWidth) + .primaryButton() + .disabled(complete) + .tint(complete ? .green : nil) + } + +} + +struct StepView: View { + + let title: LocalizedStringResource + let icon: Image + let description: LocalizedStringResource + let detail: LocalizedStringResource? + let actions: Content + + init( + title: LocalizedStringResource, + description: LocalizedStringResource, + detail: LocalizedStringResource? = nil, + systemImage: String, + actions: () -> Content + ) { + self.title = title + self.icon = Image(systemName: systemImage) + self.description = description + self.detail = detail + self.actions = actions() + } + + var body: some View { + HStack(spacing: 0) { + icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24) + Spacer() + .frame(width: 20) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .bold() + Text(description) + if let detail { + Text(detail) + .font(.callout) + .italic() + } + } + Spacer(minLength: 20) + actions + } + .padding(20) + } + +} + +extension SetupView { + + enum Constants { + static let updaterFAQURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md#whats-this-network-request-to-github")! + } + +} + +#Preview { + SetupView(setupComplete: .constant(false)) +} diff --git a/Sources/Secretive/Views/Configuration/ToolConfigurationView.swift b/Sources/Secretive/Views/Configuration/ToolConfigurationView.swift new file mode 100644 index 0000000..cd1bc69 --- /dev/null +++ b/Sources/Secretive/Views/Configuration/ToolConfigurationView.swift @@ -0,0 +1,110 @@ +import SwiftUI +import SecretKit + +struct ToolConfigurationView: View { + + private let instructions = Instructions() + let selectedInstruction: ConfigurationFileInstructions + + @Environment(\.secretStoreList) private var secretStoreList + + @State var creating = false + @State var selectedSecret: AnySecret? + + init(selectedInstruction: ConfigurationFileInstructions) { + self.selectedInstruction = selectedInstruction + } + + var body: some View { + Form { + if selectedInstruction.requiresSecret { + if secretStoreList.allSecrets.isEmpty { + Section { + Text(.integrationsConfigureUsingSecretEmptyCreate) + if let store = secretStoreList.modifiableStore { + HStack { + Spacer() + Button(.createSecretTitle) { + creating = true + } + .sheet(isPresented: $creating) { + CreateSecretView(store: store) { created in + selectedSecret = created + } + } + } + } + } + } else { + Section { + Picker(.integrationsConfigureUsingSecretSecretTitle, selection: $selectedSecret) { + if selectedSecret == nil { + Text(.integrationsConfigureUsingSecretNoSecret) + .tag(nil as (AnySecret?)) + } + ForEach(secretStoreList.allSecrets) { secret in + Text(secret.name) + .tag(secret) + } + } + } header: { + Text(.integrationsConfigureUsingSecretHeader) + } + .onAppear { + selectedSecret = secretStoreList.allSecrets.first + } + } + } + ForEach(selectedInstruction.steps) { stepGroup in + Section { + ConfigurationItemView(title: .integrationsPathTitle, value: stepGroup.path, action: .revealInFinder(stepGroup.path)) + ForEach(stepGroup.steps, id: \.self.key) { step in + ConfigurationItemView(title: .integrationsAddThisTitle, action: .copy(String(localized: step))) { + HStack { + Text(placeholdersReplaced(text: String(localized: step))) + .padding(8) + .font(.system(.subheadline, design: .monospaced)) + Spacer() + } + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 6) + .fill(.black.opacity(0.05)) + .stroke(.separator, lineWidth: 1) + } + } + } + } footer: { + if let note = stepGroup.note { + Text(note) + .font(.caption) + } + } + } + if let url = selectedInstruction.website { + Section { + Link(destination: url) { + VStack(alignment: .leading, spacing: 5) { + Text(.integrationsWebLink) + .font(.headline) + Text(url.absoluteString) + .font(.caption2) + } + } + } + } + } + .formStyle(.grouped) + + } + + func placeholdersReplaced(text: String) -> String { + guard let selectedSecret else { return text } + let writer = OpenSSHPublicKeyWriter() + let fileController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL) + return text + .replacingOccurrences(of: Instructions.Constants.publicKeyPlaceholder, with: writer.openSSHString(secret: selectedSecret)) + .replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: fileController.publicKeyPath(for: selectedSecret)) + } + +} diff --git a/Sources/Secretive/Views/ContentView.swift b/Sources/Secretive/Views/ContentView.swift deleted file mode 100644 index c48991c..0000000 --- a/Sources/Secretive/Views/ContentView.swift +++ /dev/null @@ -1,233 +0,0 @@ -import SwiftUI -import SecretKit -import SecureEnclaveSecretKit -import SmartCardSecretKit -import Brief - -struct ContentView: View { - - @Binding var showingCreation: Bool - @Binding var runningSetup: Bool - @Binding var hasRunSetup: Bool - @State var showingAgentInfo = false - @State var activeSecret: AnySecret.ID? - @Environment(\.colorScheme) var colorScheme - - @EnvironmentObject private var storeList: SecretStoreList - @EnvironmentObject private var updater: UpdaterType - @EnvironmentObject private var agentStatusChecker: AgentStatusCheckerType - - @State private var selectedUpdate: Release? - @State private var showingAppPathNotice = false - - var body: some View { - VStack { - if storeList.anyAvailable { - StoreListView(activeSecret: $activeSecret) - } else { - NoStoresView() - } - } - .frame(minWidth: 640, minHeight: 320) - .toolbar { - toolbarItem(updateNoticeView, id: "update") - toolbarItem(runningOrRunSetupView, id: "setup") - toolbarItem(appPathNoticeView, id: "appPath") - toolbarItem(newItemView, id: "new") - } - .sheet(isPresented: $runningSetup) { - SetupView(visible: $runningSetup, setupComplete: $hasRunSetup) - } - } - -} - -extension ContentView { - - - func toolbarItem(_ view: some View, id: String) -> ToolbarItem { - ToolbarItem(id: id) { view } - } - - var needsSetup: Bool { - (runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild - } - - /// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message - /// These two are mutually exclusive - @ViewBuilder - var runningOrRunSetupView: some View { - if needsSetup { - setupNoticeView - } else { - runningNoticeView - } - } - - var updateNoticeContent: (LocalizedStringKey, Color)? { - guard let update = updater.update else { return nil } - if update.critical { - return ("update_critical_notice_title", .red) - } else { - if updater.testBuild { - return ("update_test_notice_title", .blue) - } else { - return ("update_normal_notice_title", .orange) - } - } - } - - @ViewBuilder - var updateNoticeView: some View { - if let update = updater.update, let (text, color) = updateNoticeContent { - Button(action: { - selectedUpdate = update - }, label: { - Text(text) - .font(.headline) - .foregroundColor(.white) - }) - .buttonStyle(ToolbarButtonStyle(color: color)) - .popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in - UpdateDetailView(update: update) - } - } - } - - @ViewBuilder - var newItemView: some View { - if storeList.modifiableStore?.isAvailable ?? false { - Button(action: { - showingCreation = true - }, label: { - Image(systemName: "plus") - }) - .sheet(isPresented: $showingCreation) { - if let modifiable = storeList.modifiableStore { - CreateSecretView(store: modifiable, showing: $showingCreation) - .onDisappear { - guard let newest = modifiable.secrets.last?.id else { return } - activeSecret = newest - } - } - } - } - } - - @ViewBuilder - var setupNoticeView: some View { - Button(action: { - runningSetup = true - }, label: { - Group { - if hasRunSetup && !agentStatusChecker.running { - Text("agent_not_running_notice_title") - } else { - Text("agent_setup_notice_title") - } - } - .font(.headline) - .foregroundColor(.white) - }) - .buttonStyle(ToolbarButtonStyle(color: .orange)) - } - - @ViewBuilder - var runningNoticeView: some View { - Button(action: { - showingAgentInfo = true - }, label: { - HStack { - Text("agent_running_notice_title") - .font(.headline) - .foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white) - Circle() - .frame(width: 10, height: 10) - .foregroundColor(Color.green) - } - }) - .buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05))) - .popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { - VStack { - Text("agent_running_notice_detail_title") - .font(.title) - .padding(5) - Text("agent_running_notice_detail_description") - .frame(width: 300) - } - .padding() - } - } - - @ViewBuilder - var appPathNoticeView: some View { - if !ApplicationDirectoryController().isInApplicationsDirectory { - Button(action: { - showingAppPathNotice = true - }, label: { - Group { - Text("app_not_in_applications_notice_title") - } - .font(.headline) - .foregroundColor(.white) - }) - .buttonStyle(ToolbarButtonStyle(color: .orange)) - .popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { - VStack { - Image(systemName: "exclamationmark.triangle") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 64) - Text("app_not_in_applications_notice_detail_description") - .frame(maxWidth: 300) - } - .padding() - } - } - } - - var attachmentAnchor: PopoverAttachmentAnchor { - // Ideally .point(.bottom), but broken on Sonoma (FB12726503) - .rect(.bounds) - } - -} - -#if DEBUG - -struct ContentView_Previews: PreviewProvider { - - private static let storeList: SecretStoreList = { - let list = SecretStoreList() - list.add(store: SecureEnclave.Store()) - list.add(store: SmartCard.Store()) - return list - }() - private static let agentStatusChecker = AgentStatusChecker() - private static let justUpdatedChecker = JustUpdatedChecker() - - @State var hasRunSetup = false - @State private var showingSetup = false - @State private var showingCreation = false - - static var previews: some View { - Group { - // Empty on modifiable and nonmodifiable - ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true)) - .environmentObject(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)])) - .environmentObject(PreviewUpdater()) - .environmentObject(agentStatusChecker) - - // 5 items on modifiable and nonmodifiable - ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true)) - .environmentObject(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()])) - .environmentObject(PreviewUpdater()) - .environmentObject(agentStatusChecker) - } - .environmentObject(agentStatusChecker) - - } -} - -#endif - diff --git a/Sources/Secretive/Views/CopyableView.swift b/Sources/Secretive/Views/CopyableView.swift deleted file mode 100644 index 8b2630f..0000000 --- a/Sources/Secretive/Views/CopyableView.swift +++ /dev/null @@ -1,133 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct CopyableView: View { - - var title: LocalizedStringKey - var image: Image - var text: String - - @State private var interactionState: InteractionState = .normal - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - VStack(alignment: .leading) { - HStack { - image - .renderingMode(.template) - .imageScale(.large) - .foregroundColor(primaryTextColor) - Text(title) - .font(.headline) - .foregroundColor(primaryTextColor) - Spacer() - if interactionState != .normal { - Text(hoverText) - .bold() - .textCase(.uppercase) - .foregroundColor(secondaryTextColor) - .transition(.opacity) - } - - } - .padding(EdgeInsets(top: 20, leading: 20, bottom: 10, trailing: 20)) - Divider() - Text(text) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(primaryTextColor) - .padding(EdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20)) - .multilineTextAlignment(.leading) - .font(.system(.body, design: .monospaced)) - } - .background(backgroundColor) - .frame(minWidth: 150, maxWidth: .infinity) - .cornerRadius(10) - .onHover { hovering in - withAnimation { - interactionState = hovering ? .hovering : .normal - } - } - .onDrag { - NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier) - } - .onTapGesture { - copy() - withAnimation { - interactionState = .clicking - } - } - .gesture( - TapGesture() - .onEnded { - withAnimation { - interactionState = .normal - } - } - ) - } - - var hoverText: LocalizedStringKey { - switch interactionState { - case .hovering: - return "copyable_click_to_copy_button" - case .clicking: - return "copyable_copied" - case .normal: - fatalError() - } - } - - var backgroundColor: Color { - switch interactionState { - case .normal: - return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885) - case .hovering: - return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82) - case .clicking: - return .accentColor - } - } - - var primaryTextColor: Color { - switch interactionState { - case .normal, .hovering: - return Color(.textColor) - case .clicking: - return .white - } - } - - var secondaryTextColor: Color { - switch interactionState { - case .normal, .hovering: - return Color(.secondaryLabelColor) - case .clicking: - return .white - } - } - - func copy() { - NSPasteboard.general.declareTypes([.string], owner: nil) - NSPasteboard.general.setString(text, forType: .string) - } - - private enum InteractionState { - case normal, hovering, clicking - } - -} - -#if DEBUG - -struct CopyableView_Previews: PreviewProvider { - static var previews: some View { - Group { - CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "figure.wave"), text: "Hello world.") - .padding() - CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "figure.wave"), text: "Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. ") - .padding() - } - } -} - -#endif diff --git a/Sources/Secretive/Views/CreateSecretView.swift b/Sources/Secretive/Views/CreateSecretView.swift deleted file mode 100644 index accd8be..0000000 --- a/Sources/Secretive/Views/CreateSecretView.swift +++ /dev/null @@ -1,248 +0,0 @@ -import SwiftUI -import SecretKit - -struct CreateSecretView: View { - - @ObservedObject var store: StoreType - @Binding var showing: Bool - - @State private var name = "" - @State private var requiresAuthentication = true - - var body: some View { - VStack { - HStack { - VStack { - HStack { - Text("create_secret_title") - .font(.largeTitle) - Spacer() - } - HStack { - Text("create_secret_name_label") - TextField("create_secret_name_placeholder", text: $name) - .focusable() - } - ThumbnailPickerView(items: [ - ThumbnailPickerView.Item(value: true, name: "create_secret_require_authentication_title", description: "create_secret_require_authentication_description", thumbnail: AuthenticationView()), - ThumbnailPickerView.Item(value: false, name: "create_secret_notify_title", - description: "create_secret_notify_description", - thumbnail: NotificationView()) - ], selection: $requiresAuthentication) - } - } - HStack { - Spacer() - Button("create_secret_cancel_button") { - showing = false - } - .keyboardShortcut(.cancelAction) - Button("create_secret_create_button", action: save) - .disabled(name.isEmpty) - .keyboardShortcut(.defaultAction) - } - }.padding() - } - - func save() { - try! store.create(name: name, requiresAuthentication: requiresAuthentication) - showing = false - } - -} - -struct ThumbnailPickerView: View { - - private let items: [Item] - @Binding var selection: ValueType - - init(items: [ThumbnailPickerView.Item], selection: Binding) { - 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: Identifiable { - let id = UUID() - let value: ValueType - let name: LocalizedStringKey - let description: LocalizedStringKey - let thumbnail: AnyView - - init(value: ValueType, name: LocalizedStringKey, description: LocalizedStringKey, thumbnail: ViewType) { - self.value = value - self.name = name - self.description = description - self.thumbnail = AnyView(thumbnail) - } - } - -} - -@MainActor class SystemBackground: ObservableObject { - - static let shared = SystemBackground() - @Published 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 diff --git a/Sources/Secretive/Views/DeleteSecretView.swift b/Sources/Secretive/Views/DeleteSecretView.swift deleted file mode 100644 index 5e3a6f9..0000000 --- a/Sources/Secretive/Views/DeleteSecretView.swift +++ /dev/null @@ -1,56 +0,0 @@ -import SwiftUI -import SecretKit - -struct DeleteSecretView: View { - - @ObservedObject var store: StoreType - let secret: StoreType.SecretType - var dismissalBlock: (Bool) -> () - - @State private var confirm = "" - - var body: some View { - VStack { - HStack { - Image(nsImage: NSApplication.shared.applicationIconImage) - .resizable() - .frame(width: 64, height: 64) - .padding() - VStack { - HStack { - Text("delete_confirmation_title_\(secret.name)").bold() - Spacer() - } - HStack { - Text("delete_confirmation_description_\(secret.name)_\(secret.name)") - Spacer() - } - HStack { - Text("delete_confirmation_confirm_name_label") - TextField(secret.name, text: $confirm) - } - } - } - HStack { - Spacer() - Button("delete_confirmation_delete_button", action: delete) - .disabled(confirm != secret.name) - Button("delete_confirmation_cancel_button") { - dismissalBlock(false) - } - .keyboardShortcut(.cancelAction) - } - } - .padding() - .frame(minWidth: 400) - .onExitCommand { - dismissalBlock(false) - } - } - - func delete() { - try! store.delete(secret: secret) - dismissalBlock(true) - } - -} diff --git a/Sources/Secretive/Views/NoStoresView.swift b/Sources/Secretive/Views/NoStoresView.swift deleted file mode 100644 index 3ac4841..0000000 --- a/Sources/Secretive/Views/NoStoresView.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI - -struct NoStoresView: View { - - var body: some View { - VStack { - Text("no_secure_storage_title") - .bold() - Text("no_secure_storage_description") - Link("no_secure_storage_yubico_link", destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!) - }.padding() - } - -} - -#if DEBUG - -struct NoStoresView_Previews: PreviewProvider { - static var previews: some View { - NoStoresView() - } -} - -#endif diff --git a/Sources/Secretive/Views/RenameSecretView.swift b/Sources/Secretive/Views/RenameSecretView.swift deleted file mode 100644 index 915b2b2..0000000 --- a/Sources/Secretive/Views/RenameSecretView.swift +++ /dev/null @@ -1,50 +0,0 @@ -import SwiftUI -import SecretKit - -struct RenameSecretView: View { - - @ObservedObject var store: StoreType - let secret: StoreType.SecretType - var dismissalBlock: (_ renamed: Bool) -> () - - @State private var newName = "" - - var body: some View { - VStack { - HStack { - Image(nsImage: NSApplication.shared.applicationIconImage) - .resizable() - .frame(width: 64, height: 64) - .padding() - VStack { - HStack { - Text("rename_title_\(secret.name)") - Spacer() - } - HStack { - TextField(secret.name, text: $newName).focusable() - } - } - } - HStack { - Spacer() - Button("rename_rename_button", action: rename) - .disabled(newName.count == 0) - .keyboardShortcut(.return) - Button("rename_cancel_button") { - dismissalBlock(false) - }.keyboardShortcut(.cancelAction) - } - } - .padding() - .frame(minWidth: 400) - .onExitCommand { - dismissalBlock(false) - } - } - - func rename() { - try? store.update(secret: secret, name: newName) - dismissalBlock(true) - } -} diff --git a/Sources/Secretive/Views/SecretDetailView.swift b/Sources/Secretive/Views/SecretDetailView.swift deleted file mode 100644 index aefe49d..0000000 --- a/Sources/Secretive/Views/SecretDetailView.swift +++ /dev/null @@ -1,58 +0,0 @@ -import SwiftUI -import SecretKit - -struct SecretDetailView: View { - - @State var secret: SecretType - - private let keyWriter = OpenSSHKeyWriter() - private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID)) - - var body: some View { - ScrollView { - Form { - Section { - CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret)) - Spacer() - .frame(height: 20) - CopyableView(title: "secret_detail_md5_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret)) - Spacer() - .frame(height: 20) - CopyableView(title: "secret_detail_public_key_label", image: Image(systemName: "key"), text: keyString) - Spacer() - .frame(height: 20) - CopyableView(title: "secret_detail_public_key_path_label", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret)) - Spacer() - } - } - .padding() - } - .frame(minHeight: 200, maxHeight: .infinity) - } - - var dashedKeyName: String { - secret.name.replacingOccurrences(of: " ", with: "-") - } - - var dashedHostName: String { - ["secretive", Host.current().localizedName, "local"] - .compactMap { $0 } - .joined(separator: ".") - .replacingOccurrences(of: " ", with: "-") - } - - var keyString: String { - keyWriter.openSSHString(secret: secret, comment: "\(dashedKeyName)@\(dashedHostName)") - } - -} - -#if DEBUG - -struct SecretDetailView_Previews: PreviewProvider { - static var previews: some View { - SecretDetailView(secret: Preview.Store(numberOfRandomSecrets: 1).secrets[0]) - } -} - -#endif diff --git a/Sources/Secretive/Views/SecretListItemView.swift b/Sources/Secretive/Views/SecretListItemView.swift deleted file mode 100644 index 8f6bbf4..0000000 --- a/Sources/Secretive/Views/SecretListItemView.swift +++ /dev/null @@ -1,63 +0,0 @@ -import SwiftUI -import SecretKit - -struct SecretListItemView: View { - - @ObservedObject var store: AnySecretStore - var secret: AnySecret - @Binding var activeSecret: AnySecret.ID? - - @State var isDeleting: Bool = false - @State var isRenaming: Bool = false - - var deletedSecret: (AnySecret) -> Void - var renamedSecret: (AnySecret) -> Void - - var body: some View { - let showingPopupWrapped = Binding( - get: { isDeleting || isRenaming }, - set: { if $0 == false { isDeleting = false; isRenaming = false } } - ) - - return NavigationLink(destination: SecretDetailView(secret: secret), tag: secret.id, selection: $activeSecret) { - if secret.requiresAuthentication { - HStack { - Text(secret.name) - Spacer() - Image(systemName: "lock") - } - } else { - Text(secret.name) - } - } - .contextMenu { - if store is AnySecretStoreModifiable { - Button(action: { isRenaming = true }) { - Text("secret_list_rename_button") - } - Button(action: { isDeleting = true }) { - Text("secret_list_delete_button") - } - } - } - .popover(isPresented: showingPopupWrapped) { - if let modifiable = store as? AnySecretStoreModifiable { - if isDeleting { - DeleteSecretView(store: modifiable, secret: secret) { deleted in - isDeleting = false - if deleted { - deletedSecret(secret) - } - } - } else if isRenaming { - RenameSecretView(store: modifiable, secret: secret) { renamed in - isRenaming = false - if renamed { - renamedSecret(secret) - } - } - } - } - } - } -} diff --git a/Sources/Secretive/Views/Secrets/CreateSecretView.swift b/Sources/Secretive/Views/Secrets/CreateSecretView.swift new file mode 100644 index 0000000..192c3dc --- /dev/null +++ b/Sources/Secretive/Views/Secrets/CreateSecretView.swift @@ -0,0 +1,151 @@ +import SwiftUI +import SecretKit + +struct CreateSecretView: View { + + @State var store: StoreType + @Environment(\.dismiss) private var dismiss + var createdSecret: (AnySecret?) -> Void + + @State private var name = "" + @State private var keyAttribution = "" + @State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired + @State private var keyType: KeyType? + @State var advanced = false + @State var errorText: String? + + private var authenticationOptions: [AuthenticationRequirement] { + if advanced || authenticationRequirement == .biometryCurrent { + [.presenceRequired, .notRequired, .biometryCurrent] + } else { + [.presenceRequired, .notRequired] + } + } + + var body: some View { + VStack(alignment: .trailing) { + Form { + Section { + TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder)) + VStack(alignment: .leading, spacing: 10) { + Picker(.createSecretProtectionLevelTitle, 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) + .boxBackground(color: .red) + } + + } + } + 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) + .boxBackground(color: .orange) + } + } + VStack(alignment: .leading) { + TextField(.createSecretKeyAttributionLabel, text: $keyAttribution, prompt: Text(verbatim: "test@example.com")) + Text(.createSecretKeyAttributionDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + if let errorText { + Section { + } footer: { + Text(verbatim: errorText) + .errorStyle() + } + } + } + HStack { + Toggle(.createSecretAdvancedLabel, isOn: $advanced) + .toggleStyle(.button) + Spacer() + Button(.createSecretCancelButton, role: .cancel) { + dismiss() + } + Button(.createSecretCreateButton, action: save) + .keyboardShortcut(.return) + .primaryButton() + .disabled(name.isEmpty) + } + .padding() + } + .onAppear { + keyType = store.supportedKeyTypes.first + } + .formStyle(.grouped) + } + + func save() { + let attribution = keyAttribution.isEmpty ? nil : keyAttribution + Task { + do { + let new = try await store.create( + name: name, + attributes: .init( + keyType: keyType!, + authentication: authenticationRequirement, + publicKeyAttribution: attribution + ) + ) + createdSecret(AnySecret(new)) + dismiss() + } catch { + errorText = error.localizedDescription + } + } + } + +} + +//#Preview { +// CreateSecretView(store: Preview.StoreModifiable()) { _ in } +//} diff --git a/Sources/Secretive/Views/Secrets/DeleteSecretView.swift b/Sources/Secretive/Views/Secrets/DeleteSecretView.swift new file mode 100644 index 0000000..17f6610 --- /dev/null +++ b/Sources/Secretive/Views/Secrets/DeleteSecretView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import SecretKit + +extension View { + + func showingDeleteConfirmation(isPresented: Binding, _ 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 + var secret: AnySecret + var store: AnySecretStoreModifiable? + var dismissalBlock: (Bool) -> () + @State var confirmedSecretName = "" + @State private var errorText: String? + + func body(content: Content) -> some View { + content + .confirmationDialog( + .deleteConfirmationTitle(secretName: secret.name), + isPresented: isPresented, + titleVisibility: .visible, + actions: { + TextField(secret.name, text: $confirmedSecretName) + if let errorText { + Text(verbatim: errorText) + .errorStyle() + } + Button(.deleteConfirmationDeleteButton, action: delete) + .disabled(confirmedSecretName != secret.name) + Button(.deleteConfirmationCancelButton, role: .cancel) { + dismissalBlock(false) + } + }, + message: { + Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name)) + } + ) + .dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill")) + .onExitCommand { + dismissalBlock(false) + } + } + + func delete() { + Task { + do { + try await store!.delete(secret: secret) + dismissalBlock(true) + } catch { + errorText = error.localizedDescription + } + } + } + +} diff --git a/Sources/Secretive/Views/Secrets/EditSecretView.swift b/Sources/Secretive/Views/Secrets/EditSecretView.swift new file mode 100644 index 0000000..80f5af0 --- /dev/null +++ b/Sources/Secretive/Views/Secrets/EditSecretView.swift @@ -0,0 +1,67 @@ +import SwiftUI +import SecretKit + +struct EditSecretView: View { + + let store: StoreType + let secret: StoreType.SecretType + let dismissalBlock: (_ renamed: Bool) -> () + + @State private var name: String + @State private var publicKeyAttribution: String + @State var errorText: String? + + init(store: StoreType, secret: StoreType.SecretType, dismissalBlock: @escaping (Bool) -> ()) { + self.store = store + self.secret = secret + self.dismissalBlock = dismissalBlock + name = secret.name + publicKeyAttribution = secret.publicKeyAttribution ?? "" + } + + var body: some View { + VStack(alignment: .trailing) { + Form { + Section { + TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder)) + VStack(alignment: .leading) { + TextField(.createSecretKeyAttributionLabel, text: $publicKeyAttribution, prompt: Text(verbatim: "test@example.com")) + Text(.createSecretKeyAttributionDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } footer: { + if let errorText { + Text(verbatim: errorText) + .errorStyle() + } + } + } + HStack { + Button(.editCancelButton) { + dismissalBlock(false) + } + .keyboardShortcut(.cancelAction) + Button(.editSaveButton, action: rename) + .disabled(name.isEmpty) + .keyboardShortcut(.return) + .primaryButton() + } + .padding() + } + .formStyle(.grouped) + } + + func rename() { + var attributes = secret.attributes + attributes.publicKeyAttribution = publicKeyAttribution.isEmpty ? nil : publicKeyAttribution + Task { + do { + try await store.update(secret: secret, name: name, attributes: attributes) + dismissalBlock(true) + } catch { + errorText = error.localizedDescription + } + } + } +} diff --git a/Sources/Secretive/Views/EmptyStoreView.swift b/Sources/Secretive/Views/Secrets/EmptyStoreView.swift similarity index 60% rename from Sources/Secretive/Views/EmptyStoreView.swift rename to Sources/Secretive/Views/Secrets/EmptyStoreView.swift index 6a88c2b..c21cf95 100644 --- a/Sources/Secretive/Views/EmptyStoreView.swift +++ b/Sources/Secretive/Views/Secrets/EmptyStoreView.swift @@ -3,45 +3,33 @@ import SecretKit struct EmptyStoreView: View { - @ObservedObject var store: AnySecretStore - @Binding var activeSecret: AnySecret.ID? - + @State var store: AnySecretStore? + var body: some View { if store is AnySecretStoreModifiable { - NavigationLink(destination: EmptyStoreModifiableView(), tag: Constants.emptyStoreModifiableTag, selection: $activeSecret) { - Text("empty_store_modifiable_title") - } + EmptyStoreModifiableView() } else { - NavigationLink(destination: EmptyStoreImmutableView(), tag: Constants.emptyStoreTag, selection: $activeSecret) { - Text("empty_store_nonmodifiable_title") - } + EmptyStoreImmutableView() } } } -extension EmptyStoreView { - - enum Constants { - static let emptyStoreModifiableTag: AnyHashable = "emptyStoreModifiableTag" - static let emptyStoreTag: AnyHashable = "emptyStoreTag" - } - -} - struct EmptyStoreImmutableView: View { var body: some View { VStack { - Text("empty_store_nonmodifiable_title").bold() - Text("empty_store_nonmodifiable_description") - Text("empty_store_nonmodifiable_supported_key_types") + Text(.emptyStoreNonmodifiableTitle).bold() + Text(.emptyStoreNonmodifiableDescription) + Text(.emptyStoreNonmodifiableSupportedKeyTypes) }.frame(maxWidth: .infinity, maxHeight: .infinity) } } struct EmptyStoreModifiableView: View { - + + @Environment(\.justUpdatedChecker) var justUpdatedChecker + var body: some View { GeometryReader { windowGeometry in VStack { @@ -63,23 +51,37 @@ struct EmptyStoreModifiableView: View { path.addLine(to: CGPoint(x: g.size.width - 3, y: 0)) }.fill() }.frame(height: (windowGeometry.size.height/2) - 20).padding() - Text("empty_store_modifiable_click_here_title").bold() - Text("empty_store_modifiable_click_here_description") + Text(.emptyStoreModifiableClickHereTitle).bold() + Text(.emptyStoreModifiableClickHereDescription) + if justUpdatedChecker.justUpdatedOS { + Spacer() + .frame(height: 20) + VStack(spacing: 10) { + Text(.emptyStoreModifiableEmptyOsWarningTitle) + .font(.title2) + .bold() + Text(.emptyStoreModifiableEmptyOsWarningDescription) + .fixedSize(horizontal: false, vertical: true) + .bold() + } + .padding() + .boxBackground(color: .orange) + .padding() + } Spacer() }.frame(maxWidth: .infinity, maxHeight: .infinity) } } } -#if DEBUG -struct EmptyStoreModifiableView_Previews: PreviewProvider { - static var previews: some View { - Group { - EmptyStoreImmutableView() - EmptyStoreModifiableView() - } - } +#Preview { + EmptyStoreImmutableView() +} +#Preview { + EmptyStoreImmutableView() +// .environment(\.justUpdatedChecker, <#T##value: V##V#>) +} +#Preview { + EmptyStoreModifiableView() } - -#endif diff --git a/Sources/Secretive/Views/Secrets/NoStoresView.swift b/Sources/Secretive/Views/Secrets/NoStoresView.swift new file mode 100644 index 0000000..fd31ddf --- /dev/null +++ b/Sources/Secretive/Views/Secrets/NoStoresView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct NoStoresView: View { + + var body: some View { + VStack { + Text(.noSecureStorageTitle) + .bold() + Text(.noSecureStorageDescription) + Link(.noSecureStorageYubicoLink, destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!) + }.padding() + } + +} + +#Preview { + NoStoresView() +} + diff --git a/Sources/Secretive/Views/Secrets/SecretDetailView.swift b/Sources/Secretive/Views/Secrets/SecretDetailView.swift new file mode 100644 index 0000000..b3940ff --- /dev/null +++ b/Sources/Secretive/Views/Secrets/SecretDetailView.swift @@ -0,0 +1,42 @@ +import SwiftUI +import SecretKit + +struct SecretDetailView: View { + + let secret: SecretType + + private let keyWriter = OpenSSHPublicKeyWriter() + private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL) + + var body: some View { + ScrollView { + Form { + Section { + CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret)) + Spacer() + .frame(height: 20) + CopyableView(title: .secretDetailMd5FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret)) + Spacer() + .frame(height: 20) + CopyableView(title: .secretDetailPublicKeyLabel, image: Image(systemName: "key"), text: keyString) + Spacer() + .frame(height: 20) + CopyableView(title: .secretDetailPublicKeyPathLabel, image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret), showRevealInFinder: true) + Spacer() + } + } + .padding() + } + .frame(minHeight: 200, maxHeight: .infinity) + } + + + var keyString: String { + keyWriter.openSSHString(secret: secret) + } + +} + +//#Preview { +// SecretDetailView(secret: Preview.Secret(name: "Demonstration Secret")) +//} diff --git a/Sources/Secretive/Views/Secrets/SecretListItemView.swift b/Sources/Secretive/Views/Secrets/SecretListItemView.swift new file mode 100644 index 0000000..41e742b --- /dev/null +++ b/Sources/Secretive/Views/Secrets/SecretListItemView.swift @@ -0,0 +1,55 @@ +import SwiftUI +import SecretKit + +struct SecretListItemView: View { + + @State var store: AnySecretStore + var secret: AnySecret + + @State var isDeleting: Bool = false + @State var isRenaming: Bool = false + + var deletedSecret: (AnySecret) -> Void + var renamedSecret: (AnySecret) -> Void + + var body: some View { + NavigationLink(value: secret) { + if secret.authenticationRequirement.required { + HStack { + Text(secret.name) + Spacer() + Image(systemName: "lock") + } + } else { + Text(secret.name) + } + } + .contextMenu { + if store is AnySecretStoreModifiable { + Button(action: { isRenaming = true }) { + Image(systemName: "pencil") + Text(.secretListEditButton) + } + 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) { + if let modifiable = store as? AnySecretStoreModifiable { + EditSecretView(store: modifiable, secret: secret) { renamed in + isRenaming = false + if renamed { + renamedSecret(secret) + } + } + } + } + } +} diff --git a/Sources/Secretive/Views/Secrets/StoreListView.swift b/Sources/Secretive/Views/Secrets/StoreListView.swift new file mode 100644 index 0000000..2c8d439 --- /dev/null +++ b/Sources/Secretive/Views/Secrets/StoreListView.swift @@ -0,0 +1,64 @@ +import SwiftUI +import SecretKit + +struct StoreListView: View { + + @Binding var activeSecret: AnySecret? + + @Environment(\.secretStoreList) private var storeList + + private func secretDeleted(secret: AnySecret) { + activeSecret = nextDefaultSecret + } + + private func secretRenamed(secret: AnySecret) { + // Toggle so name updates in list. + activeSecret = nil + activeSecret = secret + } + + var body: some View { + NavigationSplitView { + List(selection: $activeSecret) { + ForEach(storeList.stores) { store in + if store.isAvailable { + Section(header: Text(store.name)) { + ForEach(store.secrets) { secret in + SecretListItemView( + store: store, + secret: secret, + deletedSecret: secretDeleted, + renamedSecret: secretRenamed + ) + } + } + } + } + } + } detail: { + if let activeSecret { + SecretDetailView(secret: activeSecret) + } else if let nextDefaultSecret { + // This just means onAppear hasn't executed yet. + // Do this to avoid a blip. + SecretDetailView(secret: nextDefaultSecret) + } else { + EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first) + } + } + .navigationSplitViewStyle(.balanced) + .onAppear { + activeSecret = nextDefaultSecret + } + .frame(minWidth: 100, idealWidth: 240) + + } +} + +extension StoreListView { + + private var nextDefaultSecret: AnySecret? { + return storeList.allSecrets.first + } + +} diff --git a/Sources/Secretive/Views/SetupView.swift b/Sources/Secretive/Views/SetupView.swift deleted file mode 100644 index ffd142c..0000000 --- a/Sources/Secretive/Views/SetupView.swift +++ /dev/null @@ -1,295 +0,0 @@ -import SwiftUI - -struct SetupView: View { - - @State var stepIndex = 0 - @Binding var visible: Bool - @Binding var setupComplete: Bool - - var body: some View { - GeometryReader { proxy in - VStack { - StepView(numberOfSteps: 3, currentStep: stepIndex, width: proxy.size.width) - GeometryReader { _ in - HStack(spacing: 0) { - SecretAgentSetupView(buttonAction: advance) - .frame(width: proxy.size.width) - SSHAgentSetupView(buttonAction: advance) - .frame(width: proxy.size.width) - UpdaterExplainerView { - visible = false - setupComplete = true - } - .frame(width: proxy.size.width) - } - .offset(x: -proxy.size.width * Double(stepIndex), y: 0) - } - } - } - .frame(minWidth: 500, idealWidth: 500, minHeight: 500, idealHeight: 500) - } - - - func advance() { - withAnimation(.spring()) { - stepIndex += 1 - } - } - -} - -struct StepView: View { - - let numberOfSteps: Int - let currentStep: Int - - // Ideally we'd have a geometry reader inside this view doing this for us, but that crashes on 11.0b7 - let width: Double - - var body: some View { - ZStack(alignment: .leading) { - Rectangle() - .foregroundColor(.blue) - .frame(height: 5) - Rectangle() - .foregroundColor(.green) - .frame(width: max(0, ((width - (Constants.padding * 2)) / Double(numberOfSteps - 1)) * Double(currentStep) - (Constants.circleWidth / 2)), height: 5) - HStack { - ForEach(0.. index { - Circle() - .foregroundColor(.green) - .frame(width: Constants.circleWidth, height: Constants.circleWidth) - Text("setup_step_complete_symbol") - .foregroundColor(.white) - .bold() - } else { - Circle() - .foregroundColor(.blue) - .frame(width: Constants.circleWidth, height: Constants.circleWidth) - if currentStep == index { - Circle() - .strokeBorder(Color.white, lineWidth: 3) - .frame(width: Constants.circleWidth, height: Constants.circleWidth) - } - Text(String(describing: index + 1)) - .foregroundColor(.white) - .bold() - } - } - if index < numberOfSteps - 1 { - Spacer(minLength: 30) - } - } - } - }.padding(Constants.padding) - } - -} - -extension StepView { - - enum Constants { - - static let padding: Double = 15 - static let circleWidth: Double = 30 - - } - -} - -struct SetupStepView : View where Content : View { - - let title: LocalizedStringKey - let image: Image - let bodyText: LocalizedStringKey - let buttonTitle: LocalizedStringKey - let buttonAction: () -> Void - let content: Content - - init(title: LocalizedStringKey, image: Image, bodyText: LocalizedStringKey, buttonTitle: LocalizedStringKey, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) { - self.title = title - self.image = image - self.bodyText = bodyText - self.buttonTitle = buttonTitle - self.buttonAction = buttonAction - self.content = content() - } - - var body: some View { - VStack { - Text(title) - .font(.title) - Spacer() - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 64) - Spacer() - Text(bodyText) - .multilineTextAlignment(.center) - Spacer() - content - Spacer() - Button(buttonTitle) { - buttonAction() - } - }.padding() - } - -} - -struct SecretAgentSetupView: View { - - let buttonAction: () -> Void - - var body: some View { - SetupStepView(title: "setup_agent_title", - image: Image(nsImage: NSApplication.shared.applicationIconImage), - bodyText: "setup_agent_description", - buttonTitle: "setup_agent_install_button", - buttonAction: install) { - Text("setup_agent_activity_monitor_description") - .multilineTextAlignment(.center) - } - } - - func install() { - LaunchAgentController().install() - buttonAction() - } - -} - -struct SSHAgentSetupView: View { - - let buttonAction: () -> Void - - private static let controller = ShellConfigurationController() - @State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first! - - var body: some View { - SetupStepView(title: "setup_ssh_title", - image: Image(systemName: "terminal"), - bodyText: "setup_ssh_description", - buttonTitle: "setup_ssh_added_manually_button", - buttonAction: buttonAction) { - Link("setup_third_party_faq_link", destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!) - Picker(selection: $selectedShellInstruction, label: EmptyView()) { - ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in - Text(instruction.shell) - .tag(instruction) - .padding() - } - }.pickerStyle(SegmentedPickerStyle()) - CopyableView(title: "setup_ssh_add_to_config_button_\(selectedShellInstruction.shellConfigPath)", image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text) - Button("setup_ssh_add_for_me_button") { - let controller = ShellConfigurationController() - if controller.addToShell(shellInstructions: selectedShellInstruction) { - buttonAction() - } - } - } - } - -} - -class Delegate: NSObject, NSOpenSavePanelDelegate { - - private let name: String - - init(name: String) { - self.name = name - } - - func panel(_ sender: Any, shouldEnable url: URL) -> Bool { - return url.lastPathComponent == name - } - -} - -struct UpdaterExplainerView: View { - - let buttonAction: () -> Void - - var body: some View { - SetupStepView(title: "setup_updates_title", - image: Image(systemName: "dot.radiowaves.left.and.right"), - bodyText: "setup_updates_description", - buttonTitle: "setup_updates_ok", - buttonAction: buttonAction) { - Link("setup_updates_readmore", destination: SetupView.Constants.updaterFAQURL) - } - } - -} - -extension SetupView { - - enum Constants { - static let updaterFAQURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md#whats-this-network-request-to-github")! - } - -} - -struct ShellConfigInstruction: Identifiable, Hashable { - - var shell: String - var shellConfigDirectory: String - var shellConfigFilename: String - var text: String - - var id: String { - shell - } - - var shellConfigPath: String { - return (shellConfigDirectory as NSString).appendingPathComponent(shellConfigFilename) - } - -} - -#if DEBUG - -struct SetupView_Previews: PreviewProvider { - - static var previews: some View { - Group { - SetupView(visible: .constant(true), setupComplete: .constant(false)) - } - } - -} - -struct SecretAgentSetupView_Previews: PreviewProvider { - - static var previews: some View { - Group { - SecretAgentSetupView(buttonAction: {}) - } - } - -} - -struct SSHAgentSetupView_Previews: PreviewProvider { - - static var previews: some View { - Group { - SSHAgentSetupView(buttonAction: {}) - } - } - -} - -struct UpdaterExplainerView_Previews: PreviewProvider { - - static var previews: some View { - Group { - UpdaterExplainerView(buttonAction: {}) - } - } - -} - -#endif diff --git a/Sources/Secretive/Views/StoreListView.swift b/Sources/Secretive/Views/StoreListView.swift deleted file mode 100644 index c2b64e2..0000000 --- a/Sources/Secretive/Views/StoreListView.swift +++ /dev/null @@ -1,63 +0,0 @@ -import SwiftUI -import Combine -import SecretKit - -struct StoreListView: View { - - @Binding var activeSecret: AnySecret.ID? - - @EnvironmentObject private var storeList: SecretStoreList - - private func secretDeleted(secret: AnySecret) { - activeSecret = nextDefaultSecret - } - - private func secretRenamed(secret: AnySecret) { - activeSecret = secret.id - } - - var body: some View { - NavigationView { - List(selection: $activeSecret) { - ForEach(storeList.stores) { store in - if store.isAvailable { - Section(header: Text(store.name)) { - if store.secrets.isEmpty { - EmptyStoreView(store: store, activeSecret: $activeSecret) - } else { - ForEach(store.secrets) { secret in - SecretListItemView( - store: store, - secret: secret, - activeSecret: $activeSecret, - deletedSecret: self.secretDeleted, - renamedSecret: self.secretRenamed - ) - } - } - } - } - } - } - .listStyle(SidebarListStyle()) - .onAppear { - activeSecret = nextDefaultSecret - } - .frame(minWidth: 100, idealWidth: 240) - } - } -} - -extension StoreListView { - - var nextDefaultSecret: AnyHashable? { - let fallback: AnyHashable - if storeList.modifiableStore?.isAvailable ?? false { - fallback = EmptyStoreView.Constants.emptyStoreModifiableTag - } else { - fallback = EmptyStoreView.Constants.emptyStoreTag - } - return storeList.stores.compactMap(\.secrets.first).first?.id ?? fallback - } - -} diff --git a/Sources/Secretive/Views/Styles/ActionButtonStyle.swift b/Sources/Secretive/Views/Styles/ActionButtonStyle.swift new file mode 100644 index 0000000..74284a7 --- /dev/null +++ b/Sources/Secretive/Views/Styles/ActionButtonStyle.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct PrimaryButtonModifier: ViewModifier { + + @Environment(\.colorScheme) var colorScheme + @Environment(\.isEnabled) var isEnabled + + func body(content: Content) -> some View { + // Tinted glass prominent is really hard to read on 26.0. + if #available(macOS 26.0, *), colorScheme == .dark, isEnabled { + content.buttonStyle(.glassProminent) + } else { + content.buttonStyle(.borderedProminent) + } + } + +} + +extension View { + + func primaryButton() -> some View { + modifier(PrimaryButtonModifier()) + } + +} + +struct MenuButtonModifier: ViewModifier { + + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content + .glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle) + } else { + content + .buttonStyle(.borderless) + } + } + +} + +extension View { + + func menuButton() -> some View { + modifier(MenuButtonModifier()) + } + +} + +struct NormalButtonModifier: ViewModifier { + + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content.buttonStyle(.glass) + } else { + content.buttonStyle(.bordered) + } + } + +} + +extension View { + + func normalButton() -> some View { + modifier(NormalButtonModifier()) + } + +} + +struct DangerButtonModifier: ViewModifier { + + @Environment(\.colorScheme) var colorScheme + + func body(content: Content) -> some View { + // Tinted glass prominent is really hard to read on 26.0. + if #available(macOS 26.0, *), colorScheme == .dark { + content.buttonStyle(.glassProminent) + .tint(.red) + .foregroundStyle(.white) + } else { + content.buttonStyle(.borderedProminent) + .tint(.red) + .foregroundStyle(.white) + } + } + +} + +extension View { + + func danger() -> some View { + modifier(DangerButtonModifier()) + } + +} diff --git a/Sources/Secretive/Views/Styles/BoxBackgroundStyle.swift b/Sources/Secretive/Views/Styles/BoxBackgroundStyle.swift new file mode 100644 index 0000000..8ffbf38 --- /dev/null +++ b/Sources/Secretive/Views/Styles/BoxBackgroundStyle.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct BoxBackgroundModifier: ViewModifier { + + let color: Color + + func body(content: Content) -> some View { + content + .background { + RoundedRectangle(cornerRadius: 5) + .fill(color.opacity(0.3)) + .stroke(color, lineWidth: 1) + } + } +} + +extension View { + + func boxBackground(color: Color) -> some View { + modifier(BoxBackgroundModifier(color: color)) + } + +} + +#Preview { + Text("Hello") + .boxBackground(color: .red) + .padding() + Text("Hello") + .boxBackground(color: .orange) + .padding() +} diff --git a/Sources/Secretive/Views/Styles/ErrorStyle.swift b/Sources/Secretive/Views/Styles/ErrorStyle.swift new file mode 100644 index 0000000..18917f1 --- /dev/null +++ b/Sources/Secretive/Views/Styles/ErrorStyle.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct ErrorStyleModifier: ViewModifier { + + func body(content: Content) -> some View { + content + .foregroundStyle(.red) + .font(.callout) + } + +} + +extension View { + + func errorStyle() -> some View { + modifier(ErrorStyleModifier()) + } + +} diff --git a/Sources/Secretive/Views/Styles/ToolbarButtonStyle.swift b/Sources/Secretive/Views/Styles/ToolbarButtonStyle.swift new file mode 100644 index 0000000..7dd1f65 --- /dev/null +++ b/Sources/Secretive/Views/Styles/ToolbarButtonStyle.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct ToolbarButtonStyle: ButtonStyle { + + private let lightColor: Color + private let darkColor: Color + @Environment(\.colorScheme) var colorScheme + @State var hovering = false + + init(color: Color) { + self.lightColor = color + self.darkColor = color + } + + init(lightColor: Color, darkColor: Color) { + self.lightColor = lightColor + self.darkColor = darkColor + } + + @available(macOS 26.0, *) + private var glassTint: Color { + if !hovering { + colorScheme == .light ? lightColor : darkColor + } else { + colorScheme == .light ? lightColor.exposureAdjust(1) : darkColor.exposureAdjust(1) + } + } + + func makeBody(configuration: Configuration) -> some View { + if #available(macOS 26.0, *) { + configuration + .label + .foregroundColor(.white) + .padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8)) + .glassEffect(.regular.tint(glassTint), in: .capsule) + .onHover { hovering in + self.hovering = hovering + } + } else { + configuration + .label + .background(colorScheme == .light ? lightColor : darkColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(colorScheme == .light ? .black.opacity(0.15) : .white.opacity(0.15), lineWidth: 1) + .background(hovering ? (colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.05)) : Color.clear) + ) + .onHover { hovering in + withAnimation { + self.hovering = hovering + } + } + } + } +} diff --git a/Sources/Secretive/Views/ToolbarButtonStyle.swift b/Sources/Secretive/Views/ToolbarButtonStyle.swift deleted file mode 100644 index a80cde4..0000000 --- a/Sources/Secretive/Views/ToolbarButtonStyle.swift +++ /dev/null @@ -1,37 +0,0 @@ -import SwiftUI - -struct ToolbarButtonStyle: ButtonStyle { - - private let lightColor: Color - private let darkColor: Color - @Environment(\.colorScheme) var colorScheme - @State var hovering = false - - init(color: Color) { - self.lightColor = color - self.darkColor = color - } - - init(lightColor: Color, darkColor: Color) { - self.lightColor = lightColor - self.darkColor = darkColor - } - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8)) - .background(colorScheme == .light ? lightColor : darkColor) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay( - RoundedRectangle(cornerRadius: 5) - .stroke(colorScheme == .light ? .black.opacity(0.15) : .white.opacity(0.15), lineWidth: 1) - .background(hovering ? (colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.05)) : Color.clear) - ) - .onHover { hovering in - withAnimation { - self.hovering = hovering - } - } - } -} diff --git a/Sources/Secretive/Views/Views/AgentStatusView.swift b/Sources/Secretive/Views/Views/AgentStatusView.swift new file mode 100644 index 0000000..50b50c9 --- /dev/null +++ b/Sources/Secretive/Views/Views/AgentStatusView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +struct AgentStatusView: View { + + @Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol + + var body: some View { + if agentStatusChecker.running { + AgentRunningView() + } else { + AgentNotRunningView() + } + } +} +struct AgentRunningView: View { + + @Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol + + var body: some View { + Form { + Section { + if let process = agentStatusChecker.process { + ConfigurationItemView( + title: .agentDetailsLocationTitle, + value: process.bundleURL!.path(), + action: .revealInFinder(process.bundleURL!.path()), + ) + ConfigurationItemView( + title: .agentDetailsSocketPathTitle, + value: URL.socketPath, + action: .copy(URL.socketPath), + ) + ConfigurationItemView( + title: .agentDetailsVersionTitle, + value: Bundle(url: process.bundleURL!)!.infoDictionary!["CFBundleShortVersionString"] as! String + ) + if let launchDate = process.launchDate { + ConfigurationItemView( + title: .agentDetailsRunningSinceTitle, + value: launchDate.formatted() + ) + } + } + } header: { + Text(.agentRunningNoticeDetailTitle) + .font(.headline) + .padding(.top) + } footer: { + VStack(alignment: .leading, spacing: 10) { + Text(.agentRunningNoticeDetailDescription) + HStack { + Spacer() + Menu(.agentDetailsRestartAgentButton) { + Button(.agentDetailsDisableAgentButton) { + Task { + _ = await LaunchAgentController() + .uninstall() + agentStatusChecker.check() + } + } + } primaryAction: { + Task { + let controller = LaunchAgentController() + let installed = await controller.install() + if !installed { + _ = await controller.forceLaunch() + } + agentStatusChecker.check() + } + } + } + } + .padding(.vertical) + } + + } + .formStyle(.grouped) + .frame(width: 400) + } + +} + +struct AgentNotRunningView: View { + + @Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol + @State var triedRestart = false + @State var loading = false + + var body: some View { + Form { + Section { + } header: { + Text(.agentNotRunningNoticeTitle) + .font(.headline) + .padding(.top) + } footer: { + VStack(alignment: .leading, spacing: 10) { + Text(.agentNotRunningNoticeDetailDescription) + HStack { + if !triedRestart { + Spacer() + Button { + guard !loading else { return } + loading = true + Task { + let controller = LaunchAgentController() + let installed = await controller.install() + if !installed { + _ = await controller.forceLaunch() + } + agentStatusChecker.check() + loading = false + + if !agentStatusChecker.running { + triedRestart = true + } + } + } label: { + if !loading { + Text(.agentDetailsStartAgentButton) + } else { + HStack { + Text(.agentDetailsStartAgentButtonStarting) + ProgressView() + .controlSize(.mini) + } + } + } + .primaryButton() + } else { + Text(.agentDetailsCouldNotStartError) + .bold() + .foregroundStyle(.red) + } + } + } + .padding(.bottom) + } + } + .formStyle(.grouped) + .frame(width: 400) + } + +} + +//#Preview { +// AgentStatusView() +// .environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: false)) +//} +//#Preview { +// AgentStatusView() +// .environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: true, process: .current)) +//} diff --git a/Sources/Secretive/Views/Views/ContentView.swift b/Sources/Secretive/Views/Views/ContentView.swift new file mode 100644 index 0000000..117d0d8 --- /dev/null +++ b/Sources/Secretive/Views/Views/ContentView.swift @@ -0,0 +1,208 @@ +import SwiftUI +import SecretKit +import SecureEnclaveSecretKit +import SmartCardSecretKit +import Brief + +struct ContentView: View { + + @Binding var showingCreation: Bool + @Binding var runningSetup: Bool + @Binding var hasRunSetup: Bool + @State var showingAgentInfo = false + @State var activeSecret: AnySecret? + @Environment(\.colorScheme) var colorScheme + + @Environment(\.secretStoreList) private var storeList + @Environment(\.updater) private var updater: any UpdaterProtocol + @Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol + + @State private var selectedUpdate: Release? + @State private var showingAppPathNotice = false + + var body: some View { + VStack { + if storeList.anyAvailable { + StoreListView(activeSecret: $activeSecret) + } else { + NoStoresView() + } + } + .frame(minWidth: 640, minHeight: 320) + .toolbar { + toolbarItem(updateNoticeView, id: "update") + toolbarItem(runningOrRunSetupView, id: "setup") + toolbarItem(appPathNoticeView, id: "appPath") + toolbarItem(newItemView, id: "new") + } + .sheet(isPresented: $runningSetup) { + SetupView(setupComplete: $hasRunSetup) + } + } + +} + +extension ContentView { + + + @ToolbarContentBuilder + func toolbarItem(_ view: some View, id: String) -> some ToolbarContent { + if #available(macOS 26.0, *) { + ToolbarItem(id: id) { view } + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(id: id) { view } + } + } + + /// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message + /// These two are mutually exclusive + @ViewBuilder + var runningOrRunSetupView: some View { + agentStatusToolbarView + } + + var updateNoticeContent: (LocalizedStringResource, Color)? { + guard let update = updater.update else { return nil } + if update.critical { + return (.updateCriticalNoticeTitle, .red) + } else { + if updater.currentVersion.isTestBuild { + return (.updateTestNoticeTitle, .blue) + } else { + return (.updateNormalNoticeTitle, .orange) + } + } + } + + @ViewBuilder + var updateNoticeView: some View { + if let update = updater.update, let (text, color) = updateNoticeContent { + Button(action: { + selectedUpdate = update + }, label: { + Text(text) + .font(.headline) + .foregroundColor(.white) + }) + .buttonStyle(ToolbarButtonStyle(color: color)) + .sheet(item: $selectedUpdate) { update in + VStack { + if updater.currentVersion.isTestBuild { + VStack { + if let description = updater.currentVersion.previewDescription { + Text(description) + } + Link(destination: URL(string: "https://github.com/maxgoedjen/secretive/actions/workflows/nightly.yml")!) { + Button(.updaterDownloadLatestNightlyButton) {} + .frame(maxWidth: .infinity) + .primaryButton() + } + } + .padding() + } + UpdateDetailView(update: update) + } + } + } + } + + @ViewBuilder + var newItemView: some View { + if storeList.modifiableStore?.isAvailable ?? false { + Button(.appMenuNewSecretButton, systemImage: "plus") { + showingCreation = true + } + .menuButton() + .sheet(isPresented: $showingCreation) { + if let modifiable = storeList.modifiableStore { + CreateSecretView(store: modifiable) { created in + if let created { + activeSecret = created + } + } + } + } + } + } + + @ViewBuilder + var agentStatusToolbarView: some View { + Button(action: { + showingAgentInfo = true + }, label: { + HStack { + if agentStatusChecker.running { + Text(.agentRunningNoticeTitle) + .font(.headline) + .foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white) + Circle() + .frame(width: 10, height: 10) + .foregroundColor(Color.green) + } else { + Text(.agentNotRunningNoticeTitle) + .font(.headline) + Circle() + .frame(width: 10, height: 10) + .foregroundColor(Color.red) + } + } + }) + .buttonStyle( + ToolbarButtonStyle( + lightColor: agentStatusChecker.running ? .black.opacity(0.05) : .red.opacity(0.75), + darkColor: agentStatusChecker.running ? .white.opacity(0.05) : .red.opacity(0.5), + ) + ) + .popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { + AgentStatusView() + } + } + + @ViewBuilder + var appPathNoticeView: some View { + if !ApplicationDirectoryController().isInApplicationsDirectory { + Button(action: { + showingAppPathNotice = true + }, label: { + Group { + Text(.appNotInApplicationsNoticeTitle) + } + .font(.headline) + .foregroundColor(.white) + }) + .buttonStyle(ToolbarButtonStyle(color: .orange)) + .popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { + VStack { + Image(systemName: "exclamationmark.triangle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64) + Text(.appNotInApplicationsNoticeDetailDescription) + .frame(maxWidth: 300) + } + .padding() + } + } + } + + var attachmentAnchor: PopoverAttachmentAnchor { + .rect(.bounds) + } + +} + + +//#Preview { +// // Empty on modifiable and nonmodifiable +// ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true)) +// .environment(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)])) +// .environment(PreviewUpdater()) +//} +// +//#Preview { +// // 5 items on modifiable and nonmodifiable +// ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true)) +// .environment(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()])) +// .environment(PreviewUpdater()) +//} diff --git a/Sources/Secretive/Views/Views/CopyableView.swift b/Sources/Secretive/Views/Views/CopyableView.swift new file mode 100644 index 0000000..5d5b431 --- /dev/null +++ b/Sources/Secretive/Views/Views/CopyableView.swift @@ -0,0 +1,194 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct CopyableView: View { + + var title: LocalizedStringResource + var image: Image + var text: String + var showRevealInFinder = false + + @State private var interactionState: InteractionState = .normal + + var content: some View { + VStack(alignment: .leading) { + HStack { + image + .renderingMode(.template) + .imageScale(.large) + .foregroundColor(primaryTextColor) + Text(title) + .font(.headline) + .foregroundColor(primaryTextColor) + Spacer() + if interactionState != .normal { + HStack { + if showRevealInFinder { + revealInFinderButton + } + copyButton + } + .foregroundColor(secondaryTextColor) + .transition(.opacity) + } + + } + .padding(EdgeInsets(top: 20, leading: 20, bottom: 10, trailing: 20)) + Divider() + Text(text) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(primaryTextColor) + .padding(EdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20)) + .multilineTextAlignment(.leading) + .font(.system(.body, design: .monospaced)) + } + ._background(interactionState: interactionState) + .frame(minWidth: 150, maxWidth: .infinity) + } + + var body: some View { + content + .onHover { hovering in + withAnimation { + interactionState = hovering ? .hovering : .normal + } + } + .onDrag({ + NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier) + }, preview: { + content + ._background(interactionState: .dragging) + }) + .onTapGesture { + copy() + withAnimation { + interactionState = .clicking + } + } + .gesture( + TapGesture() + .onEnded { + withAnimation { + interactionState = .normal + } + } + ) + } + + @ViewBuilder + var copyButton: some View { + switch interactionState { + case .hovering: + Button(.copyableClickToCopyButton, systemImage: "document.on.document") { + withAnimation { + // Button will eat the click, so we set interaction state manually. + interactionState = .clicking + } + copy() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + case .clicking: + Image(systemName: "checkmark.circle.fill") + .accessibilityLabel(String(localized: .copyableCopied)) + case .normal, .dragging: + EmptyView() + } + } + + var revealInFinderButton: some View { + Button(.revealInFinderButton, systemImage: "folder") { + let (processedPath, folder) = text.normalizedPathAndFolder + NSWorkspace.shared.selectFile(processedPath, inFileViewerRootedAtPath: folder) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + + var primaryTextColor: Color { + switch interactionState { + case .normal, .hovering, .dragging: + return Color(.textColor) + case .clicking: + return .white + } + } + + var secondaryTextColor: Color { + switch interactionState { + case .normal, .hovering, .dragging: + return Color(.secondaryLabelColor) + case .clicking: + return .white + } + } + + func copy() { + NSPasteboard.general.declareTypes([.string], owner: nil) + NSPasteboard.general.setString(text, forType: .string) + } + +} + +fileprivate enum InteractionState { + case normal, hovering, clicking, dragging +} + +extension View { + + fileprivate func _background(interactionState: InteractionState) -> some View { + modifier(BackgroundViewModifier(interactionState: interactionState)) + } + +} + +fileprivate struct BackgroundViewModifier: ViewModifier { + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.appearsActive) private var appearsActive + + let interactionState: InteractionState + + func body(content: Content) -> some View { + if interactionState == .dragging { + content + .background(backgroundColor(interactionState: interactionState), in: RoundedRectangle(cornerRadius: 15)) + } else { + if #available(macOS 26.0, *) { + content + // Very thin opacity lets user hover anywhere over the view, glassEffect doesn't allow. + .background(.white.opacity(0.01), in: RoundedRectangle(cornerRadius: 15)) + .glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: 15)) + + } else { + content + .background(backgroundColor(interactionState: interactionState)) + .cornerRadius(10) + } + } + } + + func backgroundColor(interactionState: InteractionState) -> Color { + guard appearsActive else { return Color.clear } + switch interactionState { + case .normal: + return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885) + case .hovering, .dragging: + return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82) + case .clicking: + return .accentColor + } + } + + +} + +#Preview { + CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "figure.wave"), text: "Hello world.") + .padding() +} + +#Preview { + CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "figure.wave"), text: "Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. ") + .padding() +} diff --git a/Sources/Secretive/Views/UpdateView.swift b/Sources/Secretive/Views/Views/UpdateView.swift similarity index 77% rename from Sources/Secretive/Views/UpdateView.swift rename to Sources/Secretive/Views/Views/UpdateView.swift index a997d3a..810e0e8 100644 --- a/Sources/Secretive/Views/UpdateView.swift +++ b/Sources/Secretive/Views/Views/UpdateView.swift @@ -1,28 +1,30 @@ import SwiftUI import Brief -struct UpdateDetailView: View { +struct UpdateDetailView: View { - @EnvironmentObject var updater: UpdaterType + @Environment(\.updater) var updater: any UpdaterProtocol let update: Release var body: some View { VStack { - Text("update_version_name_\(update.name)").font(.title) - GroupBox(label: Text("update_release_notes_title")) { + Text(.updateVersionName(updateName: update.name)).font(.title) + GroupBox(label: Text(.updateReleaseNotesTitle)) { ScrollView { attributedBody } } HStack { if !update.critical { - Button("update_ignore_button") { - updater.ignore(release: update) + Button(.updateIgnoreButton) { + Task { + await updater.ignore(release: update) + } } Spacer() } - Button("update_update_button") { + Button(.updateUpdateButton) { NSWorkspace.shared.open(update.html_url) } .keyboardShortcut(.defaultAction) diff --git a/Sources/SecretiveTests/Info.plist b/Sources/SecretiveTests/Info.plist deleted file mode 100644 index 64d65ca..0000000 --- a/Sources/SecretiveTests/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/Sources/SecretiveTests/SecretiveTests.swift b/Sources/SecretiveTests/SecretiveTests.swift deleted file mode 100644 index d596a41..0000000 --- a/Sources/SecretiveTests/SecretiveTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import XCTest -@testable import Secretive - -class SecretiveTests: XCTestCase { - - override func setUp() { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - -} diff --git a/Sources/SecretiveUpdater/Info.plist b/Sources/SecretiveUpdater/Info.plist new file mode 100644 index 0000000..c123a5d --- /dev/null +++ b/Sources/SecretiveUpdater/Info.plist @@ -0,0 +1,11 @@ + + + + + XPCService + + ServiceType + Application + + + diff --git a/Sources/SecretiveUpdater/InternetAccessPolicy.plist b/Sources/SecretiveUpdater/InternetAccessPolicy.plist new file mode 100644 index 0000000..37307a7 --- /dev/null +++ b/Sources/SecretiveUpdater/InternetAccessPolicy.plist @@ -0,0 +1,31 @@ + + + + + ApplicationDescription + Secretive is an app for storing and managing SSH keys in the Secure Enclave + DeveloperName + Max Goedjen + Website + https://github.com/maxgoedjen/secretive + Connections + + + IsIncoming + + Host + api.github.com + NetworkProtocol + TCP + Port + 443 + Purpose + Secretive checks GitHub for new versions and security updates. + DenyConsequences + If you deny these connections, you will not be notified about new versions and critical security updates. + + + Services + + + diff --git a/Sources/SecretiveUpdater/SecretiveUpdater.swift b/Sources/SecretiveUpdater/SecretiveUpdater.swift new file mode 100644 index 0000000..998fd31 --- /dev/null +++ b/Sources/SecretiveUpdater/SecretiveUpdater.swift @@ -0,0 +1,17 @@ +import Foundation +import OSLog +import XPCWrappers +import Brief + +final class SecretiveUpdater: NSObject, XPCProtocol { + + enum Constants { + static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")! + } + + func process(_: Data) async throws -> [Release] { + let (data, _) = try await URLSession.shared.data(from: Constants.updateURL) + return try JSONDecoder().decode([Release].self, from: data) + } + +} diff --git a/Sources/SecretiveUpdater/main.swift b/Sources/SecretiveUpdater/main.swift new file mode 100644 index 0000000..f3676f5 --- /dev/null +++ b/Sources/SecretiveUpdater/main.swift @@ -0,0 +1,7 @@ +import Foundation +import XPCWrappers + +let delegate = XPCServiceDelegate(exportedObject: SecretiveUpdater()) +let listener = NSXPCListener.service() +listener.delegate = delegate +listener.resume()