Files
Termind/TFI/Features/Keychain/MobileKeyEditor.swift
T
Kaede Akino 816a6ff121 feat(ssh): support hardware security key authentication
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.
2026-09-28 23:37:49 +08:00

248 lines
13 KiB
Swift

import SwiftUI
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import UniformTypeIdentifiers
struct MobileKeyEditor: View {
@Bindable var model: MobileKeyEditorModel
@State private var presenter = SecurityKeyPresenter()
@State private var choosingFile = false
@State private var choosingCertificate = false
@State private var fileError: String?
@State private var referenceIsTargeted = false
@State private var referenceDropTask: Task<Void, Never>?
var body: some View {
MobileEditorSheet(
title: title, isDirty: model.isDirty, canSave: model.canSave,
save: {
try await model.save { name, verification in
try await presenter.register(name: name, userVerification: verification)
}
},
content: {
Section {
VaultNameField(text: $model.label, prompt: Text(model.defaultName))
VaultColorField(selection: $model.colorTag)
}
if model.mode == .generate {
Section(
!model.usesWebAuthn && model.storage == .secureEnclave
? AppStrings.Keychain.secureEnclave.swiftUIKey : AppStrings.Keychain.privateKey.swiftUIKey
) {
storagePicker
if model.usesWebAuthn {
WebAuthnVerificationPicker(selection: $model.verification)
} else if model.storage == .secureEnclave {
Toggle(
AppStrings.Keychain.requireUserPresence.swiftUIKey, isOn: $model.requiresUserPresence)
} else {
Picker(AppStrings.Keychain.algorithm.swiftUIKey, selection: $model.algorithm) {
ForEach(MobileKeyEditorModel.Algorithm.allCases) { Text($0.rawValue).tag($0) }
}
if model.algorithm == .ecdsa {
Picker(AppStrings.Keychain.ellipticCurve.swiftUIKey, selection: $model.curveBits) {
Text(AppStrings.Keychain.nistP256).tag(256)
Text(AppStrings.Keychain.nistP384).tag(384)
Text(AppStrings.Keychain.nistP521).tag(521)
}
} else if model.algorithm == .rsa {
Picker(AppStrings.Keychain.keySize.swiftUIKey, selection: $model.rsaBits) {
Text(AppStrings.Keychain.rsa2048Bits).tag(2048)
Text(AppStrings.Keychain.rsa3072Bits).tag(3072)
Text(AppStrings.Keychain.rsa4096Bits).tag(4096)
}
}
LabeledContent(AppStrings.Keychain.passphrase.swiftUIKey) {
RevealableSecretField(AppStrings.Common.optional, text: $model.passphrase)
.multilineTextAlignment(.trailing)
}
LabeledContent(AppStrings.Keychain.confirmPassphrase.swiftUIKey) {
RevealableSecretField(AppStrings.Keychain.confirmPassphrase, text: $model.confirmation)
.multilineTextAlignment(.trailing).disabled(model.passphrase.isEmpty)
}
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase)
.disabled(model.passphrase.isEmpty)
if !model.passphrase.isEmpty {
Picker(AppStrings.Keychain.cipher.swiftUIKey, selection: $model.cipher) {
ForEach(SSHKeyMaterial.Cipher.allCases) { Text($0.displayName).tag($0) }
}
LabeledContent(AppStrings.Keychain.rounds.swiftUIKey) {
TextField(
AppStrings.Keychain.rounds.swiftUIKey, value: $model.rounds, format: .number
).lineLimit(1)
.multilineTextAlignment(.trailing).keyboardType(.numberPad)
}
if model.passphrase != model.confirmation {
Text(AppStrings.Keychain.passphrasesDoNotMatch).foregroundStyle(.red)
}
}
}
}
} else if model.usesWebAuthn {
Section(AppStrings.SecurityKey.reference.swiftUIKey) {
storagePicker
WebAuthnVerificationPicker(selection: $model.verification)
Button(AppStrings.Keychain.chooseFile.swiftUIKey, systemImage: "folder") {
choosingFile = true
}
TextEditor(text: $model.securityKeyReference)
.font(.body.monospaced()).frame(minHeight: 150)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.accessibilityLabel(AppStrings.SecurityKey.reference.swiftUIKey)
.onDrop(
of: [.fileURL, .data, .plainText], isTargeted: $referenceIsTargeted,
perform: receiveReference
)
.overlay {
if referenceIsTargeted {
DropTargetOverlay(
presentation: DropTargetPresentation(
title: String(localized: AppStrings.Common.dragToImport),
detail: String(localized: AppStrings.SecurityKey.reference),
systemImage: "key.radiowaves.forward.fill")
)
.allowsHitTesting(false)
}
}
}
} else if model.storage != .secureEnclave {
Section(AppStrings.Keychain.privateKey.swiftUIKey) {
storagePicker
if model.isLoadingPrivateKey {
ProgressView(AppStrings.Keychain.loadingPrivateKeyProgress.swiftUIKey)
} else {
if let error = model.privateKeyLoadError {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
Button(
model.mode == .edit
? AppStrings.Keychain.chooseReplacement.swiftUIKey
: AppStrings.Keychain.chooseFile.swiftUIKey, systemImage: "folder"
) {
choosingFile = true
}
.disabled(!model.canEditPrivateKey)
TextEditor(text: $model.privateKey)
.multilineTextAlignment(.leading)
.font(.body.monospaced())
.frame(minHeight: 150)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.disabled(!model.canEditPrivateKey)
if model.replacesPublicKey {
Label(
AppStrings.Keychain.replacePrivateKeyWarning.swiftUIKey,
systemImage: "arrow.triangle.2.circlepath"
)
.font(.caption)
.foregroundStyle(.orange)
}
RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.passphrase)
.multilineTextAlignment(.leading)
.disabled(!model.canEditPrivateKey)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase)
.disabled(!model.canEditPrivateKey || model.passphrase.isEmpty)
}
}
} else {
Section(AppStrings.Keychain.secureEnclave.swiftUIKey) {
LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) {
Label(AppStrings.Keychain.secureEnclave.swiftUIKey, systemImage: "checkmark.shield.fill")
}
}
}
if !model.usesWebAuthn && model.mode != .generate && model.storage != .secureEnclave {
Section(AppStrings.Keychain.sshCertificate.swiftUIKey) {
Button(AppStrings.Keychain.chooseFile.swiftUIKey, systemImage: "folder") {
choosingCertificate = true
}
TextEditor(text: $model.certificate).multilineTextAlignment(.leading).font(
.caption.monospaced()
).frame(minHeight: 90)
.textInputAutocapitalization(.never).autocorrectionDisabled()
}
}
if !model.usesWebAuthn, let warning = model.certificateWarning {
Text(warning).font(.caption).foregroundStyle(.orange)
}
if !model.usesWebAuthn, let publicKey = model.publicKey {
Section(AppStrings.Keychain.publicKey.swiftUIKey) {
Text(publicKey).font(.caption.monospaced()).textSelection(.enabled)
}
}
if !model.usesWebAuthn, let fingerprint = model.fingerprint {
Section(AppStrings.KnownHosts.fingerprint.swiftUIKey) {
Text(fingerprint).font(.caption.monospaced()).textSelection(.enabled)
}
}
}
)
.fileImporter(isPresented: $choosingFile, allowedContentTypes: [.data, .plainText]) { result in
Task {
do { try await model.loadFile(result.get()) } catch { fileError = error.localizedDescription }
}
}
.fileImporter(isPresented: $choosingCertificate, allowedContentTypes: [.data, .plainText]) { result in
Task {
do { try await model.loadCertificateFile(result.get()) } catch {
fileError = error.localizedDescription
}
}
}
.alert(
AppStrings.Keychain.unableToReadFile,
isPresented: Binding(get: { fileError != nil }, set: { if !$0 { fileError = nil } })
) {
Button(AppStrings.Common.ok.swiftUIKey, role: .cancel) { fileError = nil }
} message: {
Text(fileError ?? "")
}
.background(SecurityKeyAnchor(presenter: presenter).frame(width: 0, height: 0))
.onDisappear {
referenceDropTask?.cancel()
presenter.cancel()
model.clearSensitiveDrafts()
}
.task { await model.loadPrivateKey() }
.task(id: model.validationToken) { await model.derivePublicMaterial() }
}
private func receiveReference(_ providers: [NSItemProvider]) -> Bool {
guard model.usesWebAuthn, let provider = providers.first else { return false }
referenceDropTask?.cancel()
referenceDropTask = Task {
do {
let text = try await ReferenceDropLoader.load(provider)
_ = try WebAuthnSSH.importReference(text)
try Task.checkCancellation()
guard model.usesWebAuthn else { return }
model.securityKeyReference = text
} catch is CancellationError {
} catch { fileError = error.localizedDescription }
}
return true
}
private var title: String {
switch model.mode {
case .generate: String(localized: AppStrings.Keychain.generateKey)
case .importKey: String(localized: AppStrings.Keychain.importKey)
case .edit: String(localized: AppStrings.Keychain.editKey)
}
}
private var storagePicker: some View {
KeyCreationStoragePicker(
storage: $model.storage, usesWebAuthn: $model.usesWebAuthn,
allowsWebAuthn: model.mode != .edit,
allowsSecureEnclave: model.mode == .generate && model.canUseEnclave,
secureEnclaveAvailable: model.canUseEnclave
)
.disabled(!model.canEditPrivateKey || !model.canSelectThisDevice)
}
}