mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-04-09 18:57:22 +02:00
Compare commits
3 Commits
multipleau
...
sshextensi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece3865d9a | ||
|
|
6b1f5bbb7c | ||
|
|
f848eb659e |
@@ -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:
|
||||
|
||||
@@ -39,6 +39,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)
|
||||
}
|
||||
@@ -50,5 +62,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,7 +14,8 @@ public final class Agent: Sendable {
|
||||
private let signatureWriter = OpenSSHSignatureWriter()
|
||||
private let certificateHandler = OpenSSHCertificateHandler()
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
||||
private let authorizationCoordinator = AuthorizationCoordinator()
|
||||
|
||||
@MainActor private var sessionID: SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext?
|
||||
|
||||
/// Initializes an agent with a store list and a witness.
|
||||
/// - Parameters:
|
||||
@@ -34,6 +35,7 @@ public final class Agent: Sendable {
|
||||
extension Agent {
|
||||
|
||||
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
|
||||
await reloadSecretsIfNeccessary()
|
||||
var response = Data()
|
||||
@@ -44,9 +46,34 @@ extension Agent {
|
||||
response.append(await identities())
|
||||
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
|
||||
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(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))
|
||||
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):
|
||||
logger.error("Agent received unknown request of type \(value).")
|
||||
throw UnhandledRequestError()
|
||||
@@ -103,19 +130,8 @@ extension Agent {
|
||||
throw NoMatchingKeyError()
|
||||
}
|
||||
|
||||
let decision = try await authorizationCoordinator.waitForAccessIfNeeded(to: secret, provenance: provenance)
|
||||
switch decision {
|
||||
case .proceed:
|
||||
break
|
||||
case .promptForSharedAuth:
|
||||
do {
|
||||
try await store.persistAuthentication(secret: secret, forProvenance: provenance)
|
||||
await authorizationCoordinator.completedPersistence(secret: secret, forProvenance: provenance)
|
||||
} catch {
|
||||
await authorizationCoordinator.didNotCompletePersistence(secret: secret, forProvenance: provenance)
|
||||
}
|
||||
}
|
||||
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
|
||||
|
||||
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance)
|
||||
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
||||
|
||||
@@ -157,6 +173,7 @@ extension Agent {
|
||||
|
||||
struct NoMatchingKeyError: Error {}
|
||||
struct UnhandledRequestError: Error {}
|
||||
struct BindingFailure: Error {}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import Foundation
|
||||
import SecretKit
|
||||
import os
|
||||
import LocalAuthentication
|
||||
|
||||
struct PendingRequest: Identifiable, Hashable, CustomStringConvertible {
|
||||
let id: UUID = UUID()
|
||||
let secret: AnySecret
|
||||
let provenance: SigningRequestProvenance
|
||||
|
||||
var description: String {
|
||||
"\(id.uuidString) - \(secret.name) \(provenance.origin.displayName)"
|
||||
}
|
||||
|
||||
func batchable(with request: PendingRequest) -> Bool {
|
||||
secret == request.secret &&
|
||||
provenance.isSameProvenance(as: request.provenance)
|
||||
}
|
||||
}
|
||||
|
||||
enum Decision {
|
||||
case proceed
|
||||
case promptForSharedAuth
|
||||
}
|
||||
|
||||
actor RequestHolder {
|
||||
|
||||
var pending: [PendingRequest] = []
|
||||
var authorizing: PendingRequest?
|
||||
var preauthorized: PendingRequest?
|
||||
|
||||
func addPending(_ request: PendingRequest) {
|
||||
pending.append(request)
|
||||
}
|
||||
|
||||
func advanceIfIdle() {
|
||||
|
||||
}
|
||||
|
||||
func shouldBlock(_ request: PendingRequest) -> Bool {
|
||||
guard request != authorizing else { return false }
|
||||
if let preauthorized, preauthorized.batchable(with: request) {
|
||||
print("Batching: \(request)")
|
||||
pending.removeAll(where: { $0 == request })
|
||||
return false
|
||||
}
|
||||
return authorizing == nil && authorizing.
|
||||
}
|
||||
|
||||
func clear() {
|
||||
if let preauthorized, allBatchable(with: preauthorized).isEmpty {
|
||||
self.preauthorized = nil
|
||||
}
|
||||
}
|
||||
|
||||
func allBatchable(with request: PendingRequest) -> [PendingRequest] {
|
||||
pending.filter { $0.batchable(with: request) }
|
||||
}
|
||||
|
||||
func completedPersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) {
|
||||
self.preauthorized = PendingRequest(secret: secret, provenance: provenance)
|
||||
}
|
||||
|
||||
func didNotCompletePersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) {
|
||||
self.preauthorized = nil
|
||||
}
|
||||
}
|
||||
|
||||
final class AuthorizationCoordinator: Sendable {
|
||||
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AuthorizationCoordinator")
|
||||
private let holder = RequestHolder()
|
||||
|
||||
public func waitForAccessIfNeeded(to secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Decision {
|
||||
// Block on unknown, since we don't really have any way to check.
|
||||
if secret.authenticationRequirement == .unknown {
|
||||
logger.warning("\(secret.name) has unknown authentication requirement.")
|
||||
}
|
||||
guard secret.authenticationRequirement != .notRequired else {
|
||||
logger.debug("\(secret.name) does not require authentication, continuing.")
|
||||
return .proceed
|
||||
}
|
||||
logger.debug("\(secret.name) requires authentication.")
|
||||
let pending = PendingRequest(secret: secret, provenance: provenance)
|
||||
await holder.addPending(pending)
|
||||
while await holder.shouldBlock(pending) {
|
||||
logger.debug("\(pending) waiting.")
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
}
|
||||
if await holder.preauthorized == nil, await holder.allBatchable(with: pending).count > 0 {
|
||||
logger.debug("\(pending) batch suggestion.")
|
||||
return .promptForSharedAuth
|
||||
}
|
||||
logger.debug("\(pending) continuing")
|
||||
return .proceed
|
||||
}
|
||||
|
||||
func completedPersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async {
|
||||
await holder.completedPersistence(secret: secret, forProvenance: provenance)
|
||||
}
|
||||
|
||||
func didNotCompletePersistence(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async {
|
||||
await holder.didNotCompletePersistence(secret: secret, forProvenance: provenance)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import OSLog
|
||||
import SecretKit
|
||||
import SSHProtocolKit
|
||||
|
||||
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,152 @@ 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 {
|
||||
// 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? {
|
||||
@@ -104,6 +246,7 @@ extension SSHAgentInputParser {
|
||||
case unknownRequest
|
||||
case unhandledRequest
|
||||
case invalidData
|
||||
case incorrectSignature
|
||||
case openSSHReader(OpenSSHReaderError)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import LocalAuthentication
|
||||
|
||||
/// A context describing a persisted authentication.
|
||||
package struct PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
|
||||
package final class PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
|
||||
|
||||
/// The Secret to persist authentication for.
|
||||
let secret: SecretType
|
||||
@@ -35,27 +35,16 @@ package struct PersistentAuthenticationContext<SecretType: Secret>: PersistedAut
|
||||
}
|
||||
}
|
||||
|
||||
struct ScopedPersistentAuthenticationContext<SecretType: Secret>: Hashable {
|
||||
let provenance: SigningRequestProvenance
|
||||
let secret: SecretType
|
||||
}
|
||||
|
||||
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
|
||||
|
||||
private var unscopedPersistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
|
||||
private var scopedPersistedAuthenticationContexts: [ScopedPersistentAuthenticationContext<SecretType>: PersistentAuthenticationContext<SecretType>] = [:]
|
||||
private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
|
||||
|
||||
package init() {
|
||||
}
|
||||
|
||||
package func existingPersistedAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance) -> PersistentAuthenticationContext<SecretType>? {
|
||||
if let unscopedPersistence = unscopedPersistedAuthenticationContexts[secret], unscopedPersistence.valid {
|
||||
return unscopedPersistence
|
||||
}
|
||||
if let scopedPersistence = scopedPersistedAuthenticationContexts[.init(provenance: provenance, secret: secret)], scopedPersistence.valid {
|
||||
return scopedPersistence
|
||||
}
|
||||
return nil
|
||||
package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext<SecretType>? {
|
||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
||||
return persisted
|
||||
}
|
||||
|
||||
package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws {
|
||||
@@ -73,22 +62,7 @@ package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
|
||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||
guard success else { return }
|
||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||
unscopedPersistedAuthenticationContexts[secret] = context
|
||||
}
|
||||
|
||||
package func persistAuthentication(secret: SecretType, provenance: SigningRequestProvenance) async throws {
|
||||
let newContext = LAContext()
|
||||
|
||||
// FIXME: TEMPORARY
|
||||
let duration: TimeInterval = 10000
|
||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||
|
||||
newContext.localizedReason = "Batch requests"
|
||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||
guard success else { return }
|
||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||
scopedPersistedAuthenticationContexts[.init(provenance: provenance, secret: secret)] = context
|
||||
persistedAuthenticationContexts[secret] = context
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
private let _name: @MainActor @Sendable () -> String
|
||||
private let _secrets: @MainActor @Sendable () -> [AnySecret]
|
||||
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
|
||||
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret, SigningRequestProvenance) async -> PersistedAuthenticationContext?
|
||||
private let _persistAuthenticationForDuration: @Sendable (AnySecret, TimeInterval) async throws -> Void
|
||||
private let _persistAuthenticationForProvenance: @Sendable (AnySecret, SigningRequestProvenance) async throws -> Void
|
||||
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
|
||||
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
|
||||
private let _reloadSecrets: @Sendable () async -> Void
|
||||
|
||||
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
|
||||
@@ -21,9 +20,8 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
_id = { secretStore.id }
|
||||
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
||||
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
|
||||
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType, provenance: $1) }
|
||||
_persistAuthenticationForDuration = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
|
||||
_persistAuthenticationForProvenance = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forProvenance: $1) }
|
||||
_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() }
|
||||
}
|
||||
|
||||
@@ -47,16 +45,12 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
try await _sign(data, secret, provenance)
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: AnySecret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
|
||||
await _existingPersistedAuthenticationContext(secret, provenance)
|
||||
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
|
||||
await _existingPersistedAuthenticationContext(secret)
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws {
|
||||
try await _persistAuthenticationForDuration(secret, duration)
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async throws {
|
||||
try await _persistAuthenticationForProvenance(secret, provenance)
|
||||
try await _persistAuthentication(secret, duration)
|
||||
}
|
||||
|
||||
public func reloadSecrets() async {
|
||||
|
||||
@@ -26,7 +26,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
||||
/// - 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, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext?
|
||||
func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext?
|
||||
|
||||
/// Persists user authorization for access to a secret.
|
||||
/// - Parameters:
|
||||
@@ -35,8 +35,6 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
||||
/// - 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
|
||||
|
||||
func persistAuthentication(secret: SecretType, forProvenance provenance: SigningRequestProvenance) async throws
|
||||
|
||||
/// Requests that the store reload secrets from any backing store, if neccessary.
|
||||
func reloadSecrets() async
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Describes the chain of applications that requested a signature operation.
|
||||
public struct SigningRequestProvenance: Equatable, Sendable, Hashable {
|
||||
public struct SigningRequestProvenance: Equatable, Sendable {
|
||||
|
||||
/// 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`
|
||||
@@ -25,16 +25,12 @@ extension SigningRequestProvenance {
|
||||
chain.allSatisfy { $0.validSignature }
|
||||
}
|
||||
|
||||
public func isSameProvenance(as other: SigningRequestProvenance) -> Bool {
|
||||
zip(chain, other.chain).allSatisfy { $0.isSameProcess(as: $1) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SigningRequestProvenance {
|
||||
|
||||
/// Describes a process in a `SigningRequestProvenance` chain.
|
||||
public struct Process: Equatable, Sendable, Hashable {
|
||||
public struct Process: Equatable, Sendable {
|
||||
|
||||
/// The pid of the process.
|
||||
public let pid: Int32
|
||||
@@ -75,15 +71,6 @@ extension SigningRequestProvenance {
|
||||
appName ?? processName
|
||||
}
|
||||
|
||||
// Whether the
|
||||
public func isSameProcess(as other: Process) -> Bool {
|
||||
processName == other.processName &&
|
||||
appName == other.appName &&
|
||||
iconURL == other.iconURL &&
|
||||
path == other.path &&
|
||||
validSignature == other.validSignature
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ extension SecureEnclave {
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||
var context: LAContext
|
||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) {
|
||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
||||
context = unsafe existing.context
|
||||
} else {
|
||||
let newContext = LAContext()
|
||||
@@ -88,18 +88,14 @@ extension SecureEnclave {
|
||||
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
|
||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance)
|
||||
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)
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: SecureEnclave.Secret, forProvenance provenance: SigningRequestProvenance) async throws {
|
||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, provenance: provenance)
|
||||
}
|
||||
|
||||
@MainActor public func reloadSecrets() {
|
||||
let before = secrets
|
||||
secrets.removeAll()
|
||||
|
||||
@@ -60,7 +60,7 @@ extension SmartCard {
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||
guard let tokenID = await state.tokenID else { fatalError() }
|
||||
var context: LAContext
|
||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) {
|
||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
||||
context = unsafe existing.context
|
||||
} else {
|
||||
let newContext = LAContext()
|
||||
@@ -93,18 +93,14 @@ extension SmartCard {
|
||||
return signature as Data
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
|
||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance)
|
||||
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)
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: Secret, forProvenance provenance: SigningRequestProvenance) async throws {
|
||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, provenance: provenance)
|
||||
}
|
||||
|
||||
/// Reloads all secrets from the store.
|
||||
@MainActor public func reloadSecrets() {
|
||||
reloadSecretsInternal()
|
||||
|
||||
@@ -11,7 +11,7 @@ extension ProcessInfo {
|
||||
}
|
||||
|
||||
guard let value = SecTaskCopyValueForEntitlement(task, "com.apple.developer.team-identifier" as CFString, nil) as? String else {
|
||||
// assertionFailure("SecTaskCopyValueForEntitlement(com.apple.developer.team-identifier) failed")
|
||||
assertionFailure("SecTaskCopyValueForEntitlement(com.apple.developer.team-identifier) failed")
|
||||
return fallbackTeamID
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ final class Notifier: Sendable {
|
||||
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
||||
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
||||
notificationContent.interruptionLevel = .timeSensitive
|
||||
if await store.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) == nil && secret.authenticationRequirement.required {
|
||||
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required {
|
||||
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
|
||||
}
|
||||
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
|
||||
@@ -79,25 +79,6 @@ final class Notifier: Sendable {
|
||||
try? await notificationCenter.add(request)
|
||||
}
|
||||
|
||||
func notify(pendingAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
|
||||
await notificationDelegate.state.setPending(secret: secret, store: store)
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
let notificationContent = UNMutableNotificationContent()
|
||||
notificationContent.title = "pending" //String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
|
||||
notificationContent.subtitle = "pending" //String(localized: .signedNotificationDescription(secretName: secret.name))
|
||||
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
||||
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
||||
notificationContent.interruptionLevel = .timeSensitive
|
||||
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
|
||||
notificationContent.threadIdentifier = "\(secret.id)_\(provenance.hashValue)"
|
||||
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
|
||||
notificationContent.attachments = [attachment]
|
||||
}
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
|
||||
try? await notificationCenter.add(request)
|
||||
|
||||
}
|
||||
|
||||
func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async {
|
||||
await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
@@ -122,10 +103,6 @@ extension Notifier: SigningWitness {
|
||||
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
|
||||
}
|
||||
|
||||
func witness(pendingAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
|
||||
await notify(pendingAccessTo: secret, from: store, by: provenance)
|
||||
}
|
||||
|
||||
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
|
||||
await notify(accessTo: secret, from: store, by: provenance)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user