Compare commits

..

2 Commits

Author SHA1 Message Date
Max Goedjen
2b12d6df1e Add uncommitted authhandler rename 2026-04-09 20:43:32 -07:00
Max Goedjen
b68c82ae69 Splitting out auth context stuff in preparation for batch 2026-04-09 20:43:18 -07:00
21 changed files with 121 additions and 475 deletions

View File

@@ -19,7 +19,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
("nistp" + String(describing: secret.keyType.size)).lengthAndData + ("nistp" + String(describing: secret.keyType.size)).lengthAndData +
secret.publicKey.lengthAndData secret.publicKey.lengthAndData
case .mldsa: case .mldsa:
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05 // https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
openSSHIdentifier(for: secret.keyType).lengthAndData + openSSHIdentifier(for: secret.keyType).lengthAndData +
secret.publicKey.lengthAndData secret.publicKey.lengthAndData
case .rsa: case .rsa:

View File

@@ -39,18 +39,6 @@ public final class OpenSSHReader {
return convertEndianness ? T(value.bigEndian) : T(value) 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 { public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self) try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
} }
@@ -62,6 +50,5 @@ public final class OpenSSHReader {
} }
public enum OpenSSHReaderError: Error, Codable { public enum OpenSSHReaderError: Error, Codable {
case incorrectFormat
case beyondBounds case beyondBounds
} }

View File

@@ -17,7 +17,7 @@ public struct OpenSSHSignatureWriter: Sendable {
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1 // https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
ecdsaSignature(signature, keyType: secret.keyType) ecdsaSignature(signature, keyType: secret.keyType)
case .mldsa: case .mldsa:
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05 // https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-00#name-public-key-algorithms
mldsaSignature(signature, keyType: secret.keyType) mldsaSignature(signature, keyType: secret.keyType)
case .rsa: case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6 // https://datatracker.ietf.org/doc/html/rfc4253#section-6.6

View File

@@ -19,7 +19,7 @@ extension SSHAgent {
case lock case lock
case unlock case unlock
case addSmartcardKeyConstrained case addSmartcardKeyConstrained
case protocolExtension(ProtocolExtension) case protocolExtension
case unknown(UInt8) case unknown(UInt8)
public var protocolID: UInt8 { public var protocolID: UInt8 {
@@ -60,82 +60,18 @@ extension SSHAgent {
public struct SignatureRequestContext: Sendable, Codable { public struct SignatureRequestContext: Sendable, Codable {
public let keyBlob: Data public let keyBlob: Data
public let dataToSign: SignaturePayload public let dataToSign: Data
public init(keyBlob: Data, dataToSign: SignaturePayload) { public init(keyBlob: Data, dataToSign: Data) {
self.keyBlob = keyBlob self.keyBlob = keyBlob
self.dataToSign = dataToSign self.dataToSign = dataToSign
} }
public static var empty: SignatureRequestContext { public static var empty: SignatureRequestContext {
SignatureRequestContext(keyBlob: Data(), dataToSign: SignaturePayload(raw: Data(), decoded: nil)) SignatureRequestContext(keyBlob: Data(), dataToSign: Data())
}
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 /// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
@@ -152,8 +88,8 @@ extension SSHAgent {
switch self { switch self {
case .agentFailure: "SSH_AGENT_FAILURE" case .agentFailure: "SSH_AGENT_FAILURE"
case .agentSuccess: "SSH_AGENT_SUCCESS" case .agentSuccess: "SSH_AGENT_SUCCESS"
case .agentIdentitiesAnswer: "SSH2_AGENT_IDENTITIES_ANSWER" case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER"
case .agentSignResponse: "SSH2_AGENT_SIGN_RESPONSE" case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE"
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE" case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE" case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
} }

View File

@@ -1,76 +0,0 @@
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)
}
}

View File

@@ -9,21 +9,21 @@ import SSHProtocolKit
public final class Agent: Sendable { public final class Agent: Sendable {
private let storeList: SecretStoreList private let storeList: SecretStoreList
private let authenticationHandler: AuthenticationHandler
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 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")
@MainActor private var sessionID: SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext?
/// Initializes an agent with a store list and a witness. /// Initializes an agent with a store list and a witness.
/// - Parameters: /// - Parameters:
/// - storeList: The `SecretStoreList` to make available. /// - storeList: The `SecretStoreList` to make available.
/// - witness: A witness to notify of requests. /// - witness: A witness to notify of requests.
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) { public init(storeList: SecretStoreList, authenticationHandler: AuthenticationHandler, witness: SigningWitness? = nil) {
logger.debug("Agent is running") logger.debug("Agent is running")
self.storeList = storeList self.storeList = storeList
self.authenticationHandler = authenticationHandler
self.witness = witness self.witness = witness
Task { @MainActor in Task { @MainActor in
await certificateHandler.reloadCertificates(for: storeList.allSecrets) await certificateHandler.reloadCertificates(for: storeList.allSecrets)
@@ -35,7 +35,6 @@ public final class Agent: Sendable {
extension Agent { extension Agent {
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data { public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) 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 // 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()
@@ -46,34 +45,9 @@ extension Agent {
response.append(await identities()) response.append(await identities())
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)") logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
case .signRequest(let context): case .signRequest(let context):
if let boundSession = await sessionID {
switch context.dataToSign.decoded {
case .sshConnection(let payload):
guard payload.hostKey == boundSession.hostKey else {
logger.error("Agent received bind request, but host key does not match signature reqeust host key.")
throw BindingFailure()
}
case .sshSig:
// SSHSIG does not have a host binding payload.
break
default:
break
}
}
response.append(SSHAgent.Response.agentSignResponse.data) response.append(SSHAgent.Response.agentSignResponse.data)
response.append(try await sign(data: context.dataToSign.raw, keyBlob: context.keyBlob, provenance: provenance)) response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance))
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)") logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
case .protocolExtension(.openSSH(.sessionBind(let bind))):
response = try await MainActor.run {
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): case .unknown(let value):
logger.error("Agent received unknown request of type \(value).") logger.error("Agent received unknown request of type \(value).")
throw UnhandledRequestError() throw UnhandledRequestError()
@@ -132,10 +106,19 @@ extension Agent {
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance) try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance) let context: any AuthenticationContextProtocol
let offerPersistence: Bool
if let existing = await authenticationHandler.existingAuthenticationContextProtocol(secret: secret), existing.valid {
context = existing
offerPersistence = false
} else {
context = authenticationHandler.createAuthenticationContext(secret: secret, provenance: provenance, preauthorize: false)
offerPersistence = secret.authenticationRequirement.required
}
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance, context: context)
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
try await witness?.witness(accessTo: secret, from: store, by: provenance) try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: offerPersistence)
logger.debug("Agent signed request") logger.debug("Agent signed request")
@@ -173,7 +156,6 @@ extension Agent {
struct NoMatchingKeyError: Error {} struct NoMatchingKeyError: Error {}
struct UnhandledRequestError: Error {} struct UnhandledRequestError: Error {}
struct BindingFailure: Error {}
} }

View File

@@ -1,12 +1,13 @@
import LocalAuthentication @unsafe @preconcurrency import LocalAuthentication
import SecretKit
/// A context describing a persisted authentication. /// A context describing a persisted authentication.
package final class PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext { public final class AuthenticationContext: AuthenticationContextProtocol {
/// The Secret to persist authentication for. /// The Secret to persist authentication for.
let secret: SecretType public let secret: AnySecret
/// The LAContext used to authorize the persistent context. /// The LAContext used to authorize the persistent context.
package nonisolated(unsafe) let context: LAContext public let laContext: LAContext
/// An expiration date for the context. /// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back. /// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
let monotonicExpiration: UInt64 let monotonicExpiration: UInt64
@@ -16,38 +17,46 @@ package final class PersistentAuthenticationContext<SecretType: Secret>: Persist
/// - secret: The Secret to persist authentication for. /// - secret: The Secret to persist authentication for.
/// - context: The LAContext used to authorize the persistent context. /// - context: The LAContext used to authorize the persistent context.
/// - duration: The duration of the authorization context, in seconds. /// - duration: The duration of the authorization context, in seconds.
init(secret: SecretType, context: LAContext, duration: TimeInterval) { init<SecretType: Secret>(secret: SecretType, context: LAContext, duration: TimeInterval) {
self.secret = secret self.secret = AnySecret(secret)
unsafe self.context = context self.laContext = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds) self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
} }
/// A boolean describing whether or not the context is still valid. /// A boolean describing whether or not the context is still valid.
package var valid: Bool { public var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
} }
package var expiration: Date { public var expiration: Date {
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC) let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
return Date(timeIntervalSinceNow: remainingInSeconds) return Date(timeIntervalSinceNow: remainingInSeconds)
} }
} }
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable { public actor AuthenticationHandler: Sendable {
private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:] private var persistedContexts: [AnySecret: AuthenticationContext] = [:]
package init() { public init() {
} }
package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext<SecretType>? { public nonisolated func createAuthenticationContext<SecretType: Secret>(secret: SecretType, provenance: SigningRequestProvenance, preauthorize: Bool) -> AuthenticationContextProtocol {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil } let newContext = LAContext()
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
return AuthenticationContext(secret: secret, context: newContext, duration: 0)
}
public func existingAuthenticationContextProtocol<SecretType: Secret>(secret: SecretType) -> AuthenticationContextProtocol? {
guard let persisted = persistedContexts[AnySecret(secret)], persisted.valid else { return nil }
return persisted return persisted
} }
package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws { public func persistAuthentication<SecretType: Secret>(secret: SecretType, forDuration duration: TimeInterval) async throws {
let newContext = LAContext() let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
@@ -61,8 +70,8 @@ package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString)) newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
guard success else { return } guard success else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration) let context = AuthenticationContext(secret: secret, context: newContext, duration: duration)
persistedAuthenticationContexts[secret] = context persistedContexts[AnySecret(secret)] = context
} }
} }

View File

@@ -3,8 +3,6 @@ import OSLog
import SecretKit import SecretKit
import SSHProtocolKit import SSHProtocolKit
import CryptoKit
public protocol SSHAgentInputParserProtocol { public protocol SSHAgentInputParserProtocol {
func parse(data: Data) async throws -> SSHAgent.Request func parse(data: Data) async throws -> SSHAgent.Request
@@ -55,8 +53,8 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
return .unlock return .unlock
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID: case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
return .addSmartcardKeyConstrained return .addSmartcardKeyConstrained
case SSHAgent.Request.protocolExtension(.empty).protocolID: case SSHAgent.Request.protocolExtension.protocolID:
return .protocolExtension(try protocolExtension(from: body)) return .protocolExtension
default: default:
return .unknown(rawRequestInt) return .unknown(rawRequestInt)
} }
@@ -66,152 +64,12 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
extension SSHAgentInputParser { 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 { func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
let reader = OpenSSHReader(data: data) let reader = OpenSSHReader(data: data)
let rawKeyBlob = try reader.readNextChunk() let rawKeyBlob = try reader.readNextChunk()
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
let rawPayload = try reader.readNextChunk() let dataToSign = try reader.readNextChunk()
let payload: SSHAgent.Request.SignatureRequestContext.SignaturePayload return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign)
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 {
// FIXME: FACTOR OUT?
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":
// FIXME: HANDLE
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? { func certificatePublicKeyBlob(from hash: Data) -> Data? {
@@ -246,7 +104,6 @@ extension SSHAgentInputParser {
case unknownRequest case unknownRequest
case unhandledRequest case unhandledRequest
case invalidData case invalidData
case incorrectSignature
case openSSHReader(OpenSSHReaderError) case openSSHReader(OpenSSHReaderError)
} }

View File

@@ -17,6 +17,6 @@ public protocol SigningWitness: Sendable {
/// - secret: The `Secret` that will was used to sign the request. /// - secret: The `Secret` that will was used to sign the request.
/// - store: The `Store` that signed the request.. /// - store: The `Store` that signed the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request. /// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, offerPersistence: Bool) async throws
} }

View File

@@ -31,7 +31,7 @@ SecretKit is a collection of protocols describing secrets and stores.
### Authentication Persistence ### Authentication Persistence
- ``PersistedAuthenticationContext`` - ``AuthenticationContextProtocol``
### Errors ### Errors

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import LocalAuthentication
/// Type eraser for SecretStore. /// Type eraser for SecretStore.
open class AnySecretStore: SecretStore, @unchecked Sendable { open class AnySecretStore: SecretStore, @unchecked Sendable {
@@ -8,9 +9,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
private let _id: @Sendable () -> UUID private let _id: @Sendable () -> UUID
private let _name: @MainActor @Sendable () -> String private let _name: @MainActor @Sendable () -> String
private let _secrets: @MainActor @Sendable () -> [AnySecret] private let _secrets: @MainActor @Sendable () -> [AnySecret]
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, AuthenticationContextProtocol) async throws -> Data
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _reloadSecrets: @Sendable () async -> Void private let _reloadSecrets: @Sendable () async -> Void
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore { public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
@@ -19,9 +18,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
_name = { secretStore.name } _name = { secretStore.name }
_id = { secretStore.id } _id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } } _secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) } _sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2, context: $3) }
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_reloadSecrets = { await secretStore.reloadSecrets() } _reloadSecrets = { await secretStore.reloadSecrets() }
} }
@@ -41,16 +38,8 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
return _secrets() return _secrets()
} }
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) async throws -> Data { public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data {
try await _sign(data, secret, provenance) try await _sign(data, secret, provenance, context)
}
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
await _existingPersistedAuthenticationContext(secret)
}
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws {
try await _persistAuthentication(secret, duration)
} }
public func reloadSecrets() async { public func reloadSecrets() async {

View File

@@ -0,0 +1,14 @@
import Foundation
import LocalAuthentication
/// Protocol describing an authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
public protocol AuthenticationContextProtocol: Sendable {
/// Whether the context remains valid.
var valid: Bool { get }
/// The date at which the authorization expires and the context becomes invalid.
var expiration: Date { get }
var secret: AnySecret { get }
var laContext: LAContext { get }
}

View File

@@ -1,9 +0,0 @@
import Foundation
/// Protocol describing a persisted authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
public protocol PersistedAuthenticationContext: Sendable {
/// Whether the context remains valid.
var valid: Bool { get }
/// The date at which the authorization expires and the context becomes invalid.
var expiration: Date { get }
}

View File

@@ -20,20 +20,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
/// - secret: The ``Secret`` to sign with. /// - secret: The ``Secret`` to sign with.
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from. /// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
/// - Returns: The signed data. /// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data
/// Checks to see if there is currently a valid persisted authentication for a given secret.
/// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
/// - Returns: A persisted authentication context, if a valid one exists.
func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext?
/// Persists user authorization for access to a secret.
/// - Parameters:
/// - secret: The ``Secret`` to persist the authorization for.
/// - duration: The duration that the authorization should persist for.
/// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret.
func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws
/// Requests that the store reload secrets from any backing store, if neccessary. /// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets() async func reloadSecrets() async

View File

@@ -2,13 +2,17 @@ import Foundation
import AppKit import AppKit
/// Describes the chain of applications that requested a signature operation. /// Describes the chain of applications that requested a signature operation.
public struct SigningRequestProvenance: Equatable, Sendable { public struct SigningRequestProvenance: Hashable, Sendable {
/// A list of processes involved in the request. /// A list of processes involved in the request.
/// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app` /// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app`
public var chain: [Process] public var chain: [Process]
public init(root: Process) {
public var date: Date
public init(root: Process, date: Date = .now) {
self.chain = [root] self.chain = [root]
self.date = date
} }
} }
@@ -30,7 +34,7 @@ extension SigningRequestProvenance {
extension SigningRequestProvenance { extension SigningRequestProvenance {
/// Describes a process in a `SigningRequestProvenance` chain. /// Describes a process in a `SigningRequestProvenance` chain.
public struct Process: Equatable, Sendable { public struct Process: Hashable, Sendable {
/// The pid of the process. /// The pid of the process.
public let pid: Int32 public let pid: Int32

View File

@@ -17,7 +17,6 @@ extension SecureEnclave {
} }
public let id = UUID() public let id = UUID()
public let name = String(localized: .secureEnclave) public let name = String(localized: .secureEnclave)
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
/// Initializes a Store. /// Initializes a Store.
@MainActor public init() { @MainActor public init() {
@@ -37,16 +36,7 @@ extension SecureEnclave {
// MARK: SecretStore // MARK: SecretStore
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data {
var context: LAContext
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = unsafe existing.context
} else {
let newContext = LAContext()
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
let queryAttributes = KeychainDictionary([ let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass, kSecClass: Constants.keyClass,
@@ -72,15 +62,15 @@ extension SecureEnclave {
switch attributes.keyType { switch attributes.keyType {
case .ecdsa256: 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.laContext)
return try key.signature(for: data).rawRepresentation return try key.signature(for: data).rawRepresentation
case .mldsa65: 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, authenticationContext: context) let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData, authenticationContext: context.laContext)
return try key.signature(for: data) return try key.signature(for: data)
case .mldsa87: 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, authenticationContext: context) let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData, authenticationContext: context.laContext)
return try key.signature(for: data) return try key.signature(for: data)
default: default:
throw UnsupportedAlgorithmError() throw UnsupportedAlgorithmError()
@@ -88,14 +78,6 @@ extension SecureEnclave {
} }
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
@MainActor public func reloadSecrets() { @MainActor public func reloadSecrets() {
let before = secrets let before = secrets
secrets.removeAll() secrets.removeAll()

View File

@@ -34,7 +34,6 @@ extension SmartCard {
public var secrets: [Secret] { public var secrets: [Secret] {
state.secrets state.secrets
} }
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
@@ -57,17 +56,8 @@ extension SmartCard {
// MARK: Public API // MARK: Public API
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() } guard let tokenID = await state.tokenID else { fatalError() }
var context: LAContext
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = unsafe existing.context
} else {
let newContext = LAContext()
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,
@@ -93,14 +83,6 @@ extension SmartCard {
return signature as Data return signature as Data
} }
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
/// Reloads all secrets from the store. /// Reloads all secrets from the store.
@MainActor public func reloadSecrets() { @MainActor public func reloadSecrets() {
reloadSecretsInternal() reloadSecretsInternal()

View File

@@ -49,7 +49,7 @@ extension Stub {
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))") print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol?) throws -> Data {
guard !shouldThrow else { guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil) throw NSError(domain: "test", code: 0, userInfo: nil)
} }
@@ -57,7 +57,7 @@ extension Stub {
return try privateKey.signature(for: data).rawRepresentation return try privateKey.signature(for: data).rawRepresentation
} }
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? { public func existingAuthenticationContextProtocol(secret: Stub.Secret) -> AuthenticationContextProtocol? {
nil nil
} }

View File

@@ -22,9 +22,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}() }()
private let updater = Updater(checkOnLaunch: true) private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier() private let notifier = Notifier()
private let authenticationHandler = AuthenticationHandler()
private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory) private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory)
private lazy var agent: Agent = { private lazy var agent: Agent = {
Agent(storeList: storeList, witness: notifier) Agent(storeList: storeList, authenticationHandler: authenticationHandler, witness: notifier)
}() }()
private lazy var socketController: SocketController = { private lazy var socketController: SocketController = {
let path = URL.socketPath as String let path = URL.socketPath as String
@@ -50,6 +51,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
} }
Task { [notifier, authenticationHandler] in
await notifier.registerPersistenceHandler {
try await authenticationHandler.persistAuthentication(secret: $0, forDuration: $1)
}
}
Task { Task {
for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) { for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)

View File

@@ -5,6 +5,8 @@ import SecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
typealias PersistAction = (@Sendable (AnySecret, TimeInterval) async throws -> Void)
final class Notifier: Sendable { final class Notifier: Sendable {
private let notificationDelegate = NotificationDelegate() private let notificationDelegate = NotificationDelegate()
@@ -15,6 +17,12 @@ final class Notifier: Sendable {
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: []) let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: []) let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate
}
func registerPersistenceHandler(action: @escaping PersistAction) async {
let rawDurations = [ let rawDurations = [
Measurement(value: 1, unit: UnitDuration.minutes), Measurement(value: 1, unit: UnitDuration.minutes),
Measurement(value: 5, unit: UnitDuration.minutes), Measurement(value: 5, unit: UnitDuration.minutes),
@@ -24,11 +32,9 @@ final class Notifier: Sendable {
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: []) let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
var allPersistenceActions = [doNotPersistAction] var allPersistenceActions = [doNotPersistAction]
let formatter = DateComponentsFormatter() let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day] formatter.allowedUnits = [.hour, .minute, .day]
var identifiers: [String: TimeInterval] = [:] var identifiers: [String: TimeInterval] = [:]
for duration in rawDurations { for duration in rawDurations {
let seconds = duration.converted(to: .seconds).value let seconds = duration.converted(to: .seconds).value
@@ -43,16 +49,11 @@ final class Notifier: Sendable {
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) { if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle") persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
} }
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory]) var categories = await UNUserNotificationCenter.current().notificationCategories()
UNUserNotificationCenter.current().delegate = notificationDelegate categories.insert(persistAuthenticationCategory)
UNUserNotificationCenter.current().setNotificationCategories(categories)
Task {
await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
guard let duration = duration else { return }
try? await store.persistAuthentication(secret: secret, forDuration: duration)
}
}
await notificationDelegate.state.setPersistenceState(options: identifiers, action: action)
} }
func prompt() { func prompt() {
@@ -60,7 +61,7 @@ final class Notifier: Sendable {
notificationCenter.requestAuthorization(options: .alert) { _, _ in } notificationCenter.requestAuthorization(options: .alert) { _, _ in }
} }
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async { func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, offerPersistence: Bool) async {
await notificationDelegate.state.setPending(secret: secret, store: store) await notificationDelegate.state.setPending(secret: secret, store: store)
let notificationCenter = UNUserNotificationCenter.current() let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent() let notificationContent = UNMutableNotificationContent()
@@ -69,7 +70,7 @@ final class Notifier: Sendable {
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive notificationContent.interruptionLevel = .timeSensitive
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required { if offerPersistence {
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
} }
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
@@ -103,8 +104,8 @@ extension Notifier: SigningWitness {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws { func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
} }
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws { func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, offerPersistence: Bool) async throws {
await notify(accessTo: secret, from: store, by: provenance) await notify(accessTo: secret, from: store, by: provenance, offerPersistence: offerPersistence)
} }
} }
@@ -133,28 +134,24 @@ extension Notifier {
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
fileprivate actor State { fileprivate actor State {
typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
typealias IgnoreAction = (@Sendable (Release) async -> Void) typealias IgnoreAction = (@Sendable (Release) async -> Void)
fileprivate var release: Release? fileprivate var release: Release?
fileprivate var ignoreAction: IgnoreAction? fileprivate var ignoreAction: IgnoreAction?
fileprivate var persistAction: PersistAction? fileprivate var persistAction: PersistAction?
fileprivate var persistOptions: [String: TimeInterval] = [:] fileprivate var persistOptions: [String: TimeInterval] = [:]
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:] fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
func setPending(secret: AnySecret, store: AnySecretStore) { func setPending(secret: AnySecret, store: AnySecretStore) {
pendingPersistableSecrets[secret.id.description] = secret pendingPersistableSecrets[secret.id.description] = secret
pendingPersistableStores[store.id.description] = store
} }
func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? { func retrievePending(secretID: String, optionID: String) -> (AnySecret, TimeInterval)? {
guard let secret = pendingPersistableSecrets[secretID], guard let secret = pendingPersistableSecrets[secretID],
let store = pendingPersistableStores[storeID],
let options = persistOptions[optionID] else { let options = persistOptions[optionID] else {
return nil return nil
} }
pendingPersistableSecrets.removeValue(forKey: secretID) pendingPersistableSecrets.removeValue(forKey: secretID)
return (secret, store, options) return (secret, options)
} }
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) { func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
@@ -202,13 +199,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
} }
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async { func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String, guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String else {
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
return return
} }
let optionID = response.actionIdentifier let optionID = response.actionIdentifier
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return } guard let (secret, persistOptions) = await state.retrievePending(secretID: secretID, optionID: optionID) else { return }
await state.persistAction?(secret, store, persistOptions) try? await state.persistAction?(secret, persistOptions)
} }

View File

@@ -38,11 +38,11 @@ extension Preview {
self.init(secrets: new) self.init(secrets: new)
} }
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data { func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol?) throws -> Data {
return data return data
} }
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? { func existingAuthenticationContextProtocol(secret: Preview.Secret) -> AuthenticationContextProtocol? {
nil nil
} }
@@ -82,11 +82,11 @@ extension Preview {
self.init(secrets: new) self.init(secrets: new)
} }
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data { func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol?) throws -> Data {
return data return data
} }
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? { func existingAuthenticationContextProtocol(secret: Preview.Secret) -> AuthenticationContextProtocol? {
nil nil
} }