mirror of
https://github.com/maxgoedjen/secretive.git
synced 2025-09-16 09:20:56 +00:00
Merge branch 'main' into newsetup
This commit is contained in:
commit
260e63341d
126
APP_CONFIG.md
126
APP_CONFIG.md
@ -1,125 +1,3 @@
|
|||||||
# Setting up Third Party Apps FAQ
|
# App Configuration
|
||||||
|
|
||||||
## Tower
|
Instructions for setting up apps and shells has moved to [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions)!
|
||||||
|
|
||||||
Tower provides [instructions](https://www.git-tower.com/help/mac/integration/environment).
|
|
||||||
|
|
||||||
## GitHub Desktop
|
|
||||||
|
|
||||||
Should just work, no configuration needed
|
|
||||||
|
|
||||||
## Fork
|
|
||||||
|
|
||||||
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
|
|
||||||
|
|
||||||
```
|
|
||||||
Host *
|
|
||||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
## VS Code
|
|
||||||
|
|
||||||
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
|
|
||||||
|
|
||||||
```
|
|
||||||
Host *
|
|
||||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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`
|
|
||||||
|
|
||||||
```
|
|
||||||
<?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 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`
|
|
||||||
|
|
||||||
```
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>link-ssh-auth-sock</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/bin/sh</string>
|
|
||||||
<string>-c</string>
|
|
||||||
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
```
|
|
||||||
|
|
||||||
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
|
|
||||||
|
|
||||||
## Retcon
|
|
||||||
|
|
||||||
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
|
|
||||||
|
|
||||||
```
|
|
||||||
Host *
|
|
||||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
# The app I use isn't listed here!
|
|
||||||
|
|
||||||
If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome.
|
|
||||||
If you're not able to get it working, please file a [GitHub issue](https://github.com/maxgoedjen/secretive/issues/new) for it. No guarantees we'll be able to get it working, but chances are someone else in the community might be able to.
|
|
||||||
|
2
FAQ.md
2
FAQ.md
@ -6,7 +6,7 @@ The secure enclave doesn't allow import or export of private keys. For any new c
|
|||||||
|
|
||||||
### Secretive doesn't work with my git client/app
|
### Secretive doesn't work with my git client/app
|
||||||
|
|
||||||
Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [App Config FAQ](APP_CONFIG.md).
|
Secretive relies on the `SSH_AUTH_SOCK` environment variable being respected. The `git` and `ssh` command line tools natively respect this, but third party apps may require some configuration to work. A non-exhaustive list of setup steps is provided in the [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions) repo.
|
||||||
|
|
||||||
### Secretive isn't working for me
|
### Secretive isn't working for me
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Secretive  
|
# Secretive [](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) 
|
||||||
|
|
||||||
|
|
||||||
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
|
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
|
||||||
|
18
SECURITY.md
18
SECURITY.md
@ -1,5 +1,23 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
|
## Security Principles
|
||||||
|
|
||||||
|
Secretive is designed with a few general tenets in mind:
|
||||||
|
|
||||||
|
### It's Hard to Leak a Key Secretive Can't Read The Key Material
|
||||||
|
|
||||||
|
Secretive only operates on hardware-backed keys. In general terms, this means that it should be _very_ hard for Secretive to have any sort of bug that causes a key to be shared, because Secretive can't access private key data even if it wants to.
|
||||||
|
|
||||||
|
### Simplicity and Auditability
|
||||||
|
|
||||||
|
Secretive won't expand to have every feature it could possibly have. Part of the goal of the app is that it is possible for consumers to reasonably audit the code, and that often means not implementing features that might be cool, but which would significantly inflate the size of the codebase.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Both in support of the previous principle and to rule out supply chain attacks, Secretive does not rely on any third party dependencies.
|
||||||
|
|
||||||
|
There are limited exceptions to this, particularly in the build process, but the app itself does not depend on any third party code.
|
||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version.
|
The latest version on the [Releases page](https://github.com/maxgoedjen/secretive/releases) is the only currently supported version.
|
||||||
|
@ -11,7 +11,6 @@ public final class Agent: Sendable {
|
|||||||
private let witness: SigningWitness?
|
private let witness: SigningWitness?
|
||||||
private let publicKeyWriter = OpenSSHPublicKeyWriter()
|
private let publicKeyWriter = OpenSSHPublicKeyWriter()
|
||||||
private let signatureWriter = OpenSSHSignatureWriter()
|
private let signatureWriter = OpenSSHSignatureWriter()
|
||||||
private let requestTracer = SigningRequestTracer()
|
|
||||||
private let certificateHandler = OpenSSHCertificateHandler()
|
private let certificateHandler = OpenSSHCertificateHandler()
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
||||||
|
|
||||||
@ -34,28 +33,26 @@ extension Agent {
|
|||||||
|
|
||||||
/// Handles an incoming request.
|
/// Handles an incoming request.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - reader: A ``FileHandleReader`` to read the content of the request.
|
/// - data: The data to handle.
|
||||||
/// - writer: A ``FileHandleWriter`` to write the response to.
|
/// - provenance: The origin of the request.
|
||||||
/// - Return value:
|
/// - Returns: A response data payload.
|
||||||
/// - Boolean if data could be read
|
public func handle(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) async -> Bool {
|
|
||||||
logger.debug("Agent handling new data")
|
logger.debug("Agent handling new data")
|
||||||
let data = Data(reader.availableData)
|
guard data.count > 4 else {
|
||||||
guard data.count > 4 else { return false}
|
throw InvalidDataProvidedError()
|
||||||
|
}
|
||||||
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(SSHAgent.ResponseType.agentFailure.data.lengthAndData)
|
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
||||||
return true
|
return SSHAgent.ResponseType.agentFailure.data.lengthAndData
|
||||||
}
|
}
|
||||||
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 = await handle(requestType: requestType, data: subData, reader: reader)
|
let response = await handle(requestType: requestType, data: subData, provenance: provenance)
|
||||||
writer.write(response)
|
return response
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) async -> Data {
|
private func handle(requestType: SSHAgent.RequestType, data: Data, provenance: SigningRequestProvenance) async -> Data {
|
||||||
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
||||||
await reloadSecretsIfNeccessary()
|
await reloadSecretsIfNeccessary()
|
||||||
var response = Data()
|
var response = Data()
|
||||||
@ -66,7 +63,6 @@ extension Agent {
|
|||||||
response.append(await identities())
|
response.append(await 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)
|
|
||||||
response.append(SSHAgent.ResponseType.agentSignResponse.data)
|
response.append(SSHAgent.ResponseType.agentSignResponse.data)
|
||||||
response.append(try await sign(data: data, provenance: provenance))
|
response.append(try await sign(data: data, provenance: provenance))
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
|
||||||
@ -88,7 +84,7 @@ extension Agent {
|
|||||||
func identities() async -> Data {
|
func identities() async -> Data {
|
||||||
let secrets = await storeList.allSecrets
|
let secrets = await storeList.allSecrets
|
||||||
await certificateHandler.reloadCertificates(for: secrets)
|
await certificateHandler.reloadCertificates(for: secrets)
|
||||||
var count = secrets.count
|
var count = 0
|
||||||
var keyData = Data()
|
var keyData = Data()
|
||||||
|
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
@ -96,6 +92,7 @@ extension Agent {
|
|||||||
let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType)
|
let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType)
|
||||||
keyData.append(keyBlob.lengthAndData)
|
keyData.append(keyBlob.lengthAndData)
|
||||||
keyData.append(curveData.lengthAndData)
|
keyData.append(curveData.lengthAndData)
|
||||||
|
count += 1
|
||||||
|
|
||||||
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
||||||
keyData.append(certificateData.lengthAndData)
|
keyData.append(certificateData.lengthAndData)
|
||||||
@ -118,6 +115,7 @@ extension Agent {
|
|||||||
let reader = OpenSSHReader(data: data)
|
let reader = OpenSSHReader(data: data)
|
||||||
let payloadHash = reader.readNextChunk()
|
let payloadHash = reader.readNextChunk()
|
||||||
let hash: Data
|
let hash: Data
|
||||||
|
|
||||||
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
||||||
if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
|
if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
|
||||||
hash = certificatePublicKey
|
hash = certificatePublicKey
|
||||||
@ -125,22 +123,18 @@ extension Agent {
|
|||||||
hash = payloadHash
|
hash = payloadHash
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let (store, secret) = await secret(matching: hash) else {
|
guard let (secret, store) = await 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 NoMatchingKeyError()
|
||||||
}
|
}
|
||||||
|
|
||||||
if let witness = witness {
|
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
|
||||||
try await witness.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
|
|
||||||
}
|
|
||||||
|
|
||||||
let dataToSign = reader.readNextChunk()
|
let dataToSign = reader.readNextChunk()
|
||||||
let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
|
let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
|
||||||
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
||||||
|
|
||||||
if let witness = witness {
|
try await witness?.witness(accessTo: secret, from: store, by: provenance)
|
||||||
try await witness.witness(accessTo: secret, from: store, by: provenance)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Agent signed request")
|
logger.debug("Agent signed request")
|
||||||
|
|
||||||
@ -165,16 +159,10 @@ extension Agent {
|
|||||||
/// 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.
|
||||||
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
|
func secret(matching hash: Data) async -> (AnySecret, AnySecretStore)? {
|
||||||
for store in await storeList.stores {
|
await storeList.allSecretsWithStores.first {
|
||||||
let allMatching = await store.secrets.filter { secret in
|
hash == publicKeyWriter.data(secret: $0.0)
|
||||||
hash == publicKeyWriter.data(secret: secret)
|
|
||||||
}
|
|
||||||
if let matching = allMatching.first {
|
|
||||||
return (store, matching)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -182,13 +170,8 @@ extension Agent {
|
|||||||
|
|
||||||
extension Agent {
|
extension Agent {
|
||||||
|
|
||||||
/// An error involving agent operations..
|
struct InvalidDataProvidedError: Error {}
|
||||||
enum AgentError: Error {
|
struct NoMatchingKeyError: Error {}
|
||||||
case unhandledType
|
|
||||||
case noMatchingKey
|
|
||||||
case unsupportedKeyType
|
|
||||||
case notOpenSSHCertificate
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,23 +1,32 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
/// A controller that manages socket configuration and request dispatching.
|
/// A controller that manages socket configuration and request dispatching.
|
||||||
public final class SocketController {
|
public struct SocketController {
|
||||||
|
|
||||||
/// The active FileHandle.
|
/// A stream of Sessions. Each session represents one connection to a class communicating with the socket. Multiple Sessions may be active simultaneously.
|
||||||
private var fileHandle: FileHandle?
|
public let sessions: AsyncStream<Session>
|
||||||
/// The active SocketPort.
|
|
||||||
private var port: SocketPort?
|
/// A continuation to create new sessions.
|
||||||
/// A handler that will be notified when a new read/write handle is available.
|
private let sessionsContinuation: AsyncStream<Session>.Continuation
|
||||||
/// False if no data could be read
|
|
||||||
public var handler: (@Sendable (FileHandleReader, FileHandleWriter) async -> Bool)?
|
/// The active SocketPort. Must be retained to be kept valid.
|
||||||
/// Logger.
|
private let port: SocketPort
|
||||||
|
|
||||||
|
/// The FileHandle for the main socket.
|
||||||
|
private let fileHandle: FileHandle
|
||||||
|
|
||||||
|
/// Logger for the socket controller.
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "SocketController")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "SocketController")
|
||||||
|
|
||||||
|
/// Tracer which determines who originates a socket connection.
|
||||||
|
private let requestTracer = SigningRequestTracer()
|
||||||
|
|
||||||
/// Initializes a socket controller with a specified path.
|
/// Initializes a socket controller with a specified path.
|
||||||
/// - Parameter path: The path to use as a socket.
|
/// - Parameter path: The path to use as a socket.
|
||||||
public init(path: String) {
|
public init(path: String) {
|
||||||
|
(sessions, sessionsContinuation) = AsyncStream<Session>.makeStream()
|
||||||
logger.debug("Socket controller setting up at \(path)")
|
logger.debug("Socket controller setting up at \(path)")
|
||||||
if let _ = try? FileManager.default.removeItem(atPath: path) {
|
if let _ = try? FileManager.default.removeItem(atPath: path) {
|
||||||
logger.debug("Socket controller removed existing socket")
|
logger.debug("Socket controller removed existing socket")
|
||||||
@ -25,25 +34,102 @@ public final class SocketController {
|
|||||||
let exists = FileManager.default.fileExists(atPath: path)
|
let exists = FileManager.default.fileExists(atPath: path)
|
||||||
assert(!exists)
|
assert(!exists)
|
||||||
logger.debug("Socket controller path is clear")
|
logger.debug("Socket controller path is clear")
|
||||||
port = socketPort(at: path)
|
port = SocketPort(path: path)
|
||||||
configureSocket(at: path)
|
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
|
||||||
|
Task { [fileHandle, sessionsContinuation, logger] in
|
||||||
|
for await notification in NotificationCenter.default.notifications(named: .NSFileHandleConnectionAccepted) {
|
||||||
|
logger.debug("Socket controller accepted connection")
|
||||||
|
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { continue }
|
||||||
|
let session = Session(fileHandle: new)
|
||||||
|
sessionsContinuation.yield(session)
|
||||||
|
await fileHandle.acceptConnectionInBackgroundAndNotifyOnMainActor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileHandle.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
|
||||||
logger.debug("Socket listening at \(path)")
|
logger.debug("Socket listening at \(path)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configures the socket and a corresponding FileHandle.
|
}
|
||||||
/// - Parameter path: The path to use as a socket.
|
|
||||||
func configureSocket(at path: String) {
|
extension SocketController {
|
||||||
guard let port = port else { return }
|
|
||||||
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
|
/// A session represents a connection that has been established between the two ends of the socket.
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionAccept(notification:)), name: .NSFileHandleConnectionAccepted, object: nil)
|
public struct Session: Sendable {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionDataAvailable(notification:)), name: .NSFileHandleDataAvailable, object: nil)
|
|
||||||
fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
|
/// Data received by the socket.
|
||||||
|
public let messages: AsyncStream<Data>
|
||||||
|
|
||||||
|
/// The provenance of the process that established the session.
|
||||||
|
public let provenance: SigningRequestProvenance
|
||||||
|
|
||||||
|
/// A FileHandle used to communicate with the socket.
|
||||||
|
private let fileHandle: FileHandle
|
||||||
|
|
||||||
|
/// A continuation for issuing new messages.
|
||||||
|
private let messagesContinuation: AsyncStream<Data>.Continuation
|
||||||
|
|
||||||
|
/// A logger for the session.
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Session")
|
||||||
|
|
||||||
|
/// Initializes a new Session.
|
||||||
|
/// - Parameter fileHandle: The FileHandle used to communicate with the socket.
|
||||||
|
init(fileHandle: FileHandle) {
|
||||||
|
self.fileHandle = fileHandle
|
||||||
|
provenance = SigningRequestTracer().provenance(from: fileHandle)
|
||||||
|
(messages, messagesContinuation) = AsyncStream.makeStream()
|
||||||
|
Task { [messagesContinuation, logger] in
|
||||||
|
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
|
||||||
|
for await _ in NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle) {
|
||||||
|
let data = fileHandle.availableData
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
logger.debug("Socket controller received empty data, ending continuation.")
|
||||||
|
messagesContinuation.finish()
|
||||||
|
try fileHandle.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messagesContinuation.yield(data)
|
||||||
|
logger.debug("Socket controller yielded data.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes new data to the socket.
|
||||||
|
/// - Parameter data: The data to write.
|
||||||
|
public func write(_ data: Data) async throws {
|
||||||
|
try fileHandle.write(contentsOf: data)
|
||||||
|
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the socket and cleans up resources.
|
||||||
|
public func close() throws {
|
||||||
|
logger.debug("Session closed.")
|
||||||
|
messagesContinuation.finish()
|
||||||
|
try fileHandle.close()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a SocketPort for a path.
|
}
|
||||||
/// - Parameter path: The path to use as a socket.
|
|
||||||
/// - Returns: A configured SocketPort.
|
private extension FileHandle {
|
||||||
func socketPort(at path: String) -> SocketPort {
|
|
||||||
|
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
|
||||||
|
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
|
||||||
|
waitForDataInBackgroundAndNotify()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Ensures acceptConnectionInBackgroundAndNotify will be called on the main actor.
|
||||||
|
/// - Parameter modes: the runloop modes to use.
|
||||||
|
@MainActor func acceptConnectionInBackgroundAndNotifyOnMainActor(forModes modes: [RunLoop.Mode]? = [RunLoop.Mode.common]) {
|
||||||
|
acceptConnectionInBackgroundAndNotify(forModes: modes)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension SocketPort {
|
||||||
|
|
||||||
|
convenience init(path: String) {
|
||||||
var addr = sockaddr_un()
|
var addr = sockaddr_un()
|
||||||
addr.sun_family = sa_family_t(AF_UNIX)
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
|
||||||
@ -61,51 +147,7 @@ public final class SocketController {
|
|||||||
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
|
data = Data(bytes: pointer, count: MemoryLayout<sockaddr_un>.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
return SocketPort(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles a new connection being accepted, invokes the handler, and prepares to accept new connections.
|
|
||||||
/// - Parameter notification: A `Notification` that triggered the call.
|
|
||||||
@objc func handleConnectionAccept(notification: Notification) {
|
|
||||||
logger.debug("Socket controller accepted connection")
|
|
||||||
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return }
|
|
||||||
Task { [handler, fileHandle] in
|
|
||||||
_ = await handler?(new, new)
|
|
||||||
await new.waitForDataInBackgroundAndNotifyOnMainActor()
|
|
||||||
await fileHandle?.acceptConnectionInBackgroundAndNotifyOnMainActor()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles a new connection providing data and invokes the handler callback.
|
|
||||||
/// - Parameter notification: A `Notification` that triggered the call.
|
|
||||||
@objc func handleConnectionDataAvailable(notification: Notification) {
|
|
||||||
logger.debug("Socket controller has new data available")
|
|
||||||
guard let new = notification.object as? FileHandle else { return }
|
|
||||||
logger.debug("Socket controller received new file handle")
|
|
||||||
Task { [handler, logger = logger] in
|
|
||||||
if((await handler?(new, new)) == true) {
|
|
||||||
logger.debug("Socket controller handled data, wait for more data")
|
|
||||||
await new.waitForDataInBackgroundAndNotifyOnMainActor()
|
|
||||||
} else {
|
|
||||||
logger.debug("Socket controller called with empty data, socked closed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FileHandle {
|
|
||||||
|
|
||||||
/// Ensures waitForDataInBackgroundAndNotify will be called on the main actor.
|
|
||||||
@MainActor func waitForDataInBackgroundAndNotifyOnMainActor() {
|
|
||||||
waitForDataInBackgroundAndNotify()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Ensures acceptConnectionInBackgroundAndNotify will be called on the main actor.
|
|
||||||
/// - Parameter modes: the runloop modes to use.
|
|
||||||
@MainActor func acceptConnectionInBackgroundAndNotifyOnMainActor(forModes modes: [RunLoop.Mode]? = [RunLoop.Mode.common]) {
|
|
||||||
acceptConnectionInBackgroundAndNotify(forModes: modes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -4,27 +4,27 @@ import Foundation
|
|||||||
public struct AnySecret: Secret, @unchecked Sendable {
|
public struct AnySecret: Secret, @unchecked Sendable {
|
||||||
|
|
||||||
public let base: any Secret
|
public let base: any Secret
|
||||||
private let hashable: AnyHashable
|
|
||||||
private let _id: () -> AnyHashable
|
private let _id: () -> AnyHashable
|
||||||
private let _name: () -> String
|
private let _name: () -> String
|
||||||
private let _publicKey: () -> Data
|
private let _publicKey: () -> Data
|
||||||
private let _attributes: () -> Attributes
|
private let _attributes: () -> Attributes
|
||||||
|
private let _eq: (AnySecret) -> Bool
|
||||||
|
|
||||||
public init<T>(_ secret: T) where T: Secret {
|
public init<T>(_ secret: T) where T: Secret {
|
||||||
if let secret = secret as? AnySecret {
|
if let secret = secret as? AnySecret {
|
||||||
base = secret.base
|
base = secret.base
|
||||||
hashable = secret.hashable
|
|
||||||
_id = secret._id
|
_id = secret._id
|
||||||
_name = secret._name
|
_name = secret._name
|
||||||
_publicKey = secret._publicKey
|
_publicKey = secret._publicKey
|
||||||
_attributes = secret._attributes
|
_attributes = secret._attributes
|
||||||
|
_eq = secret._eq
|
||||||
} else {
|
} else {
|
||||||
base = secret
|
base = secret
|
||||||
self.hashable = secret
|
|
||||||
_id = { secret.id as AnyHashable }
|
_id = { secret.id as AnyHashable }
|
||||||
_name = { secret.name }
|
_name = { secret.name }
|
||||||
_publicKey = { secret.publicKey }
|
_publicKey = { secret.publicKey }
|
||||||
_attributes = { secret.attributes }
|
_attributes = { secret.attributes }
|
||||||
|
_eq = { secret == $0.base as? T }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,11 +45,11 @@ public struct AnySecret: Secret, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {
|
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {
|
||||||
lhs.hashable == rhs.hashable
|
lhs._eq(rhs)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
hashable.hash(into: &hasher)
|
id.hash(into: &hasher)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -61,20 +61,21 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
|
|
||||||
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
|
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
|
||||||
|
|
||||||
private let _create: @Sendable (String, Attributes) async throws -> Void
|
private let _create: @Sendable (String, Attributes) async throws -> AnySecret
|
||||||
private let _delete: @Sendable (AnySecret) async throws -> Void
|
private let _delete: @Sendable (AnySecret) async throws -> Void
|
||||||
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
|
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
|
||||||
private let _supportedKeyTypes: @Sendable () -> [KeyType]
|
private let _supportedKeyTypes: @Sendable () -> [KeyType]
|
||||||
|
|
||||||
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
||||||
_create = { try await secretStore.create(name: $0, attributes: $1) }
|
_create = { AnySecret(try await secretStore.create(name: $0, attributes: $1)) }
|
||||||
_delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
|
_delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
|
||||||
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) }
|
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) }
|
||||||
_supportedKeyTypes = { secretStore.supportedKeyTypes }
|
_supportedKeyTypes = { secretStore.supportedKeyTypes }
|
||||||
super.init(secretStore)
|
super.init(secretStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func create(name: String, attributes: Attributes) async throws {
|
@discardableResult
|
||||||
|
public func create(name: String, attributes: Attributes) async throws -> SecretType {
|
||||||
try await _create(name, attributes)
|
try await _create(name, attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,12 +53,12 @@ public extension SecretStore {
|
|||||||
/// - secret: The secret which will be used for signing.
|
/// - secret: The secret which will be used for signing.
|
||||||
/// - Returns: The appropriate algorithm.
|
/// - Returns: The appropriate algorithm.
|
||||||
func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? {
|
func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? {
|
||||||
switch (secret.keyType.algorithm, secret.keyType.size) {
|
switch secret.keyType {
|
||||||
case (.ecdsa, 256):
|
case .ecdsa256:
|
||||||
.ecdsaSignatureMessageX962SHA256
|
.ecdsaSignatureMessageX962SHA256
|
||||||
case (.ecdsa, 384):
|
case .ecdsa384:
|
||||||
.ecdsaSignatureMessageX962SHA384
|
.ecdsaSignatureMessageX962SHA384
|
||||||
case (.rsa, 2048):
|
case .rsa2048:
|
||||||
.rsaSignatureMessagePKCS1v15SHA512
|
.rsaSignatureMessagePKCS1v15SHA512
|
||||||
default:
|
default:
|
||||||
nil
|
nil
|
||||||
|
@ -31,7 +31,6 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
public func publicKeyHash(from hash: Data) -> Data? {
|
public func publicKeyHash(from hash: Data) -> Data? {
|
||||||
let reader = OpenSSHReader(data: hash)
|
let reader = OpenSSHReader(data: hash)
|
||||||
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
|
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
|
||||||
|
|
||||||
switch certType {
|
switch certType {
|
||||||
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
||||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
||||||
|
@ -75,16 +75,16 @@ extension OpenSSHPublicKeyWriter {
|
|||||||
/// - length: The key length of the algorithm.
|
/// - length: The key length of the algorithm.
|
||||||
/// - Returns: The OpenSSH identifier for the algorithm.
|
/// - Returns: The OpenSSH identifier for the algorithm.
|
||||||
public func openSSHIdentifier(for keyType: KeyType) -> String {
|
public func openSSHIdentifier(for keyType: KeyType) -> String {
|
||||||
switch (keyType.algorithm, keyType.size) {
|
switch keyType {
|
||||||
case (.ecdsa, 256):
|
case .ecdsa256:
|
||||||
"ecdsa-sha2-nistp256"
|
"ecdsa-sha2-nistp256"
|
||||||
case (.ecdsa, 384):
|
case .ecdsa384:
|
||||||
"ecdsa-sha2-nistp384"
|
"ecdsa-sha2-nistp384"
|
||||||
case (.mldsa, 65):
|
case .mldsa65:
|
||||||
"ssh-mldsa-65"
|
"ssh-mldsa-65"
|
||||||
case (.mldsa, 87):
|
case .mldsa87:
|
||||||
"ssh-mldsa-87"
|
"ssh-mldsa-87"
|
||||||
case (.rsa, _):
|
case .rsa2048:
|
||||||
"ssh-rsa"
|
"ssh-rsa"
|
||||||
default:
|
default:
|
||||||
"unknown"
|
"unknown"
|
||||||
@ -101,8 +101,7 @@ extension OpenSSHPublicKeyWriter {
|
|||||||
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
|
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
|
||||||
// Rather than parse out the whole ASN.1 blob, we'll cheat and pull values directly since
|
// Rather than parse out the whole ASN.1 blob, we'll cheat and pull values directly since
|
||||||
// we only support one key type, and the keychain always gives it in a specific format.
|
// we only support one key type, and the keychain always gives it in a specific format.
|
||||||
let keySize = secret.keyType.size
|
guard secret.keyType == .rsa2048 else { fatalError() }
|
||||||
guard secret.keyType.algorithm == .rsa && keySize == 2048 else { fatalError() }
|
|
||||||
let length = secret.keyType.size/8
|
let length = secret.keyType.size/8
|
||||||
let data = secret.publicKey
|
let data = secret.publicKey
|
||||||
let n = Data(data[8..<(9+length)])
|
let n = Data(data[8..<(9+length)])
|
||||||
|
@ -36,4 +36,12 @@ import Observation
|
|||||||
stores.flatMap(\.secrets)
|
stores.flatMap(\.secrets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var allSecretsWithStores: [(AnySecret, AnySecretStore)] {
|
||||||
|
stores.flatMap { store in
|
||||||
|
store.secrets.map { secret in
|
||||||
|
(secret, store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,13 @@ public extension Secret {
|
|||||||
|
|
||||||
/// The type of algorithm the Secret uses.
|
/// The type of algorithm the Secret uses.
|
||||||
public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible {
|
public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible {
|
||||||
|
|
||||||
|
public static let ecdsa256 = KeyType(algorithm: .ecdsa, size: 256)
|
||||||
|
public static let ecdsa384 = KeyType(algorithm: .ecdsa, size: 384)
|
||||||
|
public static let mldsa65 = KeyType(algorithm: .mldsa, size: 65)
|
||||||
|
public static let mldsa87 = KeyType(algorithm: .mldsa, size: 87)
|
||||||
|
public static let rsa2048 = KeyType(algorithm: .rsa, size: 2048)
|
||||||
|
|
||||||
public enum Algorithm: Hashable, Sendable, Codable {
|
public enum Algorithm: Hashable, Sendable, Codable {
|
||||||
case ecdsa
|
case ecdsa
|
||||||
case mldsa
|
case mldsa
|
||||||
@ -41,7 +47,7 @@ public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible {
|
|||||||
|
|
||||||
public var algorithm: Algorithm
|
public var algorithm: Algorithm
|
||||||
public var size: Int
|
public var size: Int
|
||||||
|
|
||||||
public init(algorithm: Algorithm, size: Int) {
|
public init(algorithm: Algorithm, size: Int) {
|
||||||
self.algorithm = algorithm
|
self.algorithm = algorithm
|
||||||
self.size = size
|
self.size = size
|
||||||
|
@ -46,8 +46,9 @@ public protocol SecretStoreModifiable<SecretType>: SecretStore {
|
|||||||
/// Creates a new ``Secret`` in the store.
|
/// Creates a new ``Secret`` in the store.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - name: The user-facing name for the ``Secret``.
|
/// - name: The user-facing name for the ``Secret``.
|
||||||
/// - attributes: A struct describing the options for creating the key.
|
/// - attributes: A struct describing the options for creating the key.'
|
||||||
func create(name: String, attributes: Attributes) async throws
|
@discardableResult
|
||||||
|
func create(name: String, attributes: Attributes) async throws -> SecretType
|
||||||
|
|
||||||
/// Deletes a Secret in the store.
|
/// Deletes a Secret in the store.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
|
@ -23,6 +23,10 @@ extension SecureEnclave {
|
|||||||
self.attributes = attributes
|
self.attributes = attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,11 @@ extension SecureEnclave {
|
|||||||
@MainActor public init() {
|
@MainActor public init() {
|
||||||
loadSecrets()
|
loadSecrets()
|
||||||
Task {
|
Task {
|
||||||
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
||||||
|
guard Constants.notificationToken != (note.object as? String) else {
|
||||||
|
// Don't reload if we're the ones triggering this by reloading.
|
||||||
|
return
|
||||||
|
}
|
||||||
reloadSecrets()
|
reloadSecrets()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,17 +70,17 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
||||||
|
|
||||||
switch (attributes.keyType.algorithm, attributes.keyType.size) {
|
switch attributes.keyType {
|
||||||
case (.ecdsa, 256):
|
case .ecdsa256:
|
||||||
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
|
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
|
||||||
return try key.signature(for: data).rawRepresentation
|
return try key.signature(for: data).rawRepresentation
|
||||||
case (.mldsa, 65):
|
case .mldsa65:
|
||||||
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||||
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData)
|
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
|
||||||
return try key.signature(for: data)
|
return try key.signature(for: data)
|
||||||
case (.mldsa, 87):
|
case .mldsa87:
|
||||||
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||||
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData)
|
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
|
||||||
return try key.signature(for: data)
|
return try key.signature(for: data)
|
||||||
default:
|
default:
|
||||||
throw UnsupportedAlgorithmError()
|
throw UnsupportedAlgorithmError()
|
||||||
@ -93,20 +97,26 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor public func reloadSecrets() {
|
@MainActor public func reloadSecrets() {
|
||||||
reloadSecretsInternal(notifyAgent: false)
|
let before = secrets
|
||||||
|
secrets.removeAll()
|
||||||
|
loadSecrets()
|
||||||
|
if secrets != before {
|
||||||
|
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||||
|
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: Constants.notificationToken, deliverImmediately: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: SecretStoreModifiable
|
// MARK: SecretStoreModifiable
|
||||||
|
|
||||||
public func create(name: String, attributes: Attributes) async throws {
|
public func create(name: String, attributes: Attributes) async throws -> Secret {
|
||||||
var accessError: SecurityError?
|
var accessError: SecurityError?
|
||||||
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
|
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
|
||||||
case .notRequired:
|
case .notRequired:
|
||||||
[]
|
[]
|
||||||
case .presenceRequired:
|
case .presenceRequired:
|
||||||
.userPresence
|
[.userPresence, .privateKeyUsage]
|
||||||
case .biometryCurrent:
|
case .biometryCurrent:
|
||||||
.biometryCurrentSet
|
[.biometryCurrentSet, .privateKeyUsage]
|
||||||
case .unknown:
|
case .unknown:
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
@ -119,23 +129,28 @@ extension SecureEnclave {
|
|||||||
throw error.takeRetainedValue() as Error
|
throw error.takeRetainedValue() as Error
|
||||||
}
|
}
|
||||||
let dataRep: Data
|
let dataRep: Data
|
||||||
switch (attributes.keyType.algorithm, attributes.keyType.size) {
|
let publicKey: Data
|
||||||
case (.ecdsa, 256):
|
switch attributes.keyType {
|
||||||
|
case .ecdsa256:
|
||||||
let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!)
|
let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!)
|
||||||
dataRep = created.dataRepresentation
|
dataRep = created.dataRepresentation
|
||||||
case (.mldsa, 65):
|
publicKey = created.publicKey.x963Representation
|
||||||
|
case .mldsa65:
|
||||||
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
|
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
|
||||||
let created = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(accessControl: access!)
|
let created = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(accessControl: access!)
|
||||||
dataRep = created.dataRepresentation
|
dataRep = created.dataRepresentation
|
||||||
case (.mldsa, 87):
|
publicKey = created.publicKey.rawRepresentation
|
||||||
|
case .mldsa87:
|
||||||
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
|
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
|
||||||
let created = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(accessControl: access!)
|
let created = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(accessControl: access!)
|
||||||
dataRep = created.dataRepresentation
|
dataRep = created.dataRepresentation
|
||||||
|
publicKey = created.publicKey.rawRepresentation
|
||||||
default:
|
default:
|
||||||
throw Attributes.UnsupportedOptionError()
|
throw Attributes.UnsupportedOptionError()
|
||||||
}
|
}
|
||||||
try saveKey(dataRep, name: name, attributes: attributes)
|
let id = try saveKey(dataRep, name: name, attributes: attributes)
|
||||||
await reloadSecrets()
|
await reloadSecrets()
|
||||||
|
return Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func delete(secret: Secret) async throws {
|
public func delete(secret: Secret) async throws {
|
||||||
@ -172,31 +187,22 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var supportedKeyTypes: [KeyType] {
|
public var supportedKeyTypes: [KeyType] {
|
||||||
[
|
if #available(macOS 26, *) {
|
||||||
.init(algorithm: .ecdsa, size: 256),
|
[
|
||||||
.init(algorithm: .mldsa, size: 65),
|
.ecdsa256,
|
||||||
.init(algorithm: .mldsa, size: 87),
|
.mldsa65,
|
||||||
]
|
.mldsa87,
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
[.ecdsa256]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SecureEnclave.Store {
|
extension SecureEnclave.Store {
|
||||||
|
|
||||||
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) {
|
|
||||||
let before = secrets
|
|
||||||
secrets.removeAll()
|
|
||||||
loadSecrets()
|
|
||||||
if secrets != before {
|
|
||||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
|
||||||
if notifyAgent {
|
|
||||||
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all secrets from the store.
|
/// Loads all secrets from the store.
|
||||||
@MainActor private func loadSecrets() {
|
@MainActor private func loadSecrets() {
|
||||||
let queryAttributes = KeychainDictionary([
|
let queryAttributes = KeychainDictionary([
|
||||||
@ -220,15 +226,15 @@ extension SecureEnclave.Store {
|
|||||||
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
||||||
let keyData = $0[kSecValueData] as! Data
|
let keyData = $0[kSecValueData] as! Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
switch (attributes.keyType.algorithm, attributes.keyType.size) {
|
switch attributes.keyType {
|
||||||
case (.ecdsa, 256):
|
case .ecdsa256:
|
||||||
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
|
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
|
||||||
publicKey = key.publicKey.x963Representation
|
publicKey = key.publicKey.x963Representation
|
||||||
case (.mldsa, 65):
|
case .mldsa65:
|
||||||
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||||
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData)
|
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData)
|
||||||
publicKey = key.publicKey.rawRepresentation
|
publicKey = key.publicKey.rawRepresentation
|
||||||
case (.mldsa, 87):
|
case .mldsa87:
|
||||||
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||||
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData)
|
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData)
|
||||||
publicKey = key.publicKey.rawRepresentation
|
publicKey = key.publicKey.rawRepresentation
|
||||||
@ -249,14 +255,16 @@ extension SecureEnclave.Store {
|
|||||||
/// - name: A user-facing name for the key.
|
/// - name: A user-facing name for the key.
|
||||||
/// - attributes: Attributes of the key.
|
/// - attributes: Attributes of the key.
|
||||||
/// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate.
|
/// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate.
|
||||||
func saveKey(_ key: Data, name: String, attributes: Attributes) throws {
|
@discardableResult
|
||||||
|
func saveKey(_ key: Data, name: String, attributes: Attributes) throws -> String {
|
||||||
let attributes = try JSONEncoder().encode(attributes)
|
let attributes = try JSONEncoder().encode(attributes)
|
||||||
|
let id = UUID().uuidString
|
||||||
let keychainAttributes = KeychainDictionary([
|
let keychainAttributes = KeychainDictionary([
|
||||||
kSecClass: Constants.keyClass,
|
kSecClass: Constants.keyClass,
|
||||||
kSecAttrService: Constants.keyTag,
|
kSecAttrService: Constants.keyTag,
|
||||||
kSecUseDataProtectionKeychain: true,
|
kSecUseDataProtectionKeychain: true,
|
||||||
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
kSecAttrAccount: UUID().uuidString,
|
kSecAttrAccount: id,
|
||||||
kSecValueData: key,
|
kSecValueData: key,
|
||||||
kSecAttrLabel: name,
|
kSecAttrLabel: name,
|
||||||
kSecAttrGeneric: attributes
|
kSecAttrGeneric: attributes
|
||||||
@ -265,6 +273,7 @@ extension SecureEnclave.Store {
|
|||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -274,6 +283,7 @@ extension SecureEnclave.Store {
|
|||||||
enum Constants {
|
enum Constants {
|
||||||
static let keyClass = kSecClassGenericPassword as String
|
static let keyClass = kSecClassGenericPassword as String
|
||||||
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
|
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
|
||||||
|
static let notificationToken = UUID().uuidString
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UnsupportedAlgorithmError: Error {}
|
struct UnsupportedAlgorithmError: Error {}
|
||||||
|
@ -6,81 +6,77 @@ import CryptoKit
|
|||||||
|
|
||||||
@Suite struct AgentTests {
|
@Suite struct AgentTests {
|
||||||
|
|
||||||
let stubWriter = StubFileHandleWriter()
|
|
||||||
|
|
||||||
// MARK: Identity Listing
|
// MARK: Identity Listing
|
||||||
|
|
||||||
@Test func emptyStores() async {
|
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
// let testProvenance = SigningRequestProvenance(root: .init(pid: 0, processName: "Test", appName: "Test", iconURL: nil, path: /, validSignature: true, parentPID: nil))
|
||||||
|
|
||||||
|
@Test func emptyStores() async throws {
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList())
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesEmpty)
|
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func identitiesList() async {
|
@Test func identitiesList() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
|
#expect(response == Constants.Responses.requestIdentitiesMultiple)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Signatures
|
// MARK: Signatures
|
||||||
|
|
||||||
@Test func noMatchingIdentities() async {
|
@Test func noMatchingIdentities() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let response = try await agent.handle(data: Constants.Requests.requestSignatureWithNoneMatching, provenance: .test)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ecdsaSignature() async throws {
|
// @Test func ecdsaSignature() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
// let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||||
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
// let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
||||||
_ = requestReader.readNextChunk()
|
// _ = requestReader.readNextChunk()
|
||||||
let dataToSign = requestReader.readNextChunk()
|
// let dataToSign = requestReader.readNextChunk()
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
// let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
// let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
// await agent.handle(reader: stubReader, writer: stubWriter)
|
||||||
let outer = OpenSSHReader(data: stubWriter.data[5...])
|
// let outer = OpenSSHReader(data: stubWriter.data[5...])
|
||||||
let payload = outer.readNextChunk()
|
// let payload = outer.readNextChunk()
|
||||||
let inner = OpenSSHReader(data: payload)
|
// let inner = OpenSSHReader(data: payload)
|
||||||
_ = inner.readNextChunk()
|
// _ = inner.readNextChunk()
|
||||||
let signedData = inner.readNextChunk()
|
// let signedData = inner.readNextChunk()
|
||||||
let rsData = OpenSSHReader(data: signedData)
|
// let rsData = OpenSSHReader(data: signedData)
|
||||||
var r = rsData.readNextChunk()
|
// var r = rsData.readNextChunk()
|
||||||
var s = rsData.readNextChunk()
|
// var s = rsData.readNextChunk()
|
||||||
// This is fine IRL, but it freaks out CryptoKit
|
// // This is fine IRL, but it freaks out CryptoKit
|
||||||
if r[0] == 0 {
|
// if r[0] == 0 {
|
||||||
r.removeFirst()
|
// r.removeFirst()
|
||||||
}
|
// }
|
||||||
if s[0] == 0 {
|
// if s[0] == 0 {
|
||||||
s.removeFirst()
|
// s.removeFirst()
|
||||||
}
|
// }
|
||||||
var rs = r
|
// var rs = r
|
||||||
rs.append(s)
|
// rs.append(s)
|
||||||
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
// let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||||
// Correct signature
|
// // Correct signature
|
||||||
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
// #expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
||||||
.isValidSignature(signature, for: dataToSign))
|
// .isValidSignature(signature, for: dataToSign))
|
||||||
}
|
// }
|
||||||
|
|
||||||
// MARK: Witness protocol
|
// MARK: Witness protocol
|
||||||
|
|
||||||
@Test func witnessObjectionStopsRequest() async {
|
@Test func witnessObjectionStopsRequest() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||||
let witness = StubWitness(speakNow: { _,_ in
|
let witness = StubWitness(speakNow: { _,_ in
|
||||||
return true
|
return true
|
||||||
}, witness: { _, _ in })
|
}, witness: { _, _ in })
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func witnessSignature() async {
|
@Test func witnessSignature() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||||
nonisolated(unsafe) var witnessed = false
|
nonisolated(unsafe) var witnessed = false
|
||||||
let witness = StubWitness(speakNow: { _, trace in
|
let witness = StubWitness(speakNow: { _, trace in
|
||||||
@ -89,12 +85,11 @@ import CryptoKit
|
|||||||
witnessed = true
|
witnessed = true
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||||
#expect(witnessed)
|
#expect(witnessed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func requestTracing() async {
|
@Test func requestTracing() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||||
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
|
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
|
||||||
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
|
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
|
||||||
@ -105,36 +100,38 @@ import CryptoKit
|
|||||||
witnessTrace = trace
|
witnessTrace = trace
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
_ = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||||
#expect(witnessTrace == speakNowTrace)
|
#expect(witnessTrace == speakNowTrace)
|
||||||
#expect(witnessTrace?.origin.displayName == "Finder")
|
#expect(witnessTrace == .test)
|
||||||
#expect(witnessTrace?.origin.validSignature == true)
|
|
||||||
#expect(witnessTrace?.origin.parentPID == 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Exception Handling
|
// MARK: Exception Handling
|
||||||
|
|
||||||
@Test func signatureException() async {
|
@Test func signatureException() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let store = await list.stores.first?.base as! Stub.Store
|
let store = await list.stores.first?.base as! Stub.Store
|
||||||
store.shouldThrow = true
|
store.shouldThrow = true
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Unsupported
|
// MARK: Unsupported
|
||||||
|
|
||||||
@Test func unhandledAdd() async {
|
@Test func unhandledAdd() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.addIdentity)
|
|
||||||
let agent = Agent(storeList: SecretStoreList())
|
let agent = Agent(storeList: SecretStoreList())
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
let response = try await agent.handle(data: Constants.Requests.addIdentity, provenance: .test)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
#expect(response == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SigningRequestProvenance {
|
||||||
|
|
||||||
|
static let test = SigningRequestProvenance(root: .init(pid: 0, processName: "test", appName: nil, iconURL: nil, path: "/", validSignature: true, parentPID: 0))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
extension AgentTests {
|
extension AgentTests {
|
||||||
|
|
||||||
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
import SecretAgentKit
|
|
||||||
import AppKit
|
|
||||||
|
|
||||||
struct StubFileHandleReader: FileHandleReader {
|
|
||||||
|
|
||||||
let availableData: Data
|
|
||||||
var fileDescriptor: Int32 {
|
|
||||||
NSWorkspace.shared.runningApplications.filter({ $0.localizedName == "Finder" }).first!.processIdentifier
|
|
||||||
}
|
|
||||||
var pidOfConnectedProcess: Int32 {
|
|
||||||
fileDescriptor
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SecretAgentKit
|
|
||||||
|
|
||||||
class StubFileHandleWriter: FileHandleWriter, @unchecked Sendable {
|
|
||||||
|
|
||||||
var data = Data()
|
|
||||||
|
|
||||||
func write(_ data: Data) {
|
|
||||||
self.data.append(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -33,9 +33,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||||
logger.debug("SecretAgent finished launching")
|
logger.debug("SecretAgent finished launching")
|
||||||
Task { @MainActor in
|
Task {
|
||||||
socketController.handler = { [agent] reader, writer in
|
for await session in socketController.sessions {
|
||||||
await agent.handle(reader: reader, writer: writer)
|
Task {
|
||||||
|
do {
|
||||||
|
for await message in session.messages {
|
||||||
|
let agentResponse = try await agent.handle(data: message, provenance: session.provenance)
|
||||||
|
try await session.write(agentResponse)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
|
@ -53,6 +53,7 @@
|
|||||||
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
|
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
|
||||||
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
|
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
|
||||||
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
|
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
|
||||||
|
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -142,6 +143,7 @@
|
|||||||
50B8550C24138C4F009958AC /* DeleteSecretView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteSecretView.swift; sourceTree = "<group>"; };
|
50B8550C24138C4F009958AC /* DeleteSecretView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteSecretView.swift; sourceTree = "<group>"; };
|
||||||
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStoreView.swift; sourceTree = "<group>"; };
|
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStoreView.swift; sourceTree = "<group>"; };
|
||||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretDetailView.swift; sourceTree = "<group>"; };
|
50C385A42407A76D00AF2719 /* SecretDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonStyle.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -243,6 +245,7 @@
|
|||||||
children = (
|
children = (
|
||||||
50617D8423FCE48E0099B055 /* ContentView.swift */,
|
50617D8423FCE48E0099B055 /* ContentView.swift */,
|
||||||
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
|
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
|
||||||
|
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */,
|
||||||
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
|
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
|
||||||
50153E21250DECA300525160 /* SecretListItemView.swift */,
|
50153E21250DECA300525160 /* SecretListItemView.swift */,
|
||||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
||||||
@ -438,6 +441,7 @@
|
|||||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
||||||
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
|
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
|
||||||
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
|
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
|
||||||
|
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */,
|
||||||
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
|
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
|
||||||
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
|
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
|
||||||
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */,
|
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */,
|
||||||
|
@ -61,13 +61,17 @@ extension Preview {
|
|||||||
var name: String { "Modifiable Preview Store" }
|
var name: String { "Modifiable Preview Store" }
|
||||||
let secrets: [Secret]
|
let secrets: [Secret]
|
||||||
var supportedKeyTypes: [KeyType] {
|
var supportedKeyTypes: [KeyType] {
|
||||||
[
|
if #available(macOS 26, *) {
|
||||||
.init(algorithm: .ecdsa, size: 256),
|
[
|
||||||
.init(algorithm: .mldsa, size: 65),
|
.ecdsa256,
|
||||||
.init(algorithm: .mldsa, size: 87),
|
.mldsa65,
|
||||||
]
|
.mldsa87,
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
[.ecdsa256]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(secrets: [Secret]) {
|
init(secrets: [Secret]) {
|
||||||
self.secrets = secrets
|
self.secrets = secrets
|
||||||
}
|
}
|
||||||
@ -92,7 +96,8 @@ extension Preview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func create(name: String, attributes: Attributes) throws {
|
func create(name: String, attributes: Attributes) throws -> Secret {
|
||||||
|
fatalError()
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(secret: Preview.Secret) throws {
|
func delete(secret: Preview.Secret) throws {
|
||||||
|
24
Sources/Secretive/Views/ActionButtonStyle.swift
Normal file
24
Sources/Secretive/Views/ActionButtonStyle.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PrimaryButtonModifier: ViewModifier {
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
// Tinted glass prominent is really hard to read on 26.0.
|
||||||
|
if #available(macOS 26.0, *), colorScheme == .dark {
|
||||||
|
content.buttonStyle(.glassProminent)
|
||||||
|
} else {
|
||||||
|
content.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
func primary() -> some View {
|
||||||
|
modifier(PrimaryButtonModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -103,6 +103,7 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
showing = false
|
showing = false
|
||||||
}
|
}
|
||||||
Button(.createSecretCreateButton, action: save)
|
Button(.createSecretCreateButton, action: save)
|
||||||
|
.primary()
|
||||||
.disabled(name.isEmpty)
|
.disabled(name.isEmpty)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
@ -1,63 +1,56 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
|
||||||
struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
|
extension View {
|
||||||
|
|
||||||
@State var store: StoreType
|
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ secret: AnySecret, _ store: AnySecretStoreModifiable?, dismissalBlock: @escaping (Bool) -> ()) -> some View {
|
||||||
let secret: StoreType.SecretType
|
modifier(DeleteSecretConfirmationModifier(isPresented: isPresented, secret: secret, store: store, dismissalBlock: dismissalBlock))
|
||||||
var dismissalBlock: (Bool) -> ()
|
|
||||||
|
|
||||||
@State private var confirm = ""
|
|
||||||
@State var errorText: String?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Image(nsImage: NSApplication.shared.applicationIconImage)
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 64, height: 64)
|
|
||||||
.padding()
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Text(.deleteConfirmationTitle(secretName: secret.name)).bold()
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Text(.deleteConfirmationConfirmNameLabel)
|
|
||||||
TextField(secret.name, text: $confirm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let errorText {
|
|
||||||
Text(verbatim: errorText)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.font(.callout)
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button(.deleteConfirmationDeleteButton, action: delete)
|
|
||||||
.disabled(confirm != secret.name)
|
|
||||||
Button(.deleteConfirmationCancelButton) {
|
|
||||||
dismissalBlock(false)
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.cancelAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.frame(minWidth: 400)
|
|
||||||
.onExitCommand {
|
|
||||||
dismissalBlock(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeleteSecretConfirmationModifier: ViewModifier {
|
||||||
|
|
||||||
|
var isPresented: Binding<Bool>
|
||||||
|
var secret: AnySecret
|
||||||
|
var store: AnySecretStoreModifiable?
|
||||||
|
var dismissalBlock: (Bool) -> ()
|
||||||
|
@State var confirmedSecretName = ""
|
||||||
|
@State private var errorText: String?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.confirmationDialog(
|
||||||
|
.deleteConfirmationTitle(secretName: secret.name),
|
||||||
|
isPresented: isPresented,
|
||||||
|
titleVisibility: .visible,
|
||||||
|
actions: {
|
||||||
|
TextField(secret.name, text: $confirmedSecretName)
|
||||||
|
if let errorText {
|
||||||
|
Text(verbatim: errorText)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
Button(.deleteConfirmationDeleteButton, action: delete)
|
||||||
|
.disabled(confirmedSecretName != secret.name)
|
||||||
|
Button(.deleteConfirmationCancelButton, role: .cancel) {
|
||||||
|
dismissalBlock(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill"))
|
||||||
|
.onExitCommand {
|
||||||
|
dismissalBlock(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func delete() {
|
func delete() {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await store.delete(secret: secret)
|
try await store!.delete(secret: secret)
|
||||||
dismissalBlock(true)
|
dismissalBlock(true)
|
||||||
} catch {
|
} catch {
|
||||||
errorText = error.localizedDescription
|
errorText = error.localizedDescription
|
||||||
|
@ -12,18 +12,6 @@ struct SecretListItemView: View {
|
|||||||
var deletedSecret: (AnySecret) -> Void
|
var deletedSecret: (AnySecret) -> Void
|
||||||
var renamedSecret: (AnySecret) -> Void
|
var renamedSecret: (AnySecret) -> Void
|
||||||
|
|
||||||
private var showingPopup: Binding<Bool> {
|
|
||||||
Binding(
|
|
||||||
get: { isDeleting || isRenaming },
|
|
||||||
set: {
|
|
||||||
if $0 == false {
|
|
||||||
isDeleting = false
|
|
||||||
isRenaming = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationLink(value: secret) {
|
NavigationLink(value: secret) {
|
||||||
if secret.authenticationRequirement.required {
|
if secret.authenticationRequirement.required {
|
||||||
@ -48,21 +36,17 @@ struct SecretListItemView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: showingPopup) {
|
.showingDeleteConfirmation(isPresented: $isDeleting, secret, store as? AnySecretStoreModifiable) { deleted in
|
||||||
|
if deleted {
|
||||||
|
deletedSecret(secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isRenaming) {
|
||||||
if let modifiable = store as? AnySecretStoreModifiable {
|
if let modifiable = store as? AnySecretStoreModifiable {
|
||||||
if isDeleting {
|
EditSecretView(store: modifiable, secret: secret) { renamed in
|
||||||
DeleteSecretView(store: modifiable, secret: secret) { deleted in
|
isRenaming = false
|
||||||
isDeleting = false
|
if renamed {
|
||||||
if deleted {
|
renamedSecret(secret)
|
||||||
deletedSecret(secret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if isRenaming {
|
|
||||||
EditSecretView(store: modifiable, secret: secret) { renamed in
|
|
||||||
isRenaming = false
|
|
||||||
if renamed {
|
|
||||||
renamedSecret(secret)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,8 @@ struct StoreListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func secretRenamed(secret: AnySecret) {
|
private func secretRenamed(secret: AnySecret) {
|
||||||
|
// Toggle so name updates in list.
|
||||||
|
activeSecret = nil
|
||||||
activeSecret = secret
|
activeSecret = secret
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,7 +58,7 @@ struct StoreListView: View {
|
|||||||
extension StoreListView {
|
extension StoreListView {
|
||||||
|
|
||||||
private var nextDefaultSecret: AnySecret? {
|
private var nextDefaultSecret: AnySecret? {
|
||||||
return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
|
return storeList.allSecrets.first
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user