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
|
||||
|
||||
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.
|
||||
Instructions for setting up apps and shells has moved to [secretive-config-instructions](https://github.com/maxgoedjen/secretive-config-instructions)!
|
||||
|
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 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
|
||||
|
||||
|
@ -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.
|
||||
|
18
SECURITY.md
18
SECURITY.md
@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
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 publicKeyWriter = OpenSSHPublicKeyWriter()
|
||||
private let signatureWriter = OpenSSHSignatureWriter()
|
||||
private let requestTracer = SigningRequestTracer()
|
||||
private let certificateHandler = OpenSSHCertificateHandler()
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
||||
|
||||
@ -34,28 +33,26 @@ extension Agent {
|
||||
|
||||
/// Handles an incoming request.
|
||||
/// - Parameters:
|
||||
/// - reader: A ``FileHandleReader`` to read the content of the request.
|
||||
/// - writer: A ``FileHandleWriter`` to write the response to.
|
||||
/// - Return value:
|
||||
/// - Boolean if data could be read
|
||||
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) async -> Bool {
|
||||
/// - data: The data to handle.
|
||||
/// - provenance: The origin of the request.
|
||||
/// - Returns: A response data payload.
|
||||
public func handle(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
|
||||
logger.debug("Agent handling new data")
|
||||
let data = Data(reader.availableData)
|
||||
guard data.count > 4 else { return false}
|
||||
guard data.count > 4 else {
|
||||
throw InvalidDataProvidedError()
|
||||
}
|
||||
let requestTypeInt = data[4]
|
||||
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
|
||||
writer.write(SSHAgent.ResponseType.agentFailure.data.lengthAndData)
|
||||
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)")
|
||||
let subData = Data(data[5...])
|
||||
let response = await handle(requestType: requestType, data: subData, reader: reader)
|
||||
writer.write(response)
|
||||
return true
|
||||
let response = await handle(requestType: requestType, data: subData, provenance: provenance)
|
||||
return response
|
||||
}
|
||||
|
||||
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
|
||||
await reloadSecretsIfNeccessary()
|
||||
var response = Data()
|
||||
@ -66,7 +63,6 @@ extension Agent {
|
||||
response.append(await identities())
|
||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
||||
case .signRequest:
|
||||
let provenance = requestTracer.provenance(from: reader)
|
||||
response.append(SSHAgent.ResponseType.agentSignResponse.data)
|
||||
response.append(try await sign(data: data, provenance: provenance))
|
||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
|
||||
@ -88,7 +84,7 @@ extension Agent {
|
||||
func identities() async -> Data {
|
||||
let secrets = await storeList.allSecrets
|
||||
await certificateHandler.reloadCertificates(for: secrets)
|
||||
var count = secrets.count
|
||||
var count = 0
|
||||
var keyData = Data()
|
||||
|
||||
for secret in secrets {
|
||||
@ -96,6 +92,7 @@ extension Agent {
|
||||
let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType)
|
||||
keyData.append(keyBlob.lengthAndData)
|
||||
keyData.append(curveData.lengthAndData)
|
||||
count += 1
|
||||
|
||||
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
||||
keyData.append(certificateData.lengthAndData)
|
||||
@ -118,6 +115,7 @@ extension Agent {
|
||||
let reader = OpenSSHReader(data: data)
|
||||
let payloadHash = reader.readNextChunk()
|
||||
let hash: Data
|
||||
|
||||
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
||||
if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
|
||||
hash = certificatePublicKey
|
||||
@ -125,22 +123,18 @@ extension Agent {
|
||||
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)")
|
||||
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 rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
|
||||
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")
|
||||
|
||||
@ -165,16 +159,10 @@ extension Agent {
|
||||
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
|
||||
/// - Parameter hash: The hash to match against.
|
||||
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
|
||||
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
|
||||
for store in await storeList.stores {
|
||||
let allMatching = await store.secrets.filter { secret in
|
||||
hash == publicKeyWriter.data(secret: secret)
|
||||
}
|
||||
if let matching = allMatching.first {
|
||||
return (store, matching)
|
||||
}
|
||||
func secret(matching hash: Data) async -> (AnySecret, AnySecretStore)? {
|
||||
await storeList.allSecretsWithStores.first {
|
||||
hash == publicKeyWriter.data(secret: $0.0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
@ -182,13 +170,8 @@ extension Agent {
|
||||
|
||||
extension Agent {
|
||||
|
||||
/// An error involving agent operations..
|
||||
enum AgentError: Error {
|
||||
case unhandledType
|
||||
case noMatchingKey
|
||||
case unsupportedKeyType
|
||||
case notOpenSSHCertificate
|
||||
}
|
||||
struct InvalidDataProvidedError: Error {}
|
||||
struct NoMatchingKeyError: Error {}
|
||||
|
||||
}
|
||||
|
||||
|
@ -1,23 +1,32 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SecretKit
|
||||
|
||||
/// A controller that manages socket configuration and request dispatching.
|
||||
public final class SocketController {
|
||||
public struct SocketController {
|
||||
|
||||
/// The active FileHandle.
|
||||
private var fileHandle: FileHandle?
|
||||
/// The active SocketPort.
|
||||
private var port: SocketPort?
|
||||
/// A handler that will be notified when a new read/write handle is available.
|
||||
/// False if no data could be read
|
||||
public var handler: (@Sendable (FileHandleReader, FileHandleWriter) async -> Bool)?
|
||||
/// Logger.
|
||||
/// A stream of Sessions. Each session represents one connection to a class communicating with the socket. Multiple Sessions may be active simultaneously.
|
||||
public let sessions: AsyncStream<Session>
|
||||
|
||||
/// A continuation to create new sessions.
|
||||
private let sessionsContinuation: AsyncStream<Session>.Continuation
|
||||
|
||||
/// The active SocketPort. Must be retained to be kept valid.
|
||||
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")
|
||||
|
||||
/// Tracer which determines who originates a socket connection.
|
||||
private let requestTracer = SigningRequestTracer()
|
||||
|
||||
/// Initializes a socket controller with a specified path.
|
||||
/// - Parameter path: The path to use as a socket.
|
||||
public init(path: String) {
|
||||
(sessions, sessionsContinuation) = AsyncStream<Session>.makeStream()
|
||||
logger.debug("Socket controller setting up at \(path)")
|
||||
if let _ = try? FileManager.default.removeItem(atPath: path) {
|
||||
logger.debug("Socket controller removed existing socket")
|
||||
@ -25,25 +34,102 @@ public final class SocketController {
|
||||
let exists = FileManager.default.fileExists(atPath: path)
|
||||
assert(!exists)
|
||||
logger.debug("Socket controller path is clear")
|
||||
port = socketPort(at: path)
|
||||
configureSocket(at: path)
|
||||
port = SocketPort(path: 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)")
|
||||
}
|
||||
|
||||
/// Configures the socket and a corresponding FileHandle.
|
||||
/// - Parameter path: The path to use as a socket.
|
||||
func configureSocket(at path: String) {
|
||||
guard let port = port else { return }
|
||||
fileHandle = FileHandle(fileDescriptor: port.socket, closeOnDealloc: true)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionAccept(notification:)), name: .NSFileHandleConnectionAccepted, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleConnectionDataAvailable(notification:)), name: .NSFileHandleDataAvailable, object: nil)
|
||||
fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.Mode.common])
|
||||
}
|
||||
|
||||
extension SocketController {
|
||||
|
||||
/// A session represents a connection that has been established between the two ends of the socket.
|
||||
public struct Session: Sendable {
|
||||
|
||||
/// 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.
|
||||
func socketPort(at path: String) -> SocketPort {
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension SocketPort {
|
||||
|
||||
convenience init(path: String) {
|
||||
var addr = sockaddr_un()
|
||||
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)
|
||||
}
|
||||
|
||||
return SocketPort(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)
|
||||
self.init(protocolFamily: AF_UNIX, socketType: SOCK_STREAM, protocol: 0, address: data)!
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -4,27 +4,27 @@ import Foundation
|
||||
public struct AnySecret: Secret, @unchecked Sendable {
|
||||
|
||||
public let base: any Secret
|
||||
private let hashable: AnyHashable
|
||||
private let _id: () -> AnyHashable
|
||||
private let _name: () -> String
|
||||
private let _publicKey: () -> Data
|
||||
private let _attributes: () -> Attributes
|
||||
private let _eq: (AnySecret) -> Bool
|
||||
|
||||
public init<T>(_ secret: T) where T: Secret {
|
||||
if let secret = secret as? AnySecret {
|
||||
base = secret.base
|
||||
hashable = secret.hashable
|
||||
_id = secret._id
|
||||
_name = secret._name
|
||||
_publicKey = secret._publicKey
|
||||
_attributes = secret._attributes
|
||||
_eq = secret._eq
|
||||
} else {
|
||||
base = secret
|
||||
self.hashable = secret
|
||||
_id = { secret.id as AnyHashable }
|
||||
_name = { secret.name }
|
||||
_publicKey = { secret.publicKey }
|
||||
_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 {
|
||||
lhs.hashable == rhs.hashable
|
||||
lhs._eq(rhs)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
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 _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
|
||||
private let _supportedKeyTypes: @Sendable () -> [KeyType]
|
||||
|
||||
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) }
|
||||
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) }
|
||||
_supportedKeyTypes = { secretStore.supportedKeyTypes }
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -53,12 +53,12 @@ public extension SecretStore {
|
||||
/// - secret: The secret which will be used for signing.
|
||||
/// - Returns: The appropriate algorithm.
|
||||
func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? {
|
||||
switch (secret.keyType.algorithm, secret.keyType.size) {
|
||||
case (.ecdsa, 256):
|
||||
switch secret.keyType {
|
||||
case .ecdsa256:
|
||||
.ecdsaSignatureMessageX962SHA256
|
||||
case (.ecdsa, 384):
|
||||
case .ecdsa384:
|
||||
.ecdsaSignatureMessageX962SHA384
|
||||
case (.rsa, 2048):
|
||||
case .rsa2048:
|
||||
.rsaSignatureMessagePKCS1v15SHA512
|
||||
default:
|
||||
nil
|
||||
|
@ -31,7 +31,6 @@ public actor OpenSSHCertificateHandler: Sendable {
|
||||
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",
|
||||
|
@ -75,16 +75,16 @@ extension OpenSSHPublicKeyWriter {
|
||||
/// - length: The key length of the algorithm.
|
||||
/// - Returns: The OpenSSH identifier for the algorithm.
|
||||
public func openSSHIdentifier(for keyType: KeyType) -> String {
|
||||
switch (keyType.algorithm, keyType.size) {
|
||||
case (.ecdsa, 256):
|
||||
switch keyType {
|
||||
case .ecdsa256:
|
||||
"ecdsa-sha2-nistp256"
|
||||
case (.ecdsa, 384):
|
||||
case .ecdsa384:
|
||||
"ecdsa-sha2-nistp384"
|
||||
case (.mldsa, 65):
|
||||
case .mldsa65:
|
||||
"ssh-mldsa-65"
|
||||
case (.mldsa, 87):
|
||||
case .mldsa87:
|
||||
"ssh-mldsa-87"
|
||||
case (.rsa, _):
|
||||
case .rsa2048:
|
||||
"ssh-rsa"
|
||||
default:
|
||||
"unknown"
|
||||
@ -101,8 +101,7 @@ extension OpenSSHPublicKeyWriter {
|
||||
// [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
|
||||
// we only support one key type, and the keychain always gives it in a specific format.
|
||||
let keySize = secret.keyType.size
|
||||
guard secret.keyType.algorithm == .rsa && keySize == 2048 else { fatalError() }
|
||||
guard secret.keyType == .rsa2048 else { fatalError() }
|
||||
let length = secret.keyType.size/8
|
||||
let data = secret.publicKey
|
||||
let n = Data(data[8..<(9+length)])
|
||||
|
@ -36,4 +36,12 @@ import Observation
|
||||
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.
|
||||
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 {
|
||||
case ecdsa
|
||||
case mldsa
|
||||
@ -41,7 +47,7 @@ public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible {
|
||||
|
||||
public var algorithm: Algorithm
|
||||
public var size: Int
|
||||
|
||||
|
||||
public init(algorithm: Algorithm, size: Int) {
|
||||
self.algorithm = algorithm
|
||||
self.size = size
|
||||
|
@ -46,8 +46,9 @@ public protocol SecretStoreModifiable<SecretType>: SecretStore {
|
||||
/// Creates a new ``Secret`` in the store.
|
||||
/// - Parameters:
|
||||
/// - name: The user-facing name for the ``Secret``.
|
||||
/// - attributes: A struct describing the options for creating the key.
|
||||
func create(name: String, attributes: Attributes) async throws
|
||||
/// - attributes: A struct describing the options for creating the key.'
|
||||
@discardableResult
|
||||
func create(name: String, attributes: Attributes) async throws -> SecretType
|
||||
|
||||
/// Deletes a Secret in the store.
|
||||
/// - Parameters:
|
||||
|
@ -23,6 +23,10 @@ extension SecureEnclave {
|
||||
self.attributes = attributes
|
||||
}
|
||||
|
||||
public static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -23,7 +23,11 @@ extension SecureEnclave {
|
||||
@MainActor public init() {
|
||||
loadSecrets()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -66,17 +70,17 @@ extension SecureEnclave {
|
||||
}
|
||||
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
||||
|
||||
switch (attributes.keyType.algorithm, attributes.keyType.size) {
|
||||
case (.ecdsa, 256):
|
||||
switch attributes.keyType {
|
||||
case .ecdsa256:
|
||||
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context)
|
||||
return try key.signature(for: data).rawRepresentation
|
||||
case (.mldsa, 65):
|
||||
case .mldsa65:
|
||||
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)
|
||||
case (.mldsa, 87):
|
||||
case .mldsa87:
|
||||
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)
|
||||
default:
|
||||
throw UnsupportedAlgorithmError()
|
||||
@ -93,20 +97,26 @@ extension SecureEnclave {
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
public func create(name: String, attributes: Attributes) async throws {
|
||||
public func create(name: String, attributes: Attributes) async throws -> Secret {
|
||||
var accessError: SecurityError?
|
||||
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
|
||||
case .notRequired:
|
||||
[]
|
||||
case .presenceRequired:
|
||||
.userPresence
|
||||
[.userPresence, .privateKeyUsage]
|
||||
case .biometryCurrent:
|
||||
.biometryCurrentSet
|
||||
[.biometryCurrentSet, .privateKeyUsage]
|
||||
case .unknown:
|
||||
fatalError()
|
||||
}
|
||||
@ -119,23 +129,28 @@ extension SecureEnclave {
|
||||
throw error.takeRetainedValue() as Error
|
||||
}
|
||||
let dataRep: Data
|
||||
switch (attributes.keyType.algorithm, attributes.keyType.size) {
|
||||
case (.ecdsa, 256):
|
||||
let publicKey: Data
|
||||
switch attributes.keyType {
|
||||
case .ecdsa256:
|
||||
let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!)
|
||||
dataRep = created.dataRepresentation
|
||||
case (.mldsa, 65):
|
||||
publicKey = created.publicKey.x963Representation
|
||||
case .mldsa65:
|
||||
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
|
||||
let created = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(accessControl: access!)
|
||||
dataRep = created.dataRepresentation
|
||||
case (.mldsa, 87):
|
||||
publicKey = created.publicKey.rawRepresentation
|
||||
case .mldsa87:
|
||||
guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() }
|
||||
let created = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(accessControl: access!)
|
||||
dataRep = created.dataRepresentation
|
||||
publicKey = created.publicKey.rawRepresentation
|
||||
default:
|
||||
throw Attributes.UnsupportedOptionError()
|
||||
}
|
||||
try saveKey(dataRep, name: name, attributes: attributes)
|
||||
let id = try saveKey(dataRep, name: name, attributes: attributes)
|
||||
await reloadSecrets()
|
||||
return Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
|
||||
}
|
||||
|
||||
public func delete(secret: Secret) async throws {
|
||||
@ -172,31 +187,22 @@ extension SecureEnclave {
|
||||
}
|
||||
|
||||
public var supportedKeyTypes: [KeyType] {
|
||||
[
|
||||
.init(algorithm: .ecdsa, size: 256),
|
||||
.init(algorithm: .mldsa, size: 65),
|
||||
.init(algorithm: .mldsa, size: 87),
|
||||
]
|
||||
if #available(macOS 26, *) {
|
||||
[
|
||||
.ecdsa256,
|
||||
.mldsa65,
|
||||
.mldsa87,
|
||||
]
|
||||
} else {
|
||||
[.ecdsa256]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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.
|
||||
@MainActor private func loadSecrets() {
|
||||
let queryAttributes = KeychainDictionary([
|
||||
@ -220,15 +226,15 @@ extension SecureEnclave.Store {
|
||||
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
||||
let keyData = $0[kSecValueData] as! Data
|
||||
let publicKey: Data
|
||||
switch (attributes.keyType.algorithm, attributes.keyType.size) {
|
||||
case (.ecdsa, 256):
|
||||
switch attributes.keyType {
|
||||
case .ecdsa256:
|
||||
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
|
||||
publicKey = key.publicKey.x963Representation
|
||||
case (.mldsa, 65):
|
||||
case .mldsa65:
|
||||
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||
let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData)
|
||||
publicKey = key.publicKey.rawRepresentation
|
||||
case (.mldsa, 87):
|
||||
case .mldsa87:
|
||||
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||
let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData)
|
||||
publicKey = key.publicKey.rawRepresentation
|
||||
@ -249,14 +255,16 @@ extension SecureEnclave.Store {
|
||||
/// - name: A user-facing name for 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.
|
||||
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 id = UUID().uuidString
|
||||
let keychainAttributes = KeychainDictionary([
|
||||
kSecClass: Constants.keyClass,
|
||||
kSecAttrService: Constants.keyTag,
|
||||
kSecUseDataProtectionKeychain: true,
|
||||
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
kSecAttrAccount: UUID().uuidString,
|
||||
kSecAttrAccount: id,
|
||||
kSecValueData: key,
|
||||
kSecAttrLabel: name,
|
||||
kSecAttrGeneric: attributes
|
||||
@ -265,6 +273,7 @@ extension SecureEnclave.Store {
|
||||
if status != errSecSuccess {
|
||||
throw KeychainError(statusCode: status)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
}
|
||||
@ -274,6 +283,7 @@ extension SecureEnclave.Store {
|
||||
enum Constants {
|
||||
static let keyClass = kSecClassGenericPassword as String
|
||||
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
|
||||
static let notificationToken = UUID().uuidString
|
||||
}
|
||||
|
||||
struct UnsupportedAlgorithmError: Error {}
|
||||
|
@ -6,81 +6,77 @@ import CryptoKit
|
||||
|
||||
@Suite struct AgentTests {
|
||||
|
||||
let stubWriter = StubFileHandleWriter()
|
||||
|
||||
// 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())
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesEmpty)
|
||||
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
|
||||
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
||||
}
|
||||
|
||||
@Test func identitiesList() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
||||
@Test func identitiesList() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
|
||||
let response = try await agent.handle(data: Constants.Requests.requestIdentities, provenance: .test)
|
||||
#expect(response == Constants.Responses.requestIdentitiesMultiple)
|
||||
}
|
||||
|
||||
// MARK: Signatures
|
||||
|
||||
@Test func noMatchingIdentities() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
|
||||
@Test func noMatchingIdentities() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
||||
let response = try await agent.handle(data: Constants.Requests.requestSignatureWithNoneMatching, provenance: .test)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
}
|
||||
|
||||
@Test func ecdsaSignature() async throws {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
||||
_ = requestReader.readNextChunk()
|
||||
let dataToSign = requestReader.readNextChunk()
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
let outer = OpenSSHReader(data: stubWriter.data[5...])
|
||||
let payload = outer.readNextChunk()
|
||||
let inner = OpenSSHReader(data: payload)
|
||||
_ = inner.readNextChunk()
|
||||
let signedData = inner.readNextChunk()
|
||||
let rsData = OpenSSHReader(data: signedData)
|
||||
var r = rsData.readNextChunk()
|
||||
var s = rsData.readNextChunk()
|
||||
// This is fine IRL, but it freaks out CryptoKit
|
||||
if r[0] == 0 {
|
||||
r.removeFirst()
|
||||
}
|
||||
if s[0] == 0 {
|
||||
s.removeFirst()
|
||||
}
|
||||
var rs = r
|
||||
rs.append(s)
|
||||
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||
// Correct signature
|
||||
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
||||
.isValidSignature(signature, for: dataToSign))
|
||||
}
|
||||
// @Test func ecdsaSignature() async throws {
|
||||
// let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
// let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
||||
// _ = requestReader.readNextChunk()
|
||||
// let dataToSign = requestReader.readNextChunk()
|
||||
// let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
// let agent = Agent(storeList: list)
|
||||
// await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
// let outer = OpenSSHReader(data: stubWriter.data[5...])
|
||||
// let payload = outer.readNextChunk()
|
||||
// let inner = OpenSSHReader(data: payload)
|
||||
// _ = inner.readNextChunk()
|
||||
// let signedData = inner.readNextChunk()
|
||||
// let rsData = OpenSSHReader(data: signedData)
|
||||
// var r = rsData.readNextChunk()
|
||||
// var s = rsData.readNextChunk()
|
||||
// // This is fine IRL, but it freaks out CryptoKit
|
||||
// if r[0] == 0 {
|
||||
// r.removeFirst()
|
||||
// }
|
||||
// if s[0] == 0 {
|
||||
// s.removeFirst()
|
||||
// }
|
||||
// var rs = r
|
||||
// rs.append(s)
|
||||
// let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||
// // Correct signature
|
||||
// #expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
||||
// .isValidSignature(signature, for: dataToSign))
|
||||
// }
|
||||
|
||||
// MARK: Witness protocol
|
||||
|
||||
@Test func witnessObjectionStopsRequest() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
@Test func witnessObjectionStopsRequest() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
let witness = StubWitness(speakNow: { _,_ in
|
||||
return true
|
||||
}, witness: { _, _ in })
|
||||
let agent = Agent(storeList: list, witness: witness)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
||||
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
}
|
||||
|
||||
@Test func witnessSignature() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
@Test func witnessSignature() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
nonisolated(unsafe) var witnessed = false
|
||||
let witness = StubWitness(speakNow: { _, trace in
|
||||
@ -89,12 +85,11 @@ import CryptoKit
|
||||
witnessed = true
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
@Test func requestTracing() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
@Test func requestTracing() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
|
||||
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
|
||||
@ -105,36 +100,38 @@ import CryptoKit
|
||||
witnessTrace = trace
|
||||
})
|
||||
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?.origin.displayName == "Finder")
|
||||
#expect(witnessTrace?.origin.validSignature == true)
|
||||
#expect(witnessTrace?.origin.parentPID == 1)
|
||||
#expect(witnessTrace == .test)
|
||||
}
|
||||
|
||||
// MARK: Exception Handling
|
||||
|
||||
@Test func signatureException() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
@Test func signatureException() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let store = await list.stores.first?.base as! Stub.Store
|
||||
store.shouldThrow = true
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
||||
let response = try await agent.handle(data: Constants.Requests.requestSignature, provenance: .test)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
}
|
||||
|
||||
// MARK: Unsupported
|
||||
|
||||
@Test func unhandledAdd() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.addIdentity)
|
||||
@Test func unhandledAdd() async throws {
|
||||
let agent = Agent(storeList: SecretStoreList())
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
||||
let response = try await agent.handle(data: Constants.Requests.addIdentity, provenance: .test)
|
||||
#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 {
|
||||
|
||||
@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) {
|
||||
logger.debug("SecretAgent finished launching")
|
||||
Task { @MainActor in
|
||||
socketController.handler = { [agent] reader, writer in
|
||||
await agent.handle(reader: reader, writer: writer)
|
||||
Task {
|
||||
for await session in socketController.sessions {
|
||||
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 {
|
||||
|
@ -53,6 +53,7 @@
|
||||
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
|
||||
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -142,6 +143,7 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -243,6 +245,7 @@
|
||||
children = (
|
||||
50617D8423FCE48E0099B055 /* ContentView.swift */,
|
||||
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
|
||||
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */,
|
||||
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
|
||||
50153E21250DECA300525160 /* SecretListItemView.swift */,
|
||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
||||
@ -438,6 +441,7 @@
|
||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
||||
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
|
||||
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
|
||||
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */,
|
||||
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
|
||||
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
|
||||
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */,
|
||||
|
@ -61,13 +61,17 @@ extension Preview {
|
||||
var name: String { "Modifiable Preview Store" }
|
||||
let secrets: [Secret]
|
||||
var supportedKeyTypes: [KeyType] {
|
||||
[
|
||||
.init(algorithm: .ecdsa, size: 256),
|
||||
.init(algorithm: .mldsa, size: 65),
|
||||
.init(algorithm: .mldsa, size: 87),
|
||||
]
|
||||
if #available(macOS 26, *) {
|
||||
[
|
||||
.ecdsa256,
|
||||
.mldsa65,
|
||||
.mldsa87,
|
||||
]
|
||||
} else {
|
||||
[.ecdsa256]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init(secrets: [Secret]) {
|
||||
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 {
|
||||
|
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
|
||||
}
|
||||
Button(.createSecretCreateButton, action: save)
|
||||
.primary()
|
||||
.disabled(name.isEmpty)
|
||||
}
|
||||
.padding()
|
||||
|
@ -1,63 +1,56 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
extension View {
|
||||
|
||||
@State var store: StoreType
|
||||
let secret: StoreType.SecretType
|
||||
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)
|
||||
}
|
||||
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ secret: AnySecret, _ store: AnySecretStoreModifiable?, dismissalBlock: @escaping (Bool) -> ()) -> some View {
|
||||
modifier(DeleteSecretConfirmationModifier(isPresented: isPresented, secret: secret, store: store, dismissalBlock: dismissalBlock))
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
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() {
|
||||
Task {
|
||||
do {
|
||||
try await store.delete(secret: secret)
|
||||
try await store!.delete(secret: secret)
|
||||
dismissalBlock(true)
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
|
@ -12,18 +12,6 @@ struct SecretListItemView: View {
|
||||
var deletedSecret: (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 {
|
||||
NavigationLink(value: secret) {
|
||||
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 isDeleting {
|
||||
DeleteSecretView(store: modifiable, secret: secret) { deleted in
|
||||
isDeleting = false
|
||||
if deleted {
|
||||
deletedSecret(secret)
|
||||
}
|
||||
}
|
||||
} else if isRenaming {
|
||||
EditSecretView(store: modifiable, secret: secret) { renamed in
|
||||
isRenaming = false
|
||||
if renamed {
|
||||
renamedSecret(secret)
|
||||
}
|
||||
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) {
|
||||
// Toggle so name updates in list.
|
||||
activeSecret = nil
|
||||
activeSecret = secret
|
||||
}
|
||||
|
||||
@ -56,7 +58,7 @@ struct StoreListView: View {
|
||||
extension StoreListView {
|
||||
|
||||
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