Files

260 lines
14 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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,
discardQuestion: model.mode != .edit
? AppStrings.Common.discardNewKeyQuestion : AppStrings.Common.discardChangesQuestion,
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)
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)
}
if model.privateKeyRequiresPassphrase {
RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.passphrase)
.multilineTextAlignment(.leading)
.disabled(!model.canEditPrivateKey)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase)
.disabled(!model.canEditPrivateKey)
}
}
}
} else {
Section(AppStrings.Keychain.secureEnclave.swiftUIKey) {
LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) {
Label(AppStrings.Keychain.secureEnclave.swiftUIKey, systemImage: "checkmark.shield.fill")
.labelStyle(.titleAndIcon)
}
}
}
if !model.usesWebAuthn && model.mode != .generate && model.storage != .secureEnclave {
Section(AppStrings.Keychain.publicKey.swiftUIKey) {
TextEditor(text: $model.publicKey).font(.caption.monospaced()).frame(minHeight: 90)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Text(AppStrings.KeyExport.optionalPublicKey).font(.caption).foregroundStyle(.secondary)
}
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, model.storage == .secureEnclave, !model.publicKey.isEmpty {
Section(AppStrings.Keychain.publicKey.swiftUIKey) {
Text(model.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.refreshPublicMaterial() }
}
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)
}
}