Compare commits

..

95 Commits

Author SHA1 Message Date
9d6bbd2a04 Fix focus resignation (#227) 2021-08-08 22:57:01 +00:00
35aea0729d Switch to Xcode 12.5.1 (#228)
* Update release.yml

* Update test.yml
2021-08-08 22:55:23 +00:00
c5bd4c2189 . (#221) 2021-06-01 00:21:58 -07:00
8114acf50a Add option to rename keys/secrets (#216)
* Add option to rename secrets

* Address PR comments

Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2021-05-31 23:20:38 -07:00
cd965b9ec6 Fix background issue (#217) 2021-05-10 03:05:45 +00:00
f30d1f802f Configure ssh directly instead of setting environment variable (#208) 2021-03-06 15:21:33 -08:00
af479408a4 Switch GitHub Actions releaser to use API key instead of app specific password (#212)
* Update release.yml

* Update signing.sh

* Update release.yml

* Fix weird spacing

* Update signing.sh

* Update release.yml

* Update signing.sh
2021-02-26 06:28:02 +00:00
42b034270a Changed checkbox to radio button for clearer UI. (#210) 2021-01-30 23:01:17 -08:00
dca340ad40 Change hard-coded bundleID to automatically retrieved one (#202) 2021-01-18 20:08:52 -08:00
8dfabd35aa Update readme assets for design changes (#204)
* Delete app.png

* Add files via upload

* Delete app.png

* Add files via upload

* Add template to sketch
2021-01-18 18:12:19 -08:00
9c28efab5c Improve release template (#206)
* Improve release template

* Update release.yml

* Update test.yml
2021-01-18 15:14:23 -08:00
698a69a034 Improve updater (#203) 2021-01-18 00:49:26 -08:00
4105c1d6f6 Fix arrow position (#200)
* Fix arrow position

* Restore that
2021-01-17 19:35:22 -08:00
4de805dd37 Add support for SHA256 fingerprints (#198)
* Add SHA256

* Update tests

* Fix padding
2021-01-17 16:16:38 -08:00
0544287141 Add internet access policy (#199) 2021-01-17 15:59:35 -08:00
8ec19d1fb7 brew cask install is deprecated (#192)
Use `brew install` instead of `brew cask install`
2021-01-17 13:37:30 -08:00
6c3748b6bf Comment (#181) 2020-11-29 13:15:14 -08:00
dc91f61dba Update ApplicationDirectoryController.swift (#178) 2020-11-25 18:13:46 -08:00
702388fdf8 Update FAQ.md (#176) 2020-11-25 16:00:04 -08:00
a368acea62 . (#174) 2020-11-13 00:02:01 -08:00
76514d410b Fix issues around upgrading (#173)
* Re-check after

* Completion

* Relaunch
2020-11-12 22:55:52 -08:00
08b5c7b13c Fix #170 (#171) 2020-11-12 19:07:47 -08:00
f801853878 Release 11 (#168)
* Major version test

* .
2020-11-11 17:16:36 -08:00
3c9659bd06 Major version test (#167) 2020-11-11 17:13:21 -08:00
4d4b20c38d Fix readme link (#166) 2020-11-11 17:08:02 -08:00
1f0100ff2d Relaunch agent (#165)
* Check on foreground

* Better processing.

* Update icons for agent too

* Add force launch.

* Force launch.
2020-11-11 17:00:15 -08:00
59ec40c611 Switch stuff to popovers to behave better in Big Sur (#163)
* Moving create to a popover.

* Switch to popover here too
2020-11-11 16:45:39 -08:00
edf01cc6d4 Restore changes for Big Sur images. (#160)
* Switch test to Big Sur

* Switch to new logger api

* Fix versions

* .
2020-11-11 15:32:28 -08:00
de21e1040e Break out app faq (#162)
* Break out app faq

* .
2020-11-11 15:24:32 -08:00
d47c5424ad Add Fork to FAQs (#159)
* Add Fork to FAQ's

* Update FAQ.md

Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2020-11-11 15:11:27 -08:00
b85acebae1 Update icon for big sur and add sketch (#158)
* Update icon for big sur and add sketch

* Set xcode

* Set xcode
2020-11-07 23:31:28 -08:00
4d4619bdd8 Update to checkout v2 (#153)
* Update release.yml

* Update test.yml
2020-10-12 20:48:51 -07:00
50adf4cd48 Update Credits.rtf (#150) 2020-09-27 23:20:29 -07:00
dc5c21c568 Move app name + iconurl to tracer. (#149) 2020-09-27 23:09:23 -07:00
3328ada3bf Update touch id prompt image. (#147) 2020-09-23 22:20:54 -07:00
3774352dfd Show request details in Secure prompts (#146) 2020-09-23 21:51:48 -07:00
85e096f8cc Fix broken updater check (#145) 2020-09-22 00:17:22 -07:00
331e4ed0d6 Update for Big Sur & SwiftUI 2 (#128) 2020-09-21 23:12:50 -07:00
4cc312d4fa Update FAQ.md (#132) 2020-09-18 13:22:15 -07:00
f3d70a57c5 Create SECURITY.md (#123) 2020-07-12 15:14:40 -07:00
55b57e0cbf Remove secret requirement for tests, open to PRs (#122)
* Update test.yml

* Test

* Test

* Test

* Test

* Update test.yml
2020-07-12 15:12:18 -07:00
82dcb76ee6 Update test.yml 2020-07-12 14:53:41 -07:00
bd0e27060c Update test.yml 2020-07-12 14:43:32 -07:00
393f2797ab (docs): fixed small error (#100)
Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2020-07-12 14:06:08 -07:00
f1bf863a9d Add Homebrew install intructions (#106) 2020-07-12 14:05:48 -07:00
c09d1e4020 Add FAQ entry on how to get WatchOS auth working (#118)
* Add FAQ entry on how to get WatchOS auth working

Fixes #113

* FAQ (Apple Watch) grammar/phrasing suggestions

Co-authored-by: Max Goedjen <max.goedjen@gmail.com>

Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2020-07-12 14:05:30 -07:00
52225ab5e3 Fix typo (#120) 2020-07-07 19:12:32 -07:00
8b7e3f6b87 Fixes #115 (#116) 2020-07-04 16:25:14 -07:00
bc25576c1f Add installation instructions (#102)
Fixes #101
2020-06-27 18:45:22 -07:00
b234100aa5 Fileprivate -> private (#91) 2020-05-15 23:19:00 -07:00
bc0a45366a Fix typo (#89) 2020-04-06 21:32:22 -07:00
6dbb3b7fb3 Add back Xcode 11.4 changes (#87) 2020-04-05 16:05:45 -07:00
8bbf489146 Relaunch agent if updated (#86) 2020-04-04 15:16:31 -07:00
30148ee3a4 Revert "Xcode 11.4 changes"
This reverts commit c306801149.
2020-04-03 00:44:13 -07:00
c306801149 Xcode 11.4 changes 2020-04-02 18:22:59 -07:00
9c60a0b7dd Merge branch 'master' of github.com:maxgoedjen/secretive 2020-04-02 00:54:14 -07:00
5b38ef00c1 Fix #84 2020-04-02 00:53:55 -07:00
ccbf92785d Fix padding bug (#83) 2020-04-02 00:43:45 -07:00
f1e8e43f62 Update FAQ.md 2020-04-01 22:11:45 -07:00
7b1563f167 Update FAQ.md 2020-03-31 23:11:04 -07:00
cca070dbe4 Update FAQ.md 2020-03-30 23:44:38 -07:00
31e51e45c1 Move types to common (#81) 2020-03-29 17:26:43 -07:00
4bfb3a0012 Rephrase access control. 2020-03-28 18:08:54 -07:00
a0ed880386 Fix #80. 2020-03-27 19:24:37 -07:00
92b9648e04 FAQ (#76) 2020-03-25 23:35:02 -07:00
4a2a342670 Create FUNDING.yml 2020-03-24 22:16:04 -07:00
1d147d8e34 Update README.md 2020-03-24 22:04:49 -07:00
4dcc9b113d Agent Tests (#74) 2020-03-23 23:22:22 -07:00
1f7ebcfe75 MIT notice on brief 2020-03-23 21:19:40 -07:00
b090a96975 Add option to ignore non-critical updates (#73) 2020-03-22 15:26:29 -07:00
594d855f19 Move status badges. 2020-03-21 23:40:43 -07:00
89f0798576 Add status badges 2020-03-21 23:40:10 -07:00
0d74f6f561 Really fix signing for brief 2020-03-21 23:36:52 -07:00
efeb7f09a3 Fix brief signing 2020-03-21 23:33:51 -07:00
104199cf53 Add test 2020-03-21 19:37:08 -07:00
f214da98a4 Remove test asset. 2020-03-21 19:37:03 -07:00
95cf88d3ca Add type eraser tests. 2020-03-21 19:36:11 -07:00
04835fa437 Fix weird spacing 2020-03-21 19:28:49 -07:00
31dfba8265 Add tests for reader. 2020-03-21 19:28:08 -07:00
df8eedebd0 Adding notifications for updater (#70) 2020-03-21 18:43:26 -07:00
0a9ecb039e Minimum frames (#69) 2020-03-21 18:10:37 -07:00
2e9a3eb90d Merge branch 'master' of github.com:maxgoedjen/secretive 2020-03-21 18:09:44 -07:00
df599fbe62 Update readme screenshot 2020-03-21 17:56:29 -07:00
7c60e9b04b Improve subtitle 2020-03-21 17:55:31 -07:00
f7f182ec77 Merge branch 'master' of github.com:maxgoedjen/secretive 2020-03-21 17:52:56 -07:00
a0052e8395 Richer notifications (#68) 2020-03-21 17:52:51 -07:00
10d3fa150c Better copy 2020-03-20 21:23:15 -07:00
c5abca4099 Indent. 2020-03-20 21:14:51 -07:00
f674d47507 Merge branch 'master' of github.com:maxgoedjen/secretive 2020-03-20 21:08:48 -07:00
1e3ece600d Fixes #51. 2020-03-20 21:07:58 -07:00
7481498c5b Basic no stores view (#66) 2020-03-20 20:20:20 -07:00
a6fbcdbe55 MIT licensing notices 2020-03-19 21:36:25 -07:00
36e655e527 Better links 2020-03-19 21:33:57 -07:00
dd3acf5ae1 Remove redundant import 2020-03-19 21:28:27 -07:00
2257e1e190 Fix build number 2020-03-19 21:21:28 -07:00
99 changed files with 3065 additions and 1029 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
github: maxgoedjen

BIN
.github/readme/app.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 456 KiB

BIN
.github/readme/apple_watch_auth_mac.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 190 KiB

View File

@ -10,10 +10,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k ci ci.keyc
# Import Profiles
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo $HOST_PROFILE_DATA | base64 -d -o Host.provisionprofile
HOST_UUID=`grep UUID -A1 -a Host.provisionprofile | grep -io "[-A-F0-9]\{36\}"`
cp Host.provisionprofile ~/Library/MobileDevice/Provisioning\ Profiles/$HOST_UUID.provisionprofile
echo $AGENT_PROFILE_DATA | base64 -d -o Agent.provisionprofile
AGENT_UUID=`grep UUID -A1 -a Agent.provisionprofile | grep -io "[-A-F0-9]\{36\}"`
cp Agent.provisionprofile ~/Library/MobileDevice/Provisioning\ Profiles/$AGENT_UUID.provisionprofile
# Create directories for ASC key
mkdir ~/.private_keys
echo -n "$APPLE_API_KEY_DATA" > ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8

View File

@ -6,7 +6,7 @@ on:
- '*'
jobs:
test:
runs-on: macOS-latest
runs-on: macos-11.0
timeout-minutes: 10
steps:
- uses: actions/checkout@v1
@ -16,32 +16,29 @@ jobs:
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }}
AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }}
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_12.5.1.app
- name: Test
run: xcrun xcodebuild test -project Secretive.xcodeproj -scheme Secretive
build:
runs-on: macOS-latest
runs-on: macos-11.0
timeout-minutes: 10
steps:
- uses: actions/checkout@v1
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: "Build: https://github.com/maxgoedjen/secretive/actions/runs/${{ github.run_id }}"
draft: true
prerelease: false
- uses: actions/checkout@v2
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }}
AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }}
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_12.5.1.app
- name: Update Build Number
env:
TAG_NAME: ${{ github.ref }}
@ -59,13 +56,37 @@ jobs:
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
- name: Notarize
env:
APPLE_USERNAME: ${{ secrets.APPLE_USERNAME }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
run: xcrun altool --notarize-app --primary-bundle-id "com.maxgoedjen.secretive.host" --username $APPLE_USERNAME --password $APPLE_PASSWORD --file Secretive.zip
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun altool --notarize-app --primary-bundle-id "com.maxgoedjen.secretive.host" --apiKey $APPLE_API_KEY_ID --apiIssuer $APPLE_API_ISSUER --file Secretive.zip
- name: Document SHAs
run: |
shasum -a 512 Secretive.zip
shasum -a 512 Archive.zip
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: |
Update description
## Features
## Fixes
## Minimum macOS Version
## Build
https://github.com/maxgoedjen/secretive/actions/runs/${{ github.run_id }}
draft: true
prerelease: false
- name: Upload App to Release
id: upload-release-asset
uses: actions/upload-release-asset@v1.0.1

View File

@ -1,18 +1,13 @@
name: Test
on: push
on: [push, pull_request]
jobs:
test:
runs-on: macOS-latest
runs-on: macos-11.0
timeout-minutes: 10
steps:
- uses: actions/checkout@v1
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }}
AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }}
run: ./.github/scripts/signing.sh
- uses: actions/checkout@v2
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_12.5.1.app
- name: Test
run: xcrun xcodebuild test -project Secretive.xcodeproj -scheme Secretive

1
.gitignore vendored
View File

@ -91,3 +91,4 @@ iOSInjectionProject/
# Build script products
Archive.xcarchive
.DS_Store

33
APP_CONFIG.md Normal file
View File

@ -0,0 +1,33 @@
# Setting up Third Party Apps FAQ
## Tower
Tower provides [instructions](https://www.git-tower.com/help/mac/integration/environment).
## GitHub Desktop
Should just work, no configuration needed
## Fork
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
## VS Code
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
```
Host *
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
```
# The app I use isn't listed here!
If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome.
If you're not able to get it working, please file a [GitHub issue](https://github.com/maxgoedjen/secretive/issues/new) for it. No guarantees we'll be able to get it working, but chances are someone else in the community might be able to.

19
Brief/Brief.h Normal file
View File

@ -0,0 +1,19 @@
//
// Brief.h
// Brief
//
// Created by Max Goedjen on 3/21/20.
// Copyright © 2020 Max Goedjen. All rights reserved.
//
#import <Foundation/Foundation.h>
//! Project version number for Brief.
FOUNDATION_EXPORT double BriefVersionNumber;
//! Project version string for Brief.
FOUNDATION_EXPORT const unsigned char BriefVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <Brief/PublicHeader.h>

24
Brief/Info.plist Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_NAME) is MIT Licensed.</string>
</dict>
</plist>

177
Brief/Updater.swift Normal file
View File

@ -0,0 +1,177 @@
import Foundation
import Combine
public protocol UpdaterProtocol: ObservableObject {
var update: Release? { get }
}
public class Updater: ObservableObject, UpdaterProtocol {
@Published public var update: Release?
private let osVersion: SemVer
public init(checkOnLaunch: Bool, osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion)) {
self.osVersion = osVersion
if checkOnLaunch {
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet.
checkForUpdates()
}
let timer = Timer.scheduledTimer(withTimeInterval: 60*60*24, repeats: true) { _ in
self.checkForUpdates()
}
timer.tolerance = 60*60
}
public func checkForUpdates() {
URLSession.shared.dataTask(with: Constants.updateURL) { data, _, _ in
guard let data = data else { return }
guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return }
self.evaluate(releases: releases)
}.resume()
}
public func ignore(release: Release) {
guard !release.critical else { return }
defaults.set(true, forKey: release.name)
DispatchQueue.main.async {
self.update = nil
}
}
}
extension Updater {
func evaluate(releases: [Release]) {
guard let release = releases
.sorted()
.reversed()
.filter({ !$0.prerelease })
.first(where: { $0.minimumOSVersion <= osVersion }) else { return }
guard !userIgnored(release: release) else { return }
guard !release.prerelease else { return }
let latestVersion = SemVer(release.name)
let currentVersion = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")
if latestVersion > currentVersion {
DispatchQueue.main.async {
self.update = release
}
}
}
func userIgnored(release: Release) -> Bool {
guard !release.critical else { return false }
return defaults.bool(forKey: release.name)
}
var defaults: UserDefaults {
UserDefaults(suiteName: "com.maxgoedjen.Secretive.updater.ignorelist")!
}
}
public struct SemVer {
let versionNumbers: [Int]
public init(_ version: String) {
// Betas have the format 1.2.3_beta1
let strippedBeta = version.split(separator: "_").first!
var split = strippedBeta.split(separator: ".").compactMap { Int($0) }
while split.count < 3 {
split.append(0)
}
versionNumbers = split
}
public init(_ version: OperatingSystemVersion) {
versionNumbers = [version.majorVersion, version.minorVersion, version.patchVersion]
}
}
extension SemVer: Comparable {
public static func < (lhs: SemVer, rhs: SemVer) -> Bool {
for (latest, current) in zip(lhs.versionNumbers, rhs.versionNumbers) {
if latest < current {
return true
} else if latest > current {
return false
}
}
return false
}
}
extension Updater {
enum Constants {
static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")!
}
}
public struct Release: Codable {
public let name: String
public let prerelease: Bool
public let html_url: URL
public let body: String
public init(name: String, prerelease: Bool, html_url: URL, body: String) {
self.name = name
self.prerelease = prerelease
self.html_url = html_url
self.body = body
}
}
extension Release: Identifiable {
public var id: String {
html_url.absoluteString
}
}
extension Release: Comparable {
public static func < (lhs: Release, rhs: Release) -> Bool {
lhs.version < rhs.version
}
}
extension Release {
public var critical: Bool {
body.contains(Constants.securityContent)
}
public var version: SemVer {
SemVer(name)
}
public var minimumOSVersion: SemVer {
guard let range = body.range(of: "Minimum macOS Version"),
let numberStart = body.rangeOfCharacter(from: CharacterSet.decimalDigits, options: [], range: range.upperBound..<body.endIndex) else { return SemVer("11.0.0") }
let numbersEnd = body.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines, options: [], range: numberStart.upperBound..<body.endIndex)?.lowerBound ?? body.endIndex
let version = numberStart.lowerBound..<numbersEnd
return SemVer(String(body[version]))
}
}
extension Release {
enum Constants {
static let securityContent = "Critical Security Update"
}
}

22
BriefTests/Info.plist Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View File

@ -0,0 +1,104 @@
import XCTest
@testable import Brief
class ReleaseParsingTests: XCTestCase {
func testNonCritical() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release")
XCTAssert(release.critical == false)
}
func testCritical() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
XCTAssert(release.critical == true)
}
func testOSMissing() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
XCTAssert(release.minimumOSVersion == SemVer("11.0.0"))
}
func testOSPresentWithContentBelow() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update ##Minimum macOS Version\n1.2.3\nBuild info")
XCTAssert(release.minimumOSVersion == SemVer("1.2.3"))
}
func testOSPresentAtEnd() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3")
XCTAssert(release.minimumOSVersion == SemVer("1.2.3"))
}
func testOSWithMacOSPrefix() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: macOS 1.2.3")
XCTAssert(release.minimumOSVersion == SemVer("1.2.3"))
}
func testOSGreaterThanMinimum() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3")
XCTAssert(release.minimumOSVersion < SemVer("11.0.0"))
}
func testOSEqualToMinimum() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 11.2.3")
XCTAssert(release.minimumOSVersion <= SemVer("11.2.3"))
}
func testOSLessThanMinimum() {
let release = Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update Minimum macOS Version: 1.2.3")
XCTAssert(release.minimumOSVersion > SemVer("1.0.0"))
}
func testGreatestSelectedIfOldPatchIsPublishedLater() {
// If 2.x.x series has been published, and a patch for 1.x.x is issued
// 2.x.x should still be selected if user can run it.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"))
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")
let releases = [
Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release Minimum macOS Version: 1.2.3"),
Release(name: "1.0.1", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Bug fixes Minimum macOS Version: 1.2.3"),
two,
Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"),
]
let expectation = XCTestExpectation()
updater.evaluate(releases: releases)
DispatchQueue.main.async {
XCTAssert(updater.update == two)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
}
func testLatestVersionIsRunnable() {
// If the 2.x.x series has been published but the user can't run it
// the last version the user can run should be selected.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"))
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")
let releases = [
Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release Minimum macOS Version: 1.2.3"),
Release(name: "1.0.1", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Bug fixes Minimum macOS Version: 1.2.3"),
Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available! Minimum macOS Version: 2.2.3"),
Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"),
]
let expectation = XCTestExpectation()
updater.evaluate(releases: releases)
DispatchQueue.main.async {
XCTAssert(updater.update == oneOhTwo)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
}
func testSorting() {
let two = Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available!")
let releases = [
Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release"),
Release(name: "1.0.1", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Bug fixes"),
two,
Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch!"),
]
let sorted = releases.sorted().reversed().first
XCTAssert(sorted == two)
}
}

View File

@ -0,0 +1,51 @@
import XCTest
@testable import Brief
class SemVerTests: XCTestCase {
func testEqual() {
let current = SemVer("1.0.2")
let old = SemVer("1.0.2")
XCTAssert(!(current > old))
}
func testPatchGreaterButMinorLess() {
let current = SemVer("1.1.0")
let old = SemVer("1.0.2")
XCTAssert(current > old)
}
func testMajorSameMinorGreater() {
let current = SemVer("1.0.2")
let new = SemVer("1.0.3")
XCTAssert(current < new)
}
func testMajorGreaterMinorLesser() {
let current = SemVer("1.0.2")
let new = SemVer("2.0.0")
XCTAssert(current < new)
}
func testRegularParsing() {
let current = SemVer("1.0.2")
XCTAssert(current.versionNumbers == [1, 0, 2])
}
func testNoPatch() {
let current = SemVer("1.1")
XCTAssert(current.versionNumbers == [1, 1, 0])
}
func testGarbage() {
let current = SemVer("Test")
XCTAssert(current.versionNumbers == [0, 0, 0])
}
func testBeta() {
let current = SemVer("1.0.2")
let new = SemVer("1.1.0_beta1")
XCTAssert(current < new)
}
}

View File

@ -8,12 +8,20 @@ Security is obviously paramount for a project like Secretive. As such, any contr
### Dependencies
Secretive is desigend 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.
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.
## Code of Conduct
All contributors must abide by the [Code of Conduct](CODE_OF_CONDUCT.md)
## Credits
If you make a material contribution to the app, please add yourself to the end of the [credits](https://github.com/maxgoedjen/secretive/blob/main/Secretive/Credits.rtf).
## Collaborator Status
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).
## Secretive is Opinionated
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).

View File

@ -29,12 +29,21 @@
}
},
{
"enabled" : false,
"parallelizable" : true,
"target" : {
"containerPath" : "container:Secretive.xcodeproj",
"identifier" : "50617D9323FCE48E0099B055",
"name" : "SecretiveTests"
}
},
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:Secretive.xcodeproj",
"identifier" : "5091D31E2519D56D0049FD9B",
"name" : "BriefTests"
}
}
],
"version" : 1

53
FAQ.md Normal file
View File

@ -0,0 +1,53 @@
# FAQ
### How do I import my current SSH keys, or export my Secretive Keys?
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.
### Secretive doesn't work with my git client/app
Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [App Config FAQ](APP_CONFIG.md).
### Secretive isn't working for me
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.
### Secretive prompts me to type my password instead of using my Apple Watch
1) Make sure you have enabled "Use your Apple Watch to unlock apps and your Mac" in System Preferences --> Security & Privacy:
![System Preferences Setting](.github/readme/apple_watch_system_prefs.png)
2) Ensure that unlocking your Mac with Apple Watch is working (lock and unlock at least once)
3) Now you should get prompted on the watch when your key is accessed. Double click the side button to approve:
![Apple Watch Prompt](.github/readme/apple_watch_auth_mac.png)
![Apple Watch Prompt](.github/readme/apple_watch_auth_watch.png)
### How do I tell SSH to use a specific key?
You can create a `mykey.pub` (where `mykey` is the name of your key) in your `~/.ssh/` directory with the contents of your public key, and 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
### Why should I trust you?
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).
### I want to build Secretive from source
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).
### What's this network request to GitHub?
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/Brief/Updater.swift).
### I have a security issue
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.
### I have a non-security related bug
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.
### I want to contribute to Secretive
Sweet! Please check out the [contributing guidelines](CONTRIBUTING.md) and go from there.

BIN
Icon.sketch Normal file

Binary file not shown.

View File

@ -1,9 +1,11 @@
# Secretive
# Secretive ![Test](https://github.com/maxgoedjen/secretive/workflows/Test/badge.svg) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg)
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
<img src="/.github/readme/app.png" alt="Screenshot of Secretive" width="600">
## Why?
### Safer Storage
@ -12,13 +14,13 @@ The most common setup for SSH keys is just keeping them on disk, guarded by prop
### Access Control
If your Mac has a Secure Enclave, it also has support for strong biometric access controls like Touch ID. You can configure your key so that they require Touch ID (or Watch) authentication before they're accessed.
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 key so that they require Touch ID (or Watch) authentication before they're accessed.
<img src="/.github/readme/touchid.png" alt="Screenshot of Secretive authenticating with Touch ID">
### Notifications
Secretive also notifies you whenever your keys are acceessed, so you're never caught off guard.
Secretive also notifies you whenever your keys are accessed, so you're never caught off guard.
<img src="/.github/readme/notification.png" alt="Screenshot of Secretive notifying the user">
@ -28,15 +30,21 @@ For Macs without Secure Enclaves, you can configure a Smart Card (such as a Yubi
## Getting Started
### Setup for Third Party Apps
### Installation
When you first launch Secretive, you'll be prompted to set up your command line environment. You can redisplay this prompt at any time by going to `Menu > Help -> Set Up Helper App`.
For non-command-line based apps, like GUI Git clients, you may need to go through app-specific setup.
#### Direct Download
[Tower](https://www.git-tower.com/help/mac/integration/environment)
You can download the latest release over on the [Releases Page](https://github.com/maxgoedjen/secretive/releases)
#### Using Homebrew
### Security Considerations
brew install secretive
### FAQ
There's a [FAQ here](FAQ.md).
### Auditable Build Process
Builds are produced by GitHub Actions with an auditable build and release generation process. Each build has a "Document SHAs" step, which will output SHA checksums for the build produced by the GitHub Action, so you can verify that the source code for a given build corresponds to any given release.
@ -46,8 +54,8 @@ While Secretive uses the Secure Enclave for key storage, it still relies on Keyc
### Backups and Transfers to New Machines
Beacuse 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.
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.
## Security
If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com
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."

9
SECURITY.md Normal file
View File

@ -0,0 +1,9 @@
# Security Policy
## Supported Versions
The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version.
## Reporting a Vulnerability
If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com with the subject containing "SECRETIVE SECURITY."

View File

@ -1,36 +1,40 @@
import Cocoa
import OSLog
import Combine
import SecretKit
import SecretAgentKit
import OSLog
import Brief
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
let storeList: SecretStoreList = {
private let storeList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
let notifier = Notifier()
lazy var agent: Agent = {
private let updater = Updater(checkOnLaunch: false)
private let notifier = Notifier()
private lazy var agent: Agent = {
Agent(storeList: storeList, witness: notifier)
}()
lazy var socketController: SocketController = {
private lazy var socketController: SocketController = {
let path = (NSHomeDirectory() as NSString).appendingPathComponent("socket.ssh") as String
return SocketController(path: path)
}()
private var updateSink: AnyCancellable?
func applicationDidFinishLaunching(_ aNotification: Notification) {
os_log(.debug, "SecretAgent finished launching")
Logger().debug("SecretAgent finished launching")
DispatchQueue.main.async {
self.socketController.handler = self.agent.handle(fileHandle:)
self.socketController.handler = self.agent.handle(reader:writer:)
}
notifier.prompt()
updateSink = updater.$update.sink { update in
guard let update = update else { return }
self.notifier.notify(update: update, ignore: self.updater.ignore(release:))
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}

View File

@ -31,13 +31,13 @@
"size" : "128x128"
},
{
"filename" : "Icon 2@1x.png",
"filename" : "Mac Icon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "Icon 2@2x.png",
"filename" : "Mac Icon@0.25x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -25,7 +25,7 @@
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2020 Max Goedjen. All rights reserved.</string>
<string>$(PRODUCT_NAME) is MIT Licensed.</string>
<key>NSMainStoryboardFile</key>
<string>Main</string>
<key>NSPrincipalClass</key>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ApplicationDescription</key>
<string>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&apos;t always have to keep the main Secretive app open.</string>
<key>DeveloperName</key>
<string>Max Goedjen</string>
<key>Website</key>
<string>https://github.com/maxgoedjen/secretive</string>
<key>Connections</key>
<array>
<dict>
<key>IsIncoming</key>
<false/>
<key>Host</key>
<string>api.github.com</string>
<key>NetworkProtocol</key>
<string>TCP</string>
<key>Port</key>
<string>443</string>
<key>Purpose</key>
<string>Secretive checks GitHub for new versions and security updates.</string>
<key>DenyConsequences</key>
<string>If you deny these connections, you will not be notified about new versions and critical security updates.</string>
</dict>
</array>
<key>Services</key>
<array/>
</dict>
</plist>

View File

@ -1,21 +1,53 @@
import Foundation
import UserNotifications
import AppKit
import SecretKit
import SecretAgentKit
import UserNotifications
import Brief
class Notifier {
private let notificationDelegate = NotificationDelegate()
init() {
let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: "Update", options: [])
let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: "Ignore", options: [])
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate
}
func prompt() {
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.requestAuthorization(options: .alert) { _, _ in
}
notificationCenter.requestAuthorization(options: .alert) { _, _ in }
}
func notify(accessTo secret: AnySecret, by provenance: SigningRequestProvenance) {
let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "Signed Request"
notificationContent.body = "\(secret.name) was used to sign a request from \(provenance.origin.name)."
notificationContent.title = "Signed Request from \(provenance.origin.displayName)"
notificationContent.subtitle = "Using secret \"\(secret.name)\""
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
notificationContent.attachments = [attachment]
}
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
notificationCenter.add(request, withCompletionHandler: nil)
}
func notify(update: Release, ignore: ((Release) -> Void)?) {
notificationDelegate.release = update
notificationDelegate.ignore = ignore
let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent()
if update.critical {
notificationContent.title = "Critical Security Update - \(update.name)"
} else {
notificationContent.title = "Update Available - \(update.name)"
}
notificationContent.subtitle = "Click to Update"
notificationContent.body = update.body
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
notificationCenter.add(request, withCompletionHandler: nil)
}
@ -32,3 +64,42 @@ extension Notifier: SigningWitness {
}
}
extension Notifier {
enum Constants {
static let updateCategoryIdentitifier = "com.maxgoedjen.Secretive.SecretAgent.update"
static let criticalUpdateCategoryIdentitifier = "com.maxgoedjen.Secretive.SecretAgent.update.critical"
static let updateActionIdentitifier = "com.maxgoedjen.Secretive.SecretAgent.update.updateaction"
static let ignoreActionIdentitifier = "com.maxgoedjen.Secretive.SecretAgent.update.ignoreaction"
}
}
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
fileprivate var release: Release?
fileprivate var ignore: ((Release) -> Void)?
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
guard let update = release else { return }
switch response.actionIdentifier {
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
NSWorkspace.shared.open(update.html_url)
case Notifier.Constants.ignoreActionIdentitifier:
ignore?(update)
default:
fatalError()
}
completionHandler()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.list, .banner])
}
}

View File

@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.smartcard</key>
<true/>
<key>keychain-access-groups</key>

View File

@ -2,18 +2,17 @@ import Foundation
import CryptoKit
import OSLog
import SecretKit
import SecretAgentKit
import AppKit
public class Agent {
fileprivate let storeList: SecretStoreList
fileprivate let witness: SigningWitness?
fileprivate let writer = OpenSSHKeyWriter()
fileprivate let requestTracer = SigningRequestTracer()
private let storeList: SecretStoreList
private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter()
private let requestTracer = SigningRequestTracer()
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) {
os_log(.debug, "Agent is running")
Logger().debug("Agent is running")
self.storeList = storeList
self.witness = witness
}
@ -22,47 +21,51 @@ public class Agent {
extension Agent {
public func handle(fileHandle: FileHandle) {
os_log(.debug, "Agent handling new data")
let data = fileHandle.availableData
public func handle(reader: FileHandleReader, writer: FileHandleWriter) {
Logger().debug("Agent handling new data")
let data = reader.availableData
guard !data.isEmpty else { return }
let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else { return }
os_log(.debug, "Agent handling request of type %@", requestType.debugDescription)
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return
}
Logger().debug("Agent handling request of type \(requestType.debugDescription)")
let subData = Data(data[5...])
handle(requestType: requestType, data: subData, fileHandle: fileHandle)
let response = handle(requestType: requestType, data: subData, reader: reader)
writer.write(response)
}
func handle(requestType: SSHAgent.RequestType, data: Data, fileHandle: FileHandle) {
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) -> Data {
var response = Data()
do {
switch requestType {
case .requestIdentities:
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
response.append(try identities())
os_log(.debug, "Agent returned %@", SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)
response.append(identities())
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
case .signRequest:
let provenance = requestTracer.provenance(from: reader)
response.append(SSHAgent.ResponseType.agentSignResponse.data)
response.append(try sign(data: data, from: fileHandle.fileDescriptor))
os_log(.debug, "Agent returned %@", SSHAgent.ResponseType.agentSignResponse.debugDescription)
response.append(try sign(data: data, provenance: provenance))
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
}
} catch {
response.removeAll()
response.append(SSHAgent.ResponseType.agentFailure.data)
os_log(.debug, "Agent returned %@", SSHAgent.ResponseType.agentFailure.debugDescription)
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
}
let full = OpenSSHKeyWriter().lengthAndData(of: response)
fileHandle.write(full)
return full
}
}
extension Agent {
func identities() throws -> Data {
// TODO: RESTORE ONCE XCODE 11.4 IS GM
let secrets = storeList.stores.flatMap { $0.secrets }
// let secrets = storeList.stores.flatMap(\.secrets)
func identities() -> Data {
let secrets = storeList.stores.flatMap(\.secrets)
var count = UInt32(secrets.count).bigEndian
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
var keyData = Data()
@ -73,25 +76,24 @@ extension Agent {
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
keyData.append(writer.lengthAndData(of: curveData))
}
os_log(.debug, "Agent enumerated %@ identities", secrets.count as NSNumber)
Logger().debug("Agent enumerated \(secrets.count) identities")
return countData + keyData
}
func sign(data: Data, from pid: Int32) throws -> Data {
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
let reader = OpenSSHReader(data: data)
let hash = try reader.readNextChunk()
let hash = reader.readNextChunk()
guard let (store, secret) = secret(matching: hash) else {
os_log(.debug, "Agent did not have a key matching %@", hash as NSData)
Logger().debug("Agent did not have a key matching \(hash as NSData)")
throw AgentError.noMatchingKey
}
let provenance = requestTracer.provenance(from: pid)
if let witness = witness {
try witness.speakNowOrForeverHoldYourPeace(forAccessTo: secret, by: provenance)
}
let dataToSign = try reader.readNextChunk()
let derSignature = try store.sign(data: dataToSign, with: secret)
let dataToSign = reader.readNextChunk()
let derSignature = try store.sign(data: dataToSign, with: secret, for: provenance)
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
@ -104,13 +106,22 @@ extension Agent {
case (.ellipticCurve, 384):
rawRepresentation = try CryptoKit.P384.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
default:
fatalError()
throw AgentError.unsupportedKeyType
}
let rawLength = rawRepresentation.count/2
let r = rawRepresentation[0..<rawLength]
let s = rawRepresentation[rawLength...]
// Check if we need to pad with 0x00 to prevent certain
// ssh servers from thinking r or s is negative
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
var r = Data(rawRepresentation[0..<rawLength])
if paddingRange ~= r.first! {
r.insert(0x00, at: 0)
}
var s = Data(rawRepresentation[rawLength...])
if paddingRange ~= s.first! {
s.insert(0x00, at: 0)
}
var signatureChunk = Data()
signatureChunk.append(writer.lengthAndData(of: r))
@ -126,7 +137,7 @@ extension Agent {
try witness.witness(accessTo: secret, by: provenance)
}
os_log(.debug, "Agent signed request")
Logger().debug("Agent signed request")
return signedData
}
@ -155,6 +166,7 @@ extension Agent {
enum AgentError: Error {
case unhandledType
case noMatchingKey
case unsupportedKeyType
}
}

View File

@ -0,0 +1,26 @@
import Foundation
public protocol FileHandleReader {
var availableData: Data { get }
var fileDescriptor: Int32 { get }
var pidOfConnectedProcess: Int32 { get }
}
public protocol FileHandleWriter {
func write(_ data: Data)
}
extension FileHandle: FileHandleReader, FileHandleWriter {
public var pidOfConnectedProcess: Int32 {
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1)
var len = socklen_t(MemoryLayout<Int32>.size)
getsockopt(fileDescriptor, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
return pidPointer.load(as: Int32.self)
}
}

View File

@ -19,6 +19,6 @@
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2020 Max Goedjen. All rights reserved.</string>
<string>$(PRODUCT_NAME) is MIT Licensed.</string>
</dict>
</plist>

View File

@ -12,7 +12,7 @@ extension SSHAgent {
switch self {
case .requestIdentities:
return "RequestIdentities"
default:
case .signRequest:
return "SignRequest"
}
}

View File

@ -1,11 +1,3 @@
//
// SecretAgentKit.h
// SecretAgentKit
//
// Created by Max Goedjen on 2/22/20.
// Copyright © 2020 Max Goedjen. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <Security/Security.h>

View File

@ -1,45 +0,0 @@
import Foundation
import AppKit
public struct SigningRequestProvenance {
public var chain: [Process]
public init(root: Process) {
self.chain = [root]
}
}
extension SigningRequestProvenance {
public var origin: Process {
chain.last!
}
public var intact: Bool {
return chain.reduce(true) { $0 && $1.validSignature }
}
}
extension SigningRequestProvenance {
public struct Process {
public let pid: Int32
public let name: String
public let path: String
public let validSignature: Bool
let parentPID: Int32?
init(pid: Int32, name: String, path: String, validSignature: Bool, parentPID: Int32?) {
self.pid = pid
self.name = name
self.path = path
self.validSignature = validSignature
self.parentPID = parentPID
}
}
}

View File

@ -1,15 +1,15 @@
import Foundation
import AppKit
import Security
import SecretKit
struct SigningRequestTracer {
}
func provenance(from pid: Int32) -> SigningRequestProvenance {
let pidPointer = UnsafeMutableRawPointer.allocate(byteCount: 4, alignment: 1)
var len = socklen_t(MemoryLayout<Int32>.size)
getsockopt(pid, SOCK_STREAM, LOCAL_PEERPID, pidPointer, &len)
let pid = pidPointer.load(as: Int32.self)
let firstInfo = process(from: pid)
extension SigningRequestTracer {
func provenance(from fileHandleReader: FileHandleReader) -> SigningRequestProvenance {
let firstInfo = process(from: fileHandleReader.pidOfConnectedProcess)
var provenance = SigningRequestProvenance(root: firstInfo)
while NSRunningApplication(processIdentifier: provenance.origin.pid) == nil && provenance.origin.parentPID != nil {
@ -37,7 +37,24 @@ struct SigningRequestTracer {
let flags: SecCSFlags = [.considerExpiration, .enforceRevocationChecks]
SecCodeCreateWithPID(pid, SecCSFlags(), &secCode)
let valid = SecCodeCheckValidity(secCode.takeRetainedValue(), flags, nil) == errSecSuccess
return SigningRequestProvenance.Process(pid: pid, name: procName, path: path, validSignature: valid, parentPID: ppid)
return SigningRequestProvenance.Process(pid: pid, processName: procName, appName: appName(for: pid), iconURL: iconURL(for: pid), path: path, validSignature: valid, parentPID: ppid)
}
func iconURL(for pid: Int32) -> URL? {
do {
if let app = NSRunningApplication(processIdentifier: pid), let icon = app.icon?.tiffRepresentation {
let temporaryURL = URL(fileURLWithPath: (NSTemporaryDirectory() as NSString).appendingPathComponent("\(UUID().uuidString).png"))
let bitmap = NSBitmapImageRep(data: icon)
try bitmap?.representation(using: .png, properties: [:])?.write(to: temporaryURL)
return temporaryURL
}
} catch {
}
return nil
}
func appName(for pid: Int32) -> String? {
NSRunningApplication(processIdentifier: pid)?.localizedName
}
}

View File

@ -3,21 +3,21 @@ import OSLog
public class SocketController {
fileprivate var fileHandle: FileHandle?
fileprivate var port: SocketPort?
public var handler: ((FileHandle) -> Void)?
private var fileHandle: FileHandle?
private var port: SocketPort?
public var handler: ((FileHandleReader, FileHandleWriter) -> Void)?
public init(path: String) {
os_log(.debug, "Socket controller setting up at %@", path)
Logger().debug("Socket controller setting up at \(path)")
if let _ = try? FileManager.default.removeItem(atPath: path) {
os_log(.debug, "Socket controller removed existing socket")
Logger().debug("Socket controller removed existing socket")
}
let exists = FileManager.default.fileExists(atPath: path)
assert(!exists)
os_log(.debug, "Socket controller path is clear")
Logger().debug("Socket controller path is clear")
port = socketPort(at: path)
configureSocket(at: path)
os_log(.debug, "Socket listening at %@", path)
Logger().debug("Socket listening at \(path)")
}
func configureSocket(at path: String) {
@ -33,7 +33,7 @@ public class SocketController {
addr.sun_family = sa_family_t(AF_UNIX)
var len: Int = 0
_ = withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
path.withCString { cstring in
len = strlen(cstring)
strncpy(pointer, cstring, len)
@ -42,7 +42,7 @@ public class SocketController {
addr.sun_len = UInt8(len+2)
var data: Data!
_ = withUnsafePointer(to: &addr) { pointer in
withUnsafePointer(to: &addr) { pointer in
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
}
@ -50,18 +50,18 @@ public class SocketController {
}
@objc func handleConnectionAccept(notification: Notification) {
os_log(.debug, "Socket controller accepted connection")
Logger().debug("Socket controller accepted connection")
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return }
handler?(new)
handler?(new, new)
new.waitForDataInBackgroundAndNotify()
fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.current.currentMode!])
}
@objc func handleConnectionDataAvailable(notification: Notification) {
os_log(.debug, "Socket controller has new data available")
Logger().debug("Socket controller has new data available")
guard let new = notification.object as? FileHandle else { return }
os_log(.debug, "Socket controller received new file handle")
handler?(new)
Logger().debug("Socket controller received new file handle")
handler?(new, new)
}
}

View File

@ -0,0 +1,169 @@
import Foundation
import XCTest
import CryptoKit
@testable import SecretKit
@testable import SecretAgentKit
class AgentTests: XCTestCase {
let stubWriter = StubFileHandleWriter()
// MARK: Identity Listing
func testEmptyStores() {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
let agent = Agent(storeList: SecretStoreList())
agent.handle(reader: stubReader, writer: stubWriter)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestIdentitiesEmpty)
}
func testIdentitiesList() {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
agent.handle(reader: stubReader, writer: stubWriter)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestIdentitiesMultiple)
}
// MARK: Signatures
func testNoMatchingIdentities() {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
agent.handle(reader: stubReader, writer: stubWriter)
// XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure)
}
func testSignature() {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
_ = requestReader.readNextChunk()
let dataToSign = requestReader.readNextChunk()
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list)
agent.handle(reader: stubReader, writer: stubWriter)
let outer = OpenSSHReader(data: stubWriter.data[5...])
let payload = outer.readNextChunk()
let inner = OpenSSHReader(data: payload)
_ = inner.readNextChunk()
let signedData = inner.readNextChunk()
let rsData = OpenSSHReader(data: signedData)
var r = rsData.readNextChunk()
var s = rsData.readNextChunk()
// This is fine IRL, but it freaks out CryptoKit
if r[0] == 0 {
r.removeFirst()
}
if s[0] == 0 {
s.removeFirst()
}
var rs = r
rs.append(s)
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
let valid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
XCTAssertTrue(valid)
}
// MARK: Witness protocol
func testWitnessObjectionStopsRequest() {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
let witness = StubWitness(speakNow: { _,_ in
return true
}, witness: { _, _ in })
let agent = Agent(storeList: list, witness: witness)
agent.handle(reader: stubReader, writer: stubWriter)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure)
}
func testWitnessSignature() {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
var witnessed = false
let witness = StubWitness(speakNow: { _, trace in
return false
}, witness: { _, trace in
witnessed = true
})
let agent = Agent(storeList: list, witness: witness)
agent.handle(reader: stubReader, writer: stubWriter)
XCTAssertTrue(witnessed)
}
func testRequestTracing() {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
var speakNowTrace: SigningRequestProvenance! = nil
var witnessTrace: SigningRequestProvenance! = nil
let witness = StubWitness(speakNow: { _, trace in
speakNowTrace = trace
return false
}, witness: { _, trace in
witnessTrace = trace
})
let agent = Agent(storeList: list, witness: witness)
agent.handle(reader: stubReader, writer: stubWriter)
XCTAssertEqual(witnessTrace, speakNowTrace)
XCTAssertEqual(witnessTrace.origin.displayName, "Finder")
XCTAssertEqual(witnessTrace.origin.validSignature, true)
XCTAssertEqual(witnessTrace.origin.parentPID, 1)
}
// MARK: Exception Handling
func testSignatureException() {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let store = list.stores.first?.base as! Stub.Store
store.shouldThrow = true
let agent = Agent(storeList: list)
agent.handle(reader: stubReader, writer: stubWriter)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure)
}
// MARK: Unsupported
func testUnhandledAdd() {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.addIdentity)
let agent = Agent(storeList: SecretStoreList())
agent.handle(reader: stubReader, writer: stubWriter)
XCTAssertEqual(stubWriter.data, Constants.Responses.requestFailure)
}
}
extension AgentTests {
func storeList(with secrets: [Stub.Secret]) -> SecretStoreList {
let store = Stub.Store()
store.secrets.append(contentsOf: secrets)
let storeList = SecretStoreList()
storeList.add(store: store)
return storeList
}
enum Constants {
enum Requests {
static let requestIdentities = Data(base64Encoded: "AAAAAQs=")!
static let addIdentity = Data(base64Encoded: "AAAAARE=")!
static let requestSignatureWithNoneMatching = Data(base64Encoded: "AAABhA0AAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAO8AAAAgbqmrqPUtJ8mmrtaSVexjMYyXWNqjHSnoto7zgv86xvcyAAAAA2dpdAAAAA5zc2gtY29ubmVjdGlvbgAAAAlwdWJsaWNrZXkBAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQAAAAA=")!
static let requestSignature = Data(base64Encoded: "AAABRA0AAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08AAADPAAAAIBIFsbCZ4/dhBmLNGHm0GKj7EJ4N8k/jXRxlyg+LFIYzMgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAAA==")!
}
enum Responses {
static let requestIdentitiesEmpty = Data(base64Encoded: "AAAABQwAAAAA")!
static let requestIdentitiesMultiple = Data(base64Encoded: "AAABKwwAAAACAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSszpFIlSRHAAjLQHfV+8WpW3NBWGcoW5r9nbFpeCD9hliIvkXLGh0DcPpwCEPAihGJi55dFSw6eRH/CjIYCMtPAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0")!
static let requestFailure = Data(base64Encoded: "AAAAAQU=")!
}
enum Secrets {
static let ecdsa256Secret = Stub.Secret(keySize: 256, publicKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy08=")!, privateKey: Data(base64Encoded: "BKzOkUiVJEcACMtAd9X7xalbc0FYZyhbmv2dsWl4IP2GWIi+RcsaHQNw+nAIQ8CKEYmLnl0VLDp5Ef8KMhgIy09nw780wy/TSfUmzj15iJkV234AaCLNl+H8qFL6qK8VIg==")!)
static let ecdsa384Secret = Stub.Secret(keySize: 384, publicKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDA==")!, privateKey: Data(base64Encoded: "BLKSzA5q3jCb3q0JKigvcxfWVGrJ+bklpG0Zc9YzUwrbsh9SipvlSJi+sHQI+O0m88DOpRBAtuAHX60euD/Yv250tovN7/+MEFbXGZ/hLdd0BoFpWbLfJcQj806KJGlcDHNapAOzrt9E+9QC4/KYoXS7Uw4pmdAz53uIj02tttiq3c0ZyIQ7XoscWWRqRrz8Kw==")!)
}
}
}

View File

@ -1,19 +0,0 @@
//
// SecretAgentKitTests.swift
// SecretAgentKitTests
//
// Created by Max Goedjen on 2/22/20.
// Copyright © 2020 Max Goedjen. All rights reserved.
//
import XCTest
@testable import SecretAgentKit
class SecretAgentKitTests: XCTestCase {
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
}

View File

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

View File

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

View File

@ -0,0 +1,113 @@
import SecretKit
import CryptoKit
struct Stub {}
extension Stub {
public class Store: SecretStore {
public let isAvailable = true
public let id = UUID()
public let name = "Stub"
public var secrets: [Secret] = []
public var shouldThrow = false
public init() {
// try! create(size: 256)
// try! create(size: 384)
}
public func create(size: Int) throws {
let flags: SecAccessControlCreateFlags = []
let access =
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
nil) as Any
let attributes = [
kSecAttrLabel: name,
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits: size,
kSecPrivateKeyAttrs: [
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
] as CFDictionary
var privateKey: SecKey! = nil
var publicKey: SecKey! = nil
SecKeyGeneratePair(attributes, &publicKey, &privateKey)
let publicAttributes = SecKeyCopyAttributes(publicKey) as! [CFString: Any]
let privateAttributes = SecKeyCopyAttributes(privateKey) as! [CFString: Any]
let publicData = (publicAttributes[kSecValueData] as! Data)
let privateData = (privateAttributes[kSecValueData] as! Data)
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
print(secret)
print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
guard !shouldThrow else {
throw NSError()
}
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, [
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPrivate
] as CFDictionary
, nil)!
let signatureAlgorithm: SecKeyAlgorithm
switch secret.keySize {
case 256:
signatureAlgorithm = .ecdsaSignatureMessageX962SHA256
case 384:
signatureAlgorithm = .ecdsaSignatureMessageX962SHA384
default:
fatalError()
}
return SecKeyCreateSignature(privateKey, signatureAlgorithm, data as CFData, nil)! as Data
}
}
}
extension Stub {
struct Secret: SecretKit.Secret, CustomDebugStringConvertible {
let id = UUID().uuidString.data(using: .utf8)!
let name = UUID().uuidString
let algorithm = Algorithm.ellipticCurve
let keySize: Int
let publicKey: Data
let privateKey: Data
init(keySize: Int, publicKey: Data, privateKey: Data) {
self.keySize = keySize
self.publicKey = publicKey
self.privateKey = privateKey
}
var debugDescription: String {
"""
Key Size \(keySize)
Private: \(privateKey.base64EncodedString())
Public: \(publicKey.base64EncodedString())
"""
}
}
}
extension Stub.Store {
struct StubError: Error {
}
}

View File

@ -0,0 +1,32 @@
import SecretKit
import SecretAgentKit
struct StubWitness {
let speakNow: (AnySecret, SigningRequestProvenance) -> Bool
let witness: (AnySecret, SigningRequestProvenance) -> ()
}
extension StubWitness: SigningWitness {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, by provenance: SigningRequestProvenance) throws {
let objection = speakNow(secret, provenance)
if objection {
throw TheresMyChance()
}
}
func witness(accessTo secret: AnySecret, by provenance: SigningRequestProvenance) throws {
witness(secret, provenance)
}
}
extension StubWitness {
struct TheresMyChance: Error {
}
}

View File

@ -0,0 +1,7 @@
import Foundation
extension Bundle {
public var agentBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "Host", with: "SecretAgent"))!}
public var hostBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "SecretAgent", with: "Host"))!}
}

View File

@ -3,12 +3,12 @@ import Foundation
public struct AnySecret: Secret {
let base: Any
fileprivate let hashable: AnyHashable
fileprivate let _id: () -> AnyHashable
fileprivate let _name: () -> String
fileprivate let _algorithm: () -> Algorithm
fileprivate let _keySize: () -> Int
fileprivate let _publicKey: () -> Data
private let hashable: AnyHashable
private let _id: () -> AnyHashable
private let _name: () -> String
private let _algorithm: () -> Algorithm
private let _keySize: () -> Int
private let _publicKey: () -> Data
public init<T>(_ secret: T) where T: Secret {
if let secret = secret as? AnySecret {

View File

@ -4,12 +4,12 @@ import Combine
public class AnySecretStore: SecretStore {
let base: Any
fileprivate let _isAvailable: () -> Bool
fileprivate let _id: () -> UUID
fileprivate let _name: () -> String
fileprivate let _secrets: () -> [AnySecret]
fileprivate let _sign: (Data, AnySecret) throws -> Data
fileprivate var sink: AnyCancellable?
private let _isAvailable: () -> Bool
private let _id: () -> UUID
private let _name: () -> String
private let _secrets: () -> [AnySecret]
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data
private var sink: AnyCancellable?
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
base = secretStore
@ -17,7 +17,7 @@ public class AnySecretStore: SecretStore {
_name = { secretStore.name }
_id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType) }
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
sink = secretStore.objectWillChange.sink { _ in
self.objectWillChange.send()
}
@ -39,20 +39,22 @@ public class AnySecretStore: SecretStore {
return _secrets()
}
public func sign(data: Data, with secret: AnySecret) throws -> Data {
try _sign(data, secret)
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) throws -> Data {
try _sign(data, secret, provenance)
}
}
public class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable {
fileprivate let _create: (String, Bool) throws -> Void
fileprivate let _delete: (AnySecret) throws -> Void
private let _create: (String, Bool) throws -> Void
private let _delete: (AnySecret) throws -> Void
private let _update: (AnySecret, String) throws -> Void
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { try secretStore.create(name: $0, requiresAuthentication: $1) }
_delete = { try secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
_update = { try secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1) }
super.init(secretStore)
}
@ -64,4 +66,7 @@ public class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable {
try _delete(secret)
}
public func update(secret: AnySecret, name: String) throws {
try _update(secret, name)
}
}

View File

@ -13,11 +13,21 @@ public struct OpenSSHKeyWriter {
lengthAndData(of: secret.publicKey)
}
public func openSSHString<SecretType: Secret>(secret: SecretType) -> String {
"\(curveType(for: secret.algorithm, length: secret.keySize)) \(data(secret: secret).base64EncodedString())"
public func openSSHString<SecretType: Secret>(secret: SecretType, comment: String? = nil) -> String {
[curveType(for: secret.algorithm, length: secret.keySize), data(secret: secret).base64EncodedString(), comment]
.compactMap { $0 }
.joined(separator: " ")
}
public func openSSHFingerprint<SecretType: Secret>(secret: SecretType) -> String {
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
// OpenSSL format seems to strip the padding at the end.
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
return "SHA256:\(cleaned)"
}
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
Insecure.MD5.hash(data: data(secret: secret))
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: ":")

View File

@ -8,7 +8,7 @@ public class OpenSSHReader {
remaining = Data(data)
}
public func readNextChunk() throws -> Data {
public func readNextChunk() -> Data {
let lengthRange = 0..<(UInt32.bitWidth/8)
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)

View File

@ -5,7 +5,7 @@ public class SecretStoreList: ObservableObject {
@Published public var stores: [AnySecretStore] = []
@Published public var modifiableStore: AnySecretStoreModifiable?
fileprivate var sinks: [AnyCancellable] = []
private var sinks: [AnyCancellable] = []
public init() {
}
@ -20,11 +20,15 @@ public class SecretStoreList: ObservableObject {
addInternal(store: modifiable)
}
public var anyAvailable: Bool {
stores.reduce(false, { $0 || $1.isAvailable })
}
}
extension SecretStoreList {
fileprivate func addInternal(store: AnySecretStore) {
private func addInternal(store: AnySecretStore) {
stores.append(store)
let sink = store.objectWillChange.sink {
self.objectWillChange.send()

View File

@ -7,7 +7,7 @@ public protocol Secret: Identifiable, Hashable {
}
public enum Algorithm {
public enum Algorithm: Hashable {
case ellipticCurve
public init(secAttr: NSNumber) {
let secAttrString = secAttr.stringValue as CFString

View File

@ -9,7 +9,7 @@ public protocol SecretStore: ObservableObject, Identifiable {
var name: String { get }
var secrets: [SecretType] { get }
func sign(data: Data, with secret: SecretType) throws -> Data
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data
}
@ -17,6 +17,7 @@ public protocol SecretStoreModifiable: SecretStore {
func create(name: String, requiresAuthentication: Bool) throws
func delete(secret: SecretType) throws
func update(secret: SecretType, name: String) throws
}

View File

@ -0,0 +1,53 @@
import Foundation
import AppKit
public struct SigningRequestProvenance: Equatable {
public var chain: [Process]
public init(root: Process) {
self.chain = [root]
}
}
extension SigningRequestProvenance {
public var origin: Process {
chain.last!
}
public var intact: Bool {
chain.allSatisfy { $0.validSignature }
}
}
extension SigningRequestProvenance {
public struct Process: Equatable {
public let pid: Int32
public let processName: String
public let appName: String?
public let iconURL: URL?
public let path: String
public let validSignature: Bool
public let parentPID: Int32?
public init(pid: Int32, processName: String, appName: String?, iconURL: URL?, path: String, validSignature: Bool, parentPID: Int32?) {
self.pid = pid
self.processName = processName
self.appName = appName
self.iconURL = iconURL
self.path = path
self.validSignature = validSignature
self.parentPID = parentPID
}
public var displayName: String {
appName ?? processName
}
}
}

View File

@ -19,6 +19,6 @@
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2020 Max Goedjen. All rights reserved.</string>
<string>$(PRODUCT_NAME) is MIT Licensed.</string>
</dict>
</plist>

View File

@ -1,11 +1,3 @@
//
// SecretKit.h
// SecretKit
//
// Created by Max Goedjen on 2/18/20.
// Copyright © 2020 Max Goedjen. All rights reserved.
//
#import <Foundation/Foundation.h>
//! Project version number for SecretKit.

View File

@ -1,6 +1,7 @@
import Foundation
import Security
import CryptoTokenKit
import LocalAuthentication
extension SecureEnclave {
@ -14,7 +15,7 @@ extension SecureEnclave {
}
public let id = UUID()
public let name = NSLocalizedString("Secure Enclave", comment: "Secure Enclave")
@Published public fileprivate(set) var secrets: [Secret] = []
@Published public private(set) var secrets: [Secret] = []
public init() {
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in
@ -75,7 +76,27 @@ extension SecureEnclave {
reloadSecrets()
}
public func sign(data: Data, with secret: SecretType) throws -> Data {
public func update(secret: Secret, name: String) throws {
let updateQuery = [
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
] as CFDictionary
let updatedAttributes = [
kSecAttrLabel: name,
] as CFDictionary
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadSecrets()
}
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
let context = LAContext()
context.localizedReason = "sign a request from \"\(provenance.origin.displayName)\" using secret \"\(secret.name)\""
context.localizedCancelTitle = "Deny"
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
@ -83,6 +104,7 @@ extension SecureEnclave {
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
] as CFDictionary
var untyped: CFTypeRef?
@ -107,7 +129,7 @@ extension SecureEnclave {
extension SecureEnclave.Store {
fileprivate func reloadSecrets(notify: Bool = true) {
private func reloadSecrets(notify: Bool = true) {
secrets.removeAll()
loadSecrets()
if notify {
@ -115,7 +137,7 @@ extension SecureEnclave.Store {
}
}
fileprivate func loadSecrets() {
private func loadSecrets() {
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
@ -139,7 +161,7 @@ extension SecureEnclave.Store {
secrets.append(contentsOf: wrapped)
}
fileprivate func savePublicKey(_ publicKey: SecKey, name: String) throws {
private func savePublicKey(_ publicKey: SecKey, name: String) throws {
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
@ -178,8 +200,8 @@ extension SecureEnclave {
extension SecureEnclave {
enum Constants {
fileprivate static let keyTag = "com.maxgoedjen.secretive.secureenclave.key".data(using: .utf8)! as CFData
fileprivate static let keyType = kSecAttrKeyTypeECSECPrimeRandom
static let keyTag = "com.maxgoedjen.secretive.secureenclave.key".data(using: .utf8)! as CFData
static let keyType = kSecAttrKeyTypeECSECPrimeRandom
}
}

View File

@ -1,6 +1,7 @@
import Foundation
import Security
import CryptoTokenKit
import LocalAuthentication
// TODO: Might need to split this up into "sub-stores?"
// ie, each token has its own Store.
@ -11,10 +12,10 @@ extension SmartCard {
// TODO: Read actual smart card name, eg "YubiKey 5c"
@Published public var isAvailable: Bool = false
public let id = UUID()
public fileprivate(set) var name = NSLocalizedString("Smart Card", comment: "Smart Card")
@Published public fileprivate(set) var secrets: [Secret] = []
fileprivate let watcher = TKTokenWatcher()
fileprivate var tokenID: String?
public private(set) var name = NSLocalizedString("Smart Card", comment: "Smart Card")
@Published public private(set) var secrets: [Secret] = []
private let watcher = TKTokenWatcher()
private var tokenID: String?
public init() {
tokenID = watcher.nonSecureEnclaveTokens.first
@ -43,13 +44,17 @@ extension SmartCard {
fatalError("Keys must be deleted on the smart card.")
}
public func sign(data: Data, with secret: SecretType) throws -> Data {
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
guard let tokenID = tokenID else { fatalError() }
let context = LAContext()
context.localizedReason = "sign a request from \"\(provenance.origin.displayName)\" using secret \"\(secret.name)\""
context.localizedCancelTitle = "Deny"
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrTokenID: tokenID,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
] as CFDictionary
var untyped: CFTypeRef?
@ -83,12 +88,12 @@ extension SmartCard {
extension SmartCard.Store {
fileprivate func smartcardRemoved(for tokenID: String? = nil) {
private func smartcardRemoved(for tokenID: String? = nil) {
self.tokenID = nil
reloadSecrets()
}
fileprivate func reloadSecrets() {
private func reloadSecrets() {
DispatchQueue.main.async {
self.isAvailable = self.tokenID != nil
self.secrets.removeAll()
@ -96,7 +101,7 @@ extension SmartCard.Store {
}
}
fileprivate func loadSecrets() {
private func loadSecrets() {
guard let tokenID = tokenID else { return }
// Hack to read name if there's only one smart card
let slotNames = TKSmartCardSlotManager().slotNames

View File

@ -0,0 +1,17 @@
import Foundation
import XCTest
@testable import SecretKit
class AnySecretTests: XCTestCase {
func testEraser() {
let secret = SmartCard.Secret(id: UUID().uuidString.data(using: .utf8)!, name: "Name", algorithm: .ellipticCurve, keySize: 256, publicKey: UUID().uuidString.data(using: .utf8)!)
let erased = AnySecret(secret)
XCTAssert(erased.id == secret.id as AnyHashable)
XCTAssert(erased.name == secret.name)
XCTAssert(erased.algorithm == secret.algorithm)
XCTAssert(erased.keySize == secret.keySize)
XCTAssert(erased.publicKey == secret.publicKey)
}
}

View File

@ -0,0 +1,25 @@
import Foundation
import XCTest
@testable import SecretKit
class OpenSSHReaderTests: XCTestCase {
func testSignatureRequest() {
let reader = OpenSSHReader(data: Constants.signatureRequest)
let hash = reader.readNextChunk()
XCTAssert(hash == Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBEqCbkJbOHy5S1wVCaJoKPmpS0egM4frMqllgnlRRQ/Uvnn6EVS8oV03cPA2Bz0EdESyRKA/sbmn0aBtgjIwGELxu45UXEW1TEz6TxyS0u3vuIqR3Wo1CrQWRDnkrG/pBQ=="))
let dataToSign = reader.readNextChunk()
XCTAssert(dataToSign == Data(base64Encoded: "AAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QU="))
let empty = reader.readNextChunk()
XCTAssert(empty.isEmpty)
}
}
extension OpenSSHReaderTests {
enum Constants {
static let signatureRequest = Data(base64Encoded: "AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAADvAAAAICi5xf1ixOestUlxdjvt/BDcM+rzhwy7Vo8cW5YcxA8+MgAAAANnaXQAAAAOc3NoLWNvbm5lY3Rpb24AAAAJcHVibGlja2V5AQAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAAiAAAABNlY2RzYS1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRKgm5CWzh8uUtcFQmiaCj5qUtHoDOH6zKpZYJ5UUUP1L55+hFUvKFdN3DwNgc9BHREskSgP7G5p9GgbYIyMBhC8buOVFxFtUxM+k8cktLt77iKkd1qNQq0FkQ55Kxv6QUAAAAA")!
}
}

View File

@ -6,8 +6,12 @@ class OpenSSHWriterTests: XCTestCase {
let writer = OpenSSHKeyWriter()
func testECDSA256Fingerprint() {
XCTAssertEqual(writer.openSSHFingerprint(secret: Constants.ecdsa256Secret), "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
func testECDSA256MD5Fingerprint() {
XCTAssertEqual(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret), "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
}
func testECDSA256SHA256Fingerprint() {
XCTAssertEqual(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa256Secret), "SHA256:/VQFeGyM8qKA8rB6WGMuZZxZLJln2UgXLk3F0uTF650")
}
func testECDSA256PublicKey() {
@ -19,8 +23,12 @@ class OpenSSHWriterTests: XCTestCase {
XCTAssertEqual(writer.data(secret: Constants.ecdsa256Secret), Data(base64Encoded: "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo="))
}
func testECDSA384Fingerprint() {
XCTAssertEqual(writer.openSSHFingerprint(secret: Constants.ecdsa384Secret), "66:e0:66:d7:41:ed:19:8e:e2:20:df:ce:ac:7e:2b:6e")
func testECDSA384MD5Fingerprint() {
XCTAssertEqual(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa384Secret), "66:e0:66:d7:41:ed:19:8e:e2:20:df:ce:ac:7e:2b:6e")
}
func testECDSA384SHA256Fingerprint() {
XCTAssertEqual(writer.openSSHSHA256Fingerprint(secret: Constants.ecdsa384Secret), "SHA256:GJUEymQNL9ymaMRRJCMGY4rWIJHu/Lm8Yhao/PAiz1I")
}
func testECDSA384PublicKey() {

File diff suppressed because it is too large Load Diff

96
Secretive/App.swift Normal file
View File

@ -0,0 +1,96 @@
import Cocoa
import SwiftUI
import SecretKit
import Brief
@main
struct Secretive: App {
private let storeList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
private let agentStatusChecker = AgentStatusChecker()
private let justUpdatedChecker = JustUpdatedChecker()
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false
@State private var showingCreation = false
@SceneBuilder var body: some Scene {
WindowGroup {
ContentView<Updater, AgentStatusChecker>(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environmentObject(storeList)
.environmentObject(Updater(checkOnLaunch: hasRunSetup))
.environmentObject(agentStatusChecker)
.onAppear {
if !hasRunSetup {
showingSetup = true
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
guard hasRunSetup else { return }
agentStatusChecker.check()
if agentStatusChecker.running && justUpdatedChecker.justUpdated {
// Relaunch the agent, since it'll be running from earlier update still
reinstallAgent()
} else if !agentStatusChecker.running {
forceLaunchAgent()
}
}
}
.commands {
CommandGroup(after: CommandGroupPlacement.newItem) {
Button("New Secret") {
showingCreation = true
}
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
}
CommandGroup(replacing: .help) {
Button("Help") {
NSWorkspace.shared.open(Constants.helpURL)
}
}
CommandGroup(after: .help) {
Button("Setup Secretive") {
showingSetup = true
}
}
SidebarCommands()
}
}
}
extension Secretive {
private func reinstallAgent() {
justUpdatedChecker.check()
LaunchAgentController().install {
// Wait a second for launchd to kick in (next runloop isn't enough).
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
agentStatusChecker.check()
if !agentStatusChecker.running {
forceLaunchAgent()
}
}
}
}
private func forceLaunchAgent() {
// We've run setup, we didn't just update, launchd is just not doing it's thing.
// Force a launch directly.
LaunchAgentController().forceLaunch { _ in
agentStatusChecker.check()
}
}
}
private enum Constants {
static let helpURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md")!
}

View File

@ -1,99 +0,0 @@
import Cocoa
import SwiftUI
import SecretKit
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
@IBOutlet var newMenuItem: NSMenuItem!
@IBOutlet var toolbar: NSToolbar!
let storeList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
let updater = Updater()
let agentStatusChecker = AgentStatusChecker()
func applicationDidFinishLaunching(_ aNotification: Notification) {
let contentView = ContentView(storeList: storeList, updater: updater, agentStatusChecker: agentStatusChecker, runSetupBlock: { self.runSetup(sender: nil) })
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
window.titleVisibility = .hidden
window.toolbar = toolbar
window.isReleasedWhenClosed = false
if storeList.modifiableStore?.isAvailable ?? false {
let plus = NSTitlebarAccessoryViewController()
plus.view = NSButton(image: NSImage(named: NSImage.addTemplateName)!, target: self, action: #selector(add(sender:)))
plus.layoutAttribute = .right
window.addTitlebarAccessoryViewController(plus)
newMenuItem.isEnabled = true
}
runSetupIfNeeded()
}
func applicationDidBecomeActive(_ notification: Notification) {
agentStatusChecker.check()
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
guard !flag else { return false }
window.makeKeyAndOrderFront(self)
return true
}
@IBAction func add(sender: AnyObject?) {
var addWindow: NSWindow!
let addView = CreateSecretView(store: storeList.modifiableStore!) {
self.window.endSheet(addWindow)
}
addWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
addWindow.contentView = NSHostingView(rootView: addView)
window.beginSheet(addWindow, completionHandler: nil)
}
@IBAction func runSetup(sender: AnyObject?) {
let setupWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
let setupView = SetupView() { success in
self.window.endSheet(setupWindow)
self.agentStatusChecker.check()
}
setupWindow.contentView = NSHostingView(rootView: setupView)
window.beginSheet(setupWindow, completionHandler: nil)
}
}
extension AppDelegate {
func runSetupIfNeeded() {
if !UserDefaults.standard.bool(forKey: Constants.defaultsHasRunSetup) {
UserDefaults.standard.set(true, forKey: Constants.defaultsHasRunSetup)
runSetup(sender: nil)
}
}
}
extension AppDelegate {
enum Constants {
static let defaultsHasRunSetup = "defaultsHasRunSetup"
}
}

View File

@ -31,13 +31,13 @@
"size" : "128x128"
},
{
"filename" : "Icon 2@1x.png",
"filename" : "Mac Icon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "Icon 2@2x.png",
"filename" : "Mac Icon@0.25x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -1,160 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="16085" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="16085"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Application-->
<scene sceneID="JPo-4y-FX3">
<objects>
<application id="hnw-xV-0zn" sceneMemberID="viewController">
<menu key="mainMenu" title="Main Menu" systemMenu="main" autoenablesItems="NO" id="AYu-sK-qS6">
<items>
<menuItem title="Secretive" id="1Xt-HY-uBw">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Secretive" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About Secretive" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontStandardAboutPanel:" target="Ady-hI-5gd" id="Exp-CZ-Vem"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
<menuItem title="Services" id="NMo-om-nkz">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Hide Secretive" keyEquivalent="h" id="Olw-nP-bQN">
<connections>
<action selector="hide:" target="Ady-hI-5gd" id="PnN-Uc-m68"/>
</connections>
</menuItem>
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="hideOtherApplications:" target="Ady-hI-5gd" id="VT4-aY-XCT"/>
</connections>
</menuItem>
<menuItem title="Show All" id="Kd2-mp-pUS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="unhideAllApplications:" target="Ady-hI-5gd" id="Dhg-Le-xox"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
<menuItem title="Quit Secretive" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="Ady-hI-5gd" id="Te7-pn-YzF"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="File" id="dMs-cI-mzQ">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="File" autoenablesItems="NO" id="bib-Uj-vzu">
<items>
<menuItem title="New" enabled="NO" keyEquivalent="n" id="Was-JA-tGl">
<connections>
<action selector="addWithSender:" target="Voe-Tx-rLC" id="U1t-YZ-Hn5"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="m54-Is-iLE"/>
<menuItem title="Close" keyEquivalent="w" id="DVo-aG-piG">
<connections>
<action selector="performClose:" target="Ady-hI-5gd" id="HmO-Ls-i7Q"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Window" id="aUF-d1-5bR">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
<items>
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
<connections>
<action selector="performMiniaturize:" target="Ady-hI-5gd" id="VwT-WD-YPe"/>
</connections>
</menuItem>
<menuItem title="Zoom" id="R4o-n2-Eq4">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="performZoom:" target="Ady-hI-5gd" id="DIl-cC-cCs"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="arrangeInFront:" target="Ady-hI-5gd" id="DRN-fu-gQh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
<items>
<menuItem title="Setup Helper App" id="04y-R6-7bF">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="runSetupWithSender:" target="Voe-Tx-rLC" id="Fty-2m-eng"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="Ddf-5M-Bmf"/>
<menuItem title="Secretive Help" keyEquivalent="?" id="FKE-Sm-Kum">
<connections>
<action selector="showHelp:" target="Ady-hI-5gd" id="y7X-2Q-9no"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
</connections>
</application>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="Secretive" customModuleProvider="target">
<connections>
<outlet property="newMenuItem" destination="Was-JA-tGl" id="C8s-uk-gMA"/>
<outlet property="toolbar" destination="bvo-mt-QR4" id="XSF-g2-znt"/>
</connections>
</customObject>
<toolbar implicitIdentifier="09D11707-F4A3-4FD5-970E-AC5832E91C2B" autosavesConfiguration="NO" displayMode="iconAndLabel" sizeMode="regular" id="bvo-mt-QR4">
<allowedToolbarItems>
<toolbarItem implicitItemIdentifier="NSToolbarFlexibleSpaceItem" id="9Xm-OQ-a7h"/>
<toolbarItem implicitItemIdentifier="728E7E6E-F692-41A1-9439-C6EF9BE96CBA" label="Secretive" paletteLabel="" sizingBehavior="auto" id="xbD-W8-Ypr">
<nil key="toolTip"/>
<textField key="view" horizontalHuggingPriority="251" verticalHuggingPriority="750" id="Mg0-Hm-7bW">
<rect key="frame" x="0.0" y="14" width="65" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="Secretive" id="EXw-BM-zF7">
<font key="font" usesAppearanceFont="YES"/>
<color key="textColor" name="windowFrameTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</toolbarItem>
</allowedToolbarItems>
<defaultToolbarItems>
<toolbarItem reference="9Xm-OQ-a7h"/>
<toolbarItem reference="xbD-W8-Ypr"/>
<toolbarItem reference="9Xm-OQ-a7h"/>
</defaultToolbarItems>
</toolbar>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="75" y="0.0"/>
</scene>
</scenes>
</document>

View File

@ -1,6 +1,7 @@
import Foundation
import Combine
import AppKit
import SecretKit
protocol AgentStatusCheckerProtocol: ObservableObject {
var running: Bool { get }
@ -15,19 +16,26 @@ class AgentStatusChecker: ObservableObject, AgentStatusCheckerProtocol {
}
func check() {
running = secretAgentProcess != nil
running = instanceSecretAgentProcess != nil
}
var secretAgentProcess: NSRunningApplication? {
NSRunningApplication.runningApplications(withBundleIdentifier: Constants.secretAgentAppID).first
// All processes, including ones from older versions, etc
var secretAgentProcesses: [NSRunningApplication] {
NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.agentBundleID)
}
// The process corresponding to this instance of Secretive
var instanceSecretAgentProcess: NSRunningApplication? {
let agents = secretAgentProcesses
for agent in agents {
guard let url = agent.bundleURL else { continue }
if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) {
return agent
}
}
return nil
}
}
extension AgentStatusChecker {
enum Constants {
static let secretAgentAppID = "com.maxgoedjen.Secretive.SecretAgent"
}
}

View File

@ -0,0 +1,21 @@
import Foundation
struct ApplicationDirectoryController {
}
extension ApplicationDirectoryController {
var isInApplicationsDirectory: Bool {
let bundlePath = Bundle.main.bundlePath
for directory in NSSearchPathForDirectoriesInDomains(.allApplicationsDirectory, .allDomainsMask, true) {
if bundlePath.hasPrefix(directory) {
return true
}
}
if bundlePath.contains("/Library/Developer/Xcode") {
return true
}
return false
}
}

View File

@ -0,0 +1,34 @@
import Foundation
import Combine
import AppKit
protocol JustUpdatedCheckerProtocol: ObservableObject {
var justUpdated: Bool { get }
}
class JustUpdatedChecker: ObservableObject, JustUpdatedCheckerProtocol {
@Published var justUpdated: Bool = false
init() {
check()
}
func check() {
let lastBuild = UserDefaults.standard.object(forKey: Constants.previousVersionUserDefaultsKey) as? String ?? "None"
let currentBuild = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String
UserDefaults.standard.set(currentBuild, forKey: Constants.previousVersionUserDefaultsKey)
justUpdated = lastBuild != currentBuild
}
}
extension JustUpdatedChecker {
enum Constants {
static let previousVersionUserDefaultsKey = "com.maxgoedjen.Secretive.lastBuild"
}
}

View File

@ -0,0 +1,43 @@
import Foundation
import ServiceManagement
import AppKit
import OSLog
import SecretKit
struct LaunchAgentController {
func install(completion: (() -> Void)? = nil) {
Logger().debug("Installing agent")
_ = setEnabled(false)
// This is definitely a bit of a "seems to work better" thing but:
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
// and start new?
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
_ = setEnabled(true)
completion?()
}
}
func forceLaunch(completion: ((Bool) -> Void)?) {
Logger().debug("Agent is not running, attempting to force launch")
let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app")
let config = NSWorkspace.OpenConfiguration()
config.activates = false
NSWorkspace.shared.openApplication(at: url, configuration: config) { app, error in
DispatchQueue.main.async {
completion?(error == nil)
}
if let error = error {
Logger().error("Error force launching \(error.localizedDescription)")
} else {
Logger().debug("Agent force launched")
}
}
}
private func setEnabled(_ enabled: Bool) -> Bool {
SMLoginItemSetEnabled(Bundle.main.agentBundleID as CFString, enabled)
}
}

View File

@ -0,0 +1,63 @@
import Foundation
import Cocoa
import SecretKit
struct ShellConfigurationController {
let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
var shellInstructions: [ShellConfigInstruction] {
[
ShellConfigInstruction(shell: "global",
shellConfigDirectory: "~/.ssh/",
shellConfigFilename: "config",
text: "Host *\n\tIdentityAgent \(socketPath)"),
ShellConfigInstruction(shell: "zsh",
shellConfigDirectory: "~/",
shellConfigFilename: ".zshrc",
text: "export SSH_AUTH_SOCK=\(socketPath)"),
ShellConfigInstruction(shell: "bash",
shellConfigDirectory: "~/",
shellConfigFilename: ".bashrc",
text: "export SSH_AUTH_SOCK=\(socketPath)"),
ShellConfigInstruction(shell: "fish",
shellConfigDirectory: "~/.config/fish",
shellConfigFilename: "config.fish",
text: "set -x SSH_AUTH_SOCK \(socketPath)"),
]
}
func addToShell(shellInstructions: ShellConfigInstruction) -> Bool {
let openPanel = NSOpenPanel()
// This is sync, so no need to strongly retain
let delegate = Delegate(name: shellInstructions.shellConfigFilename)
openPanel.delegate = delegate
openPanel.message = "Select \(shellInstructions.shellConfigFilename) to let Secretive configure your shell automatically."
openPanel.prompt = "Add to \(shellInstructions.shellConfigFilename)"
openPanel.canChooseFiles = true
openPanel.canChooseDirectories = false
openPanel.showsHiddenFiles = true
openPanel.directoryURL = URL(fileURLWithPath: shellInstructions.shellConfigDirectory)
openPanel.nameFieldStringValue = shellInstructions.shellConfigFilename
openPanel.allowedContentTypes = [.symbolicLink, .data, .plainText]
openPanel.runModal()
guard let fileURL = openPanel.urls.first else { return false }
let handle: FileHandle
do {
handle = try FileHandle(forUpdating: fileURL)
guard let existing = try handle.readToEnd(),
let existingString = String(data: existing, encoding: .utf8) else { return false }
guard !existingString.contains(shellInstructions.text) else {
return true
}
try handle.seekToEnd()
} catch {
return false
}
handle.write("\n# Secretive Config\n\(shellInstructions.text)\n".data(using: .utf8)!)
return true
}
}

View File

@ -1,82 +0,0 @@
import Foundation
import Combine
protocol UpdaterProtocol: ObservableObject {
var update: Release? { get }
}
class Updater: ObservableObject, UpdaterProtocol {
@Published var update: Release?
init() {
checkForUpdates()
let timer = Timer.scheduledTimer(withTimeInterval: 60*60*24, repeats: true) { _ in
self.checkForUpdates()
}
timer.tolerance = 60*60
}
func checkForUpdates() {
URLSession.shared.dataTask(with: Constants.updateURL) { data, _, _ in
guard let data = data else { return }
guard let release = try? JSONDecoder().decode(Release.self, from: data) else { return }
self.evaluate(release: release)
}.resume()
}
func evaluate(release: Release) {
let latestVersion = semVer(from: release.name)
let currentVersion = semVer(from: Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String)
for (latest, current) in zip(latestVersion, currentVersion) {
if latest > current {
DispatchQueue.main.async {
self.update = release
}
return
}
}
}
func semVer(from stringVersion: String) -> [Int] {
var split = stringVersion.split(separator: ".").compactMap { Int($0) }
while split.count < 3 {
split.append(0)
}
return split
}
}
extension Updater {
enum Constants {
static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases/latest")!
}
}
struct Release: Codable {
let name: String
let html_url: URL
let body: String
}
extension Release {
var critical: Bool {
return body.contains(Constants.securityContent)
}
}
extension Release {
enum Constants {
static let securityContent = "Critical Security Update"
}
}

View File

@ -1,23 +1,36 @@
{\rtf1\ansi\ansicpg1252\cocoartf2511
{\rtf1\ansi\ansicpg1252\cocoartf2580
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
\margl1440\margr1440\vieww9000\viewh8400\viewkind0
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6119\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
{\field{\*\fldinst{HYPERLINK "https://github.com/maxgoedjen/secretive"}}{\fldrslt
\f0\fs24 \cf0 https://github.com/maxgoedjen/secretive}}
\f0\fs24 \cf0 GitHub Repository}}
\f0\fs24 \
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
\cf0 \
GITHUB_BUILD_URL\
{\field{\*\fldinst{HYPERLINK "GITHUB_BUILD_URL"}}{\fldrslt Build Log}}\
\
Special Thanks To:\
https://github.com/bdash\
https://github.com/danielctull\
https://github.com/davedelong\
https://github.com/esttorhe\
https://github.com/joeblau\
https://github.com/marksands\
https://github.com/mergesort\
https://github.com/phillco\
https://github.com/zackdotcomputer}
\
{\field{\*\fldinst{HYPERLINK "https://github.com/maxgoedjen/secretive/graphs/contributors"}}{\fldrslt Contributors}}:\
{\field{\*\fldinst{HYPERLINK "https://github.com/0xflotus"}}{\fldrslt 0xflotus}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/aaron-trout"}}{\fldrslt Aaron Trout}}\
\pard\pardeftab720\partightenfactor0
{\field{\*\fldinst{HYPERLINK "https://github.com/EppO"}}{\fldrslt \cf0 Florent Monbillard}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/vladimyr"}}{\fldrslt Dario Vladovi\uc0\u263 }}\
{\field{\*\fldinst{HYPERLINK "https://github.com/lavalleeale"}}{\fldrslt Alex Lavallee}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/joshheyse"}}{\fldrslt Josh}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/diesal11"}}{\fldrslt Dylan Lundy}}\
\
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
\cf0 Testers:\
{\field{\*\fldinst{HYPERLINK "https://github.com/bdash"}}{\fldrslt Mark Rowe}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/danielctull"}}{\fldrslt Daniel Tull}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/davedelong"}}{\fldrslt Dave DeLong}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/esttorhe"}}{\fldrslt Esteban Torres}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/joeblau"}}{\fldrslt Joe Blau}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/marksands"}}{\fldrslt Mark Sands}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/mergesort"}}{\fldrslt Joe Fabisevich}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/phillco"}}{\fldrslt Phil Cohen}}\
{\field{\*\fldinst{HYPERLINK "https://github.com/zackdotcomputer"}}{\fldrslt Zack Sheppard}}}

View File

@ -19,13 +19,11 @@
<key>CFBundleShortVersionString</key>
<string>$(CI_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CI_VERSION)</string>
<string>$(CI_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2020 Max Goedjen. All rights reserved.</string>
<key>NSMainStoryboardFile</key>
<string>Main</string>
<string>$(PRODUCT_NAME) is MIT Licensed.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSSupportsAutomaticTermination</key>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ApplicationDescription</key>
<string>Secretive is an app for storing and managing SSH keys in the Secure Enclave</string>
<key>DeveloperName</key>
<string>Max Goedjen</string>
<key>Website</key>
<string>https://github.com/maxgoedjen/secretive</string>
<key>Connections</key>
<array>
<dict>
<key>IsIncoming</key>
<false/>
<key>Host</key>
<string>api.github.com</string>
<key>NetworkProtocol</key>
<string>TCP</string>
<key>Port</key>
<string>443</string>
<key>Purpose</key>
<string>Secretive checks GitHub for new versions and security updates.</string>
<key>DenyConsequences</key>
<string>If you deny these connections, you will not be notified about new versions and critical security updates.</string>
</dict>
</array>
<key>Services</key>
<array/>
</dict>
</plist>

View File

@ -35,14 +35,13 @@ extension Preview {
self.secrets.append(contentsOf: new)
}
func sign(data: Data, with secret: Preview.Secret) throws -> Data {
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data {
return data
}
}
class StoreModifiable: Store, SecretStoreModifiable {
override var name: String { "Modifiable Preview Store" }
func create(name: String, requiresAuthentication: Bool) throws {
@ -50,8 +49,10 @@ extension Preview {
func delete(secret: Preview.Secret) throws {
}
}
func update(secret: Preview.Secret, name: String) throws {
}
}
}
extension Preview {

View File

@ -1,5 +1,6 @@
import Foundation
import Combine
import Brief
class PreviewUpdater: UpdaterProtocol {
@ -10,9 +11,9 @@ class PreviewUpdater: UpdaterProtocol {
case .none:
self.update = nil
case .advisory:
self.update = Release(name: "10.10.10", html_url: URL(string: "https://example.com")!, body: "Some regular update")
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
case .critical:
self.update = Release(name: "10.10.10", html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
}
}

View File

@ -4,10 +4,12 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.smartcard</key>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.smartcard</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string>

View File

@ -1,143 +1,192 @@
import SwiftUI
import SecretKit
import Brief
struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentStatusCheckerProtocol>: View {
@ObservedObject var storeList: SecretStoreList
@ObservedObject var updater: UpdaterType
@ObservedObject var agentStatusChecker: AgentStatusCheckerType
var runSetupBlock: (() -> Void)?
@Binding var showingCreation: Bool
@Binding var runningSetup: Bool
@Binding var hasRunSetup: Bool
@State fileprivate var active: AnySecret.ID?
@State fileprivate var showingDeletion = false
@State fileprivate var deletingSecret: AnySecret?
@EnvironmentObject private var storeList: SecretStoreList
@EnvironmentObject private var updater: UpdaterType
@EnvironmentObject private var agentStatusChecker: AgentStatusCheckerType
@State private var selectedUpdate: Release?
@State private var showingAppPathNotice = false
var body: some View {
VStack {
if updater.update != nil {
updateNotice()
}
if !agentStatusChecker.running {
agentNotice()
}
NavigationView {
List(selection: $active) {
ForEach(storeList.stores) { store in
if store.isAvailable {
Section(header: Text(store.name)) {
if store.secrets.isEmpty {
if store is AnySecretStoreModifiable {
NavigationLink(destination: EmptyStoreModifiableView(), tag: Constants.emptyStoreModifiableTag, selection: self.$active) {
Text("No Secrets")
}
if storeList.anyAvailable {
StoreListView(showingCreation: $showingCreation)
} else {
NavigationLink(destination: EmptyStoreView(), tag: Constants.emptyStoreTag, selection: self.$active) {
Text("No Secrets")
}
}
} else {
ForEach(store.secrets) { secret in
NavigationLink(destination: SecretDetailView(secret: secret), tag: secret.id, selection: self.$active) {
Text(secret.name)
}.contextMenu {
if store is AnySecretStoreModifiable {
Button(action: { self.delete(secret: secret) }) {
Text("Delete")
}
}
}
}
}
}
}
}
}.onAppear {
self.active = self.nextDefaultSecret
}
.listStyle(SidebarListStyle())
.frame(minWidth: 100, idealWidth: 240)
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
.sheet(isPresented: $showingDeletion) {
if self.storeList.modifiableStore != nil {
DeleteSecretView(secret: self.deletingSecret!, store: self.storeList.modifiableStore!) { deleted in
self.showingDeletion = false
if deleted {
self.active = self.nextDefaultSecret
}
}
NoStoresView()
}
}
.frame(minWidth: 640, minHeight: 320)
.toolbar {
updateNotice
setupNotice
appPathNotice
newItem
}
}
func updateNotice() -> some View {
guard let update = updater.update else { return AnyView(Spacer()) }
let severity: NoticeView.Severity
}
extension ContentView {
var updateNotice: ToolbarItem<Void, AnyView> {
guard let update = updater.update else {
return ToolbarItem { AnyView(EmptyView()) }
}
let color: Color
let text: String
if update.critical {
severity = .critical
text = "Critical Security Update Required"
color = .red
} else {
severity = .advisory
text = "Update Available"
color = .orange
}
return AnyView(NoticeView(text: text, severity: severity, actionTitle: "Update") {
NSWorkspace.shared.open(update.html_url)
return ToolbarItem {
AnyView(
Button(action: {
selectedUpdate = update
}, label: {
Text(text)
.font(.headline)
.foregroundColor(.white)
})
.background(color)
.cornerRadius(5)
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
UpdateDetailView(update: update)
}
func agentNotice() -> some View {
NoticeView(text: "Secret Agent isn't running. Run setup again to fix.", severity: .advisory, actionTitle: "Run Setup") {
self.runSetupBlock?()
)
}
}
func delete<SecretType: Secret>(secret: SecretType) {
deletingSecret = AnySecret(secret)
self.showingDeletion = true
var newItem: ToolbarItem<Void, AnyView> {
guard storeList.modifiableStore?.isAvailable ?? false else {
return ToolbarItem { AnyView(EmptyView()) }
}
return ToolbarItem {
AnyView(
Button(action: {
showingCreation = true
}, label: {
Image(systemName: "plus")
})
.popover(isPresented: $showingCreation, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
if let modifiable = storeList.modifiableStore {
CreateSecretView(store: modifiable, showing: $showingCreation)
}
}
var nextDefaultSecret: AnyHashable? {
let fallback: AnyHashable
if self.storeList.modifiableStore?.isAvailable ?? false {
fallback = Constants.emptyStoreModifiableTag
)
}
}
var setupNotice: ToolbarItem<Void, AnyView> {
return ToolbarItem {
AnyView(
Group {
if runningSetup || !hasRunSetup || !agentStatusChecker.running {
Button(action: {
runningSetup = true
}, label: {
Group {
if hasRunSetup && !agentStatusChecker.running {
Text("Secret Agent Is Not Running")
} else {
fallback = Constants.emptyStoreTag
Text("Setup Secretive")
}
}
.font(.headline)
.foregroundColor(.white)
})
.background(Color.orange)
.cornerRadius(5)
} else {
EmptyView()
}
}
.sheet(isPresented: $runningSetup) {
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
}
)
}
}
var appPathNotice: ToolbarItem<Void, AnyView> {
let controller = ApplicationDirectoryController()
guard !controller.isInApplicationsDirectory else {
return ToolbarItem { AnyView(EmptyView()) }
}
return ToolbarItem {
AnyView(
Button(action: {
showingAppPathNotice = true
}, label: {
Group {
Text("Secretive Is Not in Applications Folder")
}
.font(.headline)
.foregroundColor(.white)
})
.background(Color.orange)
.cornerRadius(5)
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
VStack {
Image(systemName: "exclamationmark.triangle")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 64)
Text("Secretive needs to be in your Applications folder to work properly. Please move it and relaunch.")
.frame(maxWidth: 300)
}
.padding()
}
)
}
return self.storeList.stores.compactMap { $0.secrets.first }.first?.id ?? fallback
}
}
fileprivate enum Constants {
static let emptyStoreModifiableTag: AnyHashable = "emptyStoreModifiableTag"
static let emptyStoreTag: AnyHashable = "emptyStoreModifiableTag"
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
private static let storeList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
private static let agentStatusChecker = AgentStatusChecker()
private static let justUpdatedChecker = JustUpdatedChecker()
@State var hasRunSetup = false
@State private var showingSetup = false
@State private var showingCreation = false
static var previews: some View {
Group {
ContentView(storeList: Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)],
modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]),
updater: PreviewUpdater(),
agentStatusChecker: PreviewAgentStatusChecker())
ContentView(storeList: Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]), updater: PreviewUpdater(),
agentStatusChecker: PreviewAgentStatusChecker())
ContentView(storeList: Preview.storeList(stores: [Preview.Store()]), updater: PreviewUpdater(),
agentStatusChecker: PreviewAgentStatusChecker())
ContentView(storeList: Preview.storeList(modifiableStores: [Preview.StoreModifiable()]), updater: PreviewUpdater(),
agentStatusChecker: PreviewAgentStatusChecker())
ContentView(storeList: Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]), updater: PreviewUpdater(update: .advisory),
agentStatusChecker: PreviewAgentStatusChecker())
ContentView(storeList: Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]), updater: PreviewUpdater(update: .critical),
agentStatusChecker: PreviewAgentStatusChecker())
ContentView(storeList: Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]), updater: PreviewUpdater(update: .critical),
agentStatusChecker: PreviewAgentStatusChecker(running: false))
// Empty on modifiable and nonmodifiable
ContentView<PreviewUpdater, AgentStatusChecker>(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environmentObject(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
.environmentObject(PreviewUpdater())
.environmentObject(agentStatusChecker)
// 5 items on modifiable and nonmodifiable
ContentView<PreviewUpdater, AgentStatusChecker>(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environmentObject(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
.environmentObject(PreviewUpdater())
.environmentObject(agentStatusChecker)
}
.environmentObject(agentStatusChecker)
}
}

View File

@ -0,0 +1,135 @@
import SwiftUI
struct CopyableView: View {
var title: String
var image: Image
var text: String
@State private var interactionState: InteractionState = .normal
var body: some View {
VStack(alignment: .leading) {
HStack {
image
.renderingMode(.template)
.imageScale(.large)
.foregroundColor(primaryTextColor)
Text(title)
.font(.headline)
.foregroundColor(primaryTextColor)
Spacer()
if interactionState != .normal {
Text(hoverText)
.bold()
.textCase(.uppercase)
.foregroundColor(secondaryTextColor)
.transition(.opacity)
}
}
.padding(EdgeInsets(top: 20, leading: 20, bottom: 10, trailing: 20))
Divider()
Text(text)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(primaryTextColor)
.padding(EdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20))
.multilineTextAlignment(.leading)
.font(.system(.body, design: .monospaced))
}
.background(backgroundColor)
.frame(minWidth: 150, maxWidth: .infinity)
.cornerRadius(10)
.onHover { hovering in
withAnimation {
interactionState = hovering ? .hovering : .normal
}
}
.onDrag {
NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: kUTTypeUTF8PlainText as String)
}
.onTapGesture {
copy()
withAnimation {
interactionState = .clicking
}
}
.gesture(
TapGesture()
.onEnded {
withAnimation {
interactionState = .normal
}
}
)
}
var hoverText: String {
switch interactionState {
case .hovering:
return "Click to Copy"
case .clicking:
return "Copied"
case .normal:
fatalError()
}
}
var backgroundColor: Color {
let color: NSColor
switch interactionState {
case .normal:
color = .windowBackgroundColor
case .hovering:
color = .unemphasizedSelectedContentBackgroundColor
case .clicking:
color = .selectedContentBackgroundColor
}
return Color(color)
}
var primaryTextColor: Color {
let color: NSColor
switch interactionState {
case .normal, .hovering:
color = .textColor
case .clicking:
color = .white
}
return Color(color)
}
var secondaryTextColor: Color {
let color: NSColor
switch interactionState {
case .normal, .hovering:
color = .secondaryLabelColor
case .clicking:
color = .white
}
return Color(color)
}
func copy() {
NSPasteboard.general.declareTypes([.string], owner: nil)
NSPasteboard.general.setString(text, forType: .string)
}
private enum InteractionState {
case normal, hovering, clicking
}
}
#if DEBUG
struct CopyableView_Previews: PreviewProvider {
static var previews: some View {
Group {
CopyableView(title: "Title", image: Image(systemName: "figure.wave"), text: "Hello world.")
CopyableView(title: "Title", 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. ")
}
}
}
#endif

View File

@ -1,14 +1,13 @@
import SwiftUI
import SecretKit
struct CreateSecretView: View {
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
@ObservedObject var store: AnySecretStoreModifiable
@ObservedObject var store: StoreType
@Binding var showing: Bool
@State var name = ""
@State var requiresAuthentication = true
var dismissalBlock: () -> ()
@State private var name = ""
@State private var requiresAuthentication = true
var body: some View {
VStack {
@ -24,31 +23,35 @@ struct CreateSecretView: View {
}
HStack {
Text("Name:")
TextField("Shhhhh", text: $name)
TextField("Shhhhh", text: $name).focusable()
}
HStack {
Toggle(isOn: $requiresAuthentication) {
Text("Requires Authentication (Biometrics or Password)")
VStack(spacing: 20) {
Picker("", selection: $requiresAuthentication) {
Text("Requires Authentication (Biometrics or Password) before each use").tag(true)
Text("Authentication not required when Mac is unlocked").tag(false)
}
.pickerStyle(RadioGroupPickerStyle())
}
Spacer()
}
}
.onExitCommand(perform: dismissalBlock)
}
HStack {
Spacer()
Button(action: dismissalBlock) {
Text("Cancel")
Button("Cancel") {
showing = false
}
Button(action: save) {
Text("Create")
}.disabled(name.isEmpty)
.keyboardShortcut(.cancelAction)
Button("Create", action: save)
.disabled(name.isEmpty)
.keyboardShortcut(.defaultAction)
}
}.padding()
}
func save() {
try! store.create(name: name, requiresAuthentication: requiresAuthentication)
dismissalBlock()
showing = false
}
}

View File

@ -3,18 +3,11 @@ import SecretKit
struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
let secret: StoreType.SecretType
@ObservedObject var store: StoreType
let secret: StoreType.SecretType
var dismissalBlock: (Bool) -> ()
@State var confirm = ""
fileprivate var dismissalBlock: (Bool) -> ()
init(secret: StoreType.SecretType, store: StoreType, dismissalBlock: @escaping (Bool) -> ()) {
self.secret = secret
self.store = store
self.dismissalBlock = dismissalBlock
}
@State private var confirm = ""
var body: some View {
VStack {
@ -37,24 +30,28 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
TextField(secret.name, text: $confirm)
}
}
.onExitCommand {
self.dismissalBlock(false)
}
}
HStack {
Spacer()
Button(action: delete) {
Text("Delete")
}.disabled(confirm != secret.name)
Button(action: { self.dismissalBlock(false) }) {
Text("Don't Delete")
Button("Delete", action: delete)
.disabled(confirm != secret.name)
.keyboardShortcut(.delete)
Button("Don't Delete") {
dismissalBlock(false)
}
.keyboardShortcut(.cancelAction)
}
}
}.padding()
.padding()
.frame(minWidth: 400)
.onExitCommand {
dismissalBlock(false)
}
}
func delete() {
try! store.delete(secret: secret)
self.dismissalBlock(true)
dismissalBlock(true)
}
}

View File

@ -1,7 +1,35 @@
import SwiftUI
import SecretKit
struct EmptyStoreView: View {
@ObservedObject var store: AnySecretStore
@Binding var activeSecret: AnySecret.ID?
var body: some View {
if store is AnySecretStoreModifiable {
NavigationLink(destination: EmptyStoreModifiableView(), tag: Constants.emptyStoreModifiableTag, selection: $activeSecret) {
Text("No Secrets")
}
} else {
NavigationLink(destination: EmptyStoreImmutableView(), tag: Constants.emptyStoreTag, selection: $activeSecret) {
Text("No Secrets")
}
}
}
}
extension EmptyStoreView {
enum Constants {
static let emptyStoreModifiableTag: AnyHashable = "emptyStoreModifiableTag"
static let emptyStoreTag: AnyHashable = "emptyStoreModifiableTag"
}
}
struct EmptyStoreImmutableView: View {
var body: some View {
VStack {
Text("No Secrets").bold()
@ -25,14 +53,14 @@ struct EmptyStoreModifiableView: View {
CGPoint(x: g.size.width / 2, y: g.size.height * (1/2)), control2:
CGPoint(x: g.size.width * (3/4), y: g.size.height * (1/2)))
path.addCurve(to:
CGPoint(x: g.size.width, y: 0), control1:
CGPoint(x: g.size.width, y: g.size.height * (1/2)), control2:
CGPoint(x: g.size.width, y: 0))
CGPoint(x: g.size.width - 13, y: 0), control1:
CGPoint(x: g.size.width - 13 , y: g.size.height * (1/2)), control2:
CGPoint(x: g.size.width - 13, y: 0))
}.stroke(style: StrokeStyle(lineWidth: 5, lineCap: .round))
Path { path in
path.move(to: CGPoint(x: g.size.width - 10, y: 0))
path.addLine(to: CGPoint(x: g.size.width, y: -10))
path.addLine(to: CGPoint(x: g.size.width + 10, y: 0))
path.move(to: CGPoint(x: g.size.width - 23, y: 0))
path.addLine(to: CGPoint(x: g.size.width - 13, y: -10))
path.addLine(to: CGPoint(x: g.size.width - 3, y: 0))
}.fill()
}.frame(height: (windowGeometry.size.height/2) - 20).padding()
Text("No Secrets").bold()
@ -48,7 +76,7 @@ struct EmptyStoreModifiableView: View {
struct EmptyStoreModifiableView_Previews: PreviewProvider {
static var previews: some View {
Group {
EmptyStoreView()
EmptyStoreImmutableView()
EmptyStoreModifiableView()
}
}

View File

@ -0,0 +1,23 @@
import SwiftUI
struct NoStoresView: View {
var body: some View {
VStack {
Text("No Secure Storage Available").bold()
Text("Your Mac doesn't have a Secure Enclave, and there's not a compatible Smart Card inserted.")
Link("If you're looking to add one to your Mac, the YubiKey 5 Series are great.", destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!)
}.padding()
}
}
#if DEBUG
struct NoStoresView_Previews: PreviewProvider {
static var previews: some View {
NoStoresView()
}
}
#endif

View File

@ -1,57 +0,0 @@
import Foundation
import SwiftUI
struct NoticeView: View {
let text: String
let severity: Severity
let actionTitle: String?
let action: (() -> Void)?
var body: some View {
HStack {
Text(text).bold()
Spacer()
if action != nil {
Button(action: action!) {
Text(actionTitle!)
}
}
}.padding().background(color)
}
var color: Color {
switch severity {
case .advisory:
return Color.orange
case .critical:
return Color.red
}
}
}
extension NoticeView {
enum Severity {
case advisory, critical
}
}
#if DEBUG
struct NoticeView_Previews: PreviewProvider {
static var previews: some View {
Group {
NoticeView(text: "Agent Not Running", severity: .advisory, actionTitle: "Run Setup") {
print("OK")
}
NoticeView(text: "Critical Security Update Required", severity: .critical, actionTitle: "Update") {
print("OK")
}
}
}
}
#endif

View File

@ -0,0 +1,50 @@
import SwiftUI
import SecretKit
struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
@ObservedObject var store: StoreType
let secret: StoreType.SecretType
var dismissalBlock: (_ renamed: Bool) -> ()
@State private var newName = ""
var body: some View {
VStack {
HStack {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 64, height: 64)
.padding()
VStack {
HStack {
Text("Type your new name for \"\(secret.name)\" below.")
Spacer()
}
HStack {
TextField(secret.name, text: $newName).focusable()
}
}
}
HStack {
Spacer()
Button("Rename", action: rename)
.disabled(newName.count == 0)
.keyboardShortcut(.return)
Button("Cancel") {
dismissalBlock(false)
}.keyboardShortcut(.cancelAction)
}
}
.padding()
.frame(minWidth: 400)
.onExitCommand {
dismissalBlock(false)
}
}
func rename() {
try? store.update(secret: secret, name: newName)
dismissalBlock(true)
}
}

View File

@ -4,48 +4,39 @@ import SecretKit
struct SecretDetailView<SecretType: Secret>: View {
@State var secret: SecretType
let keyWriter = OpenSSHKeyWriter()
private let keyWriter = OpenSSHKeyWriter()
var body: some View {
Form {
Section {
GroupBox(label: Text("Fingerprint")) {
HStack {
Text(keyWriter.openSSHFingerprint(secret: secret))
CopyableView(title: "SHA256 Fingerprint", image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
Spacer()
}
.frame(minWidth: 150, maxWidth: .infinity)
.padding()
}.onDrag {
return NSItemProvider(item: NSData(data: self.keyWriter.openSSHFingerprint(secret: self.secret).data(using: .utf8)!), typeIdentifier: kUTTypeUTF8PlainText as String)
}
Spacer().frame(height: 10)
GroupBox(label: Text("Public Key")) {
VStack {
Text(keyWriter.openSSHString(secret: secret))
.multilineTextAlignment(.leading)
.frame(minWidth: 150, maxWidth: .infinity)
HStack {
.frame(height: 20)
CopyableView(title: "MD5 Fingerprint", image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
Spacer()
.frame(height: 20)
CopyableView(title: "Public Key", image: Image(systemName: "key"), text: keyString)
Spacer()
Button(action: copy) {
Text("Copy")
}
}
}
.padding()
.frame(minHeight: 200, maxHeight: .infinity)
}
.onDrag {
return NSItemProvider(item: NSData(data: self.keyString.data(using: .utf8)!), typeIdentifier: kUTTypeUTF8PlainText as String)
}
Spacer()
}
}.padding()
.frame(minHeight: 150, maxHeight: .infinity)
var dashedKeyName: String {
secret.name.replacingOccurrences(of: " ", with: "-")
}
var dashedHostName: String {
["secretive", Host.current().localizedName, "local"]
.compactMap { $0 }
.joined(separator: ".")
.replacingOccurrences(of: " ", with: "-")
}
var keyString: String {
keyWriter.openSSHString(secret: secret)
keyWriter.openSSHString(secret: secret, comment: "\(dashedKeyName)@\(dashedHostName)")
}
func copy() {
@ -53,7 +44,6 @@ struct SecretDetailView<SecretType: Secret>: View {
NSPasteboard.general.setString(keyString, forType: .string)
}
}
#if DEBUG

View File

@ -0,0 +1,54 @@
import SwiftUI
import SecretKit
struct SecretListItemView: View {
@ObservedObject var store: AnySecretStore
var secret: AnySecret
@Binding var activeSecret: AnySecret.ID?
@State var isDeleting: Bool = false
@State var isRenaming: Bool = false
var deletedSecret: (AnySecret) -> Void
var renamedSecret: (AnySecret) -> Void
var body: some View {
let showingPopupWrapped = Binding(
get: { isDeleting || isRenaming },
set: { if $0 == false { isDeleting = false; isRenaming = false } }
)
return NavigationLink(destination: SecretDetailView(secret: secret), tag: secret.id, selection: $activeSecret) {
Text(secret.name)
}.contextMenu {
if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) {
Text("Rename")
}
Button(action: { isDeleting = true }) {
Text("Delete")
}
}
}
.popover(isPresented: showingPopupWrapped) {
if let modifiable = store as? AnySecretStoreModifiable {
if isDeleting {
DeleteSecretView(store: modifiable, secret: secret) { deleted in
isDeleting = false
if deleted {
deletedSecret(secret)
}
}
} else if isRenaming {
RenameSecretView(store: modifiable, secret: secret) { renamed in
isRenaming = false
if renamed {
renamedSecret(secret)
}
}
}
}
}
}
}

View File

@ -1,114 +1,227 @@
import Foundation
import SwiftUI
import ServiceManagement
struct SetupView: View {
var completion: ((Bool) -> Void)?
@State var stepIndex = 0
@Binding var visible: Bool
@Binding var setupComplete: Bool
var body: some View {
Form {
SetupStepView<Spacer>(text: "Secretive needs to install a helper app to sign requests when the main app isn't running. This app is called \"SecretAgent\" and you might see it in Activity Manager from time to time.",
index: 1,
nestedView: nil,
actionText: "Install") {
self.installLaunchAgent()
GeometryReader { proxy in
VStack {
StepView(numberOfSteps: 3, currentStep: stepIndex, width: proxy.size.width)
GeometryReader { _ in
HStack(spacing: 0) {
SecretAgentSetupView(buttonAction: advance)
.frame(width: proxy.size.width)
SSHAgentSetupView(buttonAction: advance)
.frame(width: proxy.size.width)
UpdaterExplainerView {
visible = false
setupComplete = true
}
SetupStepView(text: "You need to add a line to your shell config (.bashrc or .zshrc) telling SSH to talk to SecretAgent when it wants to authenticate. Drag this into your config file.",
index: 2,
nestedView: SetupStepCommandView(text: Constants.socketPrompt),
actionText: "Added") {
self.markAsDone()
.frame(width: proxy.size.width)
}
HStack {
Spacer()
Button(action: { self.completion?(true) }) {
Text("Finish")
.offset(x: -proxy.size.width * CGFloat(stepIndex), y: 0)
}
.padding()
}
}
.frame(idealWidth: 500, idealHeight: 500)
}
func advance() {
withAnimation(.spring()) {
stepIndex += 1
}
}
}
struct SetupStepView<NestedViewType: View>: View {
struct StepView: View {
let text: String
let index: Int
let nestedView: NestedViewType?
@State var completed = false
let actionText: String
let action: (() -> Bool)
let numberOfSteps: Int
let currentStep: Int
// Ideally we'd have a geometry reader inside this view doing this for us, but that crashes on 11.0b7
let width: CGFloat
var body: some View {
Section {
ZStack(alignment: .leading) {
Rectangle()
.foregroundColor(.blue)
.frame(height: 5)
Rectangle()
.foregroundColor(.green)
.frame(width: max(0, ((width - (Constants.padding * 2)) / CGFloat(numberOfSteps - 1)) * CGFloat(currentStep) - (Constants.circleWidth / 2)), height: 5)
.animation(.spring())
HStack {
ForEach(0..<numberOfSteps) { index in
ZStack {
if completed {
Circle().foregroundColor(.green)
.frame(width: 30, height: 30)
if currentStep > index {
Circle()
.foregroundColor(.green)
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
Text("")
.foregroundColor(.white)
.bold()
} else {
Circle().foregroundColor(.blue)
.frame(width: 30, height: 30)
Text(String(describing: index))
Circle()
.foregroundColor(.blue)
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
if currentStep == index {
Circle()
.strokeBorder(Color.white, lineWidth: 3)
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
}
Text(String(describing: index + 1))
.foregroundColor(.white)
.bold()
}
}
.padding()
VStack {
Text(text)
.opacity(completed ? 0.5 : 1)
.lineLimit(nil)
.frame(idealHeight: 0, maxHeight: .infinity)
if nestedView != nil {
Spacer()
nestedView!
}
}
.padding()
Button(action: {
self.completed = self.action()
}) {
Text(actionText)
}.disabled(completed)
.padding()
if index < numberOfSteps - 1 {
Spacer(minLength: 30)
}
}
}
}.padding(Constants.padding)
}
struct SetupStepCommandView: View {
}
let text: String
extension StepView {
enum Constants {
static let padding: CGFloat = 15
static let circleWidth: CGFloat = 30
}
}
struct SetupStepView<Content> : View where Content : View {
let title: String
let image: Image
let bodyText: String
let buttonTitle: String
let buttonAction: () -> Void
let content: Content
init(title: String, image: Image, bodyText: String, buttonTitle: String, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
self.title = title
self.image = image
self.bodyText = bodyText
self.buttonTitle = buttonTitle
self.buttonAction = buttonAction
self.content = content()
}
var body: some View {
Text(text)
.font(.system(.caption, design: .monospaced))
.lineLimit(nil)
.frame(idealHeight: 0, maxHeight: .infinity)
VStack {
Text(title)
.font(.title)
Spacer()
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 64)
Spacer()
Text(bodyText)
.multilineTextAlignment(.center)
Spacer()
content
Spacer()
Button(buttonTitle) {
buttonAction()
}
}.padding()
}
}
struct SecretAgentSetupView: View {
let buttonAction: () -> Void
var body: some View {
SetupStepView(title: "Setup Secret Agent",
image: Image(nsImage: NSApp.applicationIconImage),
bodyText: "Secretive needs to set up a helper app to work properly. It will sign requests from SSH clients in the background, so you don't need to keep the main Secretive app open.",
buttonTitle: "Install",
buttonAction: install) {
(Text("This helper app is called ") + Text("Secret Agent").bold().underline() + Text(" and you may see it in Activity Manager from time to time."))
.multilineTextAlignment(.center)
}
}
func install() {
LaunchAgentController().install()
buttonAction()
}
}
struct SSHAgentSetupView: View {
let buttonAction: () -> Void
private static let controller = ShellConfigurationController()
@State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first!
var body: some View {
SetupStepView(title: "Configure your SSH Agent",
image: Image(systemName: "terminal"),
bodyText: "Add this line to your shell config telling SSH to talk to Secret Agent when it wants to authenticate. Secretive can either do this for you automatically, or you can copy and paste this into your config file.",
buttonTitle: "I Added it Manually",
buttonAction: buttonAction) {
Link("If you're trying to set up a third party app, check out the FAQ.", destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!)
Picker(selection: $selectedShellInstruction, label: EmptyView()) {
ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in
Text(instruction.shell)
.tag(instruction)
.padding()
.background(Color(white: 0, opacity: 0.10))
.cornerRadius(10)
.onDrag {
return NSItemProvider(item: NSData(data: self.text.data(using: .utf8)!), typeIdentifier: kUTTypeUTF8PlainText as String)
}
}.pickerStyle(SegmentedPickerStyle())
CopyableView(title: "Add to \(selectedShellInstruction.shellConfigPath)", image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
Button("Add it For Me") {
let controller = ShellConfigurationController()
if controller.addToShell(shellInstructions: selectedShellInstruction) {
buttonAction()
}
}
}
}
}
extension SetupView {
class Delegate: NSObject, NSOpenSavePanelDelegate {
func installLaunchAgent() -> Bool {
SMLoginItemSetEnabled("com.maxgoedjen.Secretive.SecretAgent" as CFString, true)
private let name: String
init(name: String) {
self.name = name
}
func markAsDone() -> Bool {
return true
func panel(_ sender: Any, shouldEnable url: URL) -> Bool {
return url.lastPathComponent == name
}
}
struct UpdaterExplainerView: View {
let buttonAction: () -> Void
var body: some View {
SetupStepView(title: "Updates",
image: Image(systemName: "dot.radiowaves.left.and.right"),
bodyText: "Secretive will periodically check with GitHub to see if there's a new release. If you see any network requests to GitHub, that's why.",
buttonTitle: "Okay",
buttonAction: buttonAction) {
Link("Read more about this here.", destination: SetupView.Constants.updaterFAQURL)
}
}
}
@ -116,8 +229,24 @@ extension SetupView {
extension SetupView {
enum Constants {
static let socketPath = (NSHomeDirectory().replacingOccurrences(of: "com.maxgoedjen.Secretive.Host", with: "com.maxgoedjen.Secretive.SecretAgent") as NSString).appendingPathComponent("socket.ssh") as String
static let socketPrompt = "export SSH_AUTH_SOCK=\(socketPath)"
static let updaterFAQURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md#whats-this-network-request-to-github")!
}
}
struct ShellConfigInstruction: Identifiable, Hashable {
var shell: String
var shellConfigDirectory: String
var shellConfigFilename: String
var text: String
var id: String {
shell
}
var shellConfigPath: String {
return (shellConfigDirectory as NSString).appendingPathComponent(shellConfigFilename)
}
}
@ -125,9 +254,43 @@ extension SetupView {
#if DEBUG
struct SetupView_Previews: PreviewProvider {
static var previews: some View {
SetupView()
Group {
SetupView(visible: .constant(true), setupComplete: .constant(false))
}
}
}
struct SecretAgentSetupView_Previews: PreviewProvider {
static var previews: some View {
Group {
SecretAgentSetupView(buttonAction: {})
}
}
}
struct SSHAgentSetupView_Previews: PreviewProvider {
static var previews: some View {
Group {
SSHAgentSetupView(buttonAction: {})
}
}
}
struct UpdaterExplainerView_Previews: PreviewProvider {
static var previews: some View {
Group {
UpdaterExplainerView(buttonAction: {})
}
}
}
#endif

View File

@ -0,0 +1,64 @@
import SwiftUI
import SecretKit
struct StoreListView: View {
@Binding var showingCreation: Bool
@State private var activeSecret: AnySecret.ID?
@EnvironmentObject private var storeList: SecretStoreList
private func secretDeleted(secret: AnySecret) {
activeSecret = nextDefaultSecret
}
private func secretRenamed(secret: AnySecret) {
activeSecret = nextDefaultSecret
}
var body: some View {
NavigationView {
List(selection: $activeSecret) {
ForEach(storeList.stores) { store in
if store.isAvailable {
Section(header: Text(store.name)) {
if store.secrets.isEmpty {
EmptyStoreView(store: store, activeSecret: $activeSecret)
} else {
ForEach(store.secrets) { secret in
SecretListItemView(
store: store,
secret: secret,
activeSecret: $activeSecret,
deletedSecret: self.secretDeleted,
renamedSecret: self.secretRenamed
)
}
}
}
}
}
}
.listStyle(SidebarListStyle())
.onAppear {
activeSecret = nextDefaultSecret
}
.frame(minWidth: 100, idealWidth: 240)
}
}
}
extension StoreListView {
var nextDefaultSecret: AnyHashable? {
let fallback: AnyHashable
if storeList.modifiableStore?.isAvailable ?? false {
fallback = EmptyStoreView.Constants.emptyStoreModifiableTag
} else {
fallback = EmptyStoreView.Constants.emptyStoreTag
}
return storeList.stores.compactMap(\.secrets.first).first?.id ?? fallback
}
}

View File

@ -0,0 +1,61 @@
import SwiftUI
import Brief
struct UpdateDetailView<UpdaterType: Updater>: View {
@EnvironmentObject var updater: UpdaterType
let update: Release
var body: some View {
VStack {
Text("Secretive \(update.name)").font(.title)
GroupBox(label: Text("Release Notes")) {
ScrollView {
attributedBody
}
}
HStack {
if !update.critical {
Button("Ignore") {
updater.ignore(release: update)
}
Spacer()
}
Button("Update") {
NSWorkspace.shared.open(update.html_url)
}
.keyboardShortcut(.defaultAction)
}
}
.padding()
.frame(maxWidth: 500)
}
var attributedBody: Text {
var text = Text("")
for line in update.body.split(whereSeparator: \.isNewline) {
let attributed: Text
let split = line.split(separator: " ")
let unprefixed = split.dropFirst().joined()
if let prefix = split.first {
switch prefix {
case "#":
attributed = Text(unprefixed).font(.title) + Text("\n")
case "##":
attributed = Text(unprefixed).font(.title2) + Text("\n")
case "###":
attributed = Text(unprefixed).font(.title3) + Text("\n")
default:
attributed = Text(line) + Text("\n\n")
}
} else {
attributed = Text(line) + Text("\n\n")
}
text = text + attributed
}
return text
}
}

View File

@ -1,11 +1,3 @@
//
// SecretiveTests.swift
// SecretiveTests
//
// Created by Max Goedjen on 2/18/20.
// Copyright © 2020 Max Goedjen. All rights reserved.
//
import XCTest
@testable import Secretive