Add OTP QR import from TOTP and entry editors (#120)
Dependency security audit / rustsec (push) Successful in 20s

This commit is contained in:
hugo committed 2026-09-12 12:06:15 +02:00
1 parent 93a08e9c65
commit 3c3574b449
12 files changed
+448 -23

No files matched your search

+249 -19
View File
@@ -2,6 +2,7 @@ import UIKit
import AVFoundation
import Vision
import VisionKit
import PhotosUI
import WatchConnectivity
extension Notification.Name {
@@ -2905,13 +2906,67 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
)
navigationItem.rightBarButtonItem?.accessibilityLabel =
unlocked ? "Lock IronStorage" : "Unlock IronStorage"
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .add,
primaryAction: UIAction { [weak self] _ in self?.createOtpRequested() }
)
navigationItem.leftBarButtonItem?.accessibilityLabel = "Add OTP Entry"
}
private func createOtpRequested() {
let alert = UIAlertController(title: "New OTP Entry", message: "Choose where to save the entry.", preferredStyle: .alert)
alert.addTextField { $0.placeholder = "Entry Name" }
alert.addTextField { $0.placeholder = "Folder"; $0.text = "otp" }
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Continue", style: .default) { [weak self, weak alert] _ in
guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return }
self?.beginCreateOtp(directory: alert?.textFields?.last?.text ?? "", name: name, passphrase: nil)
})
present(alert, animated: true)
}
private func beginCreateOtp(directory: String, name: String, passphrase: String?) {
guard let authentication else { return }
unlockTask?.cancel()
unlockTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileEntryEditorSession, AuthenticationFailure>.success(
try authentication.beginCreateEntry(directory: directory, name: name, passphrase: passphrase)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch { return .failure(.unexpected) }
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case let .success(session):
let editor = MobileEntryEditorViewController(
authentication: authentication, session: session, focusedField: nil, addingOtp: true
) { [weak self] _ in
self?.searchController.searchBar.text = ""
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
}
present(UINavigationController(rootViewController: editor), animated: true)
case let .failure(failure) where passphrase == nil
&& (failure.kind == .passphraseRequired || failure.kind == .biometryUnavailable):
let alert = UIAlertController(title: "GPG Key Passphrase", message: failure.detail, preferredStyle: .alert)
alert.addTextField { $0.isSecureTextEntry = true; $0.placeholder = "Passphrase" }
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
self?.beginCreateOtp(directory: directory, name: name, passphrase: alert?.textFields?.first?.text)
})
present(alert, animated: true)
case let .failure(failure): handle(failure)
}
}
}
private func showLocked() {
page = nil
unfilteredPage = nil
tableView.reloadData()
navigationItem.rightBarButtonItem = nil
configureLockButton()
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "lock.fill")
configuration.text = "TOTP Is Locked"
@@ -5324,6 +5379,7 @@ private final class MobileEntryEditorViewController: UITableViewController,
private var editorID: UInt64
private var page: MobileEntryEditorPage
private let focusedField: UInt64?
private var addingOtp: Bool
private let saved: (String) -> Void
private var states: [UInt64: FieldState] = [:]
private var localDirty = false
@@ -5334,12 +5390,14 @@ private final class MobileEntryEditorViewController: UITableViewController,
authentication: MobileAuthentication,
session: MobileEntryEditorSession,
focusedField: UInt64?,
addingOtp: Bool = false,
saved: @escaping (String) -> Void
) {
self.authentication = authentication
editorID = session.id
page = session.page
self.focusedField = focusedField
self.addingOtp = addingOtp
self.saved = saved
super.init(style: .insetGrouped)
resetStates()
@@ -5404,6 +5462,10 @@ private final class MobileEntryEditorViewController: UITableViewController,
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.presentationController?.delegate = self
if addingOtp {
addingOtp = false
promptForOtpUri()
}
guard !focused, let focusedField,
let index = page.fields.firstIndex(where: { $0.id == focusedField })
else { return }
@@ -5590,7 +5652,7 @@ private final class MobileEntryEditorViewController: UITableViewController,
sheet.addAction(UIAlertAction(title: "Notes", style: .default) { [weak self] _ in
self?.addField(kind: .note, name: nil, value: "")
})
sheet.addAction(UIAlertAction(title: "OTP URI", style: .default) { [weak self] _ in
sheet.addAction(UIAlertAction(title: "OTP / Scan QR Code", style: .default) { [weak self] _ in
self?.promptForOtpUri()
})
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
@@ -5619,23 +5681,16 @@ private final class MobileEntryEditorViewController: UITableViewController,
}
private func promptForOtpUri() {
let alert = UIAlertController(
title: "OTP URI",
message: "Enter the complete otpauth URI.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "otpauth://…"
field.keyboardType = .URL
field.autocapitalizationType = .none
field.autocorrectionType = .no
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return }
self?.addField(kind: .otpUri, name: nil, value: value)
})
present(alert, animated: true)
do {
try synchronizeDraft()
let importer = OtpImportViewController(authentication: authentication, editor: editorID) { [weak self] page in
guard let self else { return }
self.page = page
resetStates()
tableView.reloadData()
}
navigationController?.pushViewController(importer, animated: true)
} catch { presentEditorFailure(error) }
}
private func addField(kind: MobileEntryEditorFieldKind, name: String?, value: String) {
@@ -5778,6 +5833,181 @@ private final class MobileEntryEditorViewController: UITableViewController,
}
}
@MainActor
private final class OtpImportViewController: UITableViewController,
UIImagePickerControllerDelegate, UINavigationControllerDelegate, PHPickerViewControllerDelegate
{
private let authentication: MobileAuthentication
private let editor: UInt64
private let accepted: (MobileEntryEditorPage) -> Void
private var importing = false
private var task: Task<Void, Never>?
init(authentication: MobileAuthentication, editor: UInt64, accepted: @escaping (MobileEntryEditorPage) -> Void) {
self.authentication = authentication
self.editor = editor
self.accepted = accepted
super.init(style: .insetGrouped)
title = "Add OTP"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
deinit { task?.cancel() }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 3 }
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
"Import a standard OTP QR code or URI. Review the field in the editor, then tap Save."
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.text = ["Scan QR Code", "Choose QR Image", "Enter OTP URI"][indexPath.row]
content.image = UIImage(systemName: ["camera", "photo", "keyboard"][indexPath.row])
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard !importing else { return }
switch indexPath.row {
case 0: requestCamera()
case 1:
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
default:
let alert = UIAlertController(title: "OTP URI", message: "Enter the complete otpauth URI.", preferredStyle: .alert)
alert.addTextField {
$0.placeholder = "otpauth://…"
$0.autocorrectionType = .no
$0.autocapitalizationType = .none
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text else { return }
self?.importOtp(uri: value)
})
present(alert, animated: true)
}
}
private func requestCamera() {
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
showFailure("Camera Is Unavailable", "Choose a QR image or enter the OTP URI instead.")
return
}
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: showCamera()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
Task { @MainActor in
guard let self, self.viewIfLoaded?.window != nil else { return }
if granted { self.showCamera() } else { self.cameraDenied() }
}
}
default: cameraDenied()
}
}
private func cameraDenied() {
showFailure("Camera Access Is Off", "Allow camera access in Settings, choose a QR image, or enter the OTP URI.")
}
private func showCamera() {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.cameraCaptureMode = .photo
picker.delegate = self
present(picker, animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
let image = info[.originalImage] as? UIImage
picker.dismiss(animated: true) { [weak self] in self?.importImage(image) }
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true) { [weak self] in
guard let provider = results.first?.itemProvider else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
Task { @MainActor in
guard let self, self.viewIfLoaded?.window != nil else { return }
self.importImage(image as? UIImage)
}
}
}
}
private func importImage(_ image: UIImage?) {
guard let image, image.size.width > 0, image.size.height > 0 else {
showFailure("Image Is Unavailable", "Choose another QR image.")
return
}
let scale = min(1, 2048 / max(image.size.width, image.size.height))
let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let data = UIGraphicsImageRenderer(size: size, format: format).pngData { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
importOtp(image: data)
}
private func importOtp(uri: String? = nil, image: Data? = nil) {
guard !importing else { return }
importing = true
tableView.isUserInteractionEnabled = false
navigationItem.hidesBackButton = true
let authentication = authentication
let editor = editor
task = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try authentication.touchUserActivity()
let page: MobileEntryEditorPage
if let image {
page = try authentication.addEntryEditorOtpImage(editor: editor, image: image)
} else {
page = try authentication.addEntryEditorField(editor: editor, kind: .otpUri, name: nil, value: uri ?? "")
}
return Result<MobileEntryEditorPage, AuthenticationFailure>.success(page)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch { return .failure(.unexpected) }
}.value
guard !Task.isCancelled, let self else { return }
importing = false
tableView.isUserInteractionEnabled = true
navigationItem.hidesBackButton = false
switch result {
case let .success(page):
accepted(page)
navigationController?.popViewController(animated: true)
case let .failure(failure): presentAuthenticationFailure(failure)
}
}
}
private func showFailure(_ title: String, _ message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
@MainActor
private final class MobileEntryEditorCell: UITableViewCell, UITextFieldDelegate, UITextViewDelegate {
private let iconView = UIImageView()