255 lines
14 KiB
Swift
255 lines
14 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)
|
|
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)
|
|
}
|
|
}
|