Add native security key registration, reference import, and signing on macOS and iOS. Store references in Vault metadata and route authentication through connection, Assistant, MCP, and agent forwarding flows. Preserve signing cancellation and actionable authentication errors for security keys and Secure Enclave. Pin the released libssh2 forks as nested submodules and refresh package resolution.
129 lines
5.4 KiB
Swift
129 lines
5.4 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindPresentation
|
|
|
|
/// Each scene owns its pending requests. No UIKit presentation singleton is used.
|
|
@MainActor
|
|
@Observable
|
|
final class MobileAgentForwardingInteraction {
|
|
enum Request: Identifiable {
|
|
case keyboard(SSHKeyboardInteractiveChallenge)
|
|
case securityKey(SecurityKeySigningRequest)
|
|
var id: UUID {
|
|
switch self {
|
|
case .keyboard(let request): request.id
|
|
case .securityKey(let request): request.id
|
|
}
|
|
}
|
|
}
|
|
private(set) var pending: [Request] = []
|
|
private var signingWaiters: [UUID: CheckedContinuation<SecurityKeyAssertion, any Error>] = [:]
|
|
private var waiters: [UUID: CheckedContinuation<[String], any Error>] = [:]
|
|
var isActive = false {
|
|
didSet { if !isActive { cancelAll() } }
|
|
}
|
|
var current: Request? { pending.first }
|
|
|
|
func request(_ challenge: SSHKeyboardInteractiveChallenge) async throws -> [String] {
|
|
guard isActive, pending.count < 16 else { throw SSHInteractionRequired() }
|
|
return try await withTaskCancellationHandler {
|
|
try Task.checkCancellation()
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
waiters[challenge.id] = continuation
|
|
pending.append(.keyboard(challenge))
|
|
}
|
|
} onCancel: {
|
|
Task { @MainActor [weak self] in self?.respond(challenge.id, answers: nil) }
|
|
}
|
|
}
|
|
func respond(_ id: UUID, answers: [String]?) {
|
|
guard let waiter = waiters.removeValue(forKey: id) else { return }
|
|
pending.removeAll { $0.id == id }
|
|
if let answers { waiter.resume(returning: answers) } else { waiter.resume(throwing: CancellationError()) }
|
|
}
|
|
func sign(_ request: SecurityKeySigningRequest) async throws -> SecurityKeyAssertion {
|
|
guard isActive, pending.count < 16 else { throw SSHInteractionRequired() }
|
|
return try await withTaskCancellationHandler {
|
|
try Task.checkCancellation()
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
signingWaiters[request.id] = continuation
|
|
pending.append(.securityKey(request))
|
|
}
|
|
} onCancel: {
|
|
Task { @MainActor [weak self] in self?.respond(request.id, result: .failure(CancellationError())) }
|
|
}
|
|
}
|
|
|
|
func respond(_ id: UUID, result: Result<SecurityKeyAssertion, any Error>) {
|
|
guard let waiter = signingWaiters.removeValue(forKey: id) else { return }
|
|
pending.removeAll { $0.id == id }
|
|
waiter.resume(with: result)
|
|
}
|
|
|
|
func cancelAll() {
|
|
for id in Array(signingWaiters.keys) { respond(id, result: .failure(CancellationError())) }
|
|
for id in Array(waiters.keys) { respond(id, answers: nil) }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class MobileAgentForwardingRouter {
|
|
private struct Entry {
|
|
weak var scene: MobileSceneModel?
|
|
}
|
|
private var scenes: [UUID: Entry] = [:]
|
|
func register(_ scene: MobileSceneModel) { scenes[scene.id] = Entry(scene: scene) }
|
|
func context(for request: SSHConnectionRequest) -> SSHAgentInteractionContext {
|
|
let live = scenes.values.compactMap(\.scene)
|
|
let candidates: [MobileSceneModel]
|
|
if let contextID = request.agentForwardingContextID {
|
|
candidates = live.filter { scene in
|
|
scene.workspaceSession.workspaces.contains { $0.id == contextID }
|
|
|| scene.assistant.library.selectedConversationID == contextID
|
|
}
|
|
} else {
|
|
candidates = live.filter { $0.agentForwarding.isActive }
|
|
}
|
|
// An ambiguous or inactive owner cannot authorize a signature.
|
|
guard candidates.count == 1, let scene = candidates.first else {
|
|
return SSHAgentInteractionContext(canInteract: { false })
|
|
}
|
|
let presenter = scene.agentForwarding
|
|
return SSHAgentInteractionContext(
|
|
unlock: { [weak presenter] challenge in
|
|
guard let presenter else { throw CancellationError() }
|
|
return try await presenter.request(challenge)
|
|
},
|
|
securityKeySigning: { [weak presenter] request in
|
|
guard let presenter else { throw CancellationError() }
|
|
return try await presenter.sign(request)
|
|
},
|
|
canInteract: { [weak presenter] in presenter?.isActive == true })
|
|
}
|
|
}
|
|
|
|
struct MobileAgentForwardingSheet: View {
|
|
let interaction: MobileAgentForwardingInteraction
|
|
let request: MobileAgentForwardingInteraction.Request
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
switch request {
|
|
case .keyboard(let challenge):
|
|
SSHKeyboardInteractiveForm(
|
|
challenge: challenge,
|
|
submit: { interaction.respond(challenge.id, answers: $0) },
|
|
cancel: { interaction.respond(challenge.id, answers: nil) })
|
|
case .securityKey(let signing):
|
|
SecurityKeySigningCard(request: signing) { interaction.respond(signing.id, result: $0) }
|
|
}
|
|
}
|
|
.padding()
|
|
.navigationTitle(AppStrings.Connections.agentForwarding.swiftUIKey)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
}
|