This commit is contained in:
Max Goedjen
2026-09-07 12:49:44 -07:00
39 changed files with 1961 additions and 293 deletions
File diff suppressed because it is too large Load Diff
+8 -10
View File
@@ -34,16 +34,14 @@ import XPCWrappers
) {
self.osVersion = osVersion
self.currentVersion = currentVersion
Task {
do {
if checkOnLaunch {
try await checkForUpdates()
}
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(Int(checkFrequency)))
try await checkForUpdates()
}
} catch {}
_ = Task {
if checkOnLaunch {
try await checkForUpdates()
}
while true {
try await Task.sleep(for: .seconds(Int(checkFrequency)))
try await checkForUpdates()
}
}
}
@@ -22,37 +22,37 @@ public struct MultilineInfoView<TitleView: View, ItemView: View>: View {
self.items = items()
}
public init(title: LocalizedStringResource, subtitle: LocalizedStringResource, image: Image, items: [String]) where TitleView == HStack<TupleView<(Image, Text, Spacer)>> , ItemView == Text {
self.init {
HStack {
image
.renderingMode(.template)
// .imageScale(.large)
.foregroundColor(primaryTextColor)
Text(title)
.font(.headline)
.foregroundColor(primaryTextColor)
Spacer()
}
} items: {
[Text("Hello")]
}
// public init(title: LocalizedStringResource, subtitle: LocalizedStringResource, image: Image, items: [String]) where ItemView == Text {
// self.init {
// } items: {
// ForEach(items) { item in
// return HStack {
// Text(item)
// Spacer()
// // if let (image, _) = $0.1 {
// // image
// // .foregroundStyle(.secondary)
// // }
// }
// HStack {
// image
// .renderingMode(.template)
//// .imageScale(.large)
// .foregroundColor(primaryTextColor)
// Text(title)
// .font(.headline)
// .foregroundColor(primaryTextColor)
// Spacer()
// }
// } items: {
// [Text("Hello")]
// }
}
//
//// self.init {
//// } items: {
//// ForEach(items) { item in
//// return HStack {
//// Text(item)
//// Spacer()
//// // if let (image, _) = $0.1 {
//// // image
//// // .foregroundStyle(.secondary)
//// // }
//// }
//// }
//// }
//
// }
@State private var interactionState: InteractionState = .normal
@State private var interactionStateIndex: Int?
@@ -61,31 +61,31 @@ public struct MultilineInfoView<TitleView: View, ItemView: View>: View {
VStack(alignment: .leading, spacing: 0) {
titleView
.safeAreaPadding(20)
ForEach(Array(items.enumerated()), id: \.offset) { item in
Divider()
.ignoresSafeArea()
.opacity(item.offset == 0 ? 1 : 0.75)
items.element
.safeAreaPadding(20)
.onHover { hovering in
withAnimation {
guard item.element.action != nil else { return }
interactionState = hovering ? .hovering : .normal
interactionStateIndex = item.offset
}
}
.gesture(
TapGesture()
.onEnded {
item.element.action?.1()
withAnimation {
interactionState = .normal
interactionStateIndex = nil
}
}
)
}
// ForEach(Array(items.enumerated()), id: \.offset) { item in
// Divider()
// .ignoresSafeArea()
// .opacity(item.offset == 0 ? 1 : 0.75)
// items.element
// .safeAreaPadding(20)
// .onHover { hovering in
// withAnimation {
// guard item.element.action != nil else { return }
// interactionState = hovering ? .hovering : .normal
// interactionStateIndex = item.offset
// }
// }
// .gesture(
// TapGesture()
// .onEnded {
// item.element.action?.1()
// withAnimation {
// interactionState = .normal
// interactionStateIndex = nil
// }
// }
// )
//
// }
}
._background(interactionState: .normal)
.frame(minWidth: 150, maxWidth: .infinity)
@@ -19,7 +19,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
secret.publicKey.lengthAndData
case .mldsa:
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
openSSHIdentifier(for: secret.keyType).lengthAndData +
secret.publicKey.lengthAndData
case .rsa:
@@ -42,6 +42,18 @@ public final class OpenSSHReader {
return convertEndianness ? T(value.bigEndian) : T(value)
}
public func readNextByteAsBool() throws(OpenSSHReaderError) -> Bool {
let size = MemoryLayout<Bool>.size
guard remaining.count >= size else { throw .beyondBounds }
let lengthRange = 0..<size
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)
if remaining.isEmpty {
done = true
}
return unsafe lengthChunk.bytes.unsafeLoad(as: Bool.self)
}
public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
}
@@ -53,5 +65,6 @@ public final class OpenSSHReader {
}
public enum OpenSSHReaderError: Error, Codable {
case incorrectFormat
case beyondBounds
}
@@ -17,7 +17,7 @@ public struct OpenSSHSignatureWriter: Sendable {
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
ecdsaSignature(signature, keyType: secret.keyType)
case .mldsa:
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-00#name-public-key-algorithms
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
mldsaSignature(signature, keyType: secret.keyType)
case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
@@ -3,6 +3,8 @@ import OSLog
import SecretKit
import CertificateKit
import CryptoKit
public protocol SSHAgentInputParserProtocol {
func parse(data: Data) async throws -> SSHAgent.Request
@@ -53,8 +55,8 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
return .unlock
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
return .addSmartcardKeyConstrained
case SSHAgent.Request.protocolExtension.protocolID:
return .protocolExtension
case SSHAgent.Request.protocolExtension(.empty).protocolID:
return .protocolExtension(try protocolExtension(from: body))
default:
return .unknown(rawRequestInt)
}
@@ -64,12 +66,150 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
extension SSHAgentInputParser {
private enum Constants {
static let userAuthMagic: UInt8 = 50 // SSH2_MSG_USERAUTH_REQUEST
static let sshSigMagic = Data("SSHSIG".utf8)
}
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
let reader = OpenSSHReader(data: data)
let rawKeyBlob = try reader.readNextChunk()
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
let dataToSign = try reader.readNextChunk()
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign)
let rawPayload = try reader.readNextChunk()
let payload: SSHAgent.Request.SignatureRequestContext.SignaturePayload
do {
if rawPayload.count > 6 && rawPayload[0..<6] == Constants.sshSigMagic {
payload = .init(raw: rawPayload, decoded: .sshSig(try sshSigPayload(from: rawPayload[6...])))
} else {
payload = .init(raw: rawPayload, decoded: .sshConnection(try sshConnectionPayload(from: rawPayload)))
}
} catch {
payload = .init(raw: rawPayload, decoded: nil)
}
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: payload)
}
func sshSigPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHSigPayload {
// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L79
let payloadReader = OpenSSHReader(data: data)
let namespace = try payloadReader.readNextChunkAsString()
_ = try payloadReader.readNextChunk() // reserved
let hashAlgorithm = try payloadReader.readNextChunkAsString()
let hash = try payloadReader.readNextChunk()
return .init(
namespace: namespace,
hashAlgorithm: hashAlgorithm,
hash: hash
)
}
func sshConnectionPayload(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext.SignaturePayload.DecodedPayload.SSHConnectionPayload {
let payloadReader = OpenSSHReader(data: data)
_ = try payloadReader.readNextChunk()
let magic = try payloadReader.readNextBytes(as: UInt8.self, convertEndianness: false)
guard magic == Constants.userAuthMagic else { throw .incorrectFormat }
let username = try payloadReader.readNextChunkAsString()
_ = try payloadReader.readNextChunkAsString() // "ssh-connection"
_ = try payloadReader.readNextChunkAsString() // "publickey-hostbound-v00@openssh.com"
let hasSignature = try payloadReader.readNextByteAsBool()
let algorithm = try payloadReader.readNextChunkAsString()
let publicKeyReader = try payloadReader.readNextChunkAsSubReader()
_ = try publicKeyReader.readNextChunk()
_ = try publicKeyReader.readNextChunk()
let publicKey = try publicKeyReader.readNextChunk()
let hostKeyReader = try payloadReader.readNextChunkAsSubReader()
_ = try hostKeyReader.readNextChunk()
let hostKey = try hostKeyReader.readNextChunk()
return .init(
username: username,
hasSignature: hasSignature,
publicKeyAlgorithm: algorithm,
publicKey: publicKey,
hostKey: hostKey,
)
}
func protocolExtension(from data: Data) throws(AgentParsingError) -> SSHAgent.ProtocolExtension {
do {
let reader = OpenSSHReader(data: data)
let nameRaw = try reader.readNextChunkAsString()
let nameSplit = nameRaw.split(separator: "@")
guard nameSplit.count == 2 else {
throw AgentParsingError.invalidData
}
let (name, domain) = (nameSplit[0], nameSplit[1])
switch domain {
case SSHAgent.ProtocolExtension.OpenSSHExtension.domain:
switch name {
case SSHAgent.ProtocolExtension.OpenSSHExtension.sessionBind(.empty).name:
let hostkeyBlob = try reader.readNextChunkAsSubReader()
let hostKeyType = try hostkeyBlob.readNextChunkAsString()
let hostKeyData = try hostkeyBlob.readNextChunk()
let sessionID = try reader.readNextChunk()
let signatureBlob = try reader.readNextChunkAsSubReader()
_ = try signatureBlob.readNextChunk() // key type again
let signature = try signatureBlob.readNextChunk()
let forwarding = try reader.readNextByteAsBool()
switch hostKeyType {
case "ssh-ed25519":
let hostKey = try CryptoKit.Curve25519.Signing.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(signature, for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
case "ecdsa-sha2-nistp256":
let hostKey = try CryptoKit.P256.Signing.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
case "ecdsa-sha2-nistp384":
let hostKey = try CryptoKit.P384.Signing.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(try .init(rawRepresentation: signature), for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
case "ssh-mldsa-65":
if #available(macOS 26.0, *) {
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(signature, for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
} else {
throw AgentParsingError.unhandledRequest
}
case "ssh-mldsa-87":
if #available(macOS 26.0, *) {
let hostKey = try CryptoKit.MLDSA65.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(signature, for: sessionID) else {
throw AgentParsingError.incorrectSignature
}
} else {
throw AgentParsingError.unhandledRequest
}
case "ssh-rsa":
throw AgentParsingError.unhandledRequest
default:
throw AgentParsingError.unhandledRequest
}
let context = SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext(
hostKey: hostKeyData,
sessionID: sessionID,
signature: signature,
forwarding: forwarding
)
return .openSSH(.sessionBind(context))
default:
return .openSSH(.unknown(String(name)))
}
default:
return .unknown(nameRaw)
}
} catch let error as OpenSSHReaderError {
throw .openSSHReader(error)
} catch let error as AgentParsingError {
throw error
} catch {
throw .unknownRequest
}
}
func certificatePublicKeyBlob(from hash: Data) -> Data? {
@@ -99,6 +239,7 @@ extension SSHAgentInputParser {
case unknownRequest
case unhandledRequest
case invalidData
case incorrectSignature
case openSSHReader(OpenSSHReaderError)
}
@@ -19,7 +19,7 @@ extension SSHAgent {
case lock
case unlock
case addSmartcardKeyConstrained
case protocolExtension
case protocolExtension(ProtocolExtension)
case unknown(UInt8)
public var protocolID: UInt8 {
@@ -60,18 +60,82 @@ extension SSHAgent {
public struct SignatureRequestContext: Sendable, Codable {
public let keyBlob: Data
public let dataToSign: Data
public let dataToSign: SignaturePayload
public init(keyBlob: Data, dataToSign: Data) {
public init(keyBlob: Data, dataToSign: SignaturePayload) {
self.keyBlob = keyBlob
self.dataToSign = dataToSign
}
public static var empty: SignatureRequestContext {
SignatureRequestContext(keyBlob: Data(), dataToSign: Data())
SignatureRequestContext(keyBlob: Data(), dataToSign: SignaturePayload(raw: Data(), decoded: nil))
}
public struct SignaturePayload: Sendable, Codable {
public let raw: Data
public let decoded: DecodedPayload?
public init(
raw: Data,
decoded: DecodedPayload?
) {
self.raw = raw
self.decoded = decoded
}
public enum DecodedPayload: Sendable, Codable {
case sshConnection(SSHConnectionPayload)
case sshSig(SSHSigPayload)
public struct SSHConnectionPayload: Sendable, Codable {
public let username: String
public let hasSignature: Bool
public let publicKeyAlgorithm: String
public let publicKey: Data
public let hostKey: Data
public init(
username: String,
hasSignature: Bool,
publicKeyAlgorithm: String,
publicKey: Data,
hostKey: Data
) {
self.username = username
self.hasSignature = hasSignature
self.publicKeyAlgorithm = publicKeyAlgorithm
self.publicKey = publicKey
self.hostKey = hostKey
}
}
public struct SSHSigPayload: Sendable, Codable {
public let namespace: String
public let hashAlgorithm: String
public let hash: Data
public init(
namespace: String,
hashAlgorithm: String,
hash: Data,
) {
self.namespace = namespace
self.hashAlgorithm = hashAlgorithm
self.hash = hash
}
}
}
}
}
}
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
@@ -88,8 +152,8 @@ extension SSHAgent {
switch self {
case .agentFailure: "SSH_AGENT_FAILURE"
case .agentSuccess: "SSH_AGENT_SUCCESS"
case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER"
case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE"
case .agentIdentitiesAnswer: "SSH2_AGENT_IDENTITIES_ANSWER"
case .agentSignResponse: "SSH2_AGENT_SIGN_RESPONSE"
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
}
@@ -0,0 +1,76 @@
import Foundation
// Extensions, as defined in https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.agent
extension SSHAgent {
public enum ProtocolExtension: CustomDebugStringConvertible, Codable, Sendable {
case openSSH(OpenSSHExtension)
case unknown(String)
public var debugDescription: String {
switch self {
case let .openSSH(protocolExtension):
protocolExtension.debugDescription
case .unknown(let string):
"Unknown (\(string))"
}
}
public static var empty: ProtocolExtension {
.unknown("empty")
}
private struct ProtocolExtensionParsingError: Error {}
}
}
extension SSHAgent.ProtocolExtension {
public enum OpenSSHExtension: CustomDebugStringConvertible, Codable, Sendable {
case sessionBind(SessionBindContext)
case unknown(String)
public static var domain: String {
"openssh.com"
}
public var name: String {
switch self {
case .sessionBind:
"session-bind"
case .unknown(let name):
name
}
}
public var debugDescription: String {
"\(name)@\(OpenSSHExtension.domain)"
}
}
}
extension SSHAgent.ProtocolExtension.OpenSSHExtension {
public struct SessionBindContext: Codable, Sendable {
public let hostKey: Data
public let sessionID: Data
public let signature: Data
public let forwarding: Bool
public init(hostKey: Data, sessionID: Data, signature: Data, forwarding: Bool) {
self.hostKey = hostKey
self.sessionID = sessionID
self.signature = signature
self.forwarding = forwarding
}
public static let empty = SessionBindContext(hostKey: Data(), sessionID: Data(), signature: Data(), forwarding: false)
}
}
@@ -17,6 +17,8 @@ public final class Agent: Sendable {
private let signatureWriter = OpenSSHSignatureWriter()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
@MainActor private var sessionID: SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext?
/// Initializes an agent with a store list and a witness.
/// - Parameters:
/// - storeList: The `SecretStoreList` to make available.
@@ -38,7 +40,12 @@ public final class Agent: Sendable {
extension Agent {
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data {
public func handle(
request: SSHAgent.Request,
provenance: SigningRequestProvenance,
hosts: [Data: String]?
) async -> Data {
logger.debug("Agent received request of type \(request.debugDescription)")
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
await reloadSecretsIfNeccessary()
var response = Data()
@@ -49,9 +56,55 @@ extension Agent {
response.append(await identities())
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
case .signRequest(let context):
let target: SigningRequestTarget?
switch context.dataToSign.decoded {
case .sshConnection(let payload):
target = .connection(
.init(
username: payload.username,
hasSignature: payload.hasSignature,
publicKeyAlgorithm: payload.publicKeyAlgorithm,
publicKey: payload.publicKey,
hostKey: payload.hostKey,
host: hosts?[payload.hostKey]
)
)
if let boundSession = await sessionID {
guard payload.hostKey == boundSession.hostKey else {
logger.error("Agent received bind request, but host key does not match signature request host key.")
throw BindingFailure()
}
}
case .sshSig(let payload):
target = .signature(
.init(
namespace: payload.namespace,
hashAlgorithm: payload.hashAlgorithm,
hash: payload.hash
)
)
default:
target = nil
}
_ = target
response.append(SSHAgent.Response.agentSignResponse.data)
response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance))
response.append(try await sign(data: context.dataToSign.raw, keyBlob: context.keyBlob, provenance: provenance, target: target))
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
case .protocolExtension(.openSSH(.sessionBind(let bind))):
// This is disabled until forward enforcement is handled.
_ = bind
response = try await MainActor.run {
logger.debug("Agent received bind request but not currently supported.")
throw UnhandledRequestError()
// guard sessionID == nil else {
// logger.error("Agent received bind request, but already bound.")
// throw BindingFailure()
// }
// logger.debug("Agent bound")
// sessionID = bind
// return SSHAgent.Response.agentSuccess.data
}
logger.debug("Agent returned \(SSHAgent.Response.agentSuccess.debugDescription)")
case .unknown(let value):
logger.error("Agent received unknown request of type \(value).")
throw UnhandledRequestError()
@@ -99,16 +152,15 @@ extension Agent {
/// - data: The data to sign.
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data {
func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data {
guard let (secret, store) = await secret(matching: keyBlob) else {
let keyBlobHex = keyBlob.formatted(.hex())
logger.debug("Agent did not have a key matching \(keyBlobHex)")
throw NoMatchingKeyError()
}
logger.debug("Agent offering witness chance to object")
do {
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance, target: target)
} catch {
logger.debug("Witness objected")
throw error
@@ -117,26 +169,26 @@ extension Agent {
if secret.authenticationRequirement.required {
// Slow path, may block or suggest batching.
return try await signWithRequiredAuthentication(data: data, store: store, secret: secret, provenance: provenance)
return try await signWithRequiredAuthentication(data: data, store: store, secret: secret, provenance: provenance, target: target)
} else {
// Fast path, no blocking/enqueing required
return try await signWithoutRequiredAuthentication(data: data, store: store, secret: secret, provenance: provenance)
return try await signWithoutRequiredAuthentication(data: data, store: store, secret: secret, provenance: provenance, target: target)
}
}
func signWithoutRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data {
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance, context: nil)
func signWithoutRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data {
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance, target: target, context: nil)
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: false)
try await witness?.witness(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: false)
logger.debug("Agent signed request")
return signedData
}
func signWithRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data {
func signWithRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data {
let context = try await authenticationHandler.waitForAuthentication(for: SignatureRequest(secret: secret, provenance: provenance))
let result = try await store.sign(data: data, with: secret, for: provenance, context: context.laContext)
let result = try await store.sign(data: data, with: secret, for: provenance, target: target, context: context.laContext)
let signedData = signatureWriter.data(secret: secret, signature: result)
try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: false) // FIXME: THIS
try await witness?.witness(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: false) // FIXME: THIS
logger.debug("Agent signed request")
return signedData
}
@@ -172,6 +224,7 @@ extension Agent {
struct NoMatchingKeyError: Error {}
struct UnhandledRequestError: Error {}
struct BindingFailure: Error {}
}
@@ -1,5 +1,6 @@
import Foundation
import SecretKit
import SSHProtocolKit
/// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access.
public protocol SigningWitness: Sendable {
@@ -10,13 +11,13 @@ public protocol SigningWitness: Sendable {
/// - store: The `Store` being asked to sign the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
/// - Note: This method being called does not imply that the requst has been authorized. If a secret requires authentication, authentication will still need to be performed by the user before the request will be performed. If the user declines or fails to authenticate, the request will fail.
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws
/// Notifies the callee that a signing operation has been performed for a given secret.
/// - Parameters:
/// - secret: The `Secret` that will was used to sign the request.
/// - store: The `Store` that signed the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, offerPersistence: Bool) async throws
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws
}
@@ -9,7 +9,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
private let _id: @Sendable () -> UUID
private let _name: @MainActor @Sendable () -> String
private let _secrets: @MainActor @Sendable () -> [AnySecret]
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, LAContext?) async throws -> Data
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, SigningRequestTarget?, LAContext?) async throws -> Data
private let _reloadSecrets: @Sendable () async -> Void
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
@@ -18,7 +18,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
_name = { secretStore.name }
_id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2, context: $3) }
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2, target: $3, context: $4) }
_reloadSecrets = { await secretStore.reloadSecrets() }
}
@@ -38,8 +38,8 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
return _secrets()
}
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, context: LAContext?) async throws -> Data {
try await _sign(data, secret, provenance, context)
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
try await _sign(data, secret, provenance, target, context)
}
public func reloadSecrets() async {
@@ -21,7 +21,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
/// - secret: The ``Secret`` to sign with.
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
/// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, context: LAContext?) async throws -> Data
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data
/// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets() async
@@ -0,0 +1,55 @@
import Foundation
import AppKit
/// Describes the target of the signature operation.
public enum SigningRequestTarget: Sendable {
case connection(ConnectionPayload)
case signature(SignaturePayload)
public struct ConnectionPayload: Sendable, Codable{
public let username: String
public let hasSignature: Bool
public let publicKeyAlgorithm: String
public let publicKey: Data
public let hostKey: Data
public let host: String?
public init(
username: String,
hasSignature: Bool,
publicKeyAlgorithm: String,
publicKey: Data,
hostKey: Data,
host: String?
) {
self.username = username
self.hasSignature = hasSignature
self.publicKeyAlgorithm = publicKeyAlgorithm
self.publicKey = publicKey
self.hostKey = hostKey
self.host = host
}
}
public struct SignaturePayload: Sendable, Codable {
public let namespace: String
public let hashAlgorithm: String
public let hash: Data
public init(
namespace: String,
hashAlgorithm: String,
hash: Data,
) {
self.namespace = namespace
self.hashAlgorithm = hashAlgorithm
self.hash = hash
}
}
}
@@ -36,8 +36,7 @@ extension SecureEnclave {
// MARK: SecretStore
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: LAContext?) async throws -> Data {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
@@ -56,8 +56,7 @@ extension SmartCard {
// MARK: Public API
public func sign(data: Data, with secret: SmartCard.Secret, for provenance: SigningRequestProvenance, context: LAContext?) async throws -> Data {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() }
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
@@ -12,7 +12,9 @@ public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
let exportedObject = exportedObject
newConnection.exportedObject = exportedObject
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
#if !DEBUG
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"Z72PRUAWF6\"")
#endif
newConnection.resume()
return true
}
@@ -8,7 +8,9 @@ public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error
public init(serviceName: String, warmup: Bool = false) async throws {
let connection = NSXPCConnection(serviceName: serviceName)
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
#if !DEBUG
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"Z72PRUAWF6\"")
#endif
connection.resume()
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
self.connection = connection
@@ -13,7 +13,7 @@ import CertificateKit
@Test func emptyStores() async throws {
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test)
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestIdentitiesEmpty)
}
@@ -21,7 +21,7 @@ import CertificateKit
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list, certificateStore: CertificateStore())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
let response = await agent.handle(request: request, provenance: .test)
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
let actual = OpenSSHReader(data: response)
let expected = OpenSSHReader(data: Constants.Responses.requestIdentitiesMultiple)
@@ -35,7 +35,7 @@ import CertificateKit
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list, certificateStore: CertificateStore())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
let response = await agent.handle(request: request, provenance: .test)
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestFailure)
}
@@ -44,7 +44,7 @@ import CertificateKit
guard case SSHAgent.Request.signRequest(let context) = request else { return }
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list, certificateStore: CertificateStore())
let response = await agent.handle(request: request, provenance: .test)
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
let responseReader = OpenSSHReader(data: response)
let length = try responseReader.readNextBytes(as: UInt32.self)
let type = try responseReader.readNextBytes(as: UInt8.self)
@@ -68,7 +68,7 @@ import CertificateKit
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
// Correct signature
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
.isValidSignature(signature, for: context.dataToSign))
.isValidSignature(signature, for: context.dataToSign.raw))
}
// MARK: Witness protocol
@@ -79,7 +79,7 @@ import CertificateKit
return true
}, witness: { _, _ in })
let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
let response = await agent.handle(request: .signRequest(.empty), provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestFailure)
}
@@ -93,7 +93,7 @@ import CertificateKit
})
let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test)
_ = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(witnessed)
}
@@ -109,7 +109,7 @@ import CertificateKit
})
let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
_ = await agent.handle(request: request, provenance: .test)
_ = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(witnessTrace == speakNowTrace)
#expect(witnessTrace == .test)
}
@@ -122,7 +122,7 @@ import CertificateKit
store.shouldThrow = true
let agent = Agent(storeList: list, certificateStore: CertificateStore())
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
let response = await agent.handle(request: request, provenance: .test)
let response = await agent.handle(request: request, provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestFailure)
}
@@ -130,7 +130,7 @@ import CertificateKit
@Test func unhandledAdd() async throws {
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore())
let response = await agent.handle(request: .addIdentity, provenance: .test)
let response = await agent.handle(request: .addIdentity, provenance: .test, hosts: nil)
#expect(response == Constants.Responses.requestFailure)
}
@@ -2,6 +2,7 @@ import Foundation
import SecretKit
import CryptoKit
import SSHProtocolKit
import LocalAuthentication
struct Stub {}
@@ -49,7 +50,7 @@ extension Stub {
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol?) throws -> Data {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, target: SigningRequestTarget?, context: LAContext?) throws -> Data {
guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
@@ -57,13 +58,6 @@ extension Stub {
return try privateKey.signature(for: data).rawRepresentation
}
public func existingAuthenticationContextProtocol(secret: Stub.Secret) -> AuthenticationContextProtocol? {
nil
}
public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
}
public func reloadSecrets() {
}
@@ -10,14 +10,14 @@ struct StubWitness {
extension StubWitness: SigningWitness {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) throws {
let objection = speakNow(secret, provenance)
if objection {
throw TheresMyChance()
}
}
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws {
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) throws {
witness(secret, provenance)
}