From 3c3574b449893db4c405dffd7026d253d0710cf4 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 12 Sep 2026 12:06:15 +0200 Subject: [PATCH] Add OTP QR import from TOTP and entry editors (#120) --- apple/Generated/ironstorage_apple.swift | 18 +- apple/Generated/ironstorage_appleFFI.h | 11 + apple/Info.plist | 2 +- apple/Sources/App/IronStorageApp.swift | 268 ++++++++++++++++++-- apple/TESTING.md | 13 + apple/project.yml | 2 +- crates/apple/src/lib.rs | 11 + crates/storage/src/mobile_authentication.rs | 12 + crates/storage/src/mobile_entry.rs | 33 +++ crates/storage/src/presentation.rs | 14 +- crates/storage/tests/entry_documents.rs | 81 ++++++ crates/storage/tests/presentation.rs | 6 + 12 files changed, 448 insertions(+), 23 deletions(-) diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 5fb2ec2..5faa2dd 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -607,6 +607,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func addEntryEditorField(editor: UInt64, kind: MobileEntryEditorFieldKind, name: String?, value: String) throws -> MobileEntryEditorPage + func addEntryEditorOtpImage(editor: UInt64, image: Data) throws -> MobileEntryEditorPage + func beginCreateEntry(directory: String, name: String, passphrase: String?) throws -> MobileEntryEditorSession func beginEntryEditor(path: String) throws -> MobileEntryEditorSession @@ -764,6 +766,17 @@ open func addEntryEditorField(editor: UInt64, kind: MobileEntryEditorFieldKind, }) } +open func addEntryEditorOtpImage(editor: UInt64, image: Data)throws -> MobileEntryEditorPage { + return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_otp_image( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterData.lower(image),uniffiCallStatus + ) +}) +} + open func beginCreateEntry(directory: String, name: String, passphrase: String?)throws -> MobileEntryEditorSession { return try FfiConverterTypeMobileEntryEditorSession_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -7960,6 +7973,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field() != 58790) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_otp_image() != 41339) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_begin_create_entry() != 21567) { return InitializationResult.apiChecksumMismatch } @@ -8163,4 +8179,4 @@ public func uniffiEnsureIronstorageAppleInitialized() { } } -// swiftlint:enable all \ No newline at end of file +// swiftlint:enable all diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index a40e907..2811093 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -263,6 +263,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_acknowledge_w RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_field(uint64_t ptr, uint64_t editor, RustBuffer kind, RustBuffer name, RustBuffer value, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_otp_image(uint64_t ptr, uint64_t editor, RustBuffer image, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_create_entry(uint64_t ptr, RustBuffer directory, RustBuffer name, RustBuffer passphrase, RustCallStatus *_Nonnull out_status @@ -1064,6 +1069,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_acknowled #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_otp_image(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY diff --git a/apple/Info.plist b/apple/Info.plist index 747884b..4a25246 100644 --- a/apple/Info.plist +++ b/apple/Info.plist @@ -23,7 +23,7 @@ ITSAppUsesNonExemptEncryption NSCameraUsageDescription - Scan GPG key transfer QR codes that you choose to import. + Scan OTP and GPG key transfer QR codes that you choose to import. NSFaceIDUsageDescription Unlock your GPG key for protected password operations. UILaunchScreen diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index 3e436c0..64b11b0 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -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.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? + + 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.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() diff --git a/apple/TESTING.md b/apple/TESTING.md index 4cee56e..d91e1a6 100644 --- a/apple/TESTING.md +++ b/apple/TESTING.md @@ -15,6 +15,7 @@ actually exercised. | TOTP tab and sharing | Rust discovers, caches, parses, selects, and generates OTP data | `mobile_totp`, `otp`, and `mobile_watch` integration tests | Discover entries, read a code, change Watch selection, and verify countdown | | Preferences and biometric unlock | Rust owns preference/authentication state; LocalAuthentication and Keychain remain isolated Apple adapters | `authentication_leases`, `secret_store`, and mobile authentication tests | Enroll once, then test match, cancel, rejection, and changed enrollment using the documented sequence below | | GPG key QR transfer | Rust frames, validates, reconstructs, authenticates, and atomically stores keys; UIKit only scans/displays frames | `mobile_key_transfer` unit tests and `crypto_compatibility` | Test public/private transfer, interruption, duplicates, and capture shielding | +| OTP creation and QR import | TOTP Add and editor Add Field share the native camera/photo/URI input; Rust decodes QR, validates URI, and creates the OTP field | `mobile_otp_import_creates_or_appends_one_validated_field`, bounded QR image checks | In the existing simulator test store, create from TOTP, append from the editor, cancel, reject invalid/duplicate input, and save/reopen. Use synthetic QR images: the simulator has no camera. Validate physical camera capture later through the release process. | | Watch synchronization | Rust owns selected snapshots, pairing identity, revisions, replay rejection, receipts, and revocation | `replacement_snapshots_reject_replays_conflicts_and_pairing_changes` | On a paired device, verify background delivery, replacement, replay rejection, revoke, and pairing change | | Watch display | Rust owns OTP calculation, timing, and ready/empty/syncing/stale/locked/error presentation; SwiftUI renders it | `watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked` plus ARM64 Watch target build | Test offline and stale-snapshot behavior, wrist-down privacy, VoiceOver, Dynamic Type, and supported Watch sizes | | Lifecycle and disclosure controls | Rust drops protected state; UIKit/SwiftUI clear views, time-limit pasteboard values, and shield private QR/captured content | authentication, presentation, TOTP, and Watch lifecycle tests; source audit in `apple_mobile_audit` | Background/foreground, lock, screenshot/capture, pasteboard expiry, and protected-data loss | @@ -87,6 +88,18 @@ the tester is ready to enter the passphrase again. ## Manual evidence still required +Issue #120 was exercised on 2026-09-12 using the existing iPhone 17 Pro / iOS +26.5 simulator (`FB5C6929-C10D-40C8-9A94-5CC592037FDB`) and its simulated store. +TOTP Add imported a synthetic QR into `otp/Simulator Panel OTP`; the normal +editor appended a different synthetic QR to `Example Portal`. Both saved, +appeared in the TOTP list, and displayed the expected issuer/account and codes +after reopening. The existing portal username and URL remained unchanged. +Invalid non-OTP QR input was rejected, and Scan QR Code presented the expected +camera-unavailable fallback. Physical camera capture remains for later release +testing; no paired iPhone installation or real local store was used. +The final in-place simulator update preserved saved entries and every untouched +ciphertext file; interrupting an unsaved imported draft created no entry. + Record the device model/OS, build commit, and pass/fail result for each manual row above. Simulator validation is required during implementation. The final distribution issue remains open until the approved App Store build is installed diff --git a/apple/project.yml b/apple/project.yml index 700488d..334c890 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -27,7 +27,7 @@ targets: CFBundleVersion: $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption: false NSFaceIDUsageDescription: Unlock your GPG key for protected password operations. - NSCameraUsageDescription: Scan GPG key transfer QR codes that you choose to import. + NSCameraUsageDescription: Scan OTP and GPG key transfer QR codes that you choose to import. UILaunchScreen: {} UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index ad9327a..2385155 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -1901,6 +1901,17 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn add_entry_editor_otp_image( + &self, + editor: u64, + image: Vec, + ) -> Result { + self.authentication + .add_entry_editor_otp_image(editor, ironstorage::repository::SecretBytes::new(image)) + .map(|page| (&page).into()) + .map_err(Into::into) + } + pub fn remove_entry_editor_field( &self, editor: u64, diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 4c48a25..ff9aa9e 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -1173,6 +1173,18 @@ impl MobileAuthentication { Ok(draft.page()) } + pub fn add_entry_editor_otp_image( + &self, + editor: u64, + image: SecretBytes, + ) -> Result { + self.ensure_active()?; + let mut status = self.status()?; + let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; + draft.add_otp_image(image).map_err(editor_error)?; + Ok(draft.page()) + } + pub fn remove_entry_editor_field( &self, editor: u64, diff --git a/crates/storage/src/mobile_entry.rs b/crates/storage/src/mobile_entry.rs index 93ecd9c..5953b12 100644 --- a/crates/storage/src/mobile_entry.rs +++ b/crates/storage/src/mobile_entry.rs @@ -7,6 +7,8 @@ use crate::document::{ EntryFieldKind, EntrySensitivity, }; use crate::generate::{GenerateError, GeneratorConfig, MAX_PASSWORD_LENGTH}; +use crate::presentation::{QrError, QrMatrix}; +use crate::repository::SecretBytes; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileEntrySectionKind { @@ -330,6 +332,15 @@ impl MobileEntryDraft { name: Option, value: String, ) -> Result<(), MobileEntryEditorError> { + if kind == MobileEntryEditorFieldKind::OtpUri + && self + .document + .fields() + .iter() + .any(|field| field.metadata().kind() == EntryFieldKind::OtpUri) + { + return Err(MobileEntryEditorError::ExistingOtp); + } let draft = match kind { MobileEntryEditorFieldKind::Password => { return Err(MobileEntryEditorError::ProtectedPasswordField); @@ -343,10 +354,26 @@ impl MobileEntryDraft { } MobileEntryEditorFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?, }; + if kind == MobileEntryEditorFieldKind::OtpUri + && self.creating + && let [field] = self.document.fields() + && field.metadata().kind() == EntryFieldKind::Password + && field.value().is_empty() + { + self.document.update(field.id(), draft)?; + return Ok(()); + } self.document.add(self.document.fields().len(), draft)?; Ok(()) } + pub fn add_otp_image(&mut self, image: SecretBytes) -> Result<(), MobileEntryEditorError> { + let payload = QrMatrix::decode_image(&image).map_err(MobileEntryEditorError::Qr)?; + let uri = String::from_utf8(payload.expose().to_vec()) + .map_err(|_| MobileEntryEditorError::NonUtf8)?; + self.add(MobileEntryEditorFieldKind::OtpUri, None, uri) + } + pub fn remove(&mut self, id: u64) -> Result<(), MobileEntryEditorError> { let id = EntryFieldId::from_value(id); if self @@ -420,6 +447,8 @@ pub enum MobileEntryEditorError { MissingFieldName, ProtectedPasswordField, NonUtf8, + ExistingOtp, + Qr(QrError), } impl fmt::Display for MobileEntryEditorError { @@ -433,6 +462,10 @@ impl fmt::Display for MobileEntryEditorError { formatter.write_str("the first password field cannot be removed or moved") } Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"), + Self::ExistingOtp => formatter.write_str( + "this entry already has an OTP URI; edit its existing field to replace it", + ), + Self::Qr(error) => error.fmt(formatter), } } } diff --git a/crates/storage/src/presentation.rs b/crates/storage/src/presentation.rs index 7646f8c..7efa861 100644 --- a/crates/storage/src/presentation.rs +++ b/crates/storage/src/presentation.rs @@ -242,7 +242,19 @@ impl QrMatrix { /// Decode the first QR symbol from an encoded image without exposing the /// secret-derived payload to a presentation adapter. pub fn decode_image(image: &SecretBytes) -> Result { - let grayscale = image::load_from_memory(image.expose()) + if image.expose().len() > 32 * 1024 * 1024 { + return Err(QrError::InvalidImage); + } + let mut reader = image::ImageReader::new(std::io::Cursor::new(image.expose())) + .with_guessed_format() + .map_err(|_| QrError::InvalidImage)?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(128 * 1024 * 1024); + reader.limits(limits); + let grayscale = reader + .decode() .map_err(|_| QrError::InvalidImage)? .into_luma8(); let mut prepared = rqrr::PreparedImage::prepare_from_greyscale( diff --git a/crates/storage/tests/entry_documents.rs b/crates/storage/tests/entry_documents.rs index 759fea3..dc8a3b4 100644 --- a/crates/storage/tests/entry_documents.rs +++ b/crates/storage/tests/entry_documents.rs @@ -801,6 +801,87 @@ fn mobile_editor_creates_edits_generates_and_preserves_dynamic_fields() -> TestR Ok(()) } +#[test] +fn mobile_otp_import_creates_or_appends_one_validated_field() -> TestResult { + use ironstorage::presentation::QrMatrix; + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut secrets = FixtureSecrets::all(&fixture); + let service = EntryDocumentService::new(&repository, &keys); + let uri = "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example"; + let mut document = service.open("otp/new-camera", &mut secrets)?; + document.add(0, EntryFieldDraft::line(Vec::new())?)?; + let mut draft = MobileEntryDraft::new(document, true); + let original = draft.document().serialize().expose().to_vec(); + assert!( + draft + .add(MobileEntryEditorFieldKind::OtpUri, None, "invalid".into()) + .is_err() + ); + assert_eq!(draft.document().serialize().expose(), original); + assert!( + draft + .add_otp_image(SecretBytes::new(b"not an image".to_vec())) + .is_err() + ); + assert_eq!(draft.document().serialize().expose(), original); + + let matrix = QrMatrix::encode(&SecretBytes::new(uri.as_bytes().to_vec()))?; + let size = (matrix.width() as u32 + 8) * 8; + let image = image::GrayImage::from_fn(size, size, |x, y| { + let dark = x / 8 >= 4 + && y / 8 >= 4 + && matrix.is_dark((x / 8 - 4) as usize, (y / 8 - 4) as usize) == Some(true); + image::Luma([if dark { 0 } else { 255 }]) + }); + let mut encoded = std::io::Cursor::new(Vec::new()); + image.write_to(&mut encoded, image::ImageFormat::Png)?; + draft.add_otp_image(SecretBytes::new(encoded.into_inner()))?; + assert_eq!(draft.page().fields().len(), 1); + assert_eq!( + draft.page().fields()[0].kind(), + MobileEntryEditorFieldKind::OtpUri + ); + assert_eq!(draft.document().serialize().expose(), uri.as_bytes()); + assert!(matches!( + draft.add(MobileEntryEditorFieldKind::OtpUri, None, uri.into()), + Err(MobileEntryEditorError::ExistingOtp) + )); + service.save_recoverable(draft.document(), None, &mut Committer::default())?; + let reopened = service.open("otp/new-camera", &mut secrets)?; + let otp = reopened.fields()[0].metadata().otp().expect("OTP metadata"); + assert_eq!(otp.issuer(), Some("Example")); + assert_eq!(otp.account(), "alice"); + + write_plaintext( + &repository, + &keys, + "otp/existing", + b"keep-password\nusername: original\nnotes: keep notes\n", + )?; + let mut draft = MobileEntryDraft::new(service.open("otp/existing", &mut secrets)?, false); + let before = draft.document().serialize().expose().to_vec(); + draft.add(MobileEntryEditorFieldKind::OtpUri, None, uri.into())?; + assert!(draft.document().serialize().expose().starts_with(&before)); + assert_eq!( + draft.page().fields().last().unwrap().kind(), + MobileEntryEditorFieldKind::OtpUri + ); + service.save_recoverable(draft.document(), None, &mut Committer::default())?; + assert_eq!( + service + .open("otp/existing", &mut secrets)? + .fields() + .last() + .unwrap() + .value(), + uri.as_bytes() + ); + Ok(()) +} + #[test] fn every_compatibility_entry_round_trips_as_a_document() -> TestResult { let fixture = FixtureSet::load()?; diff --git a/crates/storage/tests/presentation.rs b/crates/storage/tests/presentation.rs index 250a6fa..ccaac7f 100644 --- a/crates/storage/tests/presentation.rs +++ b/crates/storage/tests/presentation.rs @@ -260,6 +260,12 @@ fn qr_image_import_returns_secret_bytes_and_rejects_invalid_input() -> TestResul image.write_to(&mut encoded, image::ImageFormat::Png)?; let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?; assert_eq!(decoded.expose(), payload.expose()); + let mut oversized = Cursor::new(Vec::new()); + image::GrayImage::new(4097, 1).write_to(&mut oversized, image::ImageFormat::Png)?; + assert!(matches!( + QrMatrix::decode_image(&SecretBytes::new(oversized.into_inner())), + Err(QrError::InvalidImage) + )); assert!(matches!( QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())), Err(QrError::InvalidImage)