├── .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 | 
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 | 
29 | 
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 |
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 |
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  
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 |
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 |
23 |
24 | ### Notifications
25 |
26 | Secretive also notifies you whenever your keys are accessed, so you're never caught off guard.
27 |
28 |
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 |
--------------------------------------------------------------------------------