Compare commits

..

10 Commits

16 changed files with 169 additions and 113 deletions

49
.github/workflows/nightly.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Nightly
on:
schedule:
- cron: "0 8 * * *"
jobs:
build:
runs-on: macos-11.0
timeout-minutes: 10
steps:
- 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_13.2.1.app
- name: Update Build Number
env:
RUN_ID: ${{ github.run_id }}
run: |
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
- name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIPs
run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
- name: Notarize
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Document SHAs
run: |
shasum -a 512 Secretive.zip
shasum -a 512 Archive.zip
- name: Upload App to Artifacts
uses: actions/upload-artifact@v1
with:
name: Secretive.zip
path: Secretive.zip

View File

@ -51,6 +51,30 @@ Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
Log out and log in again before launching Cyberduck.
## GitKraken
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>link-ssh-auth-sock</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
# The app I use isn't listed here!

4
FAQ.md
View File

@ -40,6 +40,10 @@ Awesome! Just bear in mind that because an app only has access to the keychain i
Secretive checks in with GitHub's releases API to check if there's a new version of Secretive available. You can audit the source code for this feature [here](https://github.com/maxgoedjen/secretive/blob/main/Sources/Packages/Sources/Brief/Updater.swift).
### How do I uninstall Secretive?
Drag Secretive.app to the trash and remove `~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent`. `SecretAgent` may continue running until you quit it or reboot.
### 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.

View File

@ -1,7 +1,7 @@
import Foundation
/// A release is a representation of a downloadable update.
public struct Release: Codable, Sendable {
public struct Release: Codable {
/// The user-facing name of the release. Typically "Secretive 1.2.3"
public let name: String
@ -30,9 +30,6 @@ public struct Release: Codable, Sendable {
}
// TODO: REMOVE WHEN(?) URL GAINS NATIVE CONFORMANCE
extension URL: @unchecked Sendable {}
extension Release: Identifiable {
public var id: String {

View File

@ -1,7 +1,7 @@
import Foundation
/// A representation of a Semantic Version.
public struct SemVer: Sendable {
public struct SemVer {
/// The SemVer broken into an array of integers.
let versionNumbers: [Int]

View File

@ -2,59 +2,53 @@ import Foundation
import Combine
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
public actor Updater: ObservableObject, UpdaterProtocol {
public class Updater: ObservableObject, UpdaterProtocol {
@MainActor @Published public var update: Release?
@Published public var update: Release?
public let testBuild: Bool
/// The current OS version.
private let osVersion: SemVer
/// The current version of the app that is running.
private let currentVersion: SemVer
/// The timer responsible for checking for updates regularly.
private var timer: Timer? = nil
/// Initializes an Updater.
/// - Parameters:
/// - checkOnLaunch: A boolean describing whether the Updater should check for available updates on launch.
/// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
/// - osVersion: The current OS version.
/// - currentVersion: The current version of the app that is running.
public init(osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion), currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")) {
public init(checkOnLaunch: Bool, checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value, osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion), currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")) {
self.osVersion = osVersion
self.currentVersion = currentVersion
testBuild = currentVersion == SemVer("0.0.0")
}
/// Begins checking for updates with the specified frequency.
/// - Parameter checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
public func beginChecking(checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value) {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: checkFrequency, repeats: true) { _ in
Task {
await self.checkForUpdates()
}
if checkOnLaunch {
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet.
checkForUpdates()
}
timer?.tolerance = 60*60
}
/// Ends checking for updates.
public func stopChecking() {
timer?.invalidate()
timer = nil
let timer = Timer.scheduledTimer(withTimeInterval: checkFrequency, repeats: true) { _ in
self.checkForUpdates()
}
timer.tolerance = 60*60
}
/// Manually trigger an update check.
public func checkForUpdates() async {
guard let (data, _) = try? await URLSession.shared.data(from: Constants.updateURL),
let releases = try? JSONDecoder().decode([Release].self, from: data) else { return }
await evaluate(releases: releases)
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()
}
/// Ignores a specified release. `update` will be nil if the user has ignored the latest available release.
/// - Parameter release: The release to ignore.
public func ignore(release: Release) async {
public func ignore(release: Release) {
guard !release.critical else { return }
defaults.set(true, forKey: release.name)
await setUpdate(update: update)
DispatchQueue.main.async {
self.update = nil
}
}
}
@ -63,7 +57,7 @@ extension Updater {
/// Evaluates the available downloadable releases, and selects the newest non-prerelease release that the user is able to run.
/// - Parameter releases: An array of ``Release`` objects.
func evaluate(releases: [Release]) async {
func evaluate(releases: [Release]) {
guard let release = releases
.sorted()
.reversed()
@ -73,14 +67,12 @@ extension Updater {
guard !release.prerelease else { return }
let latestVersion = SemVer(release.name)
if latestVersion > currentVersion {
await setUpdate(update: update)
DispatchQueue.main.async {
self.update = release
}
}
}
@MainActor private func setUpdate(update: Release?) {
self.update = update
}
/// Checks whether the user has ignored a release.
/// - Parameter release: The release to check.
/// - Returns: A boolean describing whether the user has ignored the release. Will always be false if the release is critical.
@ -103,21 +95,3 @@ extension Updater {
}
}
@available(macOS, deprecated: 12)
extension URLSession {
// Backport for macOS 11
func data(from url: URL) async throws -> (Data, URLResponse) {
try await withCheckedThrowingContinuation { continuation in
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, let response = response else {
continuation.resume(throwing: error ?? NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: nil))
return
}
continuation.resume(returning: (data, response))
}
}
}
}

View File

@ -4,9 +4,9 @@ import Foundation
public protocol UpdaterProtocol: ObservableObject {
/// The latest update
@MainActor var update: Release? { get }
var update: Release? { get }
/// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build)
@MainActor var testBuild: Bool { get }
var testBuild: Bool { get }
}

View File

@ -30,20 +30,23 @@ extension Agent {
/// - Parameters:
/// - reader: A ``FileHandleReader`` to read the content of the request.
/// - writer: A ``FileHandleWriter`` to write the response to.
public func handle(reader: FileHandleReader, writer: FileHandleWriter) {
/// - Return value:
/// - Boolean if data could be read
public func handle(reader: FileHandleReader, writer: FileHandleWriter) -> Bool {
Logger().debug("Agent handling new data")
let data = Data(reader.availableData)
guard data.count > 4 else { return }
guard data.count > 4 else { return false}
let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return
return true
}
Logger().debug("Agent handling request of type \(requestType.debugDescription)")
let subData = Data(data[5...])
let response = handle(requestType: requestType, data: subData, reader: reader)
writer.write(response)
return true
}
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) -> Data {

View File

@ -9,7 +9,9 @@ public class SocketController {
/// The active SocketPort.
private var port: SocketPort?
/// A handler that will be notified when a new read/write handle is available.
public var handler: ((FileHandleReader, FileHandleWriter) -> Void)?
/// False if no data could be read
public var handler: ((FileHandleReader, FileHandleWriter) -> Bool)?
/// Initializes a socket controller with a specified path.
/// - Parameter path: The path to use as a socket.
@ -65,7 +67,7 @@ public class SocketController {
@objc func handleConnectionAccept(notification: Notification) {
Logger().debug("Socket controller accepted connection")
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return }
handler?(new, new)
_ = handler?(new, new)
new.waitForDataInBackgroundAndNotify()
fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.current.currentMode!])
}
@ -76,7 +78,12 @@ public class SocketController {
Logger().debug("Socket controller has new data available")
guard let new = notification.object as? FileHandle else { return }
Logger().debug("Socket controller received new file handle")
handler?(new, new)
if((handler?(new, new)) == true) {
Logger().debug("Socket controller handled data, wait for more data")
new.waitForDataInBackgroundAndNotify()
} else {
Logger().debug("Socket controller called with empty data, socked closed")
}
}
}

View File

@ -6,6 +6,7 @@ public class PublicKeyFileStoreController {
private let logger = Logger()
private let directory: String
private let keyWriter = OpenSSHKeyWriter()
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: String) {
@ -21,7 +22,6 @@ public class PublicKeyFileStoreController {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: directory))
}
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
let keyWriter = OpenSSHKeyWriter()
for secret in secrets {
let path = path(for: secret)
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
@ -35,7 +35,8 @@ public class PublicKeyFileStoreController {
/// - Returns: The path to the Secret's public key.
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
public func path<SecretType: Secret>(for secret: SecretType) -> String {
directory.appending("/").appending("\(secret.name.replacingOccurrences(of: " ", with: "-")).pub")
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex).pub")
}
}

View File

@ -56,6 +56,9 @@ public protocol SecretStoreModifiable: SecretStore {
extension NSNotification.Name {
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed secrets)
public static let secretStoreUpdated = NSNotification.Name("com.maxgoedjen.Secretive.secretStore.updated")
// Internal notification that keys were reloaded from the backing store.
public static let secretStoreReloaded = NSNotification.Name("com.maxgoedjen.Secretive.secretStore.reloaded")
}

View File

@ -24,7 +24,7 @@ extension SecureEnclave {
/// Initializes a Store.
public init() {
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in
self.reloadSecrets(notify: false)
self.reloadSecrets(notifyAgent: false)
}
loadSecrets()
}
@ -171,12 +171,13 @@ extension SecureEnclave {
extension SecureEnclave.Store {
/// Reloads all secrets from the store.
/// - Parameter notify: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
private func reloadSecrets(notify: Bool = true) {
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
private func reloadSecrets(notifyAgent: Bool = true) {
secrets.removeAll()
loadSecrets()
if notify {
DistributedNotificationCenter.default().post(name: .secretStoreUpdated, object: nil)
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
}
}

View File

@ -16,7 +16,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
list.add(store: SmartCard.Store())
return list
}()
private let updater = Updater()
private let updater = Updater(checkOnLaunch: false)
private let notifier = Notifier()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private lazy var agent: Agent = {
@ -33,15 +33,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
DispatchQueue.main.async {
self.socketController.handler = self.agent.handle(reader:writer:)
}
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { [self] _ in
NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
}
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
notifier.prompt()
// updateSink = updater.$update.sink { update in
// guard let update = update else { return }
// self.notifier.notify(update: update, ignore: self.updater.ignore(release:))
// }
updateSink = updater.$update.sink { update in
guard let update = update else { return }
self.notifier.notify(update: update, ignore: self.updater.ignore(release:))
}
}
}

View File

@ -16,7 +16,6 @@ struct Secretive: App {
}()
private let agentStatusChecker = AgentStatusChecker()
private let justUpdatedChecker = JustUpdatedChecker()
private let updater = Updater()
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false
@ -26,15 +25,11 @@ struct Secretive: App {
WindowGroup {
ContentView<Updater, AgentStatusChecker>(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environmentObject(storeList)
.environmentObject(updater)
.environmentObject(Updater(checkOnLaunch: hasRunSetup))
.environmentObject(agentStatusChecker)
.onAppear {
if !hasRunSetup {
showingSetup = true
} else {
Task { [updater] in
await updater.checkForUpdates()
}
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in

View File

@ -32,6 +32,9 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
appPathNotice
newItem
}
.sheet(isPresented: $runningSetup) {
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
}
}
}
@ -65,11 +68,11 @@ extension ContentView {
.font(.headline)
.foregroundColor(.white)
})
.background(color)
.cornerRadius(5)
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
UpdateDetailView(update: update)
}
.background(color)
.cornerRadius(5)
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
UpdateDetailView(update: update)
}
)
}
}
@ -85,11 +88,11 @@ extension ContentView {
}, label: {
Image(systemName: "plus")
})
.popover(isPresented: $showingCreation, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
if let modifiable = storeList.modifiableStore {
CreateSecretView(store: modifiable, showing: $showingCreation)
}
.popover(isPresented: $showingCreation, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
if let modifiable = storeList.modifiableStore {
CreateSecretView(store: modifiable, showing: $showingCreation)
}
}
)
}
@ -113,15 +116,12 @@ extension ContentView {
.font(.headline)
.foregroundColor(.white)
})
.background(Color.orange)
.cornerRadius(5)
.background(Color.orange)
.cornerRadius(5)
} else {
EmptyView()
}
}
.sheet(isPresented: $runningSetup) {
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
}
)
}
}
@ -142,19 +142,19 @@ extension ContentView {
.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()
.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()
}
)
}
}

View File

@ -18,9 +18,7 @@ struct UpdateDetailView<UpdaterType: Updater>: View {
HStack {
if !update.critical {
Button("Ignore") {
Task { [updater, update] in
await updater.ignore(release: update)
}
updater.ignore(release: update)
}
Spacer()
}