Merge branch 'main' into newsetup

This commit is contained in:
Max Goedjen 2025-08-27 23:50:06 -07:00
commit 260e63341d
No known key found for this signature in database
27 changed files with 421 additions and 479 deletions

View File

@ -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
View File

@ -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

View File

@ -1,4 +1,4 @@
# Secretive ![Test](https://github.com/maxgoedjen/secretive/workflows/Test/badge.svg) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg) # Secretive [![Test](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/maxgoedjen/secretive/actions/workflows/test.yml) ![Release](https://github.com/maxgoedjen/secretive/workflows/Release/badge.svg)
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.

View File

@ -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.

View File

@ -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
}
} }

View File

@ -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)
} }
} }

View File

@ -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)
} }
} }

View File

@ -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)
} }

View File

@ -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

View File

@ -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",

View File

@ -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)])

View File

@ -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)
}
}
}
} }

View File

@ -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

View File

@ -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:

View File

@ -23,6 +23,10 @@ extension SecureEnclave {
self.attributes = attributes self.attributes = attributes
} }
public static func ==(lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
} }
} }

View File

@ -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 {}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -1,12 +0,0 @@
import Foundation
import SecretAgentKit
class StubFileHandleWriter: FileHandleWriter, @unchecked Sendable {
var data = Data()
func write(_ data: Data) {
self.data.append(data)
}
}

View File

@ -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 {

View File

@ -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 */,

View File

@ -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 {

View 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())
}
}

View File

@ -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()

View File

@ -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

View File

@ -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)
}
} }
} }
} }

View File

@ -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
} }
} }