Compare commits

..

6 Commits

Author SHA1 Message Date
Max Goedjen
480ef5392d Fix bugs around selection after creating/deleting/updating keys (#436)
* Fix bug where new secret wouldn't be selected

* Remove keyboard shortcut for deletion
2022-12-23 04:29:51 +00:00
Max Goedjen
8679ca3da0 Switch toolbar items to viewbuilders (#434)
* Switch toolbar items to viewbuilders

* Toolbar button style
2022-12-22 23:05:04 +00:00
Max Goedjen
f43571baa3 SSH Certificate Cleanup/Followup (#418)
* Don't delete public cert keys corrresponding to secretive-tracked keys

* First pass

* Fix fallback for name

* Split out into dedicated handler

* Stub add identities

* .
2022-12-21 23:18:27 -05:00
Max Goedjen
acdf0baf3a Create Secret Screen Redesign (#370) 2022-12-19 00:19:54 +00:00
Max Goedjen
1a362ef955 Tighten up light-scheme colors (#429)
* Tighten up light-scheme colors

* Tweak more

* Little more
2022-12-18 15:16:08 -08:00
Max Goedjen
3b254d33a5 Fix for SecretAgent acting as if it has no keys after updating macOS (#427)
* Allow reload pre-op

* Fix tests

* Make sure standin keys get rewritten on force update

* Stub store
2022-12-18 07:16:56 +00:00
24 changed files with 691 additions and 334 deletions

View File

@@ -1,5 +1,22 @@
#!/bin/bash
# Import certificate and private key
echo $SIGNING_DATA | base64 -d -o Signing.p12
security create-keychain -p ci ci.keychain
security default-keychain -s ci.keychain
security list-keychains -s ci.keychain
security import ./Signing.p12 -k ci.keychain -P $SIGNING_PASSWORD -A
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k ci ci.keychain
# 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.p8
echo -n "$APPLE_API_KEY_DATA" > ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8

View File

@@ -5,33 +5,32 @@ on:
tags:
- '*'
jobs:
# test:
# runs-on: macOS-latest
# timeout-minutes: 10
# steps:
# - uses: actions/checkout@v3
# - 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_14.1.app
# - name: Test
# run: |
# pushd Sources/Packages
# swift test
# popd
test:
runs-on: macOS-latest
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 }}
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_14.1.app
- name: Test
run: |
pushd Sources/Packages
swift test
popd
build:
runs-on: macOS-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
@@ -53,24 +52,16 @@ jobs:
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
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive -destination "generic/platform=macOS" archive
- name: Export Products
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun xcodebuild -exportArchive -archivePath Archive.xcarchive -exportPath Export -exportOptionsPlist Sources/Config/ExportOptions.plist -allowProvisioningUpdates -authenticationKeyIssuerID $APPLE_API_ISSUER -authenticationKeyID $APPLE_API_KEY_ID -authenticationKeyPath ~/.private_keys/AuthKey.p8
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIPs
run: |
ditto -c -k --sequesterRsrc --keepParent Export/Secretive.app ./Secretive.zip
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 $(pwd)/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Document SHAs
run: |
echo "sha-512:"

View File

@@ -1,10 +0,0 @@
<?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>method</key>
<string>developer-id</string>
<key>teamID</key>
<string>Z72PRUAWF6</string>
</dict>
</plist>

View File

@@ -4,25 +4,6 @@ import OSLog
import SecretKit
import AppKit
enum OpenSSHCertificateError: Error {
case unsupportedType
case parsingFailed
case doesNotExist
}
extension OpenSSHCertificateError: CustomStringConvertible {
public var description: String {
switch self {
case .unsupportedType:
return "The key type was unsupported"
case .parsingFailed:
return "Failed to properly parse the SSH certificate"
case .doesNotExist:
return "Certificate does not exist"
}
}
}
/// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores.
public class Agent {
@@ -30,16 +11,18 @@ public class Agent {
private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter()
private let requestTracer = SigningRequestTracer()
private let certsPath = (NSHomeDirectory() as NSString).appendingPathComponent("PublicKeys") as String
private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent.agent", category: "")
/// Initializes an agent with a store list and a witness.
/// - Parameters:
/// - storeList: The `SecretStoreList` to make available.
/// - witness: A witness to notify of requests.
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) {
Logger().debug("Agent is running")
logger.debug("Agent is running")
self.storeList = storeList
self.witness = witness
certificateHandler.reloadCertificates(for: storeList.allSecrets)
}
}
@@ -53,16 +36,16 @@ extension Agent {
/// - Return value:
/// - Boolean if data could be read
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) -> Bool {
Logger().debug("Agent handling new data")
logger.debug("Agent handling new data")
let data = Data(reader.availableData)
guard data.count > 4 else { return false}
let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return true
}
Logger().debug("Agent handling request of type \(requestType.debugDescription)")
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)
@@ -70,23 +53,25 @@ extension Agent {
}
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) -> Data {
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
reloadSecretsIfNeccessary()
var response = Data()
do {
switch requestType {
case .requestIdentities:
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
response.append(identities())
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
case .signRequest:
let provenance = requestTracer.provenance(from: reader)
response.append(SSHAgent.ResponseType.agentSignResponse.data)
response.append(try sign(data: data, provenance: provenance))
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
}
} catch {
response.removeAll()
response.append(SSHAgent.ResponseType.agentFailure.data)
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
}
let full = OpenSSHKeyWriter().lengthAndData(of: response)
return full
@@ -99,7 +84,8 @@ extension Agent {
/// Lists the identities available for signing operations
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
func identities() -> Data {
let secrets = storeList.stores.flatMap(\.secrets)
let secrets = storeList.allSecrets
certificateHandler.reloadCertificates(for: secrets)
var count = UInt32(secrets.count).bigEndian
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
var keyData = Data()
@@ -108,9 +94,9 @@ extension Agent {
let keyBlob: Data
let curveData: Data
if let (certBlob, certName) = try? checkForCert(secret: secret) {
keyBlob = certBlob
curveData = certName
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) {
keyBlob = certificateData
curveData = name
} else {
keyBlob = writer.data(secret: secret)
curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
@@ -120,7 +106,7 @@ extension Agent {
keyData.append(writer.lengthAndData(of: curveData))
}
Logger().debug("Agent enumerated \(secrets.count) identities")
logger.log("Agent enumerated \(secrets.count) identities")
return countData + keyData
}
@@ -131,15 +117,17 @@ extension Agent {
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
let reader = OpenSSHReader(data: data)
var hash = reader.readNextChunk()
let payloadHash = reader.readNextChunk()
let hash: Data
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
if let certPublicKey = try? getPublicKeyFromCert(certBlob: hash) {
hash = certPublicKey
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
hash = certificatePublicKey
} else {
hash = payloadHash
}
guard let (store, secret) = secret(matching: hash) else {
Logger().debug("Agent did not have a key matching \(hash as NSData)")
logger.debug("Agent did not have a key matching \(hash as NSData)")
throw AgentError.noMatchingKey
}
@@ -193,83 +181,25 @@ extension Agent {
try witness.witness(accessTo: secret, from: store, by: provenance)
}
Logger().debug("Agent signed request")
logger.debug("Agent signed request")
return signedData
}
/// Reconstructs a public key from a ``Data`` object that contains an OpenSSH certificate. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format
func getPublicKeyFromCert(certBlob: Data) throws -> Data {
let reader = OpenSSHReader(data: certBlob)
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
switch certType {
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
_ = reader.readNextChunk() // nonce
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
if let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8) {
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
} else {
throw OpenSSHCertificateError.parsingFailed
}
default:
throw OpenSSHCertificateError.unsupportedType
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: Two ``Data`` objects containing the certificate and certificate name respectively
func checkForCert(secret: AnySecret) throws -> (Data, Data) {
let minimalHex = writer.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
let certificatePath = certsPath.appending("/").appending("\(minimalHex)-cert.pub")
if FileManager.default.fileExists(atPath: certificatePath) {
Logger().debug("Found certificate for \(secret.name)")
do {
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
if certElements.count >= 2 {
if let certDecoded = Data(base64Encoded: certElements[1] as String) {
if certElements.count >= 3 {
if let certName = certElements[2].data(using: .utf8) {
return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) {
Logger().info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
}
} else {
Logger().warning("Certificate found for \(secret.name) but failed to decode base64 key")
throw OpenSSHCertificateError.parsingFailed
}
}
} catch {
Logger().warning("Certificate found for \(secret.name) but failed to load")
throw OpenSSHCertificateError.parsingFailed
}
}
throw OpenSSHCertificateError.doesNotExist
}
}
extension Agent {
/// Gives any store with no loaded secrets a chance to reload.
func reloadSecretsIfNeccessary() {
for store in storeList.stores {
if store.secrets.isEmpty {
logger.debug("Store \(store.name, privacy: .public) has no loaded secrets. Reloading.")
store.reloadSecrets()
}
}
}
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
/// - Parameter hash: The hash to match against.
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
@@ -295,6 +225,7 @@ extension Agent {
case unhandledType
case noMatchingKey
case unsupportedKeyType
case notOpenSSHCertificate
}
}

View File

@@ -1,11 +1,11 @@
import Foundation
/// A namespace for the SSH Agent Protocol, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum SSHAgent {}
extension SSHAgent {
/// The type of the SSH Agent Request, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html#rfc.section.5.1
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum RequestType: UInt8, CustomDebugStringConvertible {
case requestIdentities = 11
@@ -21,10 +21,11 @@ extension SSHAgent {
}
}
/// The type of the SSH Agent Response, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html#rfc.section.5.1
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
public enum ResponseType: UInt8, CustomDebugStringConvertible {
case agentFailure = 5
case agentSuccess = 6
case agentIdentitiesAnswer = 12
case agentSignResponse = 14
@@ -32,6 +33,8 @@ extension SSHAgent {
switch self {
case .agentFailure:
return "AgentFailure"
case .agentSuccess:
return "AgentSuccess"
case .agentIdentitiesAnswer:
return "AgentIdentitiesAnswer"
case .agentSignResponse:

View File

@@ -12,6 +12,7 @@ public class AnySecretStore: SecretStore {
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data
private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext?
private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void
private let _reloadSecrets: () -> Void
private var sink: AnyCancellable?
@@ -24,6 +25,7 @@ public class AnySecretStore: SecretStore {
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
_existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
_persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_reloadSecrets = { secretStore.reloadSecrets() }
sink = secretStore.objectWillChange.sink { _ in
self.objectWillChange.send()
}
@@ -57,6 +59,10 @@ public class AnySecretStore: SecretStore {
try _persistAuthentication(secret, duration)
}
public func reloadSecrets() {
_reloadSecrets()
}
}
public class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable {

View File

@@ -0,0 +1,120 @@
import Foundation
import OSLog
/// Manages storage and lookup for OpenSSH certificates.
public class OpenSSHCertificateHandler {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger()
private let writer = OpenSSHKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler.
public init() {
}
/// Reloads any certificates in the PublicKeys folder.
/// - Parameter secrets: the secrets to look up corresponding certificates for.
public func reloadCertificates(for secrets: [AnySecret]) {
guard publicKeyFileStoreController.hasAnyCertificates else {
logger.log("No certificates, short circuiting")
return
}
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
partialResult[next] = try? loadKeyblobAndName(for: next)
}
}
/// Whether or not the certificate handler has a certifiicate associated with a given secret.
/// - Parameter secret: The secret to check for a certificate.
/// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
keyBlobsAndNames[AnySecret(secret)] != nil
}
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
public func publicKeyHash(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash)
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
switch certType {
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
_ = reader.readNextChunk() // nonce
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)!
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
default:
return nil
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
keyBlobsAndNames[AnySecret(secret)]
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
private func loadKeyblobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
let certificatePath = publicKeyFileStoreController.sshCertificatePath(for: secret)
guard FileManager.default.fileExists(atPath: certificatePath) else {
return nil
}
logger.debug("Found certificate for \(secret.name)")
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
guard certElements.count >= 2 else {
logger.warning("Certificate found for \(secret.name) but failed to load")
throw OpenSSHCertificateError.parsingFailed
}
guard let certDecoded = Data(base64Encoded: certElements[1] as String) else {
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
throw OpenSSHCertificateError.parsingFailed
}
if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) {
return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) {
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
}
}
extension OpenSSHCertificateHandler {
enum OpenSSHCertificateError: LocalizedError {
case unsupportedType
case parsingFailed
case doesNotExist
public var errorDescription: String? {
switch self {
case .unsupportedType:
return "The key type was unsupported"
case .parsingFailed:
return "Failed to properly parse the SSH certificate"
case .doesNotExist:
return "Certificate does not exist"
}
}
}
}

View File

@@ -45,6 +45,18 @@ public class PublicKeyFileStoreController {
return directory.appending("/").appending("\(minimalHex).pub")
}
/// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
public var hasAnyCertificates: Bool {
do {
return try FileManager.default
.contentsOfDirectory(atPath: directory)
.filter { $0.hasSuffix("-cert.pub") }
.isEmpty == false
} catch {
return false
}
}
/// The path for a Secret's SSH Certificate public key.
/// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the SSH Certificate public key.

View File

@@ -8,7 +8,7 @@ public class SecretStoreList: ObservableObject {
@Published public var stores: [AnySecretStore] = []
/// A modifiable store, if one is available.
@Published public var modifiableStore: AnySecretStoreModifiable?
private var sinks: [AnyCancellable] = []
private var cancellables: Set<AnyCancellable> = []
/// Initializes a SecretStoreList.
public init() {
@@ -31,16 +31,19 @@ public class SecretStoreList: ObservableObject {
stores.reduce(false, { $0 || $1.isAvailable })
}
public var allSecrets: [AnySecret] {
stores.flatMap(\.secrets)
}
}
extension SecretStoreList {
private func addInternal(store: AnySecretStore) {
stores.append(store)
let sink = store.objectWillChange.sink {
store.objectWillChange.sink {
self.objectWillChange.send()
}
sinks.append(sink)
}.store(in: &cancellables)
}
}

View File

@@ -36,6 +36,9 @@ public protocol SecretStore: ObservableObject, Identifiable {
/// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret.
func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) throws
/// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets()
}
/// A SecretStore that the Secretive admin app can modify.

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(notifyAgent: false)
self.reloadSecretsInternal(notifyAgent: false)
}
loadSecrets()
}
@@ -68,7 +68,7 @@ extension SecureEnclave {
throw KeychainError(statusCode: nil)
}
try savePublicKey(publicKey, name: name)
reloadSecrets()
reloadSecretsInternal()
}
public func delete(secret: Secret) throws {
@@ -80,7 +80,7 @@ extension SecureEnclave {
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadSecrets()
reloadSecretsInternal()
}
public func update(secret: Secret, name: String) throws {
@@ -97,7 +97,7 @@ extension SecureEnclave {
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadSecrets()
reloadSecretsInternal()
}
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
@@ -163,6 +163,10 @@ extension SecureEnclave {
}
}
public func reloadSecrets() {
reloadSecretsInternal(notifyAgent: false)
}
}
}
@@ -171,12 +175,15 @@ extension SecureEnclave.Store {
/// Reloads all secrets from the store.
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
private func reloadSecrets(notifyAgent: Bool = true) {
private func reloadSecretsInternal(notifyAgent: Bool = true) {
let before = secrets
secrets.removeAll()
loadSecrets()
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
}
}
}

View File

@@ -89,6 +89,19 @@ extension SmartCard {
public func persistAuthentication(secret: SmartCard.Secret, forDuration: TimeInterval) throws {
}
/// Reloads all secrets from the store.
public func reloadSecrets() {
DispatchQueue.main.async {
self.isAvailable = self.tokenID != nil
let before = self.secrets
self.secrets.removeAll()
self.loadSecrets()
if self.secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
}
}
}
}
}
@@ -102,15 +115,6 @@ extension SmartCard.Store {
reloadSecrets()
}
/// Reloads all secrets from the store.
private func reloadSecrets() {
DispatchQueue.main.async {
self.isAvailable = self.tokenID != nil
self.secrets.removeAll()
self.loadSecrets()
}
}
/// Loads all secrets from the store.
private func loadSecrets() {
guard let tokenID = tokenID else { return }

View File

@@ -78,6 +78,9 @@ extension Stub {
public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
}
public func reloadSecrets() {
}
}
}

View File

@@ -34,9 +34,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.socketController.handler = self.agent.handle(reader:writer:)
}
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.allSecrets, clear: true)
}
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
notifier.prompt()
updateSink = updater.$update.sink { update in
guard let update = update else { return }

View File

@@ -31,6 +31,7 @@
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */; };
50617D9923FCE48E0099B055 /* SecretiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D9823FCE48E0099B055 /* SecretiveTests.swift */; };
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617DD123FCEFA90099B055 /* PreviewStore.swift */; };
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5065E312295517C500E16645 /* ToolbarButtonStyle.swift */; };
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C12516F303004B5A36 /* SetupView.swift */; };
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; };
5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */; };
@@ -123,6 +124,7 @@
50617D9823FCE48E0099B055 /* SecretiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretiveTests.swift; sourceTree = "<group>"; };
50617D9A23FCE48E0099B055 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50617DD123FCEFA90099B055 /* PreviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewStore.swift; sourceTree = "<group>"; };
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarButtonStyle.swift; sourceTree = "<group>"; };
5066A6C12516F303004B5A36 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
5066A6C72516FE6E004B5A36 /* CopyableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableView.swift; sourceTree = "<group>"; };
5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellConfigurationController.swift; sourceTree = "<group>"; };
@@ -264,6 +266,7 @@
isa = PBXGroup;
children = (
50617D8423FCE48E0099B055 /* ContentView.swift */,
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
50153E21250DECA300525160 /* SecretListItemView.swift */,
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
@@ -475,6 +478,7 @@
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */,
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
@@ -697,7 +701,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Secretive/Secretive.entitlements;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -713,7 +717,7 @@
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
PROVISIONING_PROFILE_SPECIFIER = "Secretive - Host";
SWIFT_VERSION = 5.0;
};
name = Release;
@@ -830,13 +834,10 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Secretive/Secretive.entitlements;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
DEVELOPMENT_TEAM = Z72PRUAWF6;
ENABLE_HARDENED_RUNTIME = NO;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = Secretive/Info.plist;
@@ -847,7 +848,6 @@
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Test;
@@ -878,12 +878,9 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = SecretAgent/SecretAgent.entitlements;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_ASSET_PATHS = "\"SecretAgent/Preview Content\"";
DEVELOPMENT_TEAM = Z72PRUAWF6;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = SecretAgent/Info.plist;
@@ -894,8 +891,6 @@
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
};
name = Test;
@@ -905,7 +900,6 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = SecretAgent/SecretAgent.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_ASSET_PATHS = "\"SecretAgent/Preview Content\"";
@@ -920,8 +914,6 @@
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
@@ -931,7 +923,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = SecretAgent/SecretAgent.entitlements;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_ASSET_PATHS = "\"SecretAgent/Preview Content\"";
@@ -946,8 +938,7 @@
MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
PROVISIONING_PROFILE_SPECIFIER = "Secretive - Secret Agent";
SWIFT_VERSION = 5.0;
};
name = Release;

View File

@@ -75,6 +75,7 @@
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@@ -47,6 +47,9 @@ extension Preview {
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
}
func reloadSecrets() {
}
}
class StoreModifiable: Store, SecretStoreModifiable {

View File

@@ -9,6 +9,9 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
@Binding var showingCreation: Bool
@Binding var runningSetup: Bool
@Binding var hasRunSetup: Bool
@State var showingAgentInfo = false
@State var activeSecret: AnySecret.ID?
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject private var storeList: SecretStoreList
@EnvironmentObject private var updater: UpdaterType
@@ -20,17 +23,17 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
var body: some View {
VStack {
if storeList.anyAvailable {
StoreListView(showingCreation: $showingCreation)
StoreListView(activeSecret: $activeSecret)
} else {
NoStoresView()
}
}
.frame(minWidth: 640, minHeight: 320)
.toolbar {
updateNotice
setupNotice
appPathNotice
newItem
toolbarItem(updateNoticeView, id: "update")
toolbarItem(runningOrRunSetupView, id: "setup")
toolbarItem(appPathNoticeView, id: "appPath")
toolbarItem(newItemView, id: "new")
}
.sheet(isPresented: $runningSetup) {
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
@@ -41,121 +44,145 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
extension ContentView {
var updateNotice: ToolbarItem<Void, AnyView> {
guard let update = updater.update else {
return ToolbarItem { AnyView(EmptyView()) }
func toolbarItem(_ view: some View, id: String) -> ToolbarItem<String, some View> {
ToolbarItem(id: id) { view }
}
var needsSetup: Bool {
(runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild
}
/// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message
/// These two are mutually exclusive
@ViewBuilder
var runningOrRunSetupView: some View {
if needsSetup {
setupNoticeView
} else {
runningNoticeView
}
let color: Color
let text: String
}
var updateNoticeContent: (String, Color)? {
guard let update = updater.update else { return nil }
if update.critical {
text = "Critical Security Update Required"
color = .red
return ("Critical Security Update Required", .red)
} else {
if updater.testBuild {
text = "Test Build"
color = .blue
return ("Test Build", .blue)
} else {
text = "Update Available"
color = .orange
return ("Update Available", .orange)
}
}
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)
}
)
}
}
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 setupNotice: ToolbarItem<Void, AnyView> {
return ToolbarItem {
AnyView(
Group {
if (runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild {
Button(action: {
runningSetup = true
}, label: {
Group {
if hasRunSetup && !agentStatusChecker.running {
Text("Secret Agent Is Not Running")
} else {
Text("Setup Secretive")
}
}
.font(.headline)
.foregroundColor(.white)
})
.background(Color.orange)
.cornerRadius(5)
} else {
EmptyView()
}
}
)
}
}
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")
}
@ViewBuilder
var updateNoticeView: some View {
if let update = updater.update, let (text, color) = updateNoticeContent {
Button(action: {
selectedUpdate = update
}, label: {
Text(text)
.font(.headline)
.foregroundColor(.white)
})
.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()
})
.buttonStyle(ToolbarButtonStyle(color: color))
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
UpdateDetailView(update: update)
}
}
}
@ViewBuilder
var newItemView: some View {
if storeList.modifiableStore?.isAvailable ?? false {
Button(action: {
showingCreation = true
}, label: {
Image(systemName: "plus")
})
.sheet(isPresented: $showingCreation) {
if let modifiable = storeList.modifiableStore {
CreateSecretView(store: modifiable, showing: $showingCreation)
.onDisappear {
guard let newest = modifiable.secrets.last?.id else { return }
activeSecret = newest
}
}
)
}
}
}
@ViewBuilder
var setupNoticeView: some View {
Button(action: {
runningSetup = true
}, label: {
Group {
if hasRunSetup && !agentStatusChecker.running {
Text("Secret Agent Is Not Running")
} else {
Text("Setup Secretive")
}
}
.font(.headline)
.foregroundColor(.white)
})
.buttonStyle(ToolbarButtonStyle(color: .orange))
}
@ViewBuilder
var runningNoticeView: some View {
Button(action: {
showingAgentInfo = true
}, label: {
HStack {
Text("Agent is Running")
.font(.headline)
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
Circle()
.frame(width: 10, height: 10)
.foregroundColor(Color.green)
}
})
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
.popover(isPresented: $showingAgentInfo, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
VStack {
Text("SecretAgent is Running")
.font(.title)
.padding(5)
Text("SecretAgent is a process that runs in the background to sign requests, so you don't need to keep Secretive open all the time.\n\n**You can close Secretive, and everything will still keep working.**")
.frame(width: 300)
}
.padding()
}
}
@ViewBuilder
var appPathNoticeView: some View {
if !ApplicationDirectoryController().isInApplicationsDirectory {
Button(action: {
showingAppPathNotice = true
}, label: {
Group {
Text("Secretive Is Not in Applications Folder")
}
.font(.headline)
.foregroundColor(.white)
})
.buttonStyle(ToolbarButtonStyle(color: .orange))
.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()
}
}
}
@@ -198,3 +225,4 @@ struct ContentView_Previews: PreviewProvider {
}
#endif

View File

@@ -8,6 +8,7 @@ struct CopyableView: View {
var text: String
@State private var interactionState: InteractionState = .normal
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(alignment: .leading) {
@@ -77,38 +78,32 @@ struct CopyableView: View {
}
var backgroundColor: Color {
let color: NSColor
switch interactionState {
case .normal:
color = .windowBackgroundColor
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
case .hovering:
color = .unemphasizedSelectedContentBackgroundColor
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
case .clicking:
color = .selectedContentBackgroundColor
return .accentColor
}
return Color(color)
}
var primaryTextColor: Color {
let color: NSColor
switch interactionState {
case .normal, .hovering:
color = .textColor
return Color(.textColor)
case .clicking:
color = .white
return .white
}
return Color(color)
}
var secondaryTextColor: Color {
let color: NSColor
switch interactionState {
case .normal, .hovering:
color = .secondaryLabelColor
return Color(.secondaryLabelColor)
case .clicking:
color = .white
return .white
}
return Color(color)
}
func copy() {
@@ -128,7 +123,9 @@ struct CopyableView_Previews: PreviewProvider {
static var previews: some View {
Group {
CopyableView(title: "Title", image: Image(systemName: "figure.wave"), text: "Hello world.")
.padding()
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. ")
.padding()
}
}
}

View File

@@ -12,28 +12,35 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
var body: some View {
VStack {
HStack {
Image(nsImage: NSApplication.shared.applicationIconImage)
.resizable()
.frame(width: 64, height: 64)
.padding()
VStack {
HStack {
Text("Create a New Secret").bold()
Text("Create a New Secret")
.font(.largeTitle)
Spacer()
}
HStack {
Text("Name:")
TextField("Shhhhh", text: $name).focusable()
TextField("Shhhhh", text: $name)
.focusable()
}
HStack {
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)
if #available(macOS 12.0, *) {
ThumbnailPickerView(items: [
ThumbnailPickerView.Item(value: true, name: "Require Authentication", description: "You will be required to authenticate using Touch ID, Apple Watch, or password before each use.", thumbnail: AuthenticationView()),
ThumbnailPickerView.Item(value: false, name: "Notify",
description: "No authentication is required while your Mac is unlocked, but you will be notified when a secret is used.",
thumbnail: NotificationView())
], selection: $requiresAuthentication)
} else {
HStack {
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(minLength: 10)
}
.pickerStyle(RadioGroupPickerStyle())
}
Spacer()
}
}
}
@@ -54,4 +61,208 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
try! store.create(name: name, requiresAuthentication: requiresAuthentication)
showing = false
}
}
struct ThumbnailPickerView<ValueType: Hashable>: View {
private let items: [Item<ValueType>]
@Binding var selection: ValueType
init(items: [ThumbnailPickerView<ValueType>.Item<ValueType>], selection: Binding<ValueType>) {
self.items = items
_selection = selection
}
var body: some View {
HStack(alignment: .top) {
ForEach(items) { item in
VStack(alignment: .leading, spacing: 15) {
item.thumbnail
.frame(height: 200)
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(lineWidth: item.value == selection ? 15 : 0))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 5) {
Text(item.name)
.bold()
Text(item.description)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(width: 250)
.onTapGesture {
withAnimation(.spring()) {
selection = item.value
}
}
}
.padding(5)
}
}
}
extension ThumbnailPickerView {
struct Item<ValueType: Hashable>: Identifiable {
let id = UUID()
let value: ValueType
let name: String
let description: String
let thumbnail: AnyView
init<ViewType: View>(value: ValueType, name: String, description: String, thumbnail: ViewType) {
self.value = value
self.name = name
self.description = description
self.thumbnail = AnyView(thumbnail)
}
}
}
@MainActor class SystemBackground: ObservableObject {
static let shared = SystemBackground()
@Published var image: NSImage?
private init() {
if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) {
image = NSImage(contentsOf: imageURL)
} else {
image = nil
}
}
}
@available(macOS 12.0, *)
struct SystemBackgroundView: View {
let anchor: UnitPoint
var body: some View {
if let image = SystemBackground.shared.image {
Image(nsImage: image)
.resizable()
.scaleEffect(3, anchor: anchor)
.clipped()
.allowsHitTesting(false)
} else {
Rectangle()
.foregroundColor(Color(.systemPurple))
}
}
}
@available(macOS 12.0, *)
struct AuthenticationView: View {
var body: some View {
ZStack {
SystemBackgroundView(anchor: .center)
GeometryReader { geometry in
VStack {
Image(systemName: "touchid")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(Color(.systemRed))
Text("Touch ID Prompt")
.font(.headline)
.foregroundColor(.primary)
.redacted(reason: .placeholder)
VStack {
Text("Touch ID Detail prompt.Detail two.")
.font(.caption2)
.foregroundColor(.primary)
Text("Touch ID Detail prompt.Detail two.")
.font(.caption2)
.foregroundColor(.primary)
}
.redacted(reason: .placeholder)
RoundedRectangle(cornerRadius: 5)
.frame(width: geometry.size.width, height: 20, alignment: .center)
.foregroundColor(.accentColor)
RoundedRectangle(cornerRadius: 5)
.frame(width: geometry.size.width, height: 20, alignment: .center)
.foregroundColor(Color(.unemphasizedSelectedContentBackgroundColor))
}
}
.padding()
.frame(width: 150)
.background(
RoundedRectangle(cornerRadius: 15)
.foregroundStyle(.ultraThickMaterial)
)
.padding()
}
}
}
@available(macOS 12.0, *)
struct NotificationView: View {
var body: some View {
ZStack {
SystemBackgroundView(anchor: .topTrailing)
VStack {
Rectangle()
.background(Color.clear)
.foregroundStyle(.thinMaterial)
.frame(height: 35)
VStack {
HStack {
Spacer()
HStack {
Image(nsImage: NSApplication.shared.applicationIconImage)
.resizable()
.frame(width: 64, height: 64)
.foregroundColor(.primary)
VStack(alignment: .leading) {
Text("Secretive")
.font(.title)
.foregroundColor(.primary)
Text("Secretive wants to sign")
.font(.body)
.foregroundColor(.primary)
}
}.padding()
.redacted(reason: .placeholder)
.background(
RoundedRectangle(cornerRadius: 15)
.foregroundStyle(.ultraThickMaterial)
)
}
Spacer()
}
.padding()
}
}
}
}
#if DEBUG
struct CreateSecretView_Previews: PreviewProvider {
static var previews: some View {
Group {
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
if #available(macOS 12.0, *) {
AuthenticationView().environment(\.colorScheme, .dark)
AuthenticationView().environment(\.colorScheme, .light)
NotificationView().environment(\.colorScheme, .dark)
NotificationView().environment(\.colorScheme, .light)
} else {
// Fallback on earlier versions
}
}
}
}
#endif

View File

@@ -35,7 +35,6 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
Spacer()
Button("Delete", action: delete)
.disabled(confirm != secret.name)
.keyboardShortcut(.delete)
Button("Don't Delete") {
dismissalBlock(false)
}

View File

@@ -29,7 +29,8 @@ struct SecretListItemView: View {
} else {
Text(secret.name)
}
}.contextMenu {
}
.contextMenu {
if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) {
Text("Rename")

View File

@@ -1,11 +1,10 @@
import SwiftUI
import Combine
import SecretKit
struct StoreListView: View {
@Binding var showingCreation: Bool
@State private var activeSecret: AnySecret.ID?
@Binding var activeSecret: AnySecret.ID?
@EnvironmentObject private var storeList: SecretStoreList
@@ -14,7 +13,7 @@ struct StoreListView: View {
}
private func secretRenamed(secret: AnySecret) {
activeSecret = nextDefaultSecret
activeSecret = secret.id
}
var body: some View {

View File

@@ -0,0 +1,37 @@
import SwiftUI
struct ToolbarButtonStyle: ButtonStyle {
private let lightColor: Color
private let darkColor: Color
@Environment(\.colorScheme) var colorScheme
@State var hovering = false
init(color: Color) {
self.lightColor = color
self.darkColor = color
}
init(lightColor: Color, darkColor: Color) {
self.lightColor = lightColor
self.darkColor = darkColor
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
.background(colorScheme == .light ? lightColor : darkColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(colorScheme == .light ? .black.opacity(0.15) : .white.opacity(0.15), lineWidth: 1)
.background(hovering ? (colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.05)) : Color.clear)
)
.onHover { hovering in
withAnimation {
self.hovering = hovering
}
}
}
}