Add OTP QR import from TOTP and entry editors (#120)
Dependency security audit / rustsec (push) Successful in 20s
Dependency security audit / rustsec (push) Successful in 20s
This commit is contained in:
1 parent
93a08e9c65
commit
3c3574b449
12 files changed
+448
-23
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user