├── .github ├── FUNDING.yml ├── readme │ ├── app-dark.png │ ├── app-light.png │ ├── apple_watch_auth_mac.png │ ├── apple_watch_auth_watch.png │ ├── apple_watch_system_prefs.png │ ├── localize_add.png │ ├── localize_sidebar.png │ ├── localize_translate.png │ ├── notification.png │ └── touchid.png ├── scripts │ └── signing.sh └── workflows │ ├── add-to-project.yml │ ├── nightly.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── APP_CONFIG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DESIGN.md ├── FAQ.md ├── LICENSE ├── LOCALIZING.md ├── README.md ├── SECURITY.md └── Sources ├── Config ├── Config.xcconfig └── Secretive.xctestplan ├── Packages ├── Package.swift ├── Sources │ ├── Brief │ │ ├── Documentation.docc │ │ │ └── Documentation.md │ │ ├── Release.swift │ │ ├── SemVer.swift │ │ ├── Updater.swift │ │ └── UpdaterProtocol.swift │ ├── SecretAgentKit │ │ ├── Agent.swift │ │ ├── Documentation.docc │ │ │ └── Documentation.md │ │ ├── FileHandleProtocols.swift │ │ ├── SSHAgentProtocol.swift │ │ ├── Sendability.swift │ │ ├── SigningRequestTracer.swift │ │ ├── SigningWitness.swift │ │ └── SocketController.swift │ ├── SecretAgentKitHeaders │ │ ├── Stub.swift │ │ ├── include │ │ │ └── SecretAgentKit.h │ │ └── module.modulemap │ ├── SecretKit │ │ ├── Documentation.docc │ │ │ └── Documentation.md │ │ ├── Erasers │ │ │ ├── AnySecret.swift │ │ │ └── AnySecretStore.swift │ │ ├── KeychainTypes.swift │ │ ├── OpenSSH │ │ │ ├── OpenSSHCertificateHandler.swift │ │ │ ├── OpenSSHKeyWriter.swift │ │ │ └── OpenSSHReader.swift │ │ ├── PublicKeyStandinFileController.swift │ │ ├── SecretStoreList.swift │ │ └── Types │ │ │ ├── PersistedAuthenticationContext.swift │ │ │ ├── Secret.swift │ │ │ ├── SecretStore.swift │ │ │ └── SigningRequestProvenance.swift │ ├── SecureEnclaveSecretKit │ │ ├── Documentation.docc │ │ │ ├── Documentation.md │ │ │ └── SecureEnclave.md │ │ ├── SecureEnclave.swift │ │ ├── SecureEnclaveSecret.swift │ │ └── SecureEnclaveStore.swift │ └── SmartCardSecretKit │ │ ├── Documentation.docc │ │ ├── Documentation.md │ │ └── SmartCard.md │ │ ├── SmartCard.swift │ │ ├── SmartCardSecret.swift │ │ └── SmartCardStore.swift └── Tests │ ├── BriefTests │ ├── ReleaseParsingTests.swift │ └── SemVerTests.swift │ ├── SecretAgentKitTests │ ├── AgentTests.swift │ ├── StubFileHandleReader.swift │ ├── StubFileHandleWriter.swift │ ├── StubStore.swift │ └── StubWitness.swift │ └── SecretKitTests │ ├── AnySecretTests.swift │ ├── OpenSSHReaderTests.swift │ └── OpenSSHWriterTests.swift ├── SecretAgent ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Mac Icon.png │ │ └── Mac Icon@0.25x.png │ └── Contents.json ├── Base.lproj │ └── Main.storyboard ├── Info.plist ├── InternetAccessPolicy.plist ├── Notifier.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── SecretAgent.entitlements ├── Secretive.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── SecretAgent.xcscheme │ └── Secretive.xcscheme ├── Secretive ├── App.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Mac Icon.png │ │ └── Mac Icon@0.25x.png │ └── Contents.json ├── Controllers │ ├── AgentStatusChecker.swift │ ├── ApplicationDirectoryController.swift │ ├── JustUpdatedChecker.swift │ ├── LaunchAgentController.swift │ └── ShellConfigurationController.swift ├── Credits.rtf ├── Helpers │ └── BundleIDs.swift ├── Info.plist ├── InternetAccessPolicy.plist ├── Localizable.xcstrings ├── Preview Content │ ├── Preview Assets.xcassets │ │ └── Contents.json │ ├── PreviewAgentStatusChecker.swift │ ├── PreviewStore.swift │ └── PreviewUpdater.swift ├── Secretive.entitlements └── Views │ ├── ContentView.swift │ ├── CopyableView.swift │ ├── CreateSecretView.swift │ ├── DeleteSecretView.swift │ ├── EmptyStoreView.swift │ ├── NoStoresView.swift │ ├── RenameSecretView.swift │ ├── SecretDetailView.swift │ ├── SecretListItemView.swift │ ├── SetupView.swift │ ├── StoreListView.swift │ ├── ToolbarButtonStyle.swift │ └── UpdateView.swift └── SecretiveTests ├── Info.plist └── SecretiveTests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: maxgoedjen 2 | -------------------------------------------------------------------------------- /.github/readme/app-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/app-dark.png -------------------------------------------------------------------------------- /.github/readme/app-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/app-light.png -------------------------------------------------------------------------------- /.github/readme/apple_watch_auth_mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/apple_watch_auth_mac.png -------------------------------------------------------------------------------- /.github/readme/apple_watch_auth_watch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/apple_watch_auth_watch.png -------------------------------------------------------------------------------- /.github/readme/apple_watch_system_prefs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/apple_watch_system_prefs.png -------------------------------------------------------------------------------- /.github/readme/localize_add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/localize_add.png -------------------------------------------------------------------------------- /.github/readme/localize_sidebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/localize_sidebar.png -------------------------------------------------------------------------------- /.github/readme/localize_translate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/localize_translate.png -------------------------------------------------------------------------------- /.github/readme/notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/notification.png -------------------------------------------------------------------------------- /.github/readme/touchid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/.github/readme/touchid.png -------------------------------------------------------------------------------- /.github/scripts/signing.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Import certificate and private key 4 | echo $SIGNING_DATA | base64 -d -o Signing.p12 5 | security create-keychain -p ci ci.keychain 6 | security default-keychain -s ci.keychain 7 | security list-keychains -s ci.keychain 8 | security import ./Signing.p12 -k ci.keychain -P $SIGNING_PASSWORD -A 9 | security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k ci ci.keychain 10 | 11 | # Import Profiles 12 | mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles 13 | echo $HOST_PROFILE_DATA | base64 -d -o Host.provisionprofile 14 | HOST_UUID=`grep UUID -A1 -a Host.provisionprofile | grep -io "[-A-F0-9]\{36\}"` 15 | cp Host.provisionprofile ~/Library/MobileDevice/Provisioning\ Profiles/$HOST_UUID.provisionprofile 16 | echo $AGENT_PROFILE_DATA | base64 -d -o Agent.provisionprofile 17 | AGENT_UUID=`grep UUID -A1 -a Agent.provisionprofile | grep -io "[-A-F0-9]\{36\}"` 18 | cp Agent.provisionprofile ~/Library/MobileDevice/Provisioning\ Profiles/$AGENT_UUID.provisionprofile 19 | 20 | # Create directories for ASC key 21 | mkdir ~/.private_keys 22 | echo -n "$APPLE_API_KEY_DATA" > ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 23 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Add bugs to bugs project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | add-to-project: 10 | name: Add issue to project 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/add-to-project@v1.0.1 14 | with: 15 | project-url: https://github.com/users/maxgoedjen/projects/1 16 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 17 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly 2 | 3 | on: 4 | schedule: 5 | - cron: "0 8 * * *" 6 | jobs: 7 | build: 8 | # runs-on: macOS-latest 9 | runs-on: macos-14 10 | timeout-minutes: 10 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Setup Signing 14 | env: 15 | SIGNING_DATA: ${{ secrets.SIGNING_DATA }} 16 | SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} 17 | HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }} 18 | AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }} 19 | APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }} 20 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} 21 | run: ./.github/scripts/signing.sh 22 | - name: Set Environment 23 | run: sudo xcrun xcode-select -s /Applications/Xcode_15.4.app 24 | - name: Update Build Number 25 | env: 26 | RUN_ID: ${{ github.run_id }} 27 | run: | 28 | sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig 29 | sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig 30 | sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf 31 | - name: Build 32 | run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive 33 | - name: Create ZIPs 34 | run: | 35 | ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip 36 | ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip 37 | - name: Notarize 38 | env: 39 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} 40 | APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} 41 | 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 42 | - name: Document SHAs 43 | run: | 44 | echo "sha-512:" 45 | shasum -a 512 Secretive.zip 46 | shasum -a 512 Archive.zip 47 | echo "sha-256:" 48 | shasum -a 256 Secretive.zip 49 | shasum -a 256 Archive.zip 50 | - name: Upload App to Artifacts 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: Secretive.zip 54 | path: Secretive.zip 55 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | jobs: 8 | test: 9 | # runs-on: macOS-latest 10 | runs-on: macos-14 11 | timeout-minutes: 10 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Setup Signing 15 | env: 16 | SIGNING_DATA: ${{ secrets.SIGNING_DATA }} 17 | SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} 18 | HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }} 19 | AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }} 20 | APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }} 21 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} 22 | run: ./.github/scripts/signing.sh 23 | - name: Set Environment 24 | run: sudo xcrun xcode-select -s /Applications/Xcode_15.4.app 25 | - name: Test 26 | run: | 27 | pushd Sources/Packages 28 | swift test 29 | popd 30 | build: 31 | # runs-on: macOS-latest 32 | runs-on: macos-14 33 | timeout-minutes: 10 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Setup Signing 37 | env: 38 | SIGNING_DATA: ${{ secrets.SIGNING_DATA }} 39 | SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} 40 | HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }} 41 | AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }} 42 | APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }} 43 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} 44 | run: ./.github/scripts/signing.sh 45 | - name: Set Environment 46 | run: sudo xcrun xcode-select -s /Applications/Xcode_15.4.app 47 | - name: Update Build Number 48 | env: 49 | TAG_NAME: ${{ github.ref }} 50 | RUN_ID: ${{ github.run_id }} 51 | run: | 52 | export CLEAN_TAG=$(echo $TAG_NAME | sed -e 's/refs\/tags\/v//') 53 | sed -i '' -e "s/GITHUB_CI_VERSION/$CLEAN_TAG/g" Sources/Config/Config.xcconfig 54 | sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig 55 | sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf 56 | - name: Build 57 | run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive 58 | - name: Create ZIPs 59 | run: | 60 | ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip 61 | ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip 62 | - name: Notarize 63 | env: 64 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} 65 | APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} 66 | 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 67 | - name: Document SHAs 68 | run: | 69 | echo "sha-512:" 70 | shasum -a 512 Secretive.zip 71 | shasum -a 512 Archive.zip 72 | echo "sha-256:" 73 | shasum -a 256 Secretive.zip 74 | shasum -a 256 Archive.zip 75 | - name: Create Release 76 | id: create_release 77 | uses: actions/create-release@v1 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | with: 81 | tag_name: ${{ github.ref }} 82 | release_name: ${{ github.ref }} 83 | body: | 84 | Update description 85 | 86 | ## Features 87 | 88 | 89 | ## Fixes 90 | 91 | 92 | ## Minimum macOS Version 93 | 94 | 95 | ## Build 96 | https://github.com/maxgoedjen/secretive/actions/runs/${{ github.run_id }} 97 | draft: true 98 | prerelease: false 99 | - name: Upload App to Release 100 | id: upload-release-asset-app 101 | uses: actions/upload-release-asset@v1.0.1 102 | env: 103 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 104 | with: 105 | upload_url: ${{ steps.create_release.outputs.upload_url }} 106 | asset_path: ./Secretive.zip 107 | asset_name: Secretive.zip 108 | asset_content_type: application/zip 109 | - name: Upload App to Artifacts 110 | uses: actions/upload-artifact@v4 111 | with: 112 | name: Secretive.zip 113 | path: Secretive.zip 114 | - name: Upload Archive to Artifacts 115 | uses: actions/upload-artifact@v4 116 | with: 117 | name: Xcode_Archive.zip 118 | path: Archive.zip 119 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | test: 6 | # runs-on: macOS-latest 7 | runs-on: macos-14 8 | timeout-minutes: 10 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set Environment 12 | run: sudo xcrun xcode-select -s /Applications/Xcode_15.4.app 13 | - name: Test 14 | run: | 15 | pushd Sources/Packages 16 | swift test 17 | popd 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # Build script products 93 | Archive.xcarchive 94 | .DS_Store 95 | contents.xcworkspacedata 96 | -------------------------------------------------------------------------------- /APP_CONFIG.md: -------------------------------------------------------------------------------- 1 | # Setting up Third Party Apps FAQ 2 | 3 | ## Tower 4 | 5 | Tower provides [instructions](https://www.git-tower.com/help/mac/integration/environment). 6 | 7 | ## GitHub Desktop 8 | 9 | Should just work, no configuration needed 10 | 11 | ## Fork 12 | 13 | Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow). 14 | 15 | ``` 16 | Host * 17 | IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh 18 | ``` 19 | 20 | ## VS Code 21 | 22 | Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow). 23 | 24 | ``` 25 | Host * 26 | IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh 27 | ``` 28 | 29 | ## nushell 30 | 31 | Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow). 32 | 33 | ``` 34 | Host * 35 | IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh 36 | ``` 37 | 38 | ## Cyberduck 39 | 40 | Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist` 41 | 42 | ``` 43 | 44 | 45 | 46 | 47 | Label 48 | link-ssh-auth-sock 49 | ProgramArguments 50 | 51 | /bin/sh 52 | -c 53 | /bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK 54 | 55 | RunAtLoad 56 | 57 | 58 | 59 | ``` 60 | 61 | Log out and log in again before launching Cyberduck. 62 | 63 | ## Mountain Duck 64 | 65 | Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist` 66 | 67 | ``` 68 | 69 | 70 | 71 | 72 | Label 73 | link-ssh-auth-sock 74 | ProgramArguments 75 | 76 | /bin/sh 77 | -c 78 | /bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK 79 | 80 | RunAtLoad 81 | 82 | 83 | 84 | ``` 85 | 86 | Log out and log in again before launching Mountain Duck. 87 | 88 | ## GitKraken 89 | 90 | Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist` 91 | 92 | ``` 93 | 94 | 95 | 96 | 97 | Label 98 | link-ssh-auth-sock 99 | ProgramArguments 100 | 101 | /bin/sh 102 | -c 103 | /bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK 104 | 105 | RunAtLoad 106 | 107 | 108 | 109 | ``` 110 | 111 | Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH) 112 | 113 | # The app I use isn't listed here! 114 | 115 | If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome. 116 | 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. 117 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at max.goedjen@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Secretive 2 | 3 | Thanks for your interest in contributing to Secretive! Before you contribute, there are a few things I'd like to lay out. 4 | 5 | ## Security 6 | 7 | Security is obviously paramount for a project like Secretive. As such, any contributions that compromise the security or auditabilty of the project will be rejected. 8 | 9 | ### Dependencies 10 | 11 | Secretive is designed to be easily auditable by people who are considering using it. In keeping with this, Secretive has no third party dependencies, and any contributions which bring in new dependencies will be rejected. 12 | 13 | ## Code of Conduct 14 | 15 | All contributors must abide by the [Code of Conduct](CODE_OF_CONDUCT.md) 16 | 17 | ## Localization 18 | 19 | If you'd like to contribute a translation, please see [Localizing](LOCALIZING.md) to get started. 20 | 21 | ## Credits 22 | 23 | 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). 24 | 25 | ## Collaborator Status 26 | 27 | I will not grant collaborator access to any contributors for this repository. This is basically just because collaborators [can accesss the secrets Secretive uses for the signing credentials stored in the repository](https://docs.github.com/en/actions/reference/encrypted-secrets#accessing-your-secrets). 28 | 29 | ## Secretive is Opinionated 30 | 31 | I'm releasing Secretive as open source so that other people can use it and audit it, feeling comfortable in knowing that the source is available so they can see what it's doing. I have a pretty strong idea of what I'd like this project to look like, and I may respectfully decline contributions that don't line up with that vision. If you'd like to propose a change before implementing, please feel free to [Open an Issue with the proposed tag](https://github.com/maxgoedjen/secretive/issues/new?labels=proposed). 32 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | # Design 2 | 3 | The art assets for the App Icon and GitHub image are located on [Sketch Cloud](https://www.sketch.com/s/574333cd-8ceb-40e1-a6d9-189da3f1e5dd). -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ### How do I import my current SSH keys, or export my Secretive Keys? 4 | 5 | The secure enclave doesn't allow import or export of private keys. For any new computer, you should just create a new set of keys. If you're using a smart card, you _might_ be able to export your private key from the vendor's software. 6 | 7 | ### Secretive doesn't work with my git client/app 8 | 9 | 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). 10 | 11 | ### Secretive isn't working for me 12 | 13 | Please run `ssh -Tv git@github.com` in your terminal and paste the output in a [new GitHub issue](https://github.com/maxgoedjen/secretive/issues/new) with a description of your issue. 14 | 15 | ### Secretive was working for me, but now it has stopped 16 | 17 | Try running the "Setup Secretive" process by clicking on "Help", then "Setup Secretive." If that doesn't work, follow the process above. 18 | 19 | ### Secretive prompts me to type my password instead of using my Apple Watch 20 | 21 | 1) Make sure you have enabled "Use your Apple Watch to unlock apps and your Mac" in System Preferences --> Security & Privacy: 22 | 23 | ![System Preferences Setting](.github/readme/apple_watch_system_prefs.png) 24 | 25 | 2) Ensure that unlocking your Mac with Apple Watch is working (lock and unlock at least once) 26 | 3) Now you should get prompted on the watch when your key is accessed. Double click the side button to approve: 27 | 28 | ![Apple Watch Prompt](.github/readme/apple_watch_auth_mac.png) 29 | ![Apple Watch Prompt](.github/readme/apple_watch_auth_watch.png) 30 | 31 | ### How do I tell SSH to use a specific key? 32 | 33 | Beginning with Secretive 2.2, every secret has an automatically generated public key file representation on disk, and the path to it is listed under "Public Key Path" in Secretive. You can specify that you want to use that key in your `~/.ssh/config`. [This ServerFault answer](https://serverfault.com/a/295771) has more details on setting that up. 34 | 35 | ### How can I generate an RSA key? 36 | 37 | The Mac's Secure Enclave only supports 256-bit EC keys, so inherently Secretive cannot support generating RSA keys. 38 | 39 | ### Can I use Secretive for SSH Agent Forwarding? 40 | 41 | Yes, you can! Once you've set up Secretive, just add `ForwardAgent yes` to the hosts you want to forward to in your SSH config file. Afterwards, any use of one of your SSH keys on the remote host must be authenticated through Secretive. 42 | 43 | ### Why should I trust you? 44 | 45 | You shouldn't, for a piece of software like this. Secretive, by design, has an auditable build process. Each build has a fully auditable build log, showing the source it was built from and a SHA of the build product. You can check the SHA of the zip you download against the SHA output in the build log (which is linked in the About window). 46 | 47 | ### I want to build Secretive from source 48 | 49 | Awesome! Just bear in mind that because an app only has access to the keychain items that it created, if you have secrets that you created with the prebuilt version of Secretive, you'll be unable to access them using your own custom build (since you'll have changed the bundled ID). 50 | 51 | ### What's this network request to GitHub? 52 | 53 | Secretive checks in with GitHub's releases API to check if there's a new version of Secretive available. You can audit the source code for this feature [here](https://github.com/maxgoedjen/secretive/blob/main/Sources/Packages/Sources/Brief/Updater.swift). 54 | 55 | ### How do I uninstall Secretive? 56 | 57 | Drag Secretive.app to the trash and remove `~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent`. `SecretAgent` may continue running until you quit it or reboot. 58 | 59 | ### I have a security issue 60 | 61 | Please contact [max.goedjen@gmail.com](mailto:max.goedjen@gmail.com) with a subject containing "SECRETIVE SECURITY" immediately with details, and I'll address the issue and credit you ASAP. 62 | 63 | ### I have a non-security related bug 64 | 65 | Please file a [GitHub issue](https://github.com/maxgoedjen/secretive/issues/new) for it. I will not provide email support with the exception of the critical security issues mentioned above. 66 | 67 | ### I want to contribute to Secretive 68 | 69 | Sweet! Please check out the [contributing guidelines](CONTRIBUTING.md) and go from there. 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Max Goedjen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LOCALIZING.md: -------------------------------------------------------------------------------- 1 | # Localizing Secretive 2 | 3 | If you speak another language, and would like to help translate Secretive to support that language, we'd love your help! 4 | 5 | ## Getting Started 6 | 7 | ### Download Xcode 8 | 9 | Download the latest version of Xcode (at minimum, Xcode 15) from [Apple](http://developer.apple.com/download/applications/). 10 | 11 | ### Clone Secretive 12 | 13 | Clone Secretive using [these instructions from GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository). 14 | 15 | ### Open Secretive 16 | 17 | Open [Sources/Secretive.xcodeproj](Sources/Secretive.xcodeproj) in Xcode. 18 | 19 | ### Translate 20 | 21 | Navigate to [Secretive/Localizable](Sources/Secretive/Localizable.xcstrings). 22 | 23 | Screenshot of Xcode navigating to the Localizable file 24 | 25 | 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. 26 | 27 | Screenshot of Xcode adding a new language 28 | 29 | Start translating! You'll see a list of english phrases, and a space to add a translation of your language. 30 | 31 | ### Create a Pull Request 32 | 33 | Push your changes and open a pull request. 34 | 35 | ### Questions 36 | 37 | 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! 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Secretive ![Test](https://github.com/maxgoedjen/secretive/workflows/Test/badge.svg) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg) 2 | 3 | 4 | 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. 5 | 6 | 7 | 8 | Screenshot of Secretive 9 | 10 | 11 | 12 | ## Why? 13 | 14 | ### Safer Storage 15 | 16 | The most common setup for SSH keys is just keeping them on disk, guarded by proper permissions. This is fine in most cases, but it's not super hard for malicious users or malware to copy your private key. If you store your keys in the Secure Enclave, it's impossible to export them, by design. 17 | 18 | ### Access Control 19 | 20 | If your Mac has a Secure Enclave, it also has support for strong access controls like Touch ID, or authentication with Apple Watch. You can configure your keys so that they require Touch ID (or Watch) authentication before they're accessed. 21 | 22 | Screenshot of Secretive authenticating with Touch ID 23 | 24 | ### Notifications 25 | 26 | Secretive also notifies you whenever your keys are accessed, so you're never caught off guard. 27 | 28 | Screenshot of Secretive notifying the user 29 | 30 | ### Support for Smart Cards Too! 31 | 32 | For Macs without Secure Enclaves, you can configure a Smart Card (such as a YubiKey) and use it for signing as well. 33 | 34 | ## Getting Started 35 | 36 | ### Installation 37 | 38 | #### Direct Download 39 | 40 | You can download the latest release over on the [Releases Page](https://github.com/maxgoedjen/secretive/releases) 41 | 42 | #### Using Homebrew 43 | 44 | brew install secretive 45 | 46 | ### FAQ 47 | 48 | There's a [FAQ here](FAQ.md). 49 | 50 | ### Auditable Build Process 51 | 52 | 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. 53 | 54 | ### A Note Around Code Signing and Keychains 55 | 56 | While Secretive uses the Secure Enclave for key storage, it still relies on Keychain APIs to access them. Keychain restricts reads of keys to the app (and specifically, the bundle ID) that created them. If you build Secretive from source, make sure you are consistent in which bundle ID you use so that the Keychain is able to locate your keys. 57 | 58 | ### Backups and Transfers to New Machines 59 | 60 | Because secrets in the Secure Enclave are not exportable, they are not able to be backed up, and you will not be able to transfer them to a new machine. If you get a new Mac, just create a new set of secrets specific to that Mac. 61 | 62 | ## Security 63 | 64 | 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." 65 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com with the subject containing "SECRETIVE SECURITY." 10 | -------------------------------------------------------------------------------- /Sources/Config/Config.xcconfig: -------------------------------------------------------------------------------- 1 | CI_VERSION = GITHUB_CI_VERSION 2 | CI_BUILD_NUMBER = GITHUB_BUILD_NUMBER 3 | -------------------------------------------------------------------------------- /Sources/Config/Secretive.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "5896AE5A-6D5A-48D3-837B-668B646A3273", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | 13 | }, 14 | "testTargets" : [ 15 | { 16 | "enabled" : false, 17 | "parallelizable" : true, 18 | "target" : { 19 | "containerPath" : "container:Secretive.xcodeproj", 20 | "identifier" : "50617D9323FCE48E0099B055", 21 | "name" : "SecretiveTests" 22 | } 23 | } 24 | ], 25 | "version" : 1 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Packages/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SecretivePackages", 8 | platforms: [ 9 | .macOS(.v12) 10 | ], 11 | products: [ 12 | .library( 13 | name: "SecretKit", 14 | targets: ["SecretKit"]), 15 | .library( 16 | name: "SecureEnclaveSecretKit", 17 | targets: ["SecureEnclaveSecretKit"]), 18 | .library( 19 | name: "SmartCardSecretKit", 20 | targets: ["SmartCardSecretKit"]), 21 | .library( 22 | name: "SecretAgentKit", 23 | targets: ["SecretAgentKit"]), 24 | .library( 25 | name: "SecretAgentKitHeaders", 26 | targets: ["SecretAgentKitHeaders"]), 27 | .library( 28 | name: "Brief", 29 | targets: ["Brief"]), 30 | ], 31 | dependencies: [ 32 | ], 33 | targets: [ 34 | .target( 35 | name: "SecretKit", 36 | dependencies: [], 37 | swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] 38 | ), 39 | .testTarget( 40 | name: "SecretKitTests", 41 | dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"], 42 | swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] 43 | ), 44 | .target( 45 | name: "SecureEnclaveSecretKit", 46 | dependencies: ["SecretKit"], 47 | swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] 48 | ), 49 | .target( 50 | name: "SmartCardSecretKit", 51 | dependencies: ["SecretKit"], 52 | swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] 53 | ), 54 | .target( 55 | name: "SecretAgentKit", 56 | dependencies: ["SecretKit", "SecretAgentKitHeaders"], 57 | swiftSettings: [.unsafeFlags(["-warnings-as-errors"])] 58 | ), 59 | .systemLibrary( 60 | name: "SecretAgentKitHeaders" 61 | ), 62 | .testTarget( 63 | name: "SecretAgentKitTests", 64 | dependencies: ["SecretAgentKit"]) 65 | , 66 | .target( 67 | name: "Brief", 68 | dependencies: [] 69 | ), 70 | .testTarget( 71 | name: "BriefTests", 72 | dependencies: ["Brief"] 73 | ), 74 | ] 75 | ) 76 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/Brief/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``Brief`` 2 | 3 | Brief is a collection of protocols and concrete implmentation describing updates. 4 | 5 | ## Topics 6 | 7 | ### Versioning 8 | 9 | - ``SemVer`` 10 | - ``Release`` 11 | 12 | ### Updater 13 | 14 | - ``UpdaterProtocol`` 15 | - ``Updater`` 16 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/Brief/Release.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A release is a representation of a downloadable update. 4 | public struct Release: Codable { 5 | 6 | /// The user-facing name of the release. Typically "Secretive 1.2.3" 7 | public let name: String 8 | 9 | /// A boolean describing whether or not the release is a prerelase build. 10 | public let prerelease: Bool 11 | 12 | /// A URL pointing to the HTML page for the release. 13 | public let html_url: URL 14 | 15 | /// A user-facing description of the contents of the update. 16 | public let body: String 17 | 18 | /// Initializes a Release. 19 | /// - Parameters: 20 | /// - name: The user-facing name of the release. 21 | /// - prerelease: A boolean describing whether or not the release is a prerelase build. 22 | /// - html_url: A URL pointing to the HTML page for the release. 23 | /// - body: A user-facing description of the contents of the update. 24 | public init(name: String, prerelease: Bool, html_url: URL, body: String) { 25 | self.name = name 26 | self.prerelease = prerelease 27 | self.html_url = html_url 28 | self.body = body 29 | } 30 | 31 | } 32 | 33 | extension Release: Identifiable { 34 | 35 | public var id: String { 36 | html_url.absoluteString 37 | } 38 | 39 | } 40 | 41 | extension Release: Comparable { 42 | 43 | public static func < (lhs: Release, rhs: Release) -> Bool { 44 | lhs.version < rhs.version 45 | } 46 | 47 | } 48 | 49 | extension Release { 50 | 51 | /// A boolean describing whether or not the release contains critical security content. 52 | /// - Note: this is determined by the presence of the phrase "Critical Security Update" in the ``body``. 53 | /// - Warning: If this property is true, the user will not be able to dismiss UI or reminders associated with the update. 54 | public var critical: Bool { 55 | body.contains(Constants.securityContent) 56 | } 57 | 58 | /// A ``SemVer`` representation of the version number of the release. 59 | public var version: SemVer { 60 | SemVer(name) 61 | } 62 | 63 | /// The minimum macOS version required to run the update. 64 | public var minimumOSVersion: SemVer { 65 | guard let range = body.range(of: "Minimum macOS Version"), 66 | let numberStart = body.rangeOfCharacter(from: CharacterSet.decimalDigits, options: [], range: range.upperBound.. Bool { 32 | for (latest, current) in zip(lhs.versionNumbers, rhs.versionNumbers) { 33 | if latest < current { 34 | return true 35 | } else if latest > current { 36 | return false 37 | } 38 | } 39 | return false 40 | } 41 | 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/Brief/Updater.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | /// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version. 5 | public final class Updater: ObservableObject, UpdaterProtocol { 6 | 7 | @Published public var update: Release? 8 | public let testBuild: Bool 9 | 10 | /// The current OS version. 11 | private let osVersion: SemVer 12 | /// The current version of the app that is running. 13 | private let currentVersion: SemVer 14 | 15 | /// Initializes an Updater. 16 | /// - Parameters: 17 | /// - checkOnLaunch: A boolean describing whether the Updater should check for available updates on launch. 18 | /// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour. 19 | /// - osVersion: The current OS version. 20 | /// - currentVersion: The current version of the app that is running. 21 | 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")) { 22 | self.osVersion = osVersion 23 | self.currentVersion = currentVersion 24 | testBuild = currentVersion == SemVer("0.0.0") 25 | if checkOnLaunch { 26 | // Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet. 27 | checkForUpdates() 28 | } 29 | let timer = Timer.scheduledTimer(withTimeInterval: checkFrequency, repeats: true) { _ in 30 | self.checkForUpdates() 31 | } 32 | timer.tolerance = 60*60 33 | } 34 | 35 | /// Manually trigger an update check. 36 | public func checkForUpdates() { 37 | URLSession.shared.dataTask(with: Constants.updateURL) { data, _, _ in 38 | guard let data = data else { return } 39 | guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return } 40 | self.evaluate(releases: releases) 41 | }.resume() 42 | } 43 | 44 | /// Ignores a specified release. `update` will be nil if the user has ignored the latest available release. 45 | /// - Parameter release: The release to ignore. 46 | public func ignore(release: Release) { 47 | guard !release.critical else { return } 48 | defaults.set(true, forKey: release.name) 49 | DispatchQueue.main.async { 50 | self.update = nil 51 | } 52 | } 53 | 54 | } 55 | 56 | extension Updater { 57 | 58 | /// Evaluates the available downloadable releases, and selects the newest non-prerelease release that the user is able to run. 59 | /// - Parameter releases: An array of ``Release`` objects. 60 | func evaluate(releases: [Release]) { 61 | guard let release = releases 62 | .sorted() 63 | .reversed() 64 | .filter({ !$0.prerelease }) 65 | .first(where: { $0.minimumOSVersion <= osVersion }) else { return } 66 | guard !userIgnored(release: release) else { return } 67 | guard !release.prerelease else { return } 68 | let latestVersion = SemVer(release.name) 69 | if latestVersion > currentVersion { 70 | DispatchQueue.main.async { 71 | self.update = release 72 | } 73 | } 74 | } 75 | 76 | /// Checks whether the user has ignored a release. 77 | /// - Parameter release: The release to check. 78 | /// - Returns: A boolean describing whether the user has ignored the release. Will always be false if the release is critical. 79 | func userIgnored(release: Release) -> Bool { 80 | guard !release.critical else { return false } 81 | return defaults.bool(forKey: release.name) 82 | } 83 | 84 | /// The user defaults used to store user ignore state. 85 | var defaults: UserDefaults { 86 | UserDefaults(suiteName: "com.maxgoedjen.Secretive.updater.ignorelist")! 87 | } 88 | 89 | } 90 | 91 | extension Updater { 92 | 93 | enum Constants { 94 | static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")! 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/Brief/UpdaterProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | /// A protocol for retreiving the latest available version of an app. 5 | public protocol UpdaterProtocol: ObservableObject { 6 | 7 | /// The latest update 8 | var update: Release? { get } 9 | /// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build) 10 | var testBuild: Bool { get } 11 | 12 | } 13 | 14 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKit/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``SecretAgentKit`` 2 | 3 | SecretAgentKit is a collection of types that allow SecretAgent to conform to the SSH agent protocol. 4 | 5 | ## Topics 6 | 7 | ### Agent 8 | 9 | - ``Agent`` 10 | 11 | ### Protocol 12 | 13 | - ``SSHAgent`` 14 | 15 | ### Request Notification 16 | 17 | - ``SigningWitness`` 18 | 19 | ### Socket Operations 20 | 21 | - ``SocketController`` 22 | - ``FileHandleReader`` 23 | - ``FileHandleWriter`` 24 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKit/FileHandleProtocols.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol abstraction of the reading aspects of FileHandle. 4 | public protocol FileHandleReader: Sendable { 5 | 6 | /// Gets data that is available for reading. 7 | var availableData: Data { get } 8 | /// A file descriptor of the handle. 9 | var fileDescriptor: Int32 { get } 10 | /// The process ID of the process coonnected to the other end of the FileHandle. 11 | var pidOfConnectedProcess: Int32 { get } 12 | 13 | } 14 | 15 | /// Protocol abstraction of the writing aspects of FileHandle. 16 | public protocol FileHandleWriter: Sendable { 17 | 18 | /// Writes data to the handle. 19 | func write(_ data: Data) 20 | 21 | } 22 | 23 | extension FileHandle: FileHandleReader, FileHandleWriter { 24 | 25 | public var pidOfConnectedProcess: Int32 { 26 | let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1) 27 | var len = socklen_t(MemoryLayout.size) 28 | getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len) 29 | return pidPointer.load(as: Int32.self) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKit/SSHAgentProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1 4 | public enum SSHAgent {} 5 | 6 | extension SSHAgent { 7 | 8 | /// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1 9 | public enum RequestType: UInt8, CustomDebugStringConvertible { 10 | 11 | case requestIdentities = 11 12 | case signRequest = 13 13 | 14 | public var debugDescription: String { 15 | switch self { 16 | case .requestIdentities: 17 | return "RequestIdentities" 18 | case .signRequest: 19 | return "SignRequest" 20 | } 21 | } 22 | } 23 | 24 | /// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1 25 | public enum ResponseType: UInt8, CustomDebugStringConvertible { 26 | 27 | case agentFailure = 5 28 | case agentSuccess = 6 29 | case agentIdentitiesAnswer = 12 30 | case agentSignResponse = 14 31 | 32 | public var debugDescription: String { 33 | switch self { 34 | case .agentFailure: 35 | return "AgentFailure" 36 | case .agentSuccess: 37 | return "AgentSuccess" 38 | case .agentIdentitiesAnswer: 39 | return "AgentIdentitiesAnswer" 40 | case .agentSignResponse: 41 | return "AgentSignResponse" 42 | } 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKit/Sendability.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct UncheckedSendable: @unchecked Sendable { 4 | 5 | let value: T 6 | 7 | init(_ value: T) { 8 | self.value = value 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKit/SigningRequestTracer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | import Security 4 | import SecretKit 5 | import SecretAgentKitHeaders 6 | 7 | /// An object responsible for generating ``SecretKit.SigningRequestProvenance`` objects. 8 | struct SigningRequestTracer { 9 | } 10 | 11 | extension SigningRequestTracer { 12 | 13 | /// Generates a ``SecretKit.SigningRequestProvenance`` from a ``FileHandleReader``. 14 | /// - Parameter fileHandleReader: The reader involved in processing the request. 15 | /// - Returns: A ``SecretKit.SigningRequestProvenance`` describing the origin of the request. 16 | func provenance(from fileHandleReader: FileHandleReader) -> SigningRequestProvenance { 17 | let firstInfo = process(from: fileHandleReader.pidOfConnectedProcess) 18 | 19 | var provenance = SigningRequestProvenance(root: firstInfo) 20 | while NSRunningApplication(processIdentifier: provenance.origin.pid) == nil && provenance.origin.parentPID != nil { 21 | provenance.chain.append(process(from: provenance.origin.parentPID!)) 22 | } 23 | return provenance 24 | } 25 | 26 | /// Generates a `kinfo_proc` representation of the provided process ID. 27 | /// - Parameter pid: The process ID to look up. 28 | /// - Returns: a `kinfo_proc` struct describing the process ID. 29 | func pidAndNameInfo(from pid: Int32) -> kinfo_proc { 30 | var len = MemoryLayout.size 31 | let infoPointer = UnsafeMutableRawPointer.allocate(byteCount: len, alignment: 1) 32 | var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] 33 | sysctl(&name, UInt32(name.count), infoPointer, &len, nil, 0) 34 | return infoPointer.load(as: kinfo_proc.self) 35 | } 36 | 37 | /// Generates a ``SecretKit.SigningRequestProvenance.Process`` from a provided process ID. 38 | /// - Parameter pid: The process ID to look up. 39 | /// - Returns: A ``SecretKit.SigningRequestProvenance.Process`` describing the process. 40 | func process(from pid: Int32) -> SigningRequestProvenance.Process { 41 | var pidAndNameInfo = self.pidAndNameInfo(from: pid) 42 | let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil 43 | let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in 44 | String(cString: pointer) 45 | } 46 | 47 | let pathPointer = UnsafeMutablePointer.allocate(capacity: Int(MAXPATHLEN)) 48 | _ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN)) 49 | let path = String(cString: pathPointer) 50 | var secCode: Unmanaged! 51 | let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks] 52 | SecCodeCreateWithPID(pid, SecCSFlags(), &secCode) 53 | let valid = SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess 54 | return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid) 55 | } 56 | 57 | /// Looks up the URL for the icon of a process ID, if it has one. 58 | /// - Parameter pid: The process ID to look up. 59 | /// - Returns: A URL to the icon, if the process has one. 60 | func iconURL(for pid: Int32) -> URL? { 61 | do { 62 | if let app = NSRunningApplication(processIdentifier: pid), let icon = app.icon?.tiffRepresentation { 63 | let temporaryURL = URL(fileURLWithPath: (NSTemporaryDirectory() as NSString).appendingPathComponent("\(app.bundleIdentifier ?? UUID().uuidString).png")) 64 | if FileManager.default.fileExists(atPath: temporaryURL.path) { 65 | return temporaryURL 66 | } 67 | let bitmap = NSBitmapImageRep(data: icon) 68 | try bitmap?.representation(using: .png, properties: [:])?.write(to: temporaryURL) 69 | return temporaryURL 70 | } 71 | } catch { 72 | } 73 | return nil 74 | } 75 | 76 | /// Looks up the application name of a process ID, if it has one. 77 | /// - Parameter pid: The process ID to look up. 78 | /// - Returns: The process's display name, if the process has one. 79 | func appName(for pid: Int32) -> String? { 80 | NSRunningApplication(processIdentifier: pid)?.localizedName 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKit/SigningWitness.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SecretKit 3 | 4 | /// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access. 5 | public protocol SigningWitness { 6 | 7 | /// 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. 8 | /// - Parameters: 9 | /// - secret: The `Secret` that will be used to sign the request. 10 | /// - store: The `Store` being asked to sign the request.. 11 | /// - provenance: A `SigningRequestProvenance` object describing the origin of the request. 12 | /// - 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. 13 | func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws 14 | 15 | /// Notifies the callee that a signing operation has been performed for a given secret. 16 | /// - Parameters: 17 | /// - secret: The `Secret` that will was used to sign the request. 18 | /// - store: The `Store` that signed the request.. 19 | /// - provenance: A `SigningRequestProvenance` object describing the origin of the request. 20 | func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKit/SocketController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OSLog 3 | 4 | /// A controller that manages socket configuration and request dispatching. 5 | public final class SocketController { 6 | 7 | /// The active FileHandle. 8 | private var fileHandle: FileHandle? 9 | /// The active SocketPort. 10 | private var port: SocketPort? 11 | /// A handler that will be notified when a new read/write handle is available. 12 | /// False if no data could be read 13 | public var handler: (@Sendable (FileHandleReader, FileHandleWriter) async -> Bool)? 14 | /// Logger. 15 | private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "SocketController") 16 | 17 | 18 | /// Initializes a socket controller with a specified path. 19 | /// - Parameter path: The path to use as a socket. 20 | public init(path: String) { 21 | logger.debug("Socket controller setting up at \(path)") 22 | if let _ = try? FileManager.default.removeItem(atPath: path) { 23 | logger.debug("Socket controller removed existing socket") 24 | } 25 | let exists = FileManager.default.fileExists(atPath: path) 26 | assert(!exists) 27 | logger.debug("Socket controller path is clear") 28 | port = socketPort(at: path) 29 | configureSocket(at: path) 30 | logger.debug("Socket listening at \(path)") 31 | } 32 | 33 | /// Configures the socket and a corresponding FileHandle. 34 | /// - Parameter path: The path to use as a socket. 35 | func configureSocket(at path: String) { 36 | guard let port = port else { return } 37 | fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true) 38 | NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionAccept(notification:)), name: .NSFileHandleConnectionAccepted, object: nil) 39 | NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionDataAvailable(notification:)), name: .NSFileHandleDataAvailable, object: nil) 40 | fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common]) 41 | } 42 | 43 | /// Creates a SocketPort for a path. 44 | /// - Parameter path: The path to use as a socket. 45 | /// - Returns: A configured SocketPort. 46 | func socketPort(at path: String) -> SocketPort { 47 | var addr = sockaddr_un() 48 | addr.sun_family = sa_family_t(AF_UNIX) 49 | 50 | var len: Int = 0 51 | withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in 52 | path.withCString { cstring in 53 | len = strlen(cstring) 54 | strncpy(pointer, cstring, len) 55 | } 56 | } 57 | addr.sun_len = UInt8(len+2) 58 | 59 | var data: Data! 60 | withUnsafePointer(to: &addr) { pointer in 61 | data = Data(bytes: pointer, count: MemoryLayout.size) 62 | } 63 | 64 | return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)! 65 | } 66 | 67 | /// Handles a new connection being accepted, invokes the handler, and prepares to accept new connections. 68 | /// - Parameter notification: A `Notification` that triggered the call. 69 | @objc func handleConnectionAccept(notification: Notification) { 70 | logger.debug("Socket controller accepted connection") 71 | guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return } 72 | Task { [handler, fileHandle] in 73 | _ = await handler?(new, new) 74 | await new.waitForDataInBackgroundAndNotifyOnMainActor() 75 | await fileHandle?.acceptConnectionInBackgroundAndNotifyOnMainActor() 76 | } 77 | } 78 | 79 | /// Handles a new connection providing data and invokes the handler callback. 80 | /// - Parameter notification: A `Notification` that triggered the call. 81 | @objc func handleConnectionDataAvailable(notification: Notification) { 82 | logger.debug("Socket controller has new data available") 83 | guard let new = notification.object as? FileHandle else { return } 84 | logger.debug("Socket controller received new file handle") 85 | Task { [handler, logger = UncheckedSendable(logger)] in 86 | if((await handler?(new, new)) == true) { 87 | logger.value.debug("Socket controller handled data, wait for more data") 88 | await new.waitForDataInBackgroundAndNotifyOnMainActor() 89 | } else { 90 | logger.value.debug("Socket controller called with empty data, socked closed") 91 | } 92 | } 93 | } 94 | 95 | } 96 | 97 | extension FileHandle { 98 | 99 | /// Ensures waitForDataInBackgroundAndNotify will be called on the main actor. 100 | @MainActor func waitForDataInBackgroundAndNotifyOnMainActor() { 101 | waitForDataInBackgroundAndNotify() 102 | } 103 | 104 | 105 | /// Ensures acceptConnectionInBackgroundAndNotify will be called on the main actor. 106 | /// - Parameter modes: the runloop modes to use. 107 | @MainActor func acceptConnectionInBackgroundAndNotifyOnMainActor(forModes modes: [RunLoop.Mode]? = [RunLoop.Mode.common]) { 108 | acceptConnectionInBackgroundAndNotify(forModes: modes) 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKitHeaders/Stub.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKitHeaders/include/SecretAgentKit.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | 5 | // Forward declarations 6 | 7 | // from libproc.h 8 | int proc_pidpath(int pid, void * buffer, uint32_t buffersize); 9 | 10 | // from SecTask.h 11 | OSStatus SecCodeCreateWithPID(int32_t, SecCSFlags, SecCodeRef *); 12 | 13 | //! Project version number for SecretAgentKit. 14 | FOUNDATION_EXPORT double SecretAgentKitVersionNumber; 15 | 16 | //! Project version string for SecretAgentKit. 17 | FOUNDATION_EXPORT const unsigned char SecretAgentKitVersionString[]; 18 | 19 | 20 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretAgentKitHeaders/module.modulemap: -------------------------------------------------------------------------------- 1 | module SecretAgentKitHeaders [system] { 2 | header "include/SecretAgentKit.h" 3 | export * 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``SecretKit`` 2 | 3 | SecretKit is a collection of protocols describing secrets and stores. 4 | 5 | ## Topics 6 | 7 | ### Base Protocols 8 | 9 | - ``Secret`` 10 | - ``SecretStore`` 11 | - ``SecretStoreModifiable`` 12 | 13 | ### Store List 14 | 15 | - ``SecretStoreList`` 16 | 17 | ### Type Erasers 18 | 19 | - ``AnySecret`` 20 | - ``AnySecretStore`` 21 | - ``AnySecretStoreModifiable`` 22 | 23 | ### OpenSSH 24 | 25 | - ``OpenSSHKeyWriter`` 26 | - ``OpenSSHReader`` 27 | 28 | ### Signing Process 29 | 30 | - ``SigningRequestProvenance`` 31 | 32 | ### Authentication Persistence 33 | 34 | - ``PersistedAuthenticationContext`` 35 | 36 | ### Errors 37 | 38 | - ``KeychainError`` 39 | - ``SigningError`` 40 | - ``SecurityError`` 41 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Type eraser for Secret. 4 | public struct AnySecret: Secret { 5 | 6 | let base: Any 7 | private let hashable: AnyHashable 8 | private let _id: () -> AnyHashable 9 | private let _name: () -> String 10 | private let _algorithm: () -> Algorithm 11 | private let _keySize: () -> Int 12 | private let _requiresAuthentication: () -> Bool 13 | private let _publicKey: () -> Data 14 | 15 | public init(_ secret: T) where T: Secret { 16 | if let secret = secret as? AnySecret { 17 | base = secret.base 18 | hashable = secret.hashable 19 | _id = secret._id 20 | _name = secret._name 21 | _algorithm = secret._algorithm 22 | _keySize = secret._keySize 23 | _requiresAuthentication = secret._requiresAuthentication 24 | _publicKey = secret._publicKey 25 | } else { 26 | base = secret as Any 27 | self.hashable = secret 28 | _id = { secret.id as AnyHashable } 29 | _name = { secret.name } 30 | _algorithm = { secret.algorithm } 31 | _keySize = { secret.keySize } 32 | _requiresAuthentication = { secret.requiresAuthentication } 33 | _publicKey = { secret.publicKey } 34 | } 35 | } 36 | 37 | public var id: AnyHashable { 38 | _id() 39 | } 40 | 41 | public var name: String { 42 | _name() 43 | } 44 | 45 | public var algorithm: Algorithm { 46 | _algorithm() 47 | } 48 | 49 | public var keySize: Int { 50 | _keySize() 51 | } 52 | 53 | public var requiresAuthentication: Bool { 54 | _requiresAuthentication() 55 | } 56 | 57 | public var publicKey: Data { 58 | _publicKey() 59 | } 60 | 61 | public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool { 62 | lhs.hashable == rhs.hashable 63 | } 64 | 65 | public func hash(into hasher: inout Hasher) { 66 | hashable.hash(into: &hasher) 67 | } 68 | 69 | } 70 | 71 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | /// Type eraser for SecretStore. 5 | public class AnySecretStore: SecretStore { 6 | 7 | let base: Any 8 | private let _isAvailable: () -> Bool 9 | private let _id: () -> UUID 10 | private let _name: () -> String 11 | private let _secrets: () -> [AnySecret] 12 | private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data 13 | private let _verify: (Data, Data, AnySecret) throws -> Bool 14 | private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext? 15 | private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void 16 | private let _reloadSecrets: () -> Void 17 | 18 | private var sink: AnyCancellable? 19 | 20 | public init(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore { 21 | base = secretStore 22 | _isAvailable = { secretStore.isAvailable } 23 | _name = { secretStore.name } 24 | _id = { secretStore.id } 25 | _secrets = { secretStore.secrets.map { AnySecret($0) } } 26 | _sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) } 27 | _verify = { try secretStore.verify(signature: $0, for: $1, with: $2.base as! SecretStoreType.SecretType) } 28 | _existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) } 29 | _persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) } 30 | _reloadSecrets = { secretStore.reloadSecrets() } 31 | sink = secretStore.objectWillChange.sink { _ in 32 | self.objectWillChange.send() 33 | } 34 | } 35 | 36 | public var isAvailable: Bool { 37 | return _isAvailable() 38 | } 39 | 40 | public var id: UUID { 41 | return _id() 42 | } 43 | 44 | public var name: String { 45 | return _name() 46 | } 47 | 48 | public var secrets: [AnySecret] { 49 | return _secrets() 50 | } 51 | 52 | public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) throws -> Data { 53 | try _sign(data, secret, provenance) 54 | } 55 | 56 | public func verify(signature: Data, for data: Data, with secret: AnySecret) throws -> Bool { 57 | try _verify(signature, data, secret) 58 | } 59 | 60 | public func existingPersistedAuthenticationContext(secret: AnySecret) -> PersistedAuthenticationContext? { 61 | _existingPersistedAuthenticationContext(secret) 62 | } 63 | 64 | public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) throws { 65 | try _persistAuthentication(secret, duration) 66 | } 67 | 68 | public func reloadSecrets() { 69 | _reloadSecrets() 70 | } 71 | 72 | } 73 | 74 | public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable { 75 | 76 | private let _create: (String, Bool) throws -> Void 77 | private let _delete: (AnySecret) throws -> Void 78 | private let _update: (AnySecret, String) throws -> Void 79 | 80 | public init(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable { 81 | _create = { try secretStore.create(name: $0, requiresAuthentication: $1) } 82 | _delete = { try secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) } 83 | _update = { try secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1) } 84 | super.init(secretStore) 85 | } 86 | 87 | public func create(name: String, requiresAuthentication: Bool) throws { 88 | try _create(name, requiresAuthentication) 89 | } 90 | 91 | public func delete(secret: AnySecret) throws { 92 | try _delete(secret) 93 | } 94 | 95 | public func update(secret: AnySecret, name: String) throws { 96 | try _update(secret, name) 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/KeychainTypes.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias SecurityError = Unmanaged 4 | 5 | /// Wraps a Swift dictionary in a CFDictionary. 6 | /// - Parameter dictionary: The Swift dictionary to wrap. 7 | /// - Returns: A CFDictionary containing the keys and values. 8 | public func KeychainDictionary(_ dictionary: [CFString: Any]) -> CFDictionary { 9 | dictionary as CFDictionary 10 | } 11 | 12 | public extension CFError { 13 | 14 | /// The CFError returned when a verification operation fails. 15 | static let verifyError = CFErrorCreate(nil, NSOSStatusErrorDomain as CFErrorDomain, CFIndex(errSecVerifyFailed), nil)! 16 | 17 | /// Equality operation that only considers domain and code. 18 | static func ~=(lhs: CFError, rhs: CFError) -> Bool { 19 | CFErrorGetDomain(lhs) == CFErrorGetDomain(rhs) && CFErrorGetCode(lhs) == CFErrorGetCode(rhs) 20 | } 21 | 22 | } 23 | 24 | /// A wrapper around an error code reported by a Keychain API. 25 | public struct KeychainError: Error { 26 | /// The status code involved, if one was reported. 27 | public let statusCode: OSStatus? 28 | 29 | /// Initializes a KeychainError with an optional error code. 30 | /// - Parameter statusCode: The status code returned by the keychain operation, if one is applicable. 31 | public init(statusCode: OSStatus?) { 32 | self.statusCode = statusCode 33 | } 34 | } 35 | 36 | /// A signing-related error. 37 | public struct SigningError: Error { 38 | /// The underlying error reported by the API, if one was returned. 39 | public let error: SecurityError? 40 | 41 | /// Initializes a SigningError with an optional SecurityError. 42 | /// - Parameter statusCode: The SecurityError, if one is applicable. 43 | public init(error: SecurityError?) { 44 | self.error = error 45 | } 46 | 47 | } 48 | 49 | public extension SecretStore { 50 | 51 | /// Returns the appropriate keychian signature algorithm to use for a given secret. 52 | /// - Parameters: 53 | /// - secret: The secret which will be used for signing. 54 | /// - allowRSA: Whether or not RSA key types should be permited. 55 | /// - Returns: The appropriate algorithm. 56 | func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm { 57 | switch (secret.algorithm, secret.keySize) { 58 | case (.ellipticCurve, 256): 59 | return .ecdsaSignatureMessageX962SHA256 60 | case (.ellipticCurve, 384): 61 | return .ecdsaSignatureMessageX962SHA384 62 | case (.rsa, 1024), (.rsa, 2048): 63 | guard allowRSA else { fatalError() } 64 | return .rsaSignatureMessagePKCS1v15SHA512 65 | default: 66 | fatalError() 67 | } 68 | 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OSLog 3 | 4 | /// Manages storage and lookup for OpenSSH certificates. 5 | public final class OpenSSHCertificateHandler { 6 | 7 | private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) 8 | private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler") 9 | private let writer = OpenSSHKeyWriter() 10 | private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:] 11 | 12 | /// Initializes an OpenSSHCertificateHandler. 13 | public init() { 14 | } 15 | 16 | /// Reloads any certificates in the PublicKeys folder. 17 | /// - Parameter secrets: the secrets to look up corresponding certificates for. 18 | public func reloadCertificates(for secrets: [AnySecret]) { 19 | guard publicKeyFileStoreController.hasAnyCertificates else { 20 | logger.log("No certificates, short circuiting") 21 | return 22 | } 23 | keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in 24 | partialResult[next] = try? loadKeyblobAndName(for: next) 25 | } 26 | } 27 | 28 | /// Whether or not the certificate handler has a certifiicate associated with a given secret. 29 | /// - Parameter secret: The secret to check for a certificate. 30 | /// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret 31 | public func hasCertificate(for secret: SecretType) -> Bool { 32 | keyBlobsAndNames[AnySecret(secret)] != nil 33 | } 34 | 35 | 36 | /// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported 37 | /// - Parameter certBlock: The openssh certificate to extract the public key from 38 | /// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil. 39 | public func publicKeyHash(from hash: Data) -> Data? { 40 | let reader = OpenSSHReader(data: hash) 41 | let certType = String(decoding: reader.readNextChunk(), as: UTF8.self) 42 | 43 | switch certType { 44 | case "ecdsa-sha2-nistp256-cert-v01@openssh.com", 45 | "ecdsa-sha2-nistp384-cert-v01@openssh.com", 46 | "ecdsa-sha2-nistp521-cert-v01@openssh.com": 47 | _ = reader.readNextChunk() // nonce 48 | let curveIdentifier = reader.readNextChunk() 49 | let publicKey = reader.readNextChunk() 50 | 51 | let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)! 52 | return writer.lengthAndData(of: curveType) + 53 | writer.lengthAndData(of: curveIdentifier) + 54 | writer.lengthAndData(of: publicKey) 55 | default: 56 | return nil 57 | } 58 | } 59 | 60 | /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` 61 | /// - Parameter secret: The secret to search for a certificate with 62 | /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. 63 | public func keyBlobAndName(for secret: SecretType) throws -> (Data, Data)? { 64 | keyBlobsAndNames[AnySecret(secret)] 65 | } 66 | 67 | /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` 68 | /// - Parameter secret: The secret to search for a certificate with 69 | /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. 70 | private func loadKeyblobAndName(for secret: SecretType) throws -> (Data, Data)? { 71 | let certificatePath = publicKeyFileStoreController.sshCertificatePath(for: secret) 72 | guard FileManager.default.fileExists(atPath: certificatePath) else { 73 | return nil 74 | } 75 | 76 | logger.debug("Found certificate for \(secret.name)") 77 | let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8) 78 | let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ") 79 | 80 | guard certElements.count >= 2 else { 81 | logger.warning("Certificate found for \(secret.name) but failed to load") 82 | throw OpenSSHCertificateError.parsingFailed 83 | } 84 | guard let certDecoded = Data(base64Encoded: certElements[1] as String) else { 85 | logger.warning("Certificate found for \(secret.name) but failed to decode base64 key") 86 | throw OpenSSHCertificateError.parsingFailed 87 | } 88 | 89 | if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) { 90 | return (certDecoded, certName) 91 | } else if let certName = secret.name.data(using: .utf8) { 92 | logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead") 93 | return (certDecoded, certName) 94 | } else { 95 | throw OpenSSHCertificateError.parsingFailed 96 | } 97 | } 98 | 99 | } 100 | 101 | extension OpenSSHCertificateHandler { 102 | 103 | enum OpenSSHCertificateError: LocalizedError { 104 | case unsupportedType 105 | case parsingFailed 106 | case doesNotExist 107 | 108 | public var errorDescription: String? { 109 | switch self { 110 | case .unsupportedType: 111 | return "The key type was unsupported" 112 | case .parsingFailed: 113 | return "Failed to properly parse the SSH certificate" 114 | case .doesNotExist: 115 | return "Certificate does not exist" 116 | } 117 | } 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CryptoKit 3 | 4 | /// Generates OpenSSH representations of Secrets. 5 | public struct OpenSSHKeyWriter { 6 | 7 | /// Initializes the writer. 8 | public init() { 9 | } 10 | 11 | /// Generates an OpenSSH data payload identifying the secret. 12 | /// - Returns: OpenSSH data payload identifying the secret. 13 | public func data(secret: SecretType) -> Data { 14 | lengthAndData(of: curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) + 15 | lengthAndData(of: curveIdentifier(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) + 16 | lengthAndData(of: secret.publicKey) 17 | } 18 | 19 | /// Generates an OpenSSH string representation of the secret. 20 | /// - Returns: OpenSSH string representation of the secret. 21 | public func openSSHString(secret: SecretType, comment: String? = nil) -> String { 22 | [curveType(for: secret.algorithm, length: secret.keySize), data(secret: secret).base64EncodedString(), comment] 23 | .compactMap { $0 } 24 | .joined(separator: " ") 25 | } 26 | 27 | /// Generates an OpenSSH SHA256 fingerprint string. 28 | /// - Returns: OpenSSH SHA256 fingerprint string. 29 | public func openSSHSHA256Fingerprint(secret: SecretType) -> String { 30 | // OpenSSL format seems to strip the padding at the end. 31 | let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString() 32 | let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..(secret: SecretType) -> String { 40 | Insecure.MD5.hash(data: data(secret: secret)) 41 | .compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) } 42 | .joined(separator: ":") 43 | } 44 | 45 | } 46 | 47 | extension OpenSSHKeyWriter { 48 | 49 | /// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload. 50 | /// - Parameter data: The data payload. 51 | /// - Returns: OpenSSH data. 52 | public func lengthAndData(of data: Data) -> Data { 53 | let rawLength = UInt32(data.count) 54 | var endian = rawLength.bigEndian 55 | return Data(bytes: &endian, count: UInt32.bitWidth/8) + data 56 | } 57 | 58 | /// The fully qualified OpenSSH identifier for the algorithm. 59 | /// - Parameters: 60 | /// - algorithm: The algorithm to identify. 61 | /// - length: The key length of the algorithm. 62 | /// - Returns: The OpenSSH identifier for the algorithm. 63 | public func curveType(for algorithm: Algorithm, length: Int) -> String { 64 | switch algorithm { 65 | case .ellipticCurve: 66 | return "ecdsa-sha2-nistp" + String(describing: length) 67 | case .rsa: 68 | // All RSA keys use the same 512 bit hash function, per 69 | // https://security.stackexchange.com/questions/255074/why-are-rsa-sha2-512-and-rsa-sha2-256-supported-but-not-reported-by-ssh-q-key 70 | return "rsa-sha2-512" 71 | } 72 | } 73 | 74 | /// The OpenSSH identifier for an algorithm. 75 | /// - Parameters: 76 | /// - algorithm: The algorithm to identify. 77 | /// - length: The key length of the algorithm. 78 | /// - Returns: The OpenSSH identifier for the algorithm. 79 | private func curveIdentifier(for algorithm: Algorithm, length: Int) -> String { 80 | switch algorithm { 81 | case .ellipticCurve: 82 | return "nistp" + String(describing: length) 83 | case .rsa: 84 | // All RSA keys use the same 512 bit hash function 85 | return "rsa-sha2-512" 86 | } 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHReader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Reads OpenSSH protocol data. 4 | public final class OpenSSHReader { 5 | 6 | var remaining: Data 7 | 8 | /// Initialize the reader with an OpenSSH data payload. 9 | /// - Parameter data: The data to read. 10 | public init(data: Data) { 11 | remaining = Data(data) 12 | } 13 | 14 | /// Reads the next chunk of data from the playload. 15 | /// - Returns: The next chunk of data. 16 | public func readNextChunk() -> Data { 17 | let lengthRange = 0..<(UInt32.bitWidth/8) 18 | let lengthChunk = remaining[lengthRange] 19 | remaining.removeSubrange(lengthRange) 20 | let littleEndianLength = lengthChunk.withUnsafeBytes { pointer in 21 | return pointer.load(as: UInt32.self) 22 | } 23 | let length = Int(littleEndianLength.bigEndian) 24 | let dataRange = 0..(for secret: SecretType) -> String { 46 | let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "") 47 | return directory.appending("/").appending("\(minimalHex).pub") 48 | } 49 | 50 | /// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory. 51 | public var hasAnyCertificates: Bool { 52 | do { 53 | return try FileManager.default 54 | .contentsOfDirectory(atPath: directory) 55 | .filter { $0.hasSuffix("-cert.pub") } 56 | .isEmpty == false 57 | } catch { 58 | return false 59 | } 60 | } 61 | 62 | /// The path for a Secret's SSH Certificate public key. 63 | /// - Parameter secret: The Secret to return the path for. 64 | /// - Returns: The path to the SSH Certificate public key. 65 | /// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be. 66 | public func sshCertificatePath(for secret: SecretType) -> String { 67 | let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "") 68 | return directory.appending("/").appending("\(minimalHex)-cert.pub") 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/SecretStoreList.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | /// A "Store Store," which holds a list of type-erased stores. 5 | public final class SecretStoreList: ObservableObject { 6 | 7 | /// The Stores managed by the SecretStoreList. 8 | @Published public var stores: [AnySecretStore] = [] 9 | /// A modifiable store, if one is available. 10 | @Published public var modifiableStore: AnySecretStoreModifiable? 11 | private var cancellables: Set = [] 12 | 13 | /// Initializes a SecretStoreList. 14 | public init() { 15 | } 16 | 17 | /// Adds a non-type-erased SecretStore to the list. 18 | public func add(store: SecretStoreType) { 19 | addInternal(store: AnySecretStore(store)) 20 | } 21 | 22 | /// Adds a non-type-erased modifiable SecretStore. 23 | public func add(store: SecretStoreType) { 24 | let modifiable = AnySecretStoreModifiable(modifiable: store) 25 | modifiableStore = modifiable 26 | addInternal(store: modifiable) 27 | } 28 | 29 | /// A boolean describing whether there are any Stores available. 30 | public var anyAvailable: Bool { 31 | stores.reduce(false, { $0 || $1.isAvailable }) 32 | } 33 | 34 | public var allSecrets: [AnySecret] { 35 | stores.flatMap(\.secrets) 36 | } 37 | 38 | } 39 | 40 | extension SecretStoreList { 41 | 42 | private func addInternal(store: AnySecretStore) { 43 | stores.append(store) 44 | store.objectWillChange.sink { 45 | self.objectWillChange.send() 46 | }.store(in: &cancellables) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/Types/PersistedAuthenticationContext.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 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. 4 | public protocol PersistedAuthenticationContext { 5 | /// Whether the context remains valid. 6 | var valid: Bool { get } 7 | /// The date at which the authorization expires and the context becomes invalid. 8 | var expiration: Date { get } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/Types/Secret.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The base protocol for describing a Secret 4 | public protocol Secret: Identifiable, Hashable { 5 | 6 | /// A user-facing string identifying the Secret. 7 | var name: String { get } 8 | /// The algorithm this secret uses. 9 | var algorithm: Algorithm { get } 10 | /// The key size for the secret. 11 | var keySize: Int { get } 12 | /// Whether the secret requires authentication before use. 13 | var requiresAuthentication: Bool { get } 14 | /// The public key data for the secret. 15 | var publicKey: Data { get } 16 | 17 | } 18 | 19 | /// The type of algorithm the Secret uses. Currently, only elliptic curve algorithms are supported. 20 | public enum Algorithm: Hashable { 21 | 22 | case ellipticCurve 23 | case rsa 24 | 25 | /// Initializes the Algorithm with a secAttr representation of an algorithm. 26 | /// - Parameter secAttr: the secAttr, represented as an NSNumber. 27 | public init(secAttr: NSNumber) { 28 | let secAttrString = secAttr.stringValue as CFString 29 | switch secAttrString { 30 | case kSecAttrKeyTypeEC: 31 | self = .ellipticCurve 32 | case kSecAttrKeyTypeRSA: 33 | self = .rsa 34 | default: 35 | fatalError() 36 | } 37 | } 38 | 39 | public var secAttrKeyType: CFString { 40 | switch self { 41 | case .ellipticCurve: 42 | return kSecAttrKeyTypeEC 43 | case .rsa: 44 | return kSecAttrKeyTypeRSA 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/Types/SecretStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | /// Manages access to Secrets, and performs signature operations on data using those Secrets. 5 | public protocol SecretStore: ObservableObject, Identifiable { 6 | 7 | associatedtype SecretType: Secret 8 | 9 | /// A boolean indicating whether or not the store is available. 10 | var isAvailable: Bool { get } 11 | /// A unique identifier for the store. 12 | var id: UUID { get } 13 | /// A user-facing name for the store. 14 | var name: String { get } 15 | /// The secrets the store manages. 16 | var secrets: [SecretType] { get } 17 | 18 | /// Signs a data payload with a specified Secret. 19 | /// - Parameters: 20 | /// - data: The data to sign. 21 | /// - secret: The ``Secret`` to sign with. 22 | /// - provenance: A ``SigningRequestProvenance`` describing where the request came from. 23 | /// - Returns: The signed data. 24 | func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data 25 | 26 | /// Verifies that a signature is valid over a specified payload. 27 | /// - Parameters: 28 | /// - signature: The signature over the data. 29 | /// - data: The data to verify the signature of. 30 | /// - secret: The secret whose signature to verify. 31 | /// - Returns: Whether the signature was verified. 32 | func verify(signature: Data, for data: Data, with secret: SecretType) throws -> Bool 33 | 34 | /// Checks to see if there is currently a valid persisted authentication for a given secret. 35 | /// - Parameters: 36 | /// - secret: The ``Secret`` to check if there is a persisted authentication for. 37 | /// - Returns: A persisted authentication context, if a valid one exists. 38 | func existingPersistedAuthenticationContext(secret: SecretType) -> PersistedAuthenticationContext? 39 | 40 | /// Persists user authorization for access to a secret. 41 | /// - Parameters: 42 | /// - secret: The ``Secret`` to persist the authorization for. 43 | /// - duration: The duration that the authorization should persist for. 44 | /// - 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. 45 | func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) throws 46 | 47 | /// Requests that the store reload secrets from any backing store, if neccessary. 48 | func reloadSecrets() 49 | 50 | } 51 | 52 | /// A SecretStore that the Secretive admin app can modify. 53 | public protocol SecretStoreModifiable: SecretStore { 54 | 55 | /// Creates a new ``Secret`` in the store. 56 | /// - Parameters: 57 | /// - name: The user-facing name for the ``Secret``. 58 | /// - requiresAuthentication: A boolean indicating whether or not the user will be required to authenticate before performing signature operations with the secret. 59 | func create(name: String, requiresAuthentication: Bool) throws 60 | 61 | /// Deletes a Secret in the store. 62 | /// - Parameters: 63 | /// - secret: The ``Secret`` to delete. 64 | func delete(secret: SecretType) throws 65 | 66 | /// Updates the name of a Secret in the store. 67 | /// - Parameters: 68 | /// - secret: The ``Secret`` to update. 69 | /// - name: The new name for the Secret. 70 | func update(secret: SecretType, name: String) throws 71 | 72 | } 73 | 74 | extension NSNotification.Name { 75 | 76 | // Distributed notification that keys were modified out of process (ie, that the management tool added/removed secrets) 77 | public static let secretStoreUpdated = NSNotification.Name("com.maxgoedjen.Secretive.secretStore.updated") 78 | // Internal notification that keys were reloaded from the backing store. 79 | public static let secretStoreReloaded = NSNotification.Name("com.maxgoedjen.Secretive.secretStore.reloaded") 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | 4 | /// Describes the chain of applications that requested a signature operation. 5 | public struct SigningRequestProvenance: Equatable { 6 | 7 | /// A list of processes involved in the request. 8 | /// - 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` 9 | public var chain: [Process] 10 | public init(root: Process) { 11 | self.chain = [root] 12 | } 13 | 14 | } 15 | 16 | extension SigningRequestProvenance { 17 | 18 | /// The `Process` which initiated the signing request. 19 | public var origin: Process { 20 | chain.last! 21 | } 22 | 23 | /// A boolean describing whether all processes in the request chain had a valid code signature. 24 | public var intact: Bool { 25 | chain.allSatisfy { $0.validSignature } 26 | } 27 | 28 | } 29 | 30 | extension SigningRequestProvenance { 31 | 32 | /// Describes a process in a `SigningRequestProvenance` chain. 33 | public struct Process: Equatable { 34 | 35 | /// The pid of the process. 36 | public let pid: Int32 37 | /// A user-facing name for the process. 38 | public let processName: String 39 | /// A user-facing name for the application, if one exists. 40 | public let appName: String? 41 | /// An icon representation of the application, if one exists. 42 | public let iconURL: URL? 43 | /// The path the process exists at. 44 | public let path: String 45 | /// A boolean describing whether or not the process has a valid code signature. 46 | public let validSignature: Bool 47 | /// The pid of the process's parent. 48 | public let parentPID: Int32? 49 | 50 | /// Initializes a Process. 51 | /// - Parameters: 52 | /// - pid: The pid of the process. 53 | /// - processName: A user-facing name for the process. 54 | /// - appName: A user-facing name for the application, if one exists. 55 | /// - iconURL: An icon representation of the application, if one exists. 56 | /// - path: The path the process exists at. 57 | /// - validSignature: A boolean describing whether or not the process has a valid code signature. 58 | /// - parentPID: The pid of the process's parent. 59 | public init(pid: Int32, processName: String, appName: String?, iconURL: URL?, path: String, validSignature: Bool, parentPID: Int32?) { 60 | self.pid = pid 61 | self.processName = processName 62 | self.appName = appName 63 | self.iconURL = iconURL 64 | self.path = path 65 | self.validSignature = validSignature 66 | self.parentPID = parentPID 67 | } 68 | 69 | /// The best user-facing name to display for the process. 70 | public var displayName: String { 71 | appName ?? processName 72 | } 73 | 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecureEnclaveSecretKit/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``SecureEnclaveSecretKit`` 2 | 3 | SecureEnclaveSecretKit contains implementations of SecretKit protocols backed by the Secure Enclave. 4 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecureEnclaveSecretKit/Documentation.docc/SecureEnclave.md: -------------------------------------------------------------------------------- 1 | # ``SecureEnclaveSecretKit/SecureEnclave`` 2 | 3 | ## Topics 4 | 5 | ### Implementations 6 | 7 | - ``Secret`` 8 | - ``Store`` 9 | 10 | ### Errors 11 | 12 | - ``KeychainError`` 13 | - ``SigningError`` 14 | - ``SecurityError`` 15 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclave.swift: -------------------------------------------------------------------------------- 1 | /// Namespace for the Secure Enclave implementations. 2 | public enum SecureEnclave {} 3 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import SecretKit 4 | 5 | extension SecureEnclave { 6 | 7 | /// An implementation of Secret backed by the Secure Enclave. 8 | public struct Secret: SecretKit.Secret { 9 | 10 | public let id: Data 11 | public let name: String 12 | public let algorithm = Algorithm.ellipticCurve 13 | public let keySize = 256 14 | public let requiresAuthentication: Bool 15 | public let publicKey: Data 16 | 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SmartCardSecretKit/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``SmartCardSecretKit`` 2 | 3 | SmartCardSecretKit contains implementations of SecretKit protocols backed by a Smart Card. 4 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SmartCardSecretKit/Documentation.docc/SmartCard.md: -------------------------------------------------------------------------------- 1 | # ``SmartCardSecretKit/SmartCard`` 2 | 3 | ## Topics 4 | 5 | ### Implementations 6 | 7 | - ``Secret`` 8 | - ``Store`` 9 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SmartCardSecretKit/SmartCard.swift: -------------------------------------------------------------------------------- 1 | /// Namespace for the Smart Card implementations. 2 | public enum SmartCard {} 3 | -------------------------------------------------------------------------------- /Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import SecretKit 4 | 5 | extension SmartCard { 6 | 7 | /// An implementation of Secret backed by a Smart Card. 8 | public struct Secret: SecretKit.Secret { 9 | 10 | public let id: Data 11 | public let name: String 12 | public let algorithm: Algorithm 13 | public let keySize: Int 14 | public let requiresAuthentication: Bool = false 15 | public let publicKey: Data 16 | 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Packages/Tests/BriefTests/ReleaseParsingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Brief 3 | 4 | class ReleaseParsingTests: XCTestCase { 5 | 6 | func testNonCritical() { 7 | let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release") 8 | XCTAssert(release.critical == false) 9 | } 10 | 11 | func testCritical() { 12 | let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update") 13 | XCTAssert(release.critical == true) 14 | } 15 | 16 | func testOSMissing() { 17 | let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update") 18 | XCTAssert(release.minimumOSVersion == SemVer("11.0.0")) 19 | } 20 | 21 | func testOSPresentWithContentBelow() { 22 | 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") 23 | XCTAssert(release.minimumOSVersion == SemVer("1.2.3")) 24 | } 25 | 26 | func testOSPresentAtEnd() { 27 | 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") 28 | XCTAssert(release.minimumOSVersion == SemVer("1.2.3")) 29 | } 30 | 31 | func testOSWithMacOSPrefix() { 32 | 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") 33 | XCTAssert(release.minimumOSVersion == SemVer("1.2.3")) 34 | } 35 | 36 | func testOSGreaterThanMinimum() { 37 | 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") 38 | XCTAssert(release.minimumOSVersion < SemVer("11.0.0")) 39 | } 40 | 41 | func testOSEqualToMinimum() { 42 | 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") 43 | XCTAssert(release.minimumOSVersion <= SemVer("11.2.3")) 44 | } 45 | 46 | func testOSLessThanMinimum() { 47 | 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") 48 | XCTAssert(release.minimumOSVersion > SemVer("1.0.0")) 49 | } 50 | 51 | func testGreatestSelectedIfOldPatchIsPublishedLater() { 52 | // If 2.x.x series has been published, and a patch for 1.x.x is issued 53 | // 2.x.x should still be selected if user can run it. 54 | let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0")) 55 | let two = Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available! Minimum macOS Version: 2.2.3") 56 | let releases = [ 57 | Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release Minimum macOS Version: 1.2.3"), 58 | Release(name: "1.0.1", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Bug fixes Minimum macOS Version: 1.2.3"), 59 | two, 60 | Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"), 61 | ] 62 | 63 | let expectation = XCTestExpectation() 64 | updater.evaluate(releases: releases) 65 | DispatchQueue.main.async { 66 | XCTAssert(updater.update == two) 67 | expectation.fulfill() 68 | } 69 | wait(for: [expectation], timeout: 1) 70 | } 71 | 72 | func testLatestVersionIsRunnable() { 73 | // If the 2.x.x series has been published but the user can't run it 74 | // the last version the user can run should be selected. 75 | let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0")) 76 | let oneOhTwo = Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3") 77 | let releases = [ 78 | Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release Minimum macOS Version: 1.2.3"), 79 | Release(name: "1.0.1", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Bug fixes Minimum macOS Version: 1.2.3"), 80 | Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available! Minimum macOS Version: 2.2.3"), 81 | Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"), 82 | ] 83 | let expectation = XCTestExpectation() 84 | updater.evaluate(releases: releases) 85 | DispatchQueue.main.async { 86 | XCTAssert(updater.update == oneOhTwo) 87 | expectation.fulfill() 88 | } 89 | wait(for: [expectation], timeout: 1) 90 | } 91 | 92 | func testSorting() { 93 | let two = Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available!") 94 | let releases = [ 95 | Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release"), 96 | Release(name: "1.0.1", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Bug fixes"), 97 | two, 98 | Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch!"), 99 | ] 100 | let sorted = releases.sorted().reversed().first 101 | XCTAssert(sorted == two) 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /Sources/Packages/Tests/BriefTests/SemVerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Brief 3 | 4 | class SemVerTests: XCTestCase { 5 | 6 | func testEqual() { 7 | let current = SemVer("1.0.2") 8 | let old = SemVer("1.0.2") 9 | XCTAssert(!(current > old)) 10 | } 11 | 12 | func testPatchGreaterButMinorLess() { 13 | let current = SemVer("1.1.0") 14 | let old = SemVer("1.0.2") 15 | XCTAssert(current > old) 16 | } 17 | 18 | func testMajorSameMinorGreater() { 19 | let current = SemVer("1.0.2") 20 | let new = SemVer("1.0.3") 21 | XCTAssert(current < new) 22 | } 23 | 24 | func testMajorGreaterMinorLesser() { 25 | let current = SemVer("1.0.2") 26 | let new = SemVer("2.0.0") 27 | XCTAssert(current < new) 28 | } 29 | 30 | func testRegularParsing() { 31 | let current = SemVer("1.0.2") 32 | XCTAssert(current.versionNumbers == [1, 0, 2]) 33 | } 34 | 35 | func testNoPatch() { 36 | let current = SemVer("1.1") 37 | XCTAssert(current.versionNumbers == [1, 1, 0]) 38 | } 39 | 40 | func testGarbage() { 41 | let current = SemVer("Test") 42 | XCTAssert(current.versionNumbers == [0, 0, 0]) 43 | } 44 | 45 | func testBeta() { 46 | let current = SemVer("1.0.2") 47 | let new = SemVer("1.1.0_beta1") 48 | XCTAssert(current < new) 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Packages/Tests/SecretAgentKitTests/StubFileHandleReader.swift: -------------------------------------------------------------------------------- 1 | import SecretAgentKit 2 | import AppKit 3 | 4 | struct StubFileHandleReader: FileHandleReader { 5 | 6 | let availableData: Data 7 | var fileDescriptor: Int32 { 8 | NSWorkspace.shared.runningApplications.filter({ $0.localizedName == "Finder" }).first!.processIdentifier 9 | } 10 | var pidOfConnectedProcess: Int32 { 11 | fileDescriptor 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Packages/Tests/SecretAgentKitTests/StubFileHandleWriter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SecretAgentKit 3 | 4 | class StubFileHandleWriter: FileHandleWriter { 5 | 6 | var data = Data() 7 | 8 | func write(_ data: Data) { 9 | self.data.append(data) 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SecretKit 3 | import CryptoKit 4 | 5 | struct Stub {} 6 | 7 | extension Stub { 8 | 9 | public final class Store: SecretStore { 10 | 11 | public let isAvailable = true 12 | public let id = UUID() 13 | public let name = "Stub" 14 | public var secrets: [Secret] = [] 15 | public var shouldThrow = false 16 | 17 | public init() { 18 | // try! create(size: 256) 19 | // try! create(size: 384) 20 | } 21 | 22 | public func create(size: Int) throws { 23 | let flags: SecAccessControlCreateFlags = [] 24 | let access = 25 | SecAccessControlCreateWithFlags(kCFAllocatorDefault, 26 | kSecAttrAccessibleWhenUnlockedThisDeviceOnly, 27 | flags, 28 | nil) as Any 29 | 30 | let attributes = KeychainDictionary([ 31 | kSecAttrLabel: name, 32 | kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, 33 | kSecAttrKeySizeInBits: size, 34 | kSecPrivateKeyAttrs: [ 35 | kSecAttrIsPermanent: true, 36 | kSecAttrAccessControl: access 37 | ] 38 | ]) 39 | 40 | let privateKey = SecKeyCreateRandomKey(attributes, nil)! 41 | let publicKey = SecKeyCopyPublicKey(privateKey)! 42 | let publicAttributes = SecKeyCopyAttributes(publicKey) as! [CFString: Any] 43 | let privateAttributes = SecKeyCopyAttributes(privateKey) as! [CFString: Any] 44 | let publicData = (publicAttributes[kSecValueData] as! Data) 45 | let privateData = (privateAttributes[kSecValueData] as! Data) 46 | let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData) 47 | print(secret) 48 | print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))") 49 | } 50 | 51 | public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { 52 | guard !shouldThrow else { 53 | throw NSError(domain: "test", code: 0, userInfo: nil) 54 | } 55 | let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([ 56 | kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, 57 | kSecAttrKeySizeInBits: secret.keySize, 58 | kSecAttrKeyClass: kSecAttrKeyClassPrivate 59 | ]) 60 | , nil)! 61 | return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data 62 | } 63 | 64 | public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool { 65 | let attributes = KeychainDictionary([ 66 | kSecAttrKeyType: secret.algorithm.secAttrKeyType, 67 | kSecAttrKeySizeInBits: secret.keySize, 68 | kSecAttrKeyClass: kSecAttrKeyClassPublic 69 | ]) 70 | var verifyError: Unmanaged? 71 | let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError) 72 | guard let untypedSafe = untyped else { 73 | throw NSError(domain: "test", code: 0, userInfo: nil) 74 | } 75 | let key = untypedSafe as! SecKey 76 | let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret), data as CFData, signature as CFData, &verifyError) 77 | if let verifyError { 78 | if verifyError.takeUnretainedValue() ~= .verifyError { 79 | return false 80 | } else { 81 | throw NSError(domain: "test", code: 0, userInfo: nil) 82 | } 83 | } 84 | return verified 85 | } 86 | 87 | public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? { 88 | nil 89 | } 90 | 91 | public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws { 92 | } 93 | 94 | public func reloadSecrets() { 95 | } 96 | 97 | } 98 | 99 | } 100 | 101 | extension Stub { 102 | 103 | struct Secret: SecretKit.Secret, CustomDebugStringConvertible { 104 | 105 | let id = UUID().uuidString.data(using: .utf8)! 106 | let name = UUID().uuidString 107 | let algorithm = Algorithm.ellipticCurve 108 | 109 | let keySize: Int 110 | let publicKey: Data 111 | let requiresAuthentication = false 112 | let privateKey: Data 113 | 114 | init(keySize: Int, publicKey: Data, privateKey: Data) { 115 | self.keySize = keySize 116 | self.publicKey = publicKey 117 | self.privateKey = privateKey 118 | } 119 | 120 | var debugDescription: String { 121 | """ 122 | Key Size \(keySize) 123 | Private: \(privateKey.base64EncodedString()) 124 | Public: \(publicKey.base64EncodedString()) 125 | """ 126 | } 127 | 128 | } 129 | 130 | } 131 | 132 | 133 | extension Stub.Store { 134 | 135 | struct StubError: Error { 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /Sources/Packages/Tests/SecretAgentKitTests/StubWitness.swift: -------------------------------------------------------------------------------- 1 | import SecretKit 2 | import SecretAgentKit 3 | 4 | struct StubWitness { 5 | 6 | let speakNow: (AnySecret, SigningRequestProvenance) -> Bool 7 | let witness: (AnySecret, SigningRequestProvenance) -> () 8 | 9 | } 10 | 11 | extension StubWitness: SigningWitness { 12 | 13 | func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws { 14 | let objection = speakNow(secret, provenance) 15 | if objection { 16 | throw TheresMyChance() 17 | } 18 | } 19 | 20 | func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws { 21 | witness(secret, provenance) 22 | } 23 | 24 | } 25 | 26 | extension StubWitness { 27 | 28 | struct TheresMyChance: Error { 29 | 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Packages/Tests/SecretKitTests/AnySecretTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import SecretKit 4 | @testable import SecureEnclaveSecretKit 5 | @testable import SmartCardSecretKit 6 | 7 | class AnySecretTests: XCTestCase { 8 | 9 | func testEraser() { 10 | let secret = SmartCard.Secret(id: UUID().uuidString.data(using: .utf8)!, name: "Name", algorithm: .ellipticCurve, keySize: 256, publicKey: UUID().uuidString.data(using: .utf8)!) 11 | let erased = AnySecret(secret) 12 | XCTAssert(erased.id == secret.id as AnyHashable) 13 | XCTAssert(erased.name == secret.name) 14 | XCTAssert(erased.algorithm == secret.algorithm) 15 | XCTAssert(erased.keySize == secret.keySize) 16 | XCTAssert(erased.publicKey == secret.publicKey) 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Packages/Tests/SecretKitTests/OpenSSHReaderTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import SecretKit 4 | @testable import SecureEnclaveSecretKit 5 | @testable import SmartCardSecretKit 6 | 7 | class OpenSSHReaderTests: XCTestCase { 8 | 9 | func testSignatureRequest() { 10 | let reader = OpenSSHReader(data: Constants.signatureRequest) 11 | let hash = reader.readNextChunk() 12 | XCTAssert(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ==")) 13 | let dataToSign = reader.readNextChunk() 14 | XCTAssert(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU=")) 15 | let empty = reader.readNextChunk() 16 | XCTAssert(empty.isEmpty) 17 | } 18 | 19 | } 20 | 21 | extension OpenSSHReaderTests { 22 | 23 | enum Constants { 24 | static let signatureRequest = Data(base64Encoded: "AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAADvAAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAAAA")! 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Packages/Tests/SecretKitTests/OpenSSHWriterTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import SecretKit 4 | @testable import SecureEnclaveSecretKit 5 | @testable import SmartCardSecretKit 6 | 7 | class OpenSSHWriterTests: XCTestCase { 8 | 9 | let writer = OpenSSHKeyWriter() 10 | 11 | func testECDSA256MD5Fingerprint() { 12 | XCTAssertEqual(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret), "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5") 13 | } 14 | 15 | func testECDSA256SHA256Fingerprint() { 16 | XCTAssertEqual(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa256Secret), "SHA256:/VQFeGyM8qKA8rB6WGMuZZxZLJln2UgXLk3F0uTF650") 17 | } 18 | 19 | func testECDSA256PublicKey() { 20 | XCTAssertEqual(writer.openSSHString(secret: Constants.ecdsa256Secret), 21 | "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=") 22 | } 23 | 24 | func testECDSA256Hash() { 25 | XCTAssertEqual(writer.data(secret: Constants.ecdsa256Secret), Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")) 26 | } 27 | 28 | func testECDSA384MD5Fingerprint() { 29 | XCTAssertEqual(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa384Secret), "66:e0:66:d7:41:ed:19:8e:e2:20:df:ce:ac:7e:2b:6e") 30 | } 31 | 32 | func testECDSA384SHA256Fingerprint() { 33 | XCTAssertEqual(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa384Secret), "SHA256:GJUEymQNL9ymaMRRJCMGY4rWIJHu/Lm8Yhao/PAiz1I") 34 | } 35 | 36 | func testECDSA384PublicKey() { 37 | XCTAssertEqual(writer.openSSHString(secret: Constants.ecdsa384Secret), 38 | "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==") 39 | } 40 | 41 | func testECDSA384Hash() { 42 | XCTAssertEqual(writer.data(secret: Constants.ecdsa384Secret), Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")) 43 | } 44 | 45 | } 46 | 47 | extension OpenSSHWriterTests { 48 | 49 | enum Constants { 50 | static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", algorithm: .ellipticCurve, keySize: 256, publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!) 51 | static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", algorithm: .ellipticCurve, keySize: 384, publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!) 52 | 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SecretAgent/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import OSLog 3 | import Combine 4 | import SecretKit 5 | import SecureEnclaveSecretKit 6 | import SmartCardSecretKit 7 | import SecretAgentKit 8 | import Brief 9 | 10 | @NSApplicationMain 11 | class AppDelegate: NSObject, NSApplicationDelegate { 12 | 13 | private let storeList: SecretStoreList = { 14 | let list = SecretStoreList() 15 | list.add(store: SecureEnclave.Store()) 16 | list.add(store: SmartCard.Store()) 17 | return list 18 | }() 19 | private let updater = Updater(checkOnLaunch: false) 20 | private let notifier = Notifier() 21 | private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) 22 | private lazy var agent: Agent = { 23 | Agent(storeList: storeList, witness: notifier) 24 | }() 25 | private lazy var socketController: SocketController = { 26 | let path = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") as String 27 | return SocketController(path: path) 28 | }() 29 | private var updateSink: AnyCancellable? 30 | private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AppDelegate") 31 | 32 | func applicationDidFinishLaunching(_ aNotification: Notification) { 33 | logger.debug("SecretAgent finished launching") 34 | DispatchQueue.main.async { 35 | self.socketController.handler = self.agent.handle(reader:writer:) 36 | } 37 | NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in 38 | try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) 39 | } 40 | try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) 41 | notifier.prompt() 42 | updateSink = updater.$update.sink { update in 43 | guard let update = update else { return } 44 | self.notifier.notify(update: update, ignore: self.updater.ignore(release:)) 45 | } 46 | } 47 | 48 | } 49 | 50 | -------------------------------------------------------------------------------- /Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "Mac Icon.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "256x256" 38 | }, 39 | { 40 | "filename" : "Mac Icon@0.25x.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "idiom" : "mac", 47 | "scale" : "1x", 48 | "size" : "512x512" 49 | }, 50 | { 51 | "idiom" : "mac", 52 | "scale" : "2x", 53 | "size" : "512x512" 54 | } 55 | ], 56 | "info" : { 57 | "author" : "xcode", 58 | "version" : 1 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon.png -------------------------------------------------------------------------------- /Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/Sources/SecretAgent/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png -------------------------------------------------------------------------------- /Sources/SecretAgent/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/SecretAgent/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(CI_VERSION) 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | LSUIElement 26 | 27 | NSHumanReadableCopyright 28 | $(PRODUCT_NAME) is MIT Licensed. 29 | NSMainStoryboardFile 30 | Main 31 | NSPrincipalClass 32 | NSApplication 33 | NSSupportsAutomaticTermination 34 | 35 | NSSupportsSuddenTermination 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Sources/SecretAgent/InternetAccessPolicy.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ApplicationDescription 6 | Secretive is an app for storing and managing SSH keys in the Secure Enclave. SecretAgent is a helper process that runs in the background to sign requests, so that you don't always have to keep the main Secretive app open. 7 | DeveloperName 8 | Max Goedjen 9 | Website 10 | https://github.com/maxgoedjen/secretive 11 | Connections 12 | 13 | 14 | IsIncoming 15 | 16 | Host 17 | api.github.com 18 | NetworkProtocol 19 | TCP 20 | Port 21 | 443 22 | Purpose 23 | Secretive checks GitHub for new versions and security updates. 24 | DenyConsequences 25 | If you deny these connections, you will not be notified about new versions and critical security updates. 26 | 27 | 28 | Services 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Sources/SecretAgent/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/SecretAgent/SecretAgent.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | com.apple.security.smartcard 10 | 11 | keychain-access-groups 12 | 13 | $(AppIdentifierPrefix)com.maxgoedjen.Secretive 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Sources/Secretive.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/Secretive.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/Secretive.xcodeproj/xcshareddata/xcschemes/SecretAgent.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 39 | 45 | 46 | 47 | 49 | 55 | 56 | 57 | 59 | 65 | 66 | 67 | 68 | 69 | 79 | 81 | 87 | 88 | 89 | 90 | 96 | 98 | 104 | 105 | 106 | 107 | 109 | 110 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /Sources/Secretive.xcodeproj/xcshareddata/xcschemes/Secretive.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 39 | 45 | 46 | 47 | 49 | 55 | 56 | 57 | 59 | 65 | 66 | 67 | 68 | 69 | 80 | 82 | 88 | 89 | 90 | 91 | 97 | 99 | 105 | 106 | 107 | 108 | 110 | 111 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /Sources/Secretive/App.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import SwiftUI 3 | import SecretKit 4 | import SecureEnclaveSecretKit 5 | import SmartCardSecretKit 6 | import Brief 7 | 8 | @main 9 | struct Secretive: App { 10 | 11 | private let storeList: SecretStoreList = { 12 | let list = SecretStoreList() 13 | list.add(store: SecureEnclave.Store()) 14 | list.add(store: SmartCard.Store()) 15 | return list 16 | }() 17 | private let agentStatusChecker = AgentStatusChecker() 18 | private let justUpdatedChecker = JustUpdatedChecker() 19 | 20 | @AppStorage("defaultsHasRunSetup") var hasRunSetup = false 21 | @State private var showingSetup = false 22 | @State private var showingCreation = false 23 | 24 | @SceneBuilder var body: some Scene { 25 | WindowGroup { 26 | ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup) 27 | .environmentObject(storeList) 28 | .environmentObject(Updater(checkOnLaunch: hasRunSetup)) 29 | .environmentObject(agentStatusChecker) 30 | .onAppear { 31 | if !hasRunSetup { 32 | showingSetup = true 33 | } 34 | } 35 | .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in 36 | guard hasRunSetup else { return } 37 | agentStatusChecker.check() 38 | if agentStatusChecker.running && justUpdatedChecker.justUpdated { 39 | // Relaunch the agent, since it'll be running from earlier update still 40 | reinstallAgent() 41 | } else if !agentStatusChecker.running && !agentStatusChecker.developmentBuild { 42 | forceLaunchAgent() 43 | } 44 | } 45 | } 46 | .commands { 47 | CommandGroup(after: CommandGroupPlacement.newItem) { 48 | Button("app_menu_new_secret_button") { 49 | showingCreation = true 50 | } 51 | .keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift])) 52 | } 53 | CommandGroup(replacing: .help) { 54 | Button("app_menu_help_button") { 55 | NSWorkspace.shared.open(Constants.helpURL) 56 | } 57 | } 58 | CommandGroup(after: .help) { 59 | Button("app_menu_setup_button") { 60 | showingSetup = true 61 | } 62 | } 63 | SidebarCommands() 64 | } 65 | } 66 | 67 | } 68 | 69 | extension Secretive { 70 | 71 | private func reinstallAgent() { 72 | justUpdatedChecker.check() 73 | LaunchAgentController().install { 74 | // Wait a second for launchd to kick in (next runloop isn't enough). 75 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 76 | agentStatusChecker.check() 77 | if !agentStatusChecker.running { 78 | forceLaunchAgent() 79 | } 80 | } 81 | } 82 | } 83 | 84 | private func forceLaunchAgent() { 85 | // We've run setup, we didn't just update, launchd is just not doing it's thing. 86 | // Force a launch directly. 87 | LaunchAgentController().forceLaunch { _ in 88 | agentStatusChecker.check() 89 | } 90 | } 91 | 92 | } 93 | 94 | 95 | private enum Constants { 96 | static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")! 97 | } 98 | 99 | -------------------------------------------------------------------------------- /Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "Mac Icon.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "256x256" 38 | }, 39 | { 40 | "filename" : "Mac Icon@0.25x.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "idiom" : "mac", 47 | "scale" : "1x", 48 | "size" : "512x512" 49 | }, 50 | { 51 | "idiom" : "mac", 52 | "scale" : "2x", 53 | "size" : "512x512" 54 | } 55 | ], 56 | "info" : { 57 | "author" : "xcode", 58 | "version" : 1 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon.png -------------------------------------------------------------------------------- /Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxgoedjen/secretive/ad5601990136e725f88219023d472c53df71bf74/Sources/Secretive/Assets.xcassets/AppIcon.appiconset/Mac Icon@0.25x.png -------------------------------------------------------------------------------- /Sources/Secretive/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Sources/Secretive/Controllers/AgentStatusChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import AppKit 4 | import SecretKit 5 | 6 | protocol AgentStatusCheckerProtocol: ObservableObject { 7 | var running: Bool { get } 8 | var developmentBuild: Bool { get } 9 | } 10 | 11 | class AgentStatusChecker: ObservableObject, AgentStatusCheckerProtocol { 12 | 13 | @Published var running: Bool = false 14 | 15 | init() { 16 | check() 17 | } 18 | 19 | func check() { 20 | running = instanceSecretAgentProcess != nil 21 | } 22 | 23 | // All processes, including ones from older versions, etc 24 | var secretAgentProcesses: [NSRunningApplication] { 25 | NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.agentBundleID) 26 | } 27 | 28 | // The process corresponding to this instance of Secretive 29 | var instanceSecretAgentProcess: NSRunningApplication? { 30 | let agents = secretAgentProcesses 31 | for agent in agents { 32 | guard let url = agent.bundleURL else { continue } 33 | if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) { 34 | return agent 35 | } 36 | } 37 | return nil 38 | } 39 | 40 | 41 | // Whether Secretive is being run in an Xcode environment. 42 | var developmentBuild: Bool { 43 | Bundle.main.bundleURL.absoluteString.contains("/Library/Developer/Xcode") 44 | } 45 | 46 | } 47 | 48 | 49 | -------------------------------------------------------------------------------- /Sources/Secretive/Controllers/ApplicationDirectoryController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ApplicationDirectoryController { 4 | } 5 | 6 | extension ApplicationDirectoryController { 7 | 8 | var isInApplicationsDirectory: Bool { 9 | let bundlePath = Bundle.main.bundlePath 10 | for directory in NSSearchPathForDirectoriesInDomains(.allApplicationsDirectory, .allDomainsMask, true) { 11 | if bundlePath.hasPrefix(directory) { 12 | return true 13 | } 14 | } 15 | if bundlePath.contains("/Library/Developer/Xcode") { 16 | return true 17 | } 18 | return false 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Secretive/Controllers/JustUpdatedChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import AppKit 4 | 5 | protocol JustUpdatedCheckerProtocol: ObservableObject { 6 | var justUpdated: Bool { get } 7 | } 8 | 9 | class JustUpdatedChecker: ObservableObject, JustUpdatedCheckerProtocol { 10 | 11 | @Published var justUpdated: Bool = false 12 | 13 | init() { 14 | check() 15 | } 16 | 17 | func check() { 18 | let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String ?? "None" 19 | let currentBuild = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String 20 | UserDefaults.standard.set(currentBuild, forKey: Constants.previousVersionUserDefaultsKey) 21 | justUpdated = lastBuild != currentBuild 22 | } 23 | 24 | 25 | 26 | } 27 | 28 | extension JustUpdatedChecker { 29 | 30 | enum Constants { 31 | static let previousVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastBuild" 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Secretive/Controllers/LaunchAgentController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ServiceManagement 3 | import AppKit 4 | import OSLog 5 | import SecretKit 6 | 7 | struct LaunchAgentController { 8 | 9 | private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController") 10 | 11 | func install(completion: (() -> Void)? = nil) { 12 | logger.debug("Installing agent") 13 | _ = setEnabled(false) 14 | // This is definitely a bit of a "seems to work better" thing but: 15 | // Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old 16 | // and start new? 17 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 18 | _ = setEnabled(true) 19 | completion?() 20 | } 21 | 22 | } 23 | 24 | func forceLaunch(completion: ((Bool) -> Void)?) { 25 | logger.debug("Agent is not running, attempting to force launch") 26 | let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app") 27 | let config = NSWorkspace.OpenConfiguration() 28 | config.activates = false 29 | NSWorkspace.shared.openApplication(at: url, configuration: config) { app, error in 30 | DispatchQueue.main.async { 31 | completion?(error == nil) 32 | } 33 | if let error = error { 34 | logger.error("Error force launching \(error.localizedDescription)") 35 | } else { 36 | logger.debug("Agent force launched") 37 | } 38 | } 39 | } 40 | 41 | private func setEnabled(_ enabled: Bool) -> Bool { 42 | SMLoginItemSetEnabled(Bundle.main.agentBundleID as CFString, enabled) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Secretive/Controllers/ShellConfigurationController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Cocoa 3 | import SecretKit 4 | 5 | struct ShellConfigurationController { 6 | 7 | let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String 8 | 9 | var shellInstructions: [ShellConfigInstruction] { 10 | [ 11 | ShellConfigInstruction(shell: "global", 12 | shellConfigDirectory: "~/.ssh/", 13 | shellConfigFilename: "config", 14 | text: "Host *\n\tIdentityAgent \(socketPath)"), 15 | ShellConfigInstruction(shell: "zsh", 16 | shellConfigDirectory: "~/", 17 | shellConfigFilename: ".zshrc", 18 | text: "export SSH_AUTH_SOCK=\(socketPath)"), 19 | ShellConfigInstruction(shell: "bash", 20 | shellConfigDirectory: "~/", 21 | shellConfigFilename: ".bashrc", 22 | text: "export SSH_AUTH_SOCK=\(socketPath)"), 23 | ShellConfigInstruction(shell: "fish", 24 | shellConfigDirectory: "~/.config/fish", 25 | shellConfigFilename: "config.fish", 26 | text: "set -x SSH_AUTH_SOCK \(socketPath)"), 27 | ] 28 | 29 | } 30 | 31 | 32 | @MainActor func addToShell(shellInstructions: ShellConfigInstruction) -> Bool { 33 | let openPanel = NSOpenPanel() 34 | // This is sync, so no need to strongly retain 35 | let delegate = Delegate(name: shellInstructions.shellConfigFilename) 36 | openPanel.delegate = delegate 37 | openPanel.message = "Select \(shellInstructions.shellConfigFilename) to let Secretive configure your shell automatically." 38 | openPanel.prompt = "Add to \(shellInstructions.shellConfigFilename)" 39 | openPanel.canChooseFiles = true 40 | openPanel.canChooseDirectories = false 41 | openPanel.showsHiddenFiles = true 42 | openPanel.directoryURL = URL(fileURLWithPath: shellInstructions.shellConfigDirectory) 43 | openPanel.nameFieldStringValue = shellInstructions.shellConfigFilename 44 | openPanel.allowedContentTypes = [.symbolicLink, .data, .plainText] 45 | openPanel.runModal() 46 | guard let fileURL = openPanel.urls.first else { return false } 47 | let handle: FileHandle 48 | do { 49 | handle = try FileHandle(forUpdating: fileURL) 50 | guard let existing = try handle.readToEnd(), 51 | let existingString = String(data: existing, encoding: .utf8) else { return false } 52 | guard !existingString.contains(shellInstructions.text) else { 53 | return true 54 | } 55 | try handle.seekToEnd() 56 | } catch { 57 | return false 58 | } 59 | handle.write("\n# Secretive Config\n\(shellInstructions.text)\n".data(using: .utf8)!) 60 | return true 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Secretive/Credits.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf2580 2 | \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;} 3 | {\colortbl;\red255\green255\blue255;} 4 | {\*\expandedcolortbl;;} 5 | \margl1440\margr1440\vieww9000\viewh8400\viewkind0 6 | \pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6119\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 7 | {\field{\*\fldinst{HYPERLINK "https://github.com/maxgoedjen/secretive"}}{\fldrslt 8 | \f0\fs24 \cf0 GitHub Repository}} 9 | \f0\fs24 \ 10 | \pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 11 | \cf0 \ 12 | {\field{\*\fldinst{HYPERLINK "GITHUB_BUILD_URL"}}{\fldrslt Build Log}}\ 13 | \ 14 | Special Thanks To:\ 15 | \ 16 | {\field{\*\fldinst{HYPERLINK "https://github.com/maxgoedjen/secretive/graphs/contributors"}}{\fldrslt Contributors}}:\ 17 | {\field{\*\fldinst{HYPERLINK "https://github.com/0xflotus"}}{\fldrslt 0xflotus}}\ 18 | {\field{\*\fldinst{HYPERLINK "https://github.com/aaron-trout"}}{\fldrslt Aaron Trout}}\ 19 | \pard\pardeftab720\partightenfactor0 20 | {\field{\*\fldinst{HYPERLINK "https://github.com/EppO"}}{\fldrslt \cf0 Florent Monbillard}}\ 21 | {\field{\*\fldinst{HYPERLINK "https://github.com/vladimyr"}}{\fldrslt Dario Vladovi\uc0\u263 }}\ 22 | {\field{\*\fldinst{HYPERLINK "https://github.com/lavalleeale"}}{\fldrslt Alex Lavallee}}\ 23 | {\field{\*\fldinst{HYPERLINK "https://github.com/joshheyse"}}{\fldrslt Josh}}\ 24 | {\field{\*\fldinst{HYPERLINK "https://github.com/diesal11"}}{\fldrslt Dylan Lundy}}\ 25 | \ 26 | \pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 27 | \cf0 Testers:\ 28 | {\field{\*\fldinst{HYPERLINK "https://github.com/bdash"}}{\fldrslt Mark Rowe}}\ 29 | {\field{\*\fldinst{HYPERLINK "https://github.com/danielctull"}}{\fldrslt Daniel Tull}}\ 30 | {\field{\*\fldinst{HYPERLINK "https://github.com/davedelong"}}{\fldrslt Dave DeLong}}\ 31 | {\field{\*\fldinst{HYPERLINK "https://github.com/esttorhe"}}{\fldrslt Esteban Torres}}\ 32 | {\field{\*\fldinst{HYPERLINK "https://github.com/joeblau"}}{\fldrslt Joe Blau}}\ 33 | {\field{\*\fldinst{HYPERLINK "https://github.com/marksands"}}{\fldrslt Mark Sands}}\ 34 | {\field{\*\fldinst{HYPERLINK "https://github.com/mergesort"}}{\fldrslt Joe Fabisevich}}\ 35 | {\field{\*\fldinst{HYPERLINK "https://github.com/phillco"}}{\fldrslt Phil Cohen}}\ 36 | {\field{\*\fldinst{HYPERLINK "https://github.com/zackdotcomputer"}}{\fldrslt Zack Sheppard}}} -------------------------------------------------------------------------------- /Sources/Secretive/Helpers/BundleIDs.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | extension Bundle { 5 | public var agentBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "Host", with: "SecretAgent"))!} 6 | public var hostBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "SecretAgent", with: "Host"))!} 7 | } 8 | -------------------------------------------------------------------------------- /Sources/Secretive/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(CI_VERSION) 21 | CFBundleVersion 22 | $(CI_BUILD_NUMBER) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | $(PRODUCT_NAME) is MIT Licensed. 27 | NSPrincipalClass 28 | NSApplication 29 | NSSupportsAutomaticTermination 30 | 31 | NSSupportsSuddenTermination 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Sources/Secretive/InternetAccessPolicy.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ApplicationDescription 6 | Secretive is an app for storing and managing SSH keys in the Secure Enclave 7 | DeveloperName 8 | Max Goedjen 9 | Website 10 | https://github.com/maxgoedjen/secretive 11 | Connections 12 | 13 | 14 | IsIncoming 15 | 16 | Host 17 | api.github.com 18 | NetworkProtocol 19 | TCP 20 | Port 21 | 443 22 | Purpose 23 | Secretive checks GitHub for new versions and security updates. 24 | DenyConsequences 25 | If you deny these connections, you will not be notified about new versions and critical security updates. 26 | 27 | 28 | Services 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Sources/Secretive/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Sources/Secretive/Preview Content/PreviewAgentStatusChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | class PreviewAgentStatusChecker: AgentStatusCheckerProtocol { 5 | 6 | let running: Bool 7 | let developmentBuild = false 8 | 9 | init(running: Bool = true) { 10 | self.running = running 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Secretive/Preview Content/PreviewStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SecretKit 3 | 4 | enum Preview {} 5 | 6 | extension Preview { 7 | 8 | struct Secret: SecretKit.Secret { 9 | 10 | let id = UUID().uuidString 11 | let name: String 12 | let algorithm = Algorithm.ellipticCurve 13 | let keySize = 256 14 | let requiresAuthentication: Bool = false 15 | let publicKey = UUID().uuidString.data(using: .utf8)! 16 | 17 | } 18 | 19 | } 20 | 21 | extension Preview { 22 | 23 | class Store: SecretStore, ObservableObject { 24 | 25 | let isAvailable = true 26 | let id = UUID() 27 | var name: String { "Preview Store" } 28 | @Published var secrets: [Secret] = [] 29 | 30 | init(secrets: [Secret]) { 31 | self.secrets.append(contentsOf: secrets) 32 | } 33 | 34 | init(numberOfRandomSecrets: Int = 5) { 35 | let new = (0.. Data { 40 | return data 41 | } 42 | 43 | func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool { 44 | true 45 | } 46 | 47 | func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? { 48 | nil 49 | } 50 | 51 | func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws { 52 | } 53 | 54 | func reloadSecrets() { 55 | } 56 | 57 | } 58 | 59 | class StoreModifiable: Store, SecretStoreModifiable { 60 | override var name: String { "Modifiable Preview Store" } 61 | 62 | func create(name: String, requiresAuthentication: Bool) throws { 63 | } 64 | 65 | func delete(secret: Preview.Secret) throws { 66 | } 67 | 68 | func update(secret: Preview.Secret, name: String) throws { 69 | } 70 | } 71 | } 72 | 73 | extension Preview { 74 | 75 | static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList { 76 | let list = SecretStoreList() 77 | for store in stores { 78 | list.add(store: store) 79 | } 80 | for storeModifiable in modifiableStores { 81 | list.add(store: storeModifiable) 82 | } 83 | return list 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /Sources/Secretive/Preview Content/PreviewUpdater.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import Brief 4 | 5 | class PreviewUpdater: UpdaterProtocol { 6 | 7 | let update: Release? 8 | let testBuild = false 9 | 10 | init(update: Update = .none) { 11 | switch update { 12 | case .none: 13 | self.update = nil 14 | case .advisory: 15 | self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update") 16 | case .critical: 17 | self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update") 18 | } 19 | } 20 | 21 | } 22 | 23 | extension PreviewUpdater { 24 | 25 | enum Update { 26 | case none, advisory, critical 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Secretive/Secretive.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.smartcard 12 | 13 | keychain-access-groups 14 | 15 | $(AppIdentifierPrefix)com.maxgoedjen.Secretive 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SecretKit 3 | import SecureEnclaveSecretKit 4 | import SmartCardSecretKit 5 | import Brief 6 | 7 | struct ContentView: View { 8 | 9 | @Binding var showingCreation: Bool 10 | @Binding var runningSetup: Bool 11 | @Binding var hasRunSetup: Bool 12 | @State var showingAgentInfo = false 13 | @State var activeSecret: AnySecret.ID? 14 | @Environment(\.colorScheme) var colorScheme 15 | 16 | @EnvironmentObject private var storeList: SecretStoreList 17 | @EnvironmentObject private var updater: UpdaterType 18 | @EnvironmentObject private var agentStatusChecker: AgentStatusCheckerType 19 | 20 | @State private var selectedUpdate: Release? 21 | @State private var showingAppPathNotice = false 22 | 23 | var body: some View { 24 | VStack { 25 | if storeList.anyAvailable { 26 | StoreListView(activeSecret: $activeSecret) 27 | } else { 28 | NoStoresView() 29 | } 30 | } 31 | .frame(minWidth: 640, minHeight: 320) 32 | .toolbar { 33 | toolbarItem(updateNoticeView, id: "update") 34 | toolbarItem(runningOrRunSetupView, id: "setup") 35 | toolbarItem(appPathNoticeView, id: "appPath") 36 | toolbarItem(newItemView, id: "new") 37 | } 38 | .sheet(isPresented: $runningSetup) { 39 | SetupView(visible: $runningSetup, setupComplete: $hasRunSetup) 40 | } 41 | } 42 | 43 | } 44 | 45 | extension ContentView { 46 | 47 | 48 | func toolbarItem(_ view: some View, id: String) -> ToolbarItem { 49 | ToolbarItem(id: id) { view } 50 | } 51 | 52 | var needsSetup: Bool { 53 | (runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild 54 | } 55 | 56 | /// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message 57 | /// These two are mutually exclusive 58 | @ViewBuilder 59 | var runningOrRunSetupView: some View { 60 | if needsSetup { 61 | setupNoticeView 62 | } else { 63 | runningNoticeView 64 | } 65 | } 66 | 67 | var updateNoticeContent: (LocalizedStringKey, Color)? { 68 | guard let update = updater.update else { return nil } 69 | if update.critical { 70 | return ("update_critical_notice_title", .red) 71 | } else { 72 | if updater.testBuild { 73 | return ("update_test_notice_title", .blue) 74 | } else { 75 | return ("update_normal_notice_title", .orange) 76 | } 77 | } 78 | } 79 | 80 | @ViewBuilder 81 | var updateNoticeView: some View { 82 | if let update = updater.update, let (text, color) = updateNoticeContent { 83 | Button(action: { 84 | selectedUpdate = update 85 | }, label: { 86 | Text(text) 87 | .font(.headline) 88 | .foregroundColor(.white) 89 | }) 90 | .buttonStyle(ToolbarButtonStyle(color: color)) 91 | .popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in 92 | UpdateDetailView(update: update) 93 | } 94 | } 95 | } 96 | 97 | @ViewBuilder 98 | var newItemView: some View { 99 | if storeList.modifiableStore?.isAvailable ?? false { 100 | Button(action: { 101 | showingCreation = true 102 | }, label: { 103 | Image(systemName: "plus") 104 | }) 105 | .sheet(isPresented: $showingCreation) { 106 | if let modifiable = storeList.modifiableStore { 107 | CreateSecretView(store: modifiable, showing: $showingCreation) 108 | .onDisappear { 109 | guard let newest = modifiable.secrets.last?.id else { return } 110 | activeSecret = newest 111 | } 112 | } 113 | } 114 | } 115 | } 116 | 117 | @ViewBuilder 118 | var setupNoticeView: some View { 119 | Button(action: { 120 | runningSetup = true 121 | }, label: { 122 | Group { 123 | if hasRunSetup && !agentStatusChecker.running { 124 | Text("agent_not_running_notice_title") 125 | } else { 126 | Text("agent_setup_notice_title") 127 | } 128 | } 129 | .font(.headline) 130 | .foregroundColor(.white) 131 | }) 132 | .buttonStyle(ToolbarButtonStyle(color: .orange)) 133 | } 134 | 135 | @ViewBuilder 136 | var runningNoticeView: some View { 137 | Button(action: { 138 | showingAgentInfo = true 139 | }, label: { 140 | HStack { 141 | Text("agent_running_notice_title") 142 | .font(.headline) 143 | .foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white) 144 | Circle() 145 | .frame(width: 10, height: 10) 146 | .foregroundColor(Color.green) 147 | } 148 | }) 149 | .buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05))) 150 | .popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { 151 | VStack { 152 | Text("agent_running_notice_detail_title") 153 | .font(.title) 154 | .padding(5) 155 | Text("agent_running_notice_detail_description") 156 | .frame(width: 300) 157 | } 158 | .padding() 159 | } 160 | } 161 | 162 | @ViewBuilder 163 | var appPathNoticeView: some View { 164 | if !ApplicationDirectoryController().isInApplicationsDirectory { 165 | Button(action: { 166 | showingAppPathNotice = true 167 | }, label: { 168 | Group { 169 | Text("app_not_in_applications_notice_title") 170 | } 171 | .font(.headline) 172 | .foregroundColor(.white) 173 | }) 174 | .buttonStyle(ToolbarButtonStyle(color: .orange)) 175 | .popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { 176 | VStack { 177 | Image(systemName: "exclamationmark.triangle") 178 | .resizable() 179 | .aspectRatio(contentMode: .fit) 180 | .frame(width: 64) 181 | Text("app_not_in_applications_notice_detail_description") 182 | .frame(maxWidth: 300) 183 | } 184 | .padding() 185 | } 186 | } 187 | } 188 | 189 | var attachmentAnchor: PopoverAttachmentAnchor { 190 | // Ideally .point(.bottom), but broken on Sonoma (FB12726503) 191 | .rect(.bounds) 192 | } 193 | 194 | } 195 | 196 | #if DEBUG 197 | 198 | struct ContentView_Previews: PreviewProvider { 199 | 200 | private static let storeList: SecretStoreList = { 201 | let list = SecretStoreList() 202 | list.add(store: SecureEnclave.Store()) 203 | list.add(store: SmartCard.Store()) 204 | return list 205 | }() 206 | private static let agentStatusChecker = AgentStatusChecker() 207 | private static let justUpdatedChecker = JustUpdatedChecker() 208 | 209 | @State var hasRunSetup = false 210 | @State private var showingSetup = false 211 | @State private var showingCreation = false 212 | 213 | static var previews: some View { 214 | Group { 215 | // Empty on modifiable and nonmodifiable 216 | ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true)) 217 | .environmentObject(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)])) 218 | .environmentObject(PreviewUpdater()) 219 | .environmentObject(agentStatusChecker) 220 | 221 | // 5 items on modifiable and nonmodifiable 222 | ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true)) 223 | .environmentObject(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()])) 224 | .environmentObject(PreviewUpdater()) 225 | .environmentObject(agentStatusChecker) 226 | } 227 | .environmentObject(agentStatusChecker) 228 | 229 | } 230 | } 231 | 232 | #endif 233 | 234 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/CopyableView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UniformTypeIdentifiers 3 | 4 | struct CopyableView: View { 5 | 6 | var title: LocalizedStringKey 7 | var image: Image 8 | var text: String 9 | 10 | @State private var interactionState: InteractionState = .normal 11 | @Environment(\.colorScheme) private var colorScheme 12 | 13 | var body: some View { 14 | VStack(alignment: .leading) { 15 | HStack { 16 | image 17 | .renderingMode(.template) 18 | .imageScale(.large) 19 | .foregroundColor(primaryTextColor) 20 | Text(title) 21 | .font(.headline) 22 | .foregroundColor(primaryTextColor) 23 | Spacer() 24 | if interactionState != .normal { 25 | Text(hoverText) 26 | .bold() 27 | .textCase(.uppercase) 28 | .foregroundColor(secondaryTextColor) 29 | .transition(.opacity) 30 | } 31 | 32 | } 33 | .padding(EdgeInsets(top: 20, leading: 20, bottom: 10, trailing: 20)) 34 | Divider() 35 | Text(text) 36 | .fixedSize(horizontal: false, vertical: true) 37 | .foregroundColor(primaryTextColor) 38 | .padding(EdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20)) 39 | .multilineTextAlignment(.leading) 40 | .font(.system(.body, design: .monospaced)) 41 | } 42 | .background(backgroundColor) 43 | .frame(minWidth: 150, maxWidth: .infinity) 44 | .cornerRadius(10) 45 | .onHover { hovering in 46 | withAnimation { 47 | interactionState = hovering ? .hovering : .normal 48 | } 49 | } 50 | .onDrag { 51 | NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier) 52 | } 53 | .onTapGesture { 54 | copy() 55 | withAnimation { 56 | interactionState = .clicking 57 | } 58 | } 59 | .gesture( 60 | TapGesture() 61 | .onEnded { 62 | withAnimation { 63 | interactionState = .normal 64 | } 65 | } 66 | ) 67 | } 68 | 69 | var hoverText: LocalizedStringKey { 70 | switch interactionState { 71 | case .hovering: 72 | return "copyable_click_to_copy_button" 73 | case .clicking: 74 | return "copyable_copied" 75 | case .normal: 76 | fatalError() 77 | } 78 | } 79 | 80 | var backgroundColor: Color { 81 | switch interactionState { 82 | case .normal: 83 | return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885) 84 | case .hovering: 85 | return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82) 86 | case .clicking: 87 | return .accentColor 88 | } 89 | } 90 | 91 | var primaryTextColor: Color { 92 | switch interactionState { 93 | case .normal, .hovering: 94 | return Color(.textColor) 95 | case .clicking: 96 | return .white 97 | } 98 | } 99 | 100 | var secondaryTextColor: Color { 101 | switch interactionState { 102 | case .normal, .hovering: 103 | return Color(.secondaryLabelColor) 104 | case .clicking: 105 | return .white 106 | } 107 | } 108 | 109 | func copy() { 110 | NSPasteboard.general.declareTypes([.string], owner: nil) 111 | NSPasteboard.general.setString(text, forType: .string) 112 | } 113 | 114 | private enum InteractionState { 115 | case normal, hovering, clicking 116 | } 117 | 118 | } 119 | 120 | #if DEBUG 121 | 122 | struct CopyableView_Previews: PreviewProvider { 123 | static var previews: some View { 124 | Group { 125 | CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "figure.wave"), text: "Hello world.") 126 | .padding() 127 | 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. ") 128 | .padding() 129 | } 130 | } 131 | } 132 | 133 | #endif 134 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/CreateSecretView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SecretKit 3 | 4 | struct CreateSecretView: View { 5 | 6 | @ObservedObject var store: StoreType 7 | @Binding var showing: Bool 8 | 9 | @State private var name = "" 10 | @State private var requiresAuthentication = true 11 | 12 | var body: some View { 13 | VStack { 14 | HStack { 15 | VStack { 16 | HStack { 17 | Text("create_secret_title") 18 | .font(.largeTitle) 19 | Spacer() 20 | } 21 | HStack { 22 | Text("create_secret_name_label") 23 | TextField("create_secret_name_placeholder", text: $name) 24 | .focusable() 25 | } 26 | ThumbnailPickerView(items: [ 27 | ThumbnailPickerView.Item(value: true, name: "create_secret_require_authentication_title", description: "create_secret_require_authentication_description", thumbnail: AuthenticationView()), 28 | ThumbnailPickerView.Item(value: false, name: "create_secret_notify_title", 29 | description: "create_secret_notify_description", 30 | thumbnail: NotificationView()) 31 | ], selection: $requiresAuthentication) 32 | } 33 | } 34 | HStack { 35 | Spacer() 36 | Button("create_secret_cancel_button") { 37 | showing = false 38 | } 39 | .keyboardShortcut(.cancelAction) 40 | Button("create_secret_create_button", action: save) 41 | .disabled(name.isEmpty) 42 | .keyboardShortcut(.defaultAction) 43 | } 44 | }.padding() 45 | } 46 | 47 | func save() { 48 | try! store.create(name: name, requiresAuthentication: requiresAuthentication) 49 | showing = false 50 | } 51 | 52 | } 53 | 54 | struct ThumbnailPickerView: View { 55 | 56 | private let items: [Item] 57 | @Binding var selection: ValueType 58 | 59 | init(items: [ThumbnailPickerView.Item], selection: Binding) { 60 | self.items = items 61 | _selection = selection 62 | } 63 | 64 | var body: some View { 65 | HStack(alignment: .top) { 66 | ForEach(items) { item in 67 | VStack(alignment: .leading, spacing: 15) { 68 | item.thumbnail 69 | .frame(height: 200) 70 | .overlay(RoundedRectangle(cornerRadius: 10) 71 | .stroke(lineWidth: item.value == selection ? 15 : 0)) 72 | .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 73 | .foregroundColor(.accentColor) 74 | VStack(alignment: .leading, spacing: 5) { 75 | Text(item.name) 76 | .bold() 77 | Text(item.description) 78 | .fixedSize(horizontal: false, vertical: true) 79 | } 80 | } 81 | .frame(width: 250) 82 | .onTapGesture { 83 | withAnimation(.spring()) { 84 | selection = item.value 85 | } 86 | } 87 | } 88 | .padding(5) 89 | } 90 | } 91 | 92 | } 93 | 94 | extension ThumbnailPickerView { 95 | 96 | struct Item: Identifiable { 97 | let id = UUID() 98 | let value: ValueType 99 | let name: LocalizedStringKey 100 | let description: LocalizedStringKey 101 | let thumbnail: AnyView 102 | 103 | init(value: ValueType, name: LocalizedStringKey, description: LocalizedStringKey, thumbnail: ViewType) { 104 | self.value = value 105 | self.name = name 106 | self.description = description 107 | self.thumbnail = AnyView(thumbnail) 108 | } 109 | } 110 | 111 | } 112 | 113 | @MainActor class SystemBackground: ObservableObject { 114 | 115 | static let shared = SystemBackground() 116 | @Published var image: NSImage? 117 | 118 | private init() { 119 | if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) { 120 | image = NSImage(contentsOf: imageURL) 121 | } else { 122 | image = nil 123 | } 124 | } 125 | 126 | } 127 | 128 | struct SystemBackgroundView: View { 129 | 130 | let anchor: UnitPoint 131 | 132 | var body: some View { 133 | if let image = SystemBackground.shared.image { 134 | Image(nsImage: image) 135 | .resizable() 136 | .scaleEffect(3, anchor: anchor) 137 | .clipped() 138 | .allowsHitTesting(false) 139 | } else { 140 | Rectangle() 141 | .foregroundColor(Color(.systemPurple)) 142 | } 143 | } 144 | } 145 | 146 | struct AuthenticationView: View { 147 | 148 | var body: some View { 149 | ZStack { 150 | SystemBackgroundView(anchor: .center) 151 | GeometryReader { geometry in 152 | VStack { 153 | Image(systemName: "touchid") 154 | .resizable() 155 | .aspectRatio(contentMode: .fit) 156 | .foregroundColor(Color(.systemRed)) 157 | Text(verbatim: "Touch ID Prompt") 158 | .font(.headline) 159 | .foregroundColor(.primary) 160 | .redacted(reason: .placeholder) 161 | VStack { 162 | Text(verbatim: "Touch ID Detail prompt.Detail two.") 163 | .font(.caption2) 164 | .foregroundColor(.primary) 165 | Text(verbatim: "Touch ID Detail prompt.Detail two.") 166 | .font(.caption2) 167 | .foregroundColor(.primary) 168 | } 169 | .redacted(reason: .placeholder) 170 | RoundedRectangle(cornerRadius: 5) 171 | .frame(width: geometry.size.width, height: 20, alignment: .center) 172 | .foregroundColor(.accentColor) 173 | RoundedRectangle(cornerRadius: 5) 174 | .frame(width: geometry.size.width, height: 20, alignment: .center) 175 | .foregroundColor(Color(.unemphasizedSelectedContentBackgroundColor)) 176 | } 177 | } 178 | .padding() 179 | .frame(width: 150) 180 | .background( 181 | RoundedRectangle(cornerRadius: 15) 182 | .foregroundStyle(.ultraThickMaterial) 183 | ) 184 | .padding() 185 | 186 | } 187 | } 188 | 189 | } 190 | 191 | struct NotificationView: View { 192 | 193 | var body: some View { 194 | ZStack { 195 | SystemBackgroundView(anchor: .topTrailing) 196 | VStack { 197 | Rectangle() 198 | .background(Color.clear) 199 | .foregroundStyle(.thinMaterial) 200 | .frame(height: 35) 201 | VStack { 202 | HStack { 203 | Spacer() 204 | HStack { 205 | Image(nsImage: NSApplication.shared.applicationIconImage) 206 | .resizable() 207 | .frame(width: 64, height: 64) 208 | .foregroundColor(.primary) 209 | VStack(alignment: .leading) { 210 | Text(verbatim: "Secretive") 211 | .font(.title) 212 | .foregroundColor(.primary) 213 | Text(verbatim: "Secretive wants to sign") 214 | .font(.body) 215 | .foregroundColor(.primary) 216 | } 217 | }.padding() 218 | .redacted(reason: .placeholder) 219 | .background( 220 | RoundedRectangle(cornerRadius: 15) 221 | .foregroundStyle(.ultraThickMaterial) 222 | ) 223 | } 224 | Spacer() 225 | } 226 | .padding() 227 | } 228 | } 229 | } 230 | 231 | } 232 | 233 | #if DEBUG 234 | 235 | struct CreateSecretView_Previews: PreviewProvider { 236 | 237 | static var previews: some View { 238 | Group { 239 | CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true)) 240 | AuthenticationView().environment(\.colorScheme, .dark) 241 | AuthenticationView().environment(\.colorScheme, .light) 242 | NotificationView().environment(\.colorScheme, .dark) 243 | NotificationView().environment(\.colorScheme, .light) 244 | } 245 | } 246 | } 247 | 248 | #endif 249 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/DeleteSecretView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SecretKit 3 | 4 | struct DeleteSecretView: View { 5 | 6 | @ObservedObject var store: StoreType 7 | let secret: StoreType.SecretType 8 | var dismissalBlock: (Bool) -> () 9 | 10 | @State private var confirm = "" 11 | 12 | var body: some View { 13 | VStack { 14 | HStack { 15 | Image(nsImage: NSApplication.shared.applicationIconImage) 16 | .resizable() 17 | .frame(width: 64, height: 64) 18 | .padding() 19 | VStack { 20 | HStack { 21 | Text("delete_confirmation_title_\(secret.name)").bold() 22 | Spacer() 23 | } 24 | HStack { 25 | Text("delete_confirmation_description_\(secret.name)_\(secret.name)") 26 | Spacer() 27 | } 28 | HStack { 29 | Text("delete_confirmation_confirm_name_label") 30 | TextField(secret.name, text: $confirm) 31 | } 32 | } 33 | } 34 | HStack { 35 | Spacer() 36 | Button("delete_confirmation_delete_button", action: delete) 37 | .disabled(confirm != secret.name) 38 | Button("delete_confirmation_cancel_button") { 39 | dismissalBlock(false) 40 | } 41 | .keyboardShortcut(.cancelAction) 42 | } 43 | } 44 | .padding() 45 | .frame(minWidth: 400) 46 | .onExitCommand { 47 | dismissalBlock(false) 48 | } 49 | } 50 | 51 | func delete() { 52 | try! store.delete(secret: secret) 53 | dismissalBlock(true) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/EmptyStoreView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SecretKit 3 | 4 | struct EmptyStoreView: View { 5 | 6 | @ObservedObject var store: AnySecretStore 7 | @Binding var activeSecret: AnySecret.ID? 8 | 9 | var body: some View { 10 | if store is AnySecretStoreModifiable { 11 | NavigationLink(destination: EmptyStoreModifiableView(), tag: Constants.emptyStoreModifiableTag, selection: $activeSecret) { 12 | Text("empty_store_modifiable_title") 13 | } 14 | } else { 15 | NavigationLink(destination: EmptyStoreImmutableView(), tag: Constants.emptyStoreTag, selection: $activeSecret) { 16 | Text("empty_store_nonmodifiable_title") 17 | } 18 | } 19 | } 20 | } 21 | 22 | extension EmptyStoreView { 23 | 24 | enum Constants { 25 | static let emptyStoreModifiableTag: AnyHashable = "emptyStoreModifiableTag" 26 | static let emptyStoreTag: AnyHashable = "emptyStoreTag" 27 | } 28 | 29 | } 30 | 31 | struct EmptyStoreImmutableView: View { 32 | 33 | var body: some View { 34 | VStack { 35 | Text("empty_store_nonmodifiable_title").bold() 36 | Text("empty_store_nonmodifiable_description") 37 | Text("empty_store_nonmodifiable_supported_key_types") 38 | }.frame(maxWidth: .infinity, maxHeight: .infinity) 39 | } 40 | 41 | } 42 | 43 | struct EmptyStoreModifiableView: View { 44 | 45 | var body: some View { 46 | GeometryReader { windowGeometry in 47 | VStack { 48 | GeometryReader { g in 49 | Path { path in 50 | path.move(to: CGPoint(x: g.size.width / 2, y: g.size.height)) 51 | path.addCurve(to: 52 | CGPoint(x: g.size.width * (3/4), y: g.size.height * (1/2)), control1: 53 | CGPoint(x: g.size.width / 2, y: g.size.height * (1/2)), control2: 54 | CGPoint(x: g.size.width * (3/4), y: g.size.height * (1/2))) 55 | path.addCurve(to: 56 | CGPoint(x: g.size.width - 13, y: 0), control1: 57 | CGPoint(x: g.size.width - 13 , y: g.size.height * (1/2)), control2: 58 | CGPoint(x: g.size.width - 13, y: 0)) 59 | }.stroke(style: StrokeStyle(lineWidth: 5, lineCap: .round)) 60 | Path { path in 61 | path.move(to: CGPoint(x: g.size.width - 23, y: 0)) 62 | path.addLine(to: CGPoint(x: g.size.width - 13, y: -10)) 63 | path.addLine(to: CGPoint(x: g.size.width - 3, y: 0)) 64 | }.fill() 65 | }.frame(height: (windowGeometry.size.height/2) - 20).padding() 66 | Text("empty_store_modifiable_click_here_title").bold() 67 | Text("empty_store_modifiable_click_here_description") 68 | Spacer() 69 | }.frame(maxWidth: .infinity, maxHeight: .infinity) 70 | } 71 | } 72 | } 73 | 74 | #if DEBUG 75 | 76 | struct EmptyStoreModifiableView_Previews: PreviewProvider { 77 | static var previews: some View { 78 | Group { 79 | EmptyStoreImmutableView() 80 | EmptyStoreModifiableView() 81 | } 82 | } 83 | } 84 | 85 | #endif 86 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/NoStoresView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct NoStoresView: View { 4 | 5 | var body: some View { 6 | VStack { 7 | Text("no_secure_storage_title") 8 | .bold() 9 | Text("no_secure_storage_description") 10 | Link("no_secure_storage_yubico_link", destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!) 11 | }.padding() 12 | } 13 | 14 | } 15 | 16 | #if DEBUG 17 | 18 | struct NoStoresView_Previews: PreviewProvider { 19 | static var previews: some View { 20 | NoStoresView() 21 | } 22 | } 23 | 24 | #endif 25 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/RenameSecretView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SecretKit 3 | 4 | struct RenameSecretView: View { 5 | 6 | @ObservedObject var store: StoreType 7 | let secret: StoreType.SecretType 8 | var dismissalBlock: (_ renamed: Bool) -> () 9 | 10 | @State private var newName = "" 11 | 12 | var body: some View { 13 | VStack { 14 | HStack { 15 | Image(nsImage: NSApplication.shared.applicationIconImage) 16 | .resizable() 17 | .frame(width: 64, height: 64) 18 | .padding() 19 | VStack { 20 | HStack { 21 | Text("rename_title_\(secret.name)") 22 | Spacer() 23 | } 24 | HStack { 25 | TextField(secret.name, text: $newName).focusable() 26 | } 27 | } 28 | } 29 | HStack { 30 | Spacer() 31 | Button("rename_rename_button", action: rename) 32 | .disabled(newName.count == 0) 33 | .keyboardShortcut(.return) 34 | Button("rename_cancel_button") { 35 | dismissalBlock(false) 36 | }.keyboardShortcut(.cancelAction) 37 | } 38 | } 39 | .padding() 40 | .frame(minWidth: 400) 41 | .onExitCommand { 42 | dismissalBlock(false) 43 | } 44 | } 45 | 46 | func rename() { 47 | try? store.update(secret: secret, name: newName) 48 | dismissalBlock(true) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/SecretDetailView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SecretKit 3 | 4 | struct SecretDetailView: View { 5 | 6 | @State var secret: SecretType 7 | 8 | private let keyWriter = OpenSSHKeyWriter() 9 | private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID)) 10 | 11 | var body: some View { 12 | ScrollView { 13 | Form { 14 | Section { 15 | CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret)) 16 | Spacer() 17 | .frame(height: 20) 18 | CopyableView(title: "secret_detail_md5_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret)) 19 | Spacer() 20 | .frame(height: 20) 21 | CopyableView(title: "secret_detail_public_key_label", image: Image(systemName: "key"), text: keyString) 22 | Spacer() 23 | .frame(height: 20) 24 | CopyableView(title: "secret_detail_public_key_path_label", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret)) 25 | Spacer() 26 | } 27 | } 28 | .padding() 29 | } 30 | .frame(minHeight: 200, maxHeight: .infinity) 31 | } 32 | 33 | var dashedKeyName: String { 34 | secret.name.replacingOccurrences(of: " ", with: "-") 35 | } 36 | 37 | var dashedHostName: String { 38 | ["secretive", Host.current().localizedName, "local"] 39 | .compactMap { $0 } 40 | .joined(separator: ".") 41 | .replacingOccurrences(of: " ", with: "-") 42 | } 43 | 44 | var keyString: String { 45 | keyWriter.openSSHString(secret: secret, comment: "\(dashedKeyName)@\(dashedHostName)") 46 | } 47 | 48 | } 49 | 50 | #if DEBUG 51 | 52 | struct SecretDetailView_Previews: PreviewProvider { 53 | static var previews: some View { 54 | SecretDetailView(secret: Preview.Store(numberOfRandomSecrets: 1).secrets[0]) 55 | } 56 | } 57 | 58 | #endif 59 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/SecretListItemView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SecretKit 3 | 4 | struct SecretListItemView: View { 5 | 6 | @ObservedObject var store: AnySecretStore 7 | var secret: AnySecret 8 | @Binding var activeSecret: AnySecret.ID? 9 | 10 | @State var isDeleting: Bool = false 11 | @State var isRenaming: Bool = false 12 | 13 | var deletedSecret: (AnySecret) -> Void 14 | var renamedSecret: (AnySecret) -> Void 15 | 16 | var body: some View { 17 | let showingPopupWrapped = Binding( 18 | get: { isDeleting || isRenaming }, 19 | set: { if $0 == false { isDeleting = false; isRenaming = false } } 20 | ) 21 | 22 | return NavigationLink(destination: SecretDetailView(secret: secret), tag: secret.id, selection: $activeSecret) { 23 | if secret.requiresAuthentication { 24 | HStack { 25 | Text(secret.name) 26 | Spacer() 27 | Image(systemName: "lock") 28 | } 29 | } else { 30 | Text(secret.name) 31 | } 32 | } 33 | .contextMenu { 34 | if store is AnySecretStoreModifiable { 35 | Button(action: { isRenaming = true }) { 36 | Text("secret_list_rename_button") 37 | } 38 | Button(action: { isDeleting = true }) { 39 | Text("secret_list_delete_button") 40 | } 41 | } 42 | } 43 | .popover(isPresented: showingPopupWrapped) { 44 | if let modifiable = store as? AnySecretStoreModifiable { 45 | if isDeleting { 46 | DeleteSecretView(store: modifiable, secret: secret) { deleted in 47 | isDeleting = false 48 | if deleted { 49 | deletedSecret(secret) 50 | } 51 | } 52 | } else if isRenaming { 53 | RenameSecretView(store: modifiable, secret: secret) { renamed in 54 | isRenaming = false 55 | if renamed { 56 | renamedSecret(secret) 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/SetupView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SetupView: View { 4 | 5 | @State var stepIndex = 0 6 | @Binding var visible: Bool 7 | @Binding var setupComplete: Bool 8 | 9 | var body: some View { 10 | GeometryReader { proxy in 11 | VStack { 12 | StepView(numberOfSteps: 3, currentStep: stepIndex, width: proxy.size.width) 13 | GeometryReader { _ in 14 | HStack(spacing: 0) { 15 | SecretAgentSetupView(buttonAction: advance) 16 | .frame(width: proxy.size.width) 17 | SSHAgentSetupView(buttonAction: advance) 18 | .frame(width: proxy.size.width) 19 | UpdaterExplainerView { 20 | visible = false 21 | setupComplete = true 22 | } 23 | .frame(width: proxy.size.width) 24 | } 25 | .offset(x: -proxy.size.width * Double(stepIndex), y: 0) 26 | } 27 | } 28 | } 29 | .frame(minWidth: 500, idealWidth: 500, minHeight: 500, idealHeight: 500) 30 | } 31 | 32 | 33 | func advance() { 34 | withAnimation(.spring()) { 35 | stepIndex += 1 36 | } 37 | } 38 | 39 | } 40 | 41 | struct StepView: View { 42 | 43 | let numberOfSteps: Int 44 | let currentStep: Int 45 | 46 | // Ideally we'd have a geometry reader inside this view doing this for us, but that crashes on 11.0b7 47 | let width: Double 48 | 49 | var body: some View { 50 | ZStack(alignment: .leading) { 51 | Rectangle() 52 | .foregroundColor(.blue) 53 | .frame(height: 5) 54 | Rectangle() 55 | .foregroundColor(.green) 56 | .frame(width: max(0, ((width - (Constants.padding * 2)) / Double(numberOfSteps - 1)) * Double(currentStep) - (Constants.circleWidth / 2)), height: 5) 57 | HStack { 58 | ForEach(0.. index { 61 | Circle() 62 | .foregroundColor(.green) 63 | .frame(width: Constants.circleWidth, height: Constants.circleWidth) 64 | Text("setup_step_complete_symbol") 65 | .foregroundColor(.white) 66 | .bold() 67 | } else { 68 | Circle() 69 | .foregroundColor(.blue) 70 | .frame(width: Constants.circleWidth, height: Constants.circleWidth) 71 | if currentStep == index { 72 | Circle() 73 | .strokeBorder(Color.white, lineWidth: 3) 74 | .frame(width: Constants.circleWidth, height: Constants.circleWidth) 75 | } 76 | Text(String(describing: index + 1)) 77 | .foregroundColor(.white) 78 | .bold() 79 | } 80 | } 81 | if index < numberOfSteps - 1 { 82 | Spacer(minLength: 30) 83 | } 84 | } 85 | } 86 | }.padding(Constants.padding) 87 | } 88 | 89 | } 90 | 91 | extension StepView { 92 | 93 | enum Constants { 94 | 95 | static let padding: Double = 15 96 | static let circleWidth: Double = 30 97 | 98 | } 99 | 100 | } 101 | 102 | struct SetupStepView : View where Content : View { 103 | 104 | let title: LocalizedStringKey 105 | let image: Image 106 | let bodyText: LocalizedStringKey 107 | let buttonTitle: LocalizedStringKey 108 | let buttonAction: () -> Void 109 | let content: Content 110 | 111 | init(title: LocalizedStringKey, image: Image, bodyText: LocalizedStringKey, buttonTitle: LocalizedStringKey, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) { 112 | self.title = title 113 | self.image = image 114 | self.bodyText = bodyText 115 | self.buttonTitle = buttonTitle 116 | self.buttonAction = buttonAction 117 | self.content = content() 118 | } 119 | 120 | var body: some View { 121 | VStack { 122 | Text(title) 123 | .font(.title) 124 | Spacer() 125 | image 126 | .resizable() 127 | .aspectRatio(contentMode: .fit) 128 | .frame(width: 64) 129 | Spacer() 130 | Text(bodyText) 131 | .multilineTextAlignment(.center) 132 | Spacer() 133 | content 134 | Spacer() 135 | Button(buttonTitle) { 136 | buttonAction() 137 | } 138 | }.padding() 139 | } 140 | 141 | } 142 | 143 | struct SecretAgentSetupView: View { 144 | 145 | let buttonAction: () -> Void 146 | 147 | var body: some View { 148 | SetupStepView(title: "setup_agent_title", 149 | image: Image(nsImage: NSApplication.shared.applicationIconImage), 150 | bodyText: "setup_agent_description", 151 | buttonTitle: "setup_agent_install_button", 152 | buttonAction: install) { 153 | Text("setup_agent_activity_monitor_description") 154 | .multilineTextAlignment(.center) 155 | } 156 | } 157 | 158 | func install() { 159 | LaunchAgentController().install() 160 | buttonAction() 161 | } 162 | 163 | } 164 | 165 | struct SSHAgentSetupView: View { 166 | 167 | let buttonAction: () -> Void 168 | 169 | private static let controller = ShellConfigurationController() 170 | @State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first! 171 | 172 | var body: some View { 173 | SetupStepView(title: "setup_ssh_title", 174 | image: Image(systemName: "terminal"), 175 | bodyText: "setup_ssh_description", 176 | buttonTitle: "setup_ssh_added_manually_button", 177 | buttonAction: buttonAction) { 178 | Link("setup_third_party_faq_link", destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!) 179 | Picker(selection: $selectedShellInstruction, label: EmptyView()) { 180 | ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in 181 | Text(instruction.shell) 182 | .tag(instruction) 183 | .padding() 184 | } 185 | }.pickerStyle(SegmentedPickerStyle()) 186 | CopyableView(title: "setup_ssh_add_to_config_button_\(selectedShellInstruction.shellConfigPath)", image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text) 187 | Button("setup_ssh_add_for_me_button") { 188 | let controller = ShellConfigurationController() 189 | if controller.addToShell(shellInstructions: selectedShellInstruction) { 190 | buttonAction() 191 | } 192 | } 193 | } 194 | } 195 | 196 | } 197 | 198 | class Delegate: NSObject, NSOpenSavePanelDelegate { 199 | 200 | private let name: String 201 | 202 | init(name: String) { 203 | self.name = name 204 | } 205 | 206 | func panel(_ sender: Any, shouldEnable url: URL) -> Bool { 207 | return url.lastPathComponent == name 208 | } 209 | 210 | } 211 | 212 | struct UpdaterExplainerView: View { 213 | 214 | let buttonAction: () -> Void 215 | 216 | var body: some View { 217 | SetupStepView(title: "setup_updates_title", 218 | image: Image(systemName: "dot.radiowaves.left.and.right"), 219 | bodyText: "setup_updates_description", 220 | buttonTitle: "setup_updates_ok", 221 | buttonAction: buttonAction) { 222 | Link("setup_updates_readmore", destination: SetupView.Constants.updaterFAQURL) 223 | } 224 | } 225 | 226 | } 227 | 228 | extension SetupView { 229 | 230 | enum Constants { 231 | static let updaterFAQURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md#whats-this-network-request-to-github")! 232 | } 233 | 234 | } 235 | 236 | struct ShellConfigInstruction: Identifiable, Hashable { 237 | 238 | var shell: String 239 | var shellConfigDirectory: String 240 | var shellConfigFilename: String 241 | var text: String 242 | 243 | var id: String { 244 | shell 245 | } 246 | 247 | var shellConfigPath: String { 248 | return (shellConfigDirectory as NSString).appendingPathComponent(shellConfigFilename) 249 | } 250 | 251 | } 252 | 253 | #if DEBUG 254 | 255 | struct SetupView_Previews: PreviewProvider { 256 | 257 | static var previews: some View { 258 | Group { 259 | SetupView(visible: .constant(true), setupComplete: .constant(false)) 260 | } 261 | } 262 | 263 | } 264 | 265 | struct SecretAgentSetupView_Previews: PreviewProvider { 266 | 267 | static var previews: some View { 268 | Group { 269 | SecretAgentSetupView(buttonAction: {}) 270 | } 271 | } 272 | 273 | } 274 | 275 | struct SSHAgentSetupView_Previews: PreviewProvider { 276 | 277 | static var previews: some View { 278 | Group { 279 | SSHAgentSetupView(buttonAction: {}) 280 | } 281 | } 282 | 283 | } 284 | 285 | struct UpdaterExplainerView_Previews: PreviewProvider { 286 | 287 | static var previews: some View { 288 | Group { 289 | UpdaterExplainerView(buttonAction: {}) 290 | } 291 | } 292 | 293 | } 294 | 295 | #endif 296 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/StoreListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SecretKit 4 | 5 | struct StoreListView: View { 6 | 7 | @Binding var activeSecret: AnySecret.ID? 8 | 9 | @EnvironmentObject private var storeList: SecretStoreList 10 | 11 | private func secretDeleted(secret: AnySecret) { 12 | activeSecret = nextDefaultSecret 13 | } 14 | 15 | private func secretRenamed(secret: AnySecret) { 16 | activeSecret = secret.id 17 | } 18 | 19 | var body: some View { 20 | NavigationView { 21 | List(selection: $activeSecret) { 22 | ForEach(storeList.stores) { store in 23 | if store.isAvailable { 24 | Section(header: Text(store.name)) { 25 | if store.secrets.isEmpty { 26 | EmptyStoreView(store: store, activeSecret: $activeSecret) 27 | } else { 28 | ForEach(store.secrets) { secret in 29 | SecretListItemView( 30 | store: store, 31 | secret: secret, 32 | activeSecret: $activeSecret, 33 | deletedSecret: self.secretDeleted, 34 | renamedSecret: self.secretRenamed 35 | ) 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | .listStyle(SidebarListStyle()) 43 | .onAppear { 44 | activeSecret = nextDefaultSecret 45 | } 46 | .frame(minWidth: 100, idealWidth: 240) 47 | } 48 | } 49 | } 50 | 51 | extension StoreListView { 52 | 53 | var nextDefaultSecret: AnyHashable? { 54 | let fallback: AnyHashable 55 | if storeList.modifiableStore?.isAvailable ?? false { 56 | fallback = EmptyStoreView.Constants.emptyStoreModifiableTag 57 | } else { 58 | fallback = EmptyStoreView.Constants.emptyStoreTag 59 | } 60 | return storeList.stores.compactMap(\.secrets.first).first?.id ?? fallback 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/ToolbarButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ToolbarButtonStyle: ButtonStyle { 4 | 5 | private let lightColor: Color 6 | private let darkColor: Color 7 | @Environment(\.colorScheme) var colorScheme 8 | @State var hovering = false 9 | 10 | init(color: Color) { 11 | self.lightColor = color 12 | self.darkColor = color 13 | } 14 | 15 | init(lightColor: Color, darkColor: Color) { 16 | self.lightColor = lightColor 17 | self.darkColor = darkColor 18 | } 19 | 20 | func makeBody(configuration: Configuration) -> some View { 21 | configuration.label 22 | .padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8)) 23 | .background(colorScheme == .light ? lightColor : darkColor) 24 | .foregroundColor(.white) 25 | .clipShape(RoundedRectangle(cornerRadius: 5)) 26 | .overlay( 27 | RoundedRectangle(cornerRadius: 5) 28 | .stroke(colorScheme == .light ? .black.opacity(0.15) : .white.opacity(0.15), lineWidth: 1) 29 | .background(hovering ? (colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.05)) : Color.clear) 30 | ) 31 | .onHover { hovering in 32 | withAnimation { 33 | self.hovering = hovering 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Secretive/Views/UpdateView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Brief 3 | 4 | struct UpdateDetailView: View { 5 | 6 | @EnvironmentObject var updater: UpdaterType 7 | 8 | let update: Release 9 | 10 | var body: some View { 11 | VStack { 12 | Text("update_version_name_\(update.name)").font(.title) 13 | GroupBox(label: Text("update_release_notes_title")) { 14 | ScrollView { 15 | attributedBody 16 | } 17 | } 18 | HStack { 19 | if !update.critical { 20 | Button("update_ignore_button") { 21 | updater.ignore(release: update) 22 | } 23 | Spacer() 24 | } 25 | Button("update_update_button") { 26 | NSWorkspace.shared.open(update.html_url) 27 | } 28 | .keyboardShortcut(.defaultAction) 29 | } 30 | 31 | } 32 | .padding() 33 | .frame(maxWidth: 500) 34 | } 35 | 36 | var attributedBody: Text { 37 | var text = Text(verbatim: "") 38 | for line in update.body.split(whereSeparator: \.isNewline) { 39 | let attributed: Text 40 | let split = line.split(separator: " ") 41 | let unprefixed = split.dropFirst().joined(separator: " ") 42 | if let prefix = split.first { 43 | switch prefix { 44 | case "#": 45 | attributed = Text(unprefixed).font(.title) + Text(verbatim: "\n") 46 | case "##": 47 | attributed = Text(unprefixed).font(.title2) + Text(verbatim: "\n") 48 | case "###": 49 | attributed = Text(unprefixed).font(.title3) + Text(verbatim: "\n") 50 | default: 51 | attributed = Text(line) + Text(verbatim: "\n\n") 52 | } 53 | } else { 54 | attributed = Text(line) + Text(verbatim: "\n\n") 55 | } 56 | text = text + attributed 57 | } 58 | return text 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Sources/SecretiveTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/SecretiveTests/SecretiveTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Secretive 3 | 4 | class SecretiveTests: XCTestCase { 5 | 6 | override func setUp() { 7 | // Put setup code here. This method is called before the invocation of each test method in the class. 8 | } 9 | 10 | override func tearDown() { 11 | // Put teardown code here. This method is called after the invocation of each test method in the class. 12 | } 13 | 14 | func testExample() { 15 | // This is an example of a functional test case. 16 | // Use XCTAssert and related functions to verify your tests produce the correct results. 17 | } 18 | 19 | 20 | } 21 | --------------------------------------------------------------------------------