mirror of
https://github.com/maxgoedjen/secretive.git
synced 2025-07-01 09:43:37 +00:00
Compare commits
17 Commits
socketport
...
v2.3.0
Author | SHA1 | Date | |
---|---|---|---|
480ef5392d | |||
8679ca3da0 | |||
f43571baa3 | |||
acdf0baf3a | |||
1a362ef955 | |||
3b254d33a5 | |||
382913cb99 | |||
20cbaac6f6 | |||
47d736cb0d | |||
fa0e81cd8e | |||
e31db0f4fa | |||
403709ac83 | |||
5f055efa18 | |||
e77812c06c | |||
8744313ba1 | |||
26d6ced9ee | |||
71b4780488 |
16
.github/workflows/add-to-project.yml
vendored
Normal file
16
.github/workflows/add-to-project.yml
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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 * * *"
|
- cron: "0 8 * * *"
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-11.0
|
runs-on: macOS-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
@ -19,7 +19,7 @@ jobs:
|
|||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
run: ./.github/scripts/signing.sh
|
run: ./.github/scripts/signing.sh
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_13.2.1.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_14.1.app
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
@ -40,8 +40,12 @@ 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
|
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
|
- name: Document SHAs
|
||||||
run: |
|
run: |
|
||||||
|
echo "sha-512:"
|
||||||
shasum -a 512 Secretive.zip
|
shasum -a 512 Secretive.zip
|
||||||
shasum -a 512 Archive.zip
|
shasum -a 512 Archive.zip
|
||||||
|
echo "sha-256:"
|
||||||
|
shasum -a 256 Secretive.zip
|
||||||
|
shasum -a 256 Archive.zip
|
||||||
- name: Upload App to Artifacts
|
- name: Upload App to Artifacts
|
||||||
uses: actions/upload-artifact@v1
|
uses: actions/upload-artifact@v1
|
||||||
with:
|
with:
|
||||||
|
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@ -6,7 +6,7 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: macos-11.0
|
runs-on: macOS-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
@ -20,14 +20,14 @@ jobs:
|
|||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
run: ./.github/scripts/signing.sh
|
run: ./.github/scripts/signing.sh
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_13.2.1.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_14.1.app
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
pushd Sources/Packages
|
pushd Sources/Packages
|
||||||
swift test
|
swift test
|
||||||
popd
|
popd
|
||||||
build:
|
build:
|
||||||
runs-on: macos-11.0
|
runs-on: macOS-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
@ -41,7 +41,7 @@ jobs:
|
|||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
run: ./.github/scripts/signing.sh
|
run: ./.github/scripts/signing.sh
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_13.2.1.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_14.1.app
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
TAG_NAME: ${{ github.ref }}
|
TAG_NAME: ${{ github.ref }}
|
||||||
@ -64,8 +64,12 @@ 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
|
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
|
- name: Document SHAs
|
||||||
run: |
|
run: |
|
||||||
|
echo "sha-512:"
|
||||||
shasum -a 512 Secretive.zip
|
shasum -a 512 Secretive.zip
|
||||||
shasum -a 512 Archive.zip
|
shasum -a 512 Archive.zip
|
||||||
|
echo "sha-256:"
|
||||||
|
shasum -a 256 Secretive.zip
|
||||||
|
shasum -a 256 Archive.zip
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: actions/create-release@v1
|
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]
|
on: [push, pull_request]
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: macos-11.0
|
runs-on: macOS-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_13.2.1.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_14.1.app
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
pushd Sources/Packages
|
pushd Sources/Packages
|
||||||
|
@ -26,6 +26,15 @@ Host *
|
|||||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
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
|
## Cyberduck
|
||||||
|
|
||||||
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
||||||
@ -51,6 +60,31 @@ Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
|||||||
|
|
||||||
Log out and log in again before launching Cyberduck.
|
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
|
## GitKraken
|
||||||
|
|
||||||
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
||||||
|
4
FAQ.md
4
FAQ.md
@ -12,6 +12,10 @@ 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.
|
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
|
### 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:
|
1) Make sure you have enabled "Use your Apple Watch to unlock apps and your Mac" in System Preferences --> Security & Privacy:
|
||||||
|
@ -11,15 +11,18 @@ public class Agent {
|
|||||||
private let witness: SigningWitness?
|
private let witness: SigningWitness?
|
||||||
private let writer = OpenSSHKeyWriter()
|
private let writer = OpenSSHKeyWriter()
|
||||||
private let requestTracer = SigningRequestTracer()
|
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.
|
/// Initializes an agent with a store list and a witness.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - storeList: The `SecretStoreList` to make available.
|
/// - storeList: The `SecretStoreList` to make available.
|
||||||
/// - witness: A witness to notify of requests.
|
/// - witness: A witness to notify of requests.
|
||||||
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) {
|
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) {
|
||||||
Logger().debug("Agent is running")
|
logger.debug("Agent is running")
|
||||||
self.storeList = storeList
|
self.storeList = storeList
|
||||||
self.witness = witness
|
self.witness = witness
|
||||||
|
certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -33,16 +36,16 @@ extension Agent {
|
|||||||
/// - Return value:
|
/// - Return value:
|
||||||
/// - Boolean if data could be read
|
/// - Boolean if data could be read
|
||||||
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) -> Bool {
|
@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)
|
let data = Data(reader.availableData)
|
||||||
guard data.count > 4 else { return false}
|
guard data.count > 4 else { return false}
|
||||||
let requestTypeInt = data[4]
|
let requestTypeInt = data[4]
|
||||||
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
|
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
|
||||||
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
|
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
|
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 subData = Data(data[5...])
|
||||||
let response = handle(requestType: requestType, data: subData, reader: reader)
|
let response = handle(requestType: requestType, data: subData, reader: reader)
|
||||||
writer.write(response)
|
writer.write(response)
|
||||||
@ -50,23 +53,25 @@ extension Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) -> Data {
|
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()
|
var response = Data()
|
||||||
do {
|
do {
|
||||||
switch requestType {
|
switch requestType {
|
||||||
case .requestIdentities:
|
case .requestIdentities:
|
||||||
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
|
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
|
||||||
response.append(identities())
|
response.append(identities())
|
||||||
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
||||||
case .signRequest:
|
case .signRequest:
|
||||||
let provenance = requestTracer.provenance(from: reader)
|
let provenance = requestTracer.provenance(from: reader)
|
||||||
response.append(SSHAgent.ResponseType.agentSignResponse.data)
|
response.append(SSHAgent.ResponseType.agentSignResponse.data)
|
||||||
response.append(try sign(data: data, provenance: provenance))
|
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 {
|
} catch {
|
||||||
response.removeAll()
|
response.removeAll()
|
||||||
response.append(SSHAgent.ResponseType.agentFailure.data)
|
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)
|
let full = OpenSSHKeyWriter().lengthAndData(of: response)
|
||||||
return full
|
return full
|
||||||
@ -79,18 +84,29 @@ extension Agent {
|
|||||||
/// Lists the identities available for signing operations
|
/// Lists the identities available for signing operations
|
||||||
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
||||||
func identities() -> Data {
|
func identities() -> Data {
|
||||||
let secrets = storeList.stores.flatMap(\.secrets)
|
let secrets = storeList.allSecrets
|
||||||
|
certificateHandler.reloadCertificates(for: secrets)
|
||||||
var count = UInt32(secrets.count).bigEndian
|
var count = UInt32(secrets.count).bigEndian
|
||||||
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
|
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
|
||||||
var keyData = Data()
|
var keyData = Data()
|
||||||
let writer = OpenSSHKeyWriter()
|
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
let keyBlob = writer.data(secret: secret)
|
let keyBlob: Data
|
||||||
keyData.append(writer.lengthAndData(of: keyBlob))
|
let curveData: Data
|
||||||
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
|
|
||||||
keyData.append(writer.lengthAndData(of: curveData))
|
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)!
|
||||||
}
|
}
|
||||||
Logger().debug("Agent enumerated \(secrets.count) identities")
|
|
||||||
|
keyData.append(writer.lengthAndData(of: keyBlob))
|
||||||
|
keyData.append(writer.lengthAndData(of: curveData))
|
||||||
|
|
||||||
|
}
|
||||||
|
logger.log("Agent enumerated \(secrets.count) identities")
|
||||||
return countData + keyData
|
return countData + keyData
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,9 +117,17 @@ extension Agent {
|
|||||||
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
||||||
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
|
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
|
||||||
let reader = OpenSSHReader(data: data)
|
let reader = OpenSSHReader(data: data)
|
||||||
let 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 certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
|
||||||
|
hash = certificatePublicKey
|
||||||
|
} else {
|
||||||
|
hash = payloadHash
|
||||||
|
}
|
||||||
|
|
||||||
guard let (store, secret) = secret(matching: hash) else {
|
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
|
throw AgentError.noMatchingKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,7 +181,7 @@ extension Agent {
|
|||||||
try witness.witness(accessTo: secret, from: store, by: provenance)
|
try witness.witness(accessTo: secret, from: store, by: provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger().debug("Agent signed request")
|
logger.debug("Agent signed request")
|
||||||
|
|
||||||
return signedData
|
return signedData
|
||||||
}
|
}
|
||||||
@ -166,6 +190,16 @@ extension Agent {
|
|||||||
|
|
||||||
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.
|
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
|
||||||
/// - Parameter hash: The hash to match against.
|
/// - Parameter hash: The hash to match against.
|
||||||
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
|
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
|
||||||
@ -191,6 +225,7 @@ extension Agent {
|
|||||||
case unhandledType
|
case unhandledType
|
||||||
case noMatchingKey
|
case noMatchingKey
|
||||||
case unsupportedKeyType
|
case unsupportedKeyType
|
||||||
|
case notOpenSSHCertificate
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import Foundation
|
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 {}
|
public enum SSHAgent {}
|
||||||
|
|
||||||
extension 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 {
|
public enum RequestType: UInt8, CustomDebugStringConvertible {
|
||||||
|
|
||||||
case requestIdentities = 11
|
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 {
|
public enum ResponseType: UInt8, CustomDebugStringConvertible {
|
||||||
|
|
||||||
case agentFailure = 5
|
case agentFailure = 5
|
||||||
|
case agentSuccess = 6
|
||||||
case agentIdentitiesAnswer = 12
|
case agentIdentitiesAnswer = 12
|
||||||
case agentSignResponse = 14
|
case agentSignResponse = 14
|
||||||
|
|
||||||
@ -32,6 +33,8 @@ extension SSHAgent {
|
|||||||
switch self {
|
switch self {
|
||||||
case .agentFailure:
|
case .agentFailure:
|
||||||
return "AgentFailure"
|
return "AgentFailure"
|
||||||
|
case .agentSuccess:
|
||||||
|
return "AgentSuccess"
|
||||||
case .agentIdentitiesAnswer:
|
case .agentIdentitiesAnswer:
|
||||||
return "AgentIdentitiesAnswer"
|
return "AgentIdentitiesAnswer"
|
||||||
case .agentSignResponse:
|
case .agentSignResponse:
|
||||||
|
@ -40,7 +40,10 @@ extension SigningRequestTracer {
|
|||||||
func process(from pid: Int32) -> SigningRequestProvenance.Process {
|
func process(from pid: Int32) -> SigningRequestProvenance.Process {
|
||||||
var pidAndNameInfo = self.pidAndNameInfo(from: pid)
|
var pidAndNameInfo = self.pidAndNameInfo(from: pid)
|
||||||
let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
|
let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
|
||||||
let procName = String(cString: &pidAndNameInfo.kp_proc.p_comm.0)
|
let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
|
||||||
|
String(cString: pointer)
|
||||||
|
}
|
||||||
|
|
||||||
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
|
||||||
_ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
_ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
|
||||||
let path = String(cString: pathPointer)
|
let path = String(cString: pathPointer)
|
||||||
|
@ -45,14 +45,20 @@ public class SocketController {
|
|||||||
var addr = sockaddr_un()
|
var addr = sockaddr_un()
|
||||||
addr.sun_family = sa_family_t(AF_UNIX)
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
|
||||||
let len = MemoryLayout.size(ofValue: addr.sun_path) - 1
|
var len: Int = 0
|
||||||
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
|
withUnsafeMutablePointer(to: &addr.sun_path.0) { pointer in
|
||||||
// The buffer is pre-zeroed, so manual termination is unnecessary.
|
path.withCString { cstring in
|
||||||
precondition(memccpy(pointer, path, 0, len) != nil)
|
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)
|
||||||
}
|
}
|
||||||
addr.sun_len = UInt8(len)
|
|
||||||
|
|
||||||
let data = withUnsafeBytes(of: &addr, Data.init(_:))
|
|
||||||
return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ public class AnySecretStore: SecretStore {
|
|||||||
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data
|
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data
|
||||||
private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext?
|
private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext?
|
||||||
private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void
|
private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void
|
||||||
|
private let _reloadSecrets: () -> Void
|
||||||
|
|
||||||
private var sink: AnyCancellable?
|
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) }
|
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
|
||||||
_existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
|
_existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
|
||||||
_persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
|
_persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
|
||||||
|
_reloadSecrets = { secretStore.reloadSecrets() }
|
||||||
sink = secretStore.objectWillChange.sink { _ in
|
sink = secretStore.objectWillChange.sink { _ in
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
}
|
}
|
||||||
@ -57,6 +59,10 @@ public class AnySecretStore: SecretStore {
|
|||||||
try _persistAuthentication(secret, duration)
|
try _persistAuthentication(secret, duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func reloadSecrets() {
|
||||||
|
_reloadSecrets()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable {
|
public class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable {
|
||||||
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -15,15 +15,21 @@ public class PublicKeyFileStoreController {
|
|||||||
|
|
||||||
/// Writes out the keys specified to disk.
|
/// Writes out the keys specified to disk.
|
||||||
/// - Parameter secrets: The Secrets to generate keys for.
|
/// - Parameter secrets: The Secrets to generate keys for.
|
||||||
/// - Parameter clear: Whether or not the directory should be erased before writing keys.
|
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
|
||||||
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
||||||
logger.log("Writing public keys to disk")
|
logger.log("Writing public keys to disk")
|
||||||
if clear {
|
if clear {
|
||||||
try? FileManager.default.removeItem(at: URL(fileURLWithPath: directory))
|
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.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
|
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
let path = path(for: secret)
|
let path = publicKeyPath(for: secret)
|
||||||
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
|
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
|
||||||
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
||||||
}
|
}
|
||||||
@ -34,9 +40,30 @@ public class PublicKeyFileStoreController {
|
|||||||
/// - Parameter secret: The Secret to return the path for.
|
/// - Parameter secret: The Secret to return the path for.
|
||||||
/// - Returns: The path to the Secret's public key.
|
/// - 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.
|
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
|
||||||
public func path<SecretType: Secret>(for secret: SecretType) -> String {
|
public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||||
return directory.appending("/").appending("\(minimalHex).pub")
|
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] = []
|
@Published public var stores: [AnySecretStore] = []
|
||||||
/// A modifiable store, if one is available.
|
/// A modifiable store, if one is available.
|
||||||
@Published public var modifiableStore: AnySecretStoreModifiable?
|
@Published public var modifiableStore: AnySecretStoreModifiable?
|
||||||
private var sinks: [AnyCancellable] = []
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
|
||||||
/// Initializes a SecretStoreList.
|
/// Initializes a SecretStoreList.
|
||||||
public init() {
|
public init() {
|
||||||
@ -31,16 +31,19 @@ public class SecretStoreList: ObservableObject {
|
|||||||
stores.reduce(false, { $0 || $1.isAvailable })
|
stores.reduce(false, { $0 || $1.isAvailable })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var allSecrets: [AnySecret] {
|
||||||
|
stores.flatMap(\.secrets)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SecretStoreList {
|
extension SecretStoreList {
|
||||||
|
|
||||||
private func addInternal(store: AnySecretStore) {
|
private func addInternal(store: AnySecretStore) {
|
||||||
stores.append(store)
|
stores.append(store)
|
||||||
let sink = store.objectWillChange.sink {
|
store.objectWillChange.sink {
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
}
|
}.store(in: &cancellables)
|
||||||
sinks.append(sink)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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.
|
/// - 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
|
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.
|
/// A SecretStore that the Secretive admin app can modify.
|
||||||
|
@ -24,7 +24,7 @@ extension SecureEnclave {
|
|||||||
/// Initializes a Store.
|
/// Initializes a Store.
|
||||||
public init() {
|
public init() {
|
||||||
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in
|
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in
|
||||||
self.reloadSecrets(notifyAgent: false)
|
self.reloadSecretsInternal(notifyAgent: false)
|
||||||
}
|
}
|
||||||
loadSecrets()
|
loadSecrets()
|
||||||
}
|
}
|
||||||
@ -68,7 +68,7 @@ extension SecureEnclave {
|
|||||||
throw KeychainError(statusCode: nil)
|
throw KeychainError(statusCode: nil)
|
||||||
}
|
}
|
||||||
try savePublicKey(publicKey, name: name)
|
try savePublicKey(publicKey, name: name)
|
||||||
reloadSecrets()
|
reloadSecretsInternal()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func delete(secret: Secret) throws {
|
public func delete(secret: Secret) throws {
|
||||||
@ -80,7 +80,7 @@ extension SecureEnclave {
|
|||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
reloadSecrets()
|
reloadSecretsInternal()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update(secret: Secret, name: String) throws {
|
public func update(secret: Secret, name: String) throws {
|
||||||
@ -97,7 +97,7 @@ extension SecureEnclave {
|
|||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
reloadSecrets()
|
reloadSecretsInternal()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
|
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,14 +175,17 @@ extension SecureEnclave.Store {
|
|||||||
|
|
||||||
/// Reloads all secrets from the 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.
|
/// - 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()
|
secrets.removeAll()
|
||||||
loadSecrets()
|
loadSecrets()
|
||||||
|
if secrets != before {
|
||||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||||
if notifyAgent {
|
if notifyAgent {
|
||||||
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
|
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Loads all secrets from the store.
|
/// Loads all secrets from the store.
|
||||||
private func loadSecrets() {
|
private func loadSecrets() {
|
||||||
|
@ -89,6 +89,19 @@ extension SmartCard {
|
|||||||
public func persistAuthentication(secret: SmartCard.Secret, forDuration: TimeInterval) throws {
|
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()
|
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.
|
/// Loads all secrets from the store.
|
||||||
private func loadSecrets() {
|
private func loadSecrets() {
|
||||||
guard let tokenID = tokenID else { return }
|
guard let tokenID = tokenID else { return }
|
||||||
|
@ -78,6 +78,9 @@ extension Stub {
|
|||||||
public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
|
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:)
|
self.socketController.handler = self.agent.handle(reader:writer:)
|
||||||
}
|
}
|
||||||
NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in
|
NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in
|
||||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
|
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||||
}
|
}
|
||||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
|
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||||
notifier.prompt()
|
notifier.prompt()
|
||||||
updateSink = updater.$update.sink { update in
|
updateSink = updater.$update.sink { update in
|
||||||
guard let update = update else { return }
|
guard let update = update else { return }
|
||||||
|
@ -154,7 +154,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
|
case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
|
||||||
handlePersistAuthenticationResponse(response: response)
|
handlePersistAuthenticationResponse(response: response)
|
||||||
default:
|
default:
|
||||||
fatalError()
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
|
@ -31,6 +31,7 @@
|
|||||||
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */; };
|
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */; };
|
||||||
50617D9923FCE48E0099B055 /* SecretiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D9823FCE48E0099B055 /* SecretiveTests.swift */; };
|
50617D9923FCE48E0099B055 /* SecretiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D9823FCE48E0099B055 /* SecretiveTests.swift */; };
|
||||||
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617DD123FCEFA90099B055 /* PreviewStore.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 */; };
|
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C12516F303004B5A36 /* SetupView.swift */; };
|
||||||
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; };
|
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; };
|
||||||
5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6F6251829B1004B5A36 /* ShellConfigurationController.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellConfigurationController.swift; sourceTree = "<group>"; };
|
||||||
@ -264,6 +266,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
50617D8423FCE48E0099B055 /* ContentView.swift */,
|
50617D8423FCE48E0099B055 /* ContentView.swift */,
|
||||||
|
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
|
||||||
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
|
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
|
||||||
50153E21250DECA300525160 /* SecretListItemView.swift */,
|
50153E21250DECA300525160 /* SecretListItemView.swift */,
|
||||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
||||||
@ -475,6 +478,7 @@
|
|||||||
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */,
|
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */,
|
||||||
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
||||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
||||||
|
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
|
||||||
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
|
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
|
||||||
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
|
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
|
||||||
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
|
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
|
||||||
|
@ -75,6 +75,7 @@
|
|||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
@ -47,6 +47,9 @@ extension Preview {
|
|||||||
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
|
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reloadSecrets() {
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class StoreModifiable: Store, SecretStoreModifiable {
|
class StoreModifiable: Store, SecretStoreModifiable {
|
||||||
|
@ -9,6 +9,9 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
|||||||
@Binding var showingCreation: Bool
|
@Binding var showingCreation: Bool
|
||||||
@Binding var runningSetup: Bool
|
@Binding var runningSetup: Bool
|
||||||
@Binding var hasRunSetup: 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 storeList: SecretStoreList
|
||||||
@EnvironmentObject private var updater: UpdaterType
|
@EnvironmentObject private var updater: UpdaterType
|
||||||
@ -20,17 +23,17 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
if storeList.anyAvailable {
|
if storeList.anyAvailable {
|
||||||
StoreListView(showingCreation: $showingCreation)
|
StoreListView(activeSecret: $activeSecret)
|
||||||
} else {
|
} else {
|
||||||
NoStoresView()
|
NoStoresView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 640, minHeight: 320)
|
.frame(minWidth: 640, minHeight: 320)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
updateNotice
|
toolbarItem(updateNoticeView, id: "update")
|
||||||
setupNotice
|
toolbarItem(runningOrRunSetupView, id: "setup")
|
||||||
appPathNotice
|
toolbarItem(appPathNoticeView, id: "appPath")
|
||||||
newItem
|
toolbarItem(newItemView, id: "new")
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $runningSetup) {
|
.sheet(isPresented: $runningSetup) {
|
||||||
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
|
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
|
||||||
@ -41,26 +44,42 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
|||||||
|
|
||||||
extension ContentView {
|
extension ContentView {
|
||||||
|
|
||||||
var updateNotice: ToolbarItem<Void, AnyView> {
|
|
||||||
guard let update = updater.update else {
|
func toolbarItem(_ view: some View, id: String) -> ToolbarItem<String, some View> {
|
||||||
return ToolbarItem { AnyView(EmptyView()) }
|
ToolbarItem(id: id) { view }
|
||||||
}
|
}
|
||||||
let color: Color
|
|
||||||
let text: String
|
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 updateNoticeContent: (String, Color)? {
|
||||||
|
guard let update = updater.update else { return nil }
|
||||||
if update.critical {
|
if update.critical {
|
||||||
text = "Critical Security Update Required"
|
return ("Critical Security Update Required", .red)
|
||||||
color = .red
|
|
||||||
} else {
|
} else {
|
||||||
if updater.testBuild {
|
if updater.testBuild {
|
||||||
text = "Test Build"
|
return ("Test Build", .blue)
|
||||||
color = .blue
|
|
||||||
} else {
|
} else {
|
||||||
text = "Update Available"
|
return ("Update Available", .orange)
|
||||||
color = .orange
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ToolbarItem {
|
}
|
||||||
AnyView(
|
|
||||||
|
@ViewBuilder
|
||||||
|
var updateNoticeView: some View {
|
||||||
|
if let update = updater.update, let (text, color) = updateNoticeContent {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedUpdate = update
|
selectedUpdate = update
|
||||||
}, label: {
|
}, label: {
|
||||||
@ -68,41 +87,35 @@ extension ContentView {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
})
|
})
|
||||||
.background(color)
|
.buttonStyle(ToolbarButtonStyle(color: color))
|
||||||
.cornerRadius(5)
|
|
||||||
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
|
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
|
||||||
UpdateDetailView(update: update)
|
UpdateDetailView(update: update)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var newItem: ToolbarItem<Void, AnyView> {
|
@ViewBuilder
|
||||||
guard storeList.modifiableStore?.isAvailable ?? false else {
|
var newItemView: some View {
|
||||||
return ToolbarItem { AnyView(EmptyView()) }
|
if storeList.modifiableStore?.isAvailable ?? false {
|
||||||
}
|
|
||||||
return ToolbarItem {
|
|
||||||
AnyView(
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingCreation = true
|
showingCreation = true
|
||||||
}, label: {
|
}, label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
})
|
})
|
||||||
.popover(isPresented: $showingCreation, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
|
.sheet(isPresented: $showingCreation) {
|
||||||
if let modifiable = storeList.modifiableStore {
|
if let modifiable = storeList.modifiableStore {
|
||||||
CreateSecretView(store: modifiable, showing: $showingCreation)
|
CreateSecretView(store: modifiable, showing: $showingCreation)
|
||||||
|
.onDisappear {
|
||||||
|
guard let newest = modifiable.secrets.last?.id else { return }
|
||||||
|
activeSecret = newest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
)
|
@ViewBuilder
|
||||||
}
|
var setupNoticeView: some View {
|
||||||
}
|
|
||||||
|
|
||||||
var setupNotice: ToolbarItem<Void, AnyView> {
|
|
||||||
return ToolbarItem {
|
|
||||||
AnyView(
|
|
||||||
Group {
|
|
||||||
if (runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild {
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
runningSetup = true
|
runningSetup = true
|
||||||
}, label: {
|
}, label: {
|
||||||
@ -116,23 +129,39 @@ extension ContentView {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
})
|
})
|
||||||
.background(Color.orange)
|
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
||||||
.cornerRadius(5)
|
|
||||||
} else {
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var appPathNotice: ToolbarItem<Void, AnyView> {
|
@ViewBuilder
|
||||||
let controller = ApplicationDirectoryController()
|
var appPathNoticeView: some View {
|
||||||
guard !controller.isInApplicationsDirectory else {
|
if !ApplicationDirectoryController().isInApplicationsDirectory {
|
||||||
return ToolbarItem { AnyView(EmptyView()) }
|
|
||||||
}
|
|
||||||
return ToolbarItem {
|
|
||||||
AnyView(
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingAppPathNotice = true
|
showingAppPathNotice = true
|
||||||
}, label: {
|
}, label: {
|
||||||
@ -142,8 +171,7 @@ extension ContentView {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
})
|
})
|
||||||
.background(Color.orange)
|
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
||||||
.cornerRadius(5)
|
|
||||||
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
|
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
|
||||||
VStack {
|
VStack {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
@ -155,7 +183,6 @@ extension ContentView {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,3 +225,4 @@ struct ContentView_Previews: PreviewProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ struct CopyableView: View {
|
|||||||
var text: String
|
var text: String
|
||||||
|
|
||||||
@State private var interactionState: InteractionState = .normal
|
@State private var interactionState: InteractionState = .normal
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@ -77,38 +78,32 @@ struct CopyableView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var backgroundColor: Color {
|
var backgroundColor: Color {
|
||||||
let color: NSColor
|
|
||||||
switch interactionState {
|
switch interactionState {
|
||||||
case .normal:
|
case .normal:
|
||||||
color = .windowBackgroundColor
|
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
|
||||||
case .hovering:
|
case .hovering:
|
||||||
color = .unemphasizedSelectedContentBackgroundColor
|
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
|
||||||
case .clicking:
|
case .clicking:
|
||||||
color = .selectedContentBackgroundColor
|
return .accentColor
|
||||||
}
|
}
|
||||||
return Color(color)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var primaryTextColor: Color {
|
var primaryTextColor: Color {
|
||||||
let color: NSColor
|
|
||||||
switch interactionState {
|
switch interactionState {
|
||||||
case .normal, .hovering:
|
case .normal, .hovering:
|
||||||
color = .textColor
|
return Color(.textColor)
|
||||||
case .clicking:
|
case .clicking:
|
||||||
color = .white
|
return .white
|
||||||
}
|
}
|
||||||
return Color(color)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var secondaryTextColor: Color {
|
var secondaryTextColor: Color {
|
||||||
let color: NSColor
|
|
||||||
switch interactionState {
|
switch interactionState {
|
||||||
case .normal, .hovering:
|
case .normal, .hovering:
|
||||||
color = .secondaryLabelColor
|
return Color(.secondaryLabelColor)
|
||||||
case .clicking:
|
case .clicking:
|
||||||
color = .white
|
return .white
|
||||||
}
|
}
|
||||||
return Color(color)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func copy() {
|
func copy() {
|
||||||
@ -128,7 +123,9 @@ struct CopyableView_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
CopyableView(title: "Title", image: Image(systemName: "figure.wave"), text: "Hello world.")
|
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. ")
|
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,19 +12,25 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Image(nsImage: NSApplication.shared.applicationIconImage)
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 64, height: 64)
|
|
||||||
.padding()
|
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Create a New Secret").bold()
|
Text("Create a New Secret")
|
||||||
|
.font(.largeTitle)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Text("Name:")
|
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 {
|
HStack {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Picker("", selection: $requiresAuthentication) {
|
Picker("", selection: $requiresAuthentication) {
|
||||||
@ -32,8 +38,9 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
Text("Authentication not required when Mac is unlocked").tag(false)
|
Text("Authentication not required when Mac is unlocked").tag(false)
|
||||||
}
|
}
|
||||||
.pickerStyle(RadioGroupPickerStyle())
|
.pickerStyle(RadioGroupPickerStyle())
|
||||||
|
Spacer(minLength: 10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,4 +61,208 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
try! store.create(name: name, requiresAuthentication: requiresAuthentication)
|
try! store.create(name: name, requiresAuthentication: requiresAuthentication)
|
||||||
showing = false
|
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,7 +35,6 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Button("Delete", action: delete)
|
Button("Delete", action: delete)
|
||||||
.disabled(confirm != secret.name)
|
.disabled(confirm != secret.name)
|
||||||
.keyboardShortcut(.delete)
|
|
||||||
Button("Don't Delete") {
|
Button("Don't Delete") {
|
||||||
dismissalBlock(false)
|
dismissalBlock(false)
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@ struct SecretDetailView<SecretType: Secret>: View {
|
|||||||
CopyableView(title: "Public Key", image: Image(systemName: "key"), text: keyString)
|
CopyableView(title: "Public Key", image: Image(systemName: "key"), text: keyString)
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
CopyableView(title: "Public Key Path", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.path(for: secret))
|
CopyableView(title: "Public Key Path", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,8 @@ struct SecretListItemView: View {
|
|||||||
} else {
|
} else {
|
||||||
Text(secret.name)
|
Text(secret.name)
|
||||||
}
|
}
|
||||||
}.contextMenu {
|
}
|
||||||
|
.contextMenu {
|
||||||
if store is AnySecretStoreModifiable {
|
if store is AnySecretStoreModifiable {
|
||||||
Button(action: { isRenaming = true }) {
|
Button(action: { isRenaming = true }) {
|
||||||
Text("Rename")
|
Text("Rename")
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
|
||||||
struct StoreListView: View {
|
struct StoreListView: View {
|
||||||
|
|
||||||
@Binding var showingCreation: Bool
|
@Binding var activeSecret: AnySecret.ID?
|
||||||
|
|
||||||
@State private var activeSecret: AnySecret.ID?
|
|
||||||
|
|
||||||
@EnvironmentObject private var storeList: SecretStoreList
|
@EnvironmentObject private var storeList: SecretStoreList
|
||||||
|
|
||||||
@ -14,7 +13,7 @@ struct StoreListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func secretRenamed(secret: AnySecret) {
|
private func secretRenamed(secret: AnySecret) {
|
||||||
activeSecret = nextDefaultSecret
|
activeSecret = secret.id
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
37
Sources/Secretive/Views/ToolbarButtonStyle.swift
Normal file
37
Sources/Secretive/Views/ToolbarButtonStyle.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user