Files

239 lines
9.6 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AppKit
import AuthenticationServices
import Foundation
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
@MainActor
final class SecurityKeyPresenter: NSObject, ASAuthorizationControllerDelegate,
ASAuthorizationControllerPresentationContextProviding
{
weak var anchor: ASPresentationAnchor?
private var controller: ASAuthorizationController?
private nonisolated enum Response: Sendable {
case registration(credentialID: Data, attestation: Data, clientDataJSON: Data)
case assertion(SecurityKeyAssertion)
}
private var operationID: UUID?
private var completion: ((Result<Response, any Error>) -> Void)?
func register(name: String, userVerification: SecurityKeyCredential.UserVerification) async throws
-> SecurityKeyCredential
{
let challenge = Data((0..<32).map { _ in UInt8.random(in: 0...255) })
let provider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(
relyingPartyIdentifier: SecurityKeyCredential.relyingParty)
let request = provider.createCredentialRegistrationRequest(
challenge: challenge, displayName: name, name: name, userID: Data(UUID().uuidString.utf8))
request.credentialParameters = [ASAuthorizationPublicKeyCredentialParameters(algorithm: .ES256)]
request.residentKeyPreference = .discouraged
request.userVerificationPreference = preference(userVerification)
guard case .registration(let credentialID, let attestation, let clientDataJSON) = try await perform(request)
else { throw WebAuthnSSH.Failure.invalidResponse }
return try WebAuthnSSH.registration(
credentialID: credentialID, attestation: attestation,
clientDataJSON: clientDataJSON, challenge: challenge,
userVerification: userVerification)
}
func sign(_ input: SecurityKeySigningRequest) async throws -> SecurityKeyAssertion {
guard input.allowsUserInteraction else { throw SSHInteractionRequired() }
try WebAuthnSSH.validate(input.credential)
let provider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(
relyingPartyIdentifier: SecurityKeyCredential.relyingParty)
let request = provider.createCredentialAssertionRequest(challenge: input.challenge)
request.allowedCredentials = [.init(credentialID: input.credential.credentialID, transports: [])]
request.userVerificationPreference = preference(input.credential.userVerification)
guard case .assertion(let response) = try await perform(request) else {
throw WebAuthnSSH.Failure.invalidResponse
}
return response
}
private func preference(_ value: SecurityKeyCredential.UserVerification)
-> ASAuthorizationPublicKeyCredentialUserVerificationPreference
{
switch value {
case .required: .required
case .preferred: .preferred
case .discouraged: .discouraged
}
}
private func perform(_ request: ASAuthorizationRequest) async throws -> Response {
guard controller == nil, anchor != nil else { throw SSHInteractionRequired() }
let id = UUID()
return try await withTaskCancellationHandler {
try Task.checkCancellation()
return try await withCheckedThrowingContinuation { continuation in
operationID = id
completion = { continuation.resume(with: $0) }
let controller = ASAuthorizationController(authorizationRequests: [request])
self.controller = controller
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
}
} onCancel: {
Task { @MainActor [weak self] in
guard self?.operationID == id else { return }
self?.cancel()
}
}
}
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
anchor ?? ASPresentationAnchor()
}
func authorizationController(
controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization
) {
guard controller === self.controller else { return }
if let credential = authorization.credential as? ASAuthorizationSecurityKeyPublicKeyCredentialRegistration,
let attestation = credential.rawAttestationObject
{
finish(
.success(
.registration(
credentialID: credential.credentialID, attestation: attestation,
clientDataJSON: credential.rawClientDataJSON)))
} else if let credential = authorization.credential as? ASAuthorizationSecurityKeyPublicKeyCredentialAssertion {
finish(
.success(
.assertion(
SecurityKeyAssertion(
credentialID: credential.credentialID,
authenticatorData: credential.rawAuthenticatorData,
clientDataJSON: credential.rawClientDataJSON,
signature: credential.signature))))
} else {
finish(.failure(WebAuthnSSH.Failure.invalidResponse))
}
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) {
guard controller === self.controller else { return }
if (error as? ASAuthorizationError)?.code == .canceled {
finish(.failure(CancellationError()))
} else {
finish(.failure(error))
}
}
func cancel() {
let controller = controller
finish(.failure(CancellationError()))
controller?.cancel()
}
private func finish(_ result: Result<Response, any Error>) {
let completion = completion
self.completion = nil
operationID = nil
controller = nil
completion?(result)
}
}
struct SecurityKeySigningCard: View {
let request: SecurityKeySigningRequest
@State private var presenter: SecurityKeyPresenter
@State private var session: SecurityKeySigningSession
init(request: SecurityKeySigningRequest, coordinator: SecurityKeySigningCoordinator) {
self.init(request: request) { coordinator.respond(request.id, result: $0) }
}
init(
request: SecurityKeySigningRequest,
respond: @escaping @MainActor (Result<SecurityKeyAssertion, any Error>) -> Void
) {
self.request = request
let presenter = SecurityKeyPresenter()
_presenter = State(initialValue: presenter)
_session = State(
initialValue: SecurityKeySigningSession(
sign: { try await presenter.sign(request) }, respond: respond))
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill").font(.headline)
Text(verbatim: request.keyName).font(.subheadline)
Text(AppStrings.SecurityKey.touchPrompt).foregroundStyle(.secondary)
HStack {
Button(AppStrings.Common.cancel.swiftUIKey) { cancel() }
.keyboardShortcut(.cancelAction)
Spacer()
if session.isRunning { ProgressView().controlSize(.small) }
Button(AppStrings.SecurityKey.continueAction.swiftUIKey) { session.start() }
.disabled(session.isRunning || session.isFinished)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(SecurityKeyAnchor(presenter: presenter, onReady: autoContinue).frame(width: 0, height: 0))
.onDisappear { cancel() }
}
private func autoContinue() {
guard presenter.anchor != nil else { return }
session.startAutomatically()
}
private func cancel() {
session.cancel()
presenter.cancel()
}
}
struct SecurityKeyAnchor: NSViewRepresentable {
let presenter: SecurityKeyPresenter
var onReady: (() -> Void)? = nil
final class AnchorView: NSView {
weak var presenter: SecurityKeyPresenter?
var onReady: (() -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
presenter?.anchor = window
if window != nil { Task { @MainActor [weak self] in self?.onReady?() } }
}
}
func makeNSView(context: Context) -> AnchorView {
let view = AnchorView(frame: .zero)
view.presenter = presenter
view.onReady = onReady
return view
}
func updateNSView(_ view: AnchorView, context: Context) {
view.presenter = presenter
presenter.anchor = view.window
view.onReady = onReady
if view.window != nil { Task { @MainActor [weak view] in view?.onReady?() } }
}
}
struct AgentSecurityKeySigningCard: View {
let request: SecurityKeySigningRequest
let respond: (AgentSSHAuthenticationResponse) -> Void
var body: some View {
SecurityKeySigningCard(request: request) { result in
switch result {
case .success(let assertion): respond(.securityKey(assertion))
case .failure(let error) where error is CancellationError: respond(.cancelled)
case .failure(let error): respond(.securityKeyFailed(error.localizedDescription))
}
}
.id(request.id)
}
}