mirror of
https://github.com/maxgoedjen/secretive.git
synced 2025-07-01 17:53:36 +00:00
Compare commits
1 Commits
v2.3.0
...
socketport
Author | SHA1 | Date | |
---|---|---|---|
64b2cc6f29 |
16
.github/workflows/add-to-project.yml
vendored
16
.github/workflows/add-to-project.yml
vendored
@ -1,16 +0,0 @@
|
||||
name: Add bugs to bugs project
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-to-project:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.0.3
|
||||
with:
|
||||
project-url: https://github.com/users/maxgoedjen/projects/1
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
8
.github/workflows/nightly.yml
vendored
8
.github/workflows/nightly.yml
vendored
@ -5,7 +5,7 @@ on:
|
||||
- cron: "0 8 * * *"
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macOS-latest
|
||||
runs-on: macos-11.0
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@ -19,7 +19,7 @@ jobs:
|
||||
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
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_13.2.1.app
|
||||
- name: Update Build Number
|
||||
env:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
@ -40,12 +40,8 @@ jobs:
|
||||
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:"
|
||||
shasum -a 512 Secretive.zip
|
||||
shasum -a 512 Archive.zip
|
||||
echo "sha-256:"
|
||||
shasum -a 256 Secretive.zip
|
||||
shasum -a 256 Archive.zip
|
||||
- name: Upload App to Artifacts
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
|
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@ -6,7 +6,7 @@ on:
|
||||
- '*'
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macOS-latest
|
||||
runs-on: macos-11.0
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
@ -20,14 +20,14 @@ jobs:
|
||||
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
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_13.2.1.app
|
||||
- name: Test
|
||||
run: |
|
||||
pushd Sources/Packages
|
||||
swift test
|
||||
popd
|
||||
build:
|
||||
runs-on: macOS-latest
|
||||
runs-on: macos-11.0
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@ -41,7 +41,7 @@ jobs:
|
||||
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
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_13.2.1.app
|
||||
- name: Update Build Number
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref }}
|
||||
@ -64,12 +64,8 @@ jobs:
|
||||
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:"
|
||||
shasum -a 512 Secretive.zip
|
||||
shasum -a 512 Archive.zip
|
||||
echo "sha-256:"
|
||||
shasum -a 256 Secretive.zip
|
||||
shasum -a 256 Archive.zip
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
|
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@ -3,12 +3,12 @@ name: Test
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macOS-latest
|
||||
runs-on: macos-11.0
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set Environment
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_14.1.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_13.2.1.app
|
||||
- name: Test
|
||||
run: |
|
||||
pushd Sources/Packages
|
||||
|
@ -26,15 +26,6 @@ Host *
|
||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
||||
```
|
||||
|
||||
## nushell
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Cyberduck
|
||||
|
||||
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
||||
@ -60,31 +51,6 @@ Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
||||
|
||||
Log out and log in again before launching Cyberduck.
|
||||
|
||||
## Mountain Duck
|
||||
|
||||
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 Mountain Duck.
|
||||
|
||||
## GitKraken
|
||||
|
||||
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
||||
|
4
FAQ.md
4
FAQ.md
@ -12,10 +12,6 @@ Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. Th
|
||||
|
||||
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 was working for me, but now it has stopped
|
||||
|
||||
Try running the "Setup Secretive" process by clicking on "Help", then "Setup Secretive." If that doesn't work, follow the process above.
|
||||
|
||||
### 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:
|
||||
|
@ -11,18 +11,15 @@ public class Agent {
|
||||
private let witness: SigningWitness?
|
||||
private let writer = OpenSSHKeyWriter()
|
||||
private let requestTracer = SigningRequestTracer()
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@ -36,16 +33,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)
|
||||
@ -53,25 +50,23 @@ 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
|
||||
@ -84,29 +79,18 @@ 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.allSecrets
|
||||
certificateHandler.reloadCertificates(for: secrets)
|
||||
let secrets = storeList.stores.flatMap(\.secrets)
|
||||
var count = UInt32(secrets.count).bigEndian
|
||||
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
|
||||
var keyData = Data()
|
||||
|
||||
let writer = OpenSSHKeyWriter()
|
||||
for secret in secrets {
|
||||
let keyBlob: Data
|
||||
let curveData: Data
|
||||
|
||||
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)!
|
||||
}
|
||||
|
||||
let keyBlob = writer.data(secret: secret)
|
||||
keyData.append(writer.lengthAndData(of: keyBlob))
|
||||
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
|
||||
keyData.append(writer.lengthAndData(of: curveData))
|
||||
|
||||
}
|
||||
logger.log("Agent enumerated \(secrets.count) identities")
|
||||
Logger().debug("Agent enumerated \(secrets.count) identities")
|
||||
return countData + keyData
|
||||
}
|
||||
|
||||
@ -117,17 +101,9 @@ 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)
|
||||
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 certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
|
||||
hash = certificatePublicKey
|
||||
} else {
|
||||
hash = payloadHash
|
||||
}
|
||||
|
||||
let hash = reader.readNextChunk()
|
||||
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
|
||||
}
|
||||
|
||||
@ -181,7 +157,7 @@ extension Agent {
|
||||
try witness.witness(accessTo: secret, from: store, by: provenance)
|
||||
}
|
||||
|
||||
logger.debug("Agent signed request")
|
||||
Logger().debug("Agent signed request")
|
||||
|
||||
return signedData
|
||||
}
|
||||
@ -190,16 +166,6 @@ extension Agent {
|
||||
|
||||
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.
|
||||
@ -225,7 +191,6 @@ extension Agent {
|
||||
case unhandledType
|
||||
case noMatchingKey
|
||||
case unsupportedKeyType
|
||||
case notOpenSSHCertificate
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||
/// A namespace for the SSH Agent Protocol, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html
|
||||
public enum SSHAgent {}
|
||||
|
||||
extension SSHAgent {
|
||||
|
||||
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||
/// 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
|
||||
public enum RequestType: UInt8, CustomDebugStringConvertible {
|
||||
|
||||
case requestIdentities = 11
|
||||
@ -21,11 +21,10 @@ extension SSHAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||
/// 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
|
||||
public enum ResponseType: UInt8, CustomDebugStringConvertible {
|
||||
|
||||
case agentFailure = 5
|
||||
case agentSuccess = 6
|
||||
case agentIdentitiesAnswer = 12
|
||||
case agentSignResponse = 14
|
||||
|
||||
@ -33,8 +32,6 @@ extension SSHAgent {
|
||||
switch self {
|
||||
case .agentFailure:
|
||||
return "AgentFailure"
|
||||
case .agentSuccess:
|
||||
return "AgentSuccess"
|
||||
case .agentIdentitiesAnswer:
|
||||
return "AgentIdentitiesAnswer"
|
||||
case .agentSignResponse:
|
||||
|
@ -40,10 +40,7 @@ extension SigningRequestTracer {
|
||||
func process(from pid: Int32) -> SigningRequestProvenance.Process {
|
||||
var pidAndNameInfo = self.pidAndNameInfo(from: pid)
|
||||
let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
|
||||
let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
||||
String(cString: pointer)
|
||||
}
|
||||
|
||||
let procName = String(cString: &pidAndNameInfo.kp_proc.p_comm.0)
|
||||
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
||||
_ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
||||
let path = String(cString: pathPointer)
|
||||
|
@ -45,20 +45,14 @@ public class SocketController {
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
|
||||
var len: Int = 0
|
||||
let len = MemoryLayout.size(ofValue: addr.sun_path) - 1
|
||||
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
|
||||
path.withCString { cstring in
|
||||
len = strlen(cstring)
|
||||
strncpy(pointer, cstring, len)
|
||||
}
|
||||
}
|
||||
addr.sun_len = UInt8(len+2)
|
||||
|
||||
var data: Data!
|
||||
withUnsafePointer(to: &addr) { pointer in
|
||||
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
|
||||
// The buffer is pre-zeroed, so manual termination is unnecessary.
|
||||
precondition(memccpy(pointer, path, 0, len) != nil)
|
||||
}
|
||||
addr.sun_len = UInt8(len)
|
||||
|
||||
let data = withUnsafeBytes(of: &addr, Data.init(_:))
|
||||
return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,6 @@ 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?
|
||||
|
||||
@ -25,7 +24,6 @@ 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()
|
||||
}
|
||||
@ -59,10 +57,6 @@ public class AnySecretStore: SecretStore {
|
||||
try _persistAuthentication(secret, duration)
|
||||
}
|
||||
|
||||
public func reloadSecrets() {
|
||||
_reloadSecrets()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable {
|
||||
|
@ -1,120 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -15,21 +15,15 @@ public class PublicKeyFileStoreController {
|
||||
|
||||
/// Writes out the keys specified to disk.
|
||||
/// - Parameter secrets: The Secrets to generate keys for.
|
||||
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
|
||||
/// - Parameter clear: Whether or not the directory should be erased before writing keys.
|
||||
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
||||
logger.log("Writing public keys to disk")
|
||||
if clear {
|
||||
let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) }))
|
||||
let untracked = Set(try FileManager.default.contentsOfDirectory(atPath: directory)
|
||||
.map { "\(directory)/\($0)" })
|
||||
.subtracting(validPaths)
|
||||
for path in untracked {
|
||||
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
|
||||
}
|
||||
try? FileManager.default.removeItem(at: URL(fileURLWithPath: directory))
|
||||
}
|
||||
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
|
||||
for secret in secrets {
|
||||
let path = publicKeyPath(for: secret)
|
||||
let path = path(for: secret)
|
||||
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
|
||||
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
||||
}
|
||||
@ -40,30 +34,9 @@ public class PublicKeyFileStoreController {
|
||||
/// - Parameter secret: The Secret to return the path for.
|
||||
/// - 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 publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||
public func path<SecretType: Secret>(for secret: SecretType) -> String {
|
||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||
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.
|
||||
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
|
||||
public func sshCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||
return directory.appending("/").appending("\(minimalHex)-cert.pub")
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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 cancellables: Set<AnyCancellable> = []
|
||||
private var sinks: [AnyCancellable] = []
|
||||
|
||||
/// Initializes a SecretStoreList.
|
||||
public init() {
|
||||
@ -31,19 +31,16 @@ 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)
|
||||
store.objectWillChange.sink {
|
||||
let sink = store.objectWillChange.sink {
|
||||
self.objectWillChange.send()
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
sinks.append(sink)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -36,9 +36,6 @@ 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.
|
||||
|
@ -24,7 +24,7 @@ extension SecureEnclave {
|
||||
/// Initializes a Store.
|
||||
public init() {
|
||||
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in
|
||||
self.reloadSecretsInternal(notifyAgent: false)
|
||||
self.reloadSecrets(notifyAgent: false)
|
||||
}
|
||||
loadSecrets()
|
||||
}
|
||||
@ -68,7 +68,7 @@ extension SecureEnclave {
|
||||
throw KeychainError(statusCode: nil)
|
||||
}
|
||||
try savePublicKey(publicKey, name: name)
|
||||
reloadSecretsInternal()
|
||||
reloadSecrets()
|
||||
}
|
||||
|
||||
public func delete(secret: Secret) throws {
|
||||
@ -80,7 +80,7 @@ extension SecureEnclave {
|
||||
if status != errSecSuccess {
|
||||
throw KeychainError(statusCode: status)
|
||||
}
|
||||
reloadSecretsInternal()
|
||||
reloadSecrets()
|
||||
}
|
||||
|
||||
public func update(secret: Secret, name: String) throws {
|
||||
@ -97,7 +97,7 @@ extension SecureEnclave {
|
||||
if status != errSecSuccess {
|
||||
throw KeychainError(statusCode: status)
|
||||
}
|
||||
reloadSecretsInternal()
|
||||
reloadSecrets()
|
||||
}
|
||||
|
||||
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
|
||||
@ -163,10 +163,6 @@ extension SecureEnclave {
|
||||
}
|
||||
}
|
||||
|
||||
public func reloadSecrets() {
|
||||
reloadSecretsInternal(notifyAgent: false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -175,15 +171,12 @@ 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 reloadSecretsInternal(notifyAgent: Bool = true) {
|
||||
let before = secrets
|
||||
private func reloadSecrets(notifyAgent: Bool = true) {
|
||||
secrets.removeAll()
|
||||
loadSecrets()
|
||||
if secrets != before {
|
||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||
if notifyAgent {
|
||||
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
|
||||
}
|
||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||
if notifyAgent {
|
||||
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -89,19 +89,6 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -115,6 +102,15 @@ 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 }
|
||||
|
@ -78,9 +78,6 @@ extension Stub {
|
||||
public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
|
||||
}
|
||||
|
||||
public func reloadSecrets() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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.allSecrets, clear: true)
|
||||
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)
|
||||
notifier.prompt()
|
||||
updateSink = updater.$update.sink { update in
|
||||
guard let update = update else { return }
|
||||
|
@ -154,7 +154,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
|
||||
handlePersistAuthenticationResponse(response: response)
|
||||
default:
|
||||
break
|
||||
fatalError()
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
|
@ -31,7 +31,6 @@
|
||||
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 */; };
|
||||
@ -124,7 +123,6 @@
|
||||
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>"; };
|
||||
@ -266,7 +264,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50617D8423FCE48E0099B055 /* ContentView.swift */,
|
||||
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
|
||||
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
|
||||
50153E21250DECA300525160 /* SecretListItemView.swift */,
|
||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
||||
@ -478,7 +475,6 @@
|
||||
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 */,
|
||||
|
@ -75,7 +75,6 @@
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
|
@ -47,9 +47,6 @@ extension Preview {
|
||||
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
|
||||
}
|
||||
|
||||
func reloadSecrets() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class StoreModifiable: Store, SecretStoreModifiable {
|
||||
|
@ -9,9 +9,6 @@ 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
|
||||
@ -23,17 +20,17 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
||||
var body: some View {
|
||||
VStack {
|
||||
if storeList.anyAvailable {
|
||||
StoreListView(activeSecret: $activeSecret)
|
||||
StoreListView(showingCreation: $showingCreation)
|
||||
} else {
|
||||
NoStoresView()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 320)
|
||||
.toolbar {
|
||||
toolbarItem(updateNoticeView, id: "update")
|
||||
toolbarItem(runningOrRunSetupView, id: "setup")
|
||||
toolbarItem(appPathNoticeView, id: "appPath")
|
||||
toolbarItem(newItemView, id: "new")
|
||||
updateNotice
|
||||
setupNotice
|
||||
appPathNotice
|
||||
newItem
|
||||
}
|
||||
.sheet(isPresented: $runningSetup) {
|
||||
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
|
||||
@ -44,145 +41,121 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
||||
|
||||
extension ContentView {
|
||||
|
||||
|
||||
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
|
||||
var updateNotice: ToolbarItem<Void, AnyView> {
|
||||
guard let update = updater.update else {
|
||||
return ToolbarItem { AnyView(EmptyView()) }
|
||||
}
|
||||
}
|
||||
|
||||
var updateNoticeContent: (String, Color)? {
|
||||
guard let update = updater.update else { return nil }
|
||||
let color: Color
|
||||
let text: String
|
||||
if update.critical {
|
||||
return ("Critical Security Update Required", .red)
|
||||
text = "Critical Security Update Required"
|
||||
color = .red
|
||||
} else {
|
||||
if updater.testBuild {
|
||||
return ("Test Build", .blue)
|
||||
text = "Test Build"
|
||||
color = .blue
|
||||
} else {
|
||||
return ("Update Available", .orange)
|
||||
text = "Update Available"
|
||||
color = .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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var updateNoticeView: some View {
|
||||
if let update = updater.update, let (text, color) = updateNoticeContent {
|
||||
Button(action: {
|
||||
selectedUpdate = update
|
||||
}, label: {
|
||||
Text(text)
|
||||
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")
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
})
|
||||
.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
|
||||
}
|
||||
})
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,4 +198,3 @@ struct ContentView_Previews: PreviewProvider {
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
@ -8,7 +8,6 @@ struct CopyableView: View {
|
||||
var text: String
|
||||
|
||||
@State private var interactionState: InteractionState = .normal
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
@ -78,32 +77,38 @@ struct CopyableView: View {
|
||||
}
|
||||
|
||||
var backgroundColor: Color {
|
||||
let color: NSColor
|
||||
switch interactionState {
|
||||
case .normal:
|
||||
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
|
||||
color = .windowBackgroundColor
|
||||
case .hovering:
|
||||
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
|
||||
color = .unemphasizedSelectedContentBackgroundColor
|
||||
case .clicking:
|
||||
return .accentColor
|
||||
color = .selectedContentBackgroundColor
|
||||
}
|
||||
return Color(color)
|
||||
}
|
||||
|
||||
var primaryTextColor: Color {
|
||||
let color: NSColor
|
||||
switch interactionState {
|
||||
case .normal, .hovering:
|
||||
return Color(.textColor)
|
||||
color = .textColor
|
||||
case .clicking:
|
||||
return .white
|
||||
color = .white
|
||||
}
|
||||
return Color(color)
|
||||
}
|
||||
|
||||
var secondaryTextColor: Color {
|
||||
let color: NSColor
|
||||
switch interactionState {
|
||||
case .normal, .hovering:
|
||||
return Color(.secondaryLabelColor)
|
||||
color = .secondaryLabelColor
|
||||
case .clicking:
|
||||
return .white
|
||||
color = .white
|
||||
}
|
||||
return Color(color)
|
||||
}
|
||||
|
||||
func copy() {
|
||||
@ -123,9 +128,7 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,35 +12,28 @@ 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")
|
||||
.font(.largeTitle)
|
||||
Text("Create a New Secret").bold()
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Text("Name:")
|
||||
TextField("Shhhhh", text: $name)
|
||||
.focusable()
|
||||
TextField("Shhhhh", text: $name).focusable()
|
||||
}
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -61,208 +54,4 @@ 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
|
||||
|
@ -35,6 +35,7 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
Spacer()
|
||||
Button("Delete", action: delete)
|
||||
.disabled(confirm != secret.name)
|
||||
.keyboardShortcut(.delete)
|
||||
Button("Don't Delete") {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ struct SecretDetailView<SecretType: Secret>: View {
|
||||
CopyableView(title: "Public Key", image: Image(systemName: "key"), text: keyString)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: "Public Key Path", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret))
|
||||
CopyableView(title: "Public Key Path", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.path(for: secret))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
@ -29,8 +29,7 @@ struct SecretListItemView: View {
|
||||
} else {
|
||||
Text(secret.name)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
}.contextMenu {
|
||||
if store is AnySecretStoreModifiable {
|
||||
Button(action: { isRenaming = true }) {
|
||||
Text("Rename")
|
||||
|
@ -1,10 +1,11 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import SecretKit
|
||||
|
||||
struct StoreListView: View {
|
||||
|
||||
@Binding var activeSecret: AnySecret.ID?
|
||||
@Binding var showingCreation: Bool
|
||||
|
||||
@State private var activeSecret: AnySecret.ID?
|
||||
|
||||
@EnvironmentObject private var storeList: SecretStoreList
|
||||
|
||||
@ -13,7 +14,7 @@ struct StoreListView: View {
|
||||
}
|
||||
|
||||
private func secretRenamed(secret: AnySecret) {
|
||||
activeSecret = secret.id
|
||||
activeSecret = nextDefaultSecret
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@ -1,37 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user