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

+17 -1
View File
@@ -607,6 +607,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
func addEntryEditorField(editor: UInt64, kind: MobileEntryEditorFieldKind, name: String?, value: String) throws -> MobileEntryEditorPage 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 beginCreateEntry(directory: String, name: String, passphrase: String?) throws -> MobileEntryEditorSession
func beginEntryEditor(path: 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 { open func beginCreateEntry(directory: String, name: String, passphrase: String?)throws -> MobileEntryEditorSession {
return try FfiConverterTypeMobileEntryEditorSession_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { return try FfiConverterTypeMobileEntryEditorSession_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
@@ -7960,6 +7973,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field() != 58790) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field() != 58790) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_begin_create_entry() != 21567) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
@@ -8163,4 +8179,4 @@ public func uniffiEnsureIronstorageAppleInitialized() {
} }
} }
// swiftlint:enable all // swiftlint:enable all
+11
View File
@@ -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 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 #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 #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY
#define 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 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 #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 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY
+1 -1
View File
@@ -23,7 +23,7 @@
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Scan GPG key transfer QR codes that you choose to import.</string> <string>Scan OTP and GPG key transfer QR codes that you choose to import.</string>
<key>NSFaceIDUsageDescription</key> <key>NSFaceIDUsageDescription</key>
<string>Unlock your GPG key for protected password operations.</string> <string>Unlock your GPG key for protected password operations.</string>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
+249 -19
View File
@@ -2,6 +2,7 @@ import UIKit
import AVFoundation import AVFoundation
import Vision import Vision
import VisionKit import VisionKit
import PhotosUI
import WatchConnectivity import WatchConnectivity
extension Notification.Name { extension Notification.Name {
@@ -2905,13 +2906,67 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
) )
navigationItem.rightBarButtonItem?.accessibilityLabel = navigationItem.rightBarButtonItem?.accessibilityLabel =
unlocked ? "Lock IronStorage" : "Unlock IronStorage" 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() { private func showLocked() {
page = nil page = nil
unfilteredPage = nil unfilteredPage = nil
tableView.reloadData() tableView.reloadData()
navigationItem.rightBarButtonItem = nil configureLockButton()
var configuration = UIContentUnavailableConfiguration.empty() var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "lock.fill") configuration.image = UIImage(systemName: "lock.fill")
configuration.text = "TOTP Is Locked" configuration.text = "TOTP Is Locked"
@@ -5324,6 +5379,7 @@ private final class MobileEntryEditorViewController: UITableViewController,
private var editorID: UInt64 private var editorID: UInt64
private var page: MobileEntryEditorPage private var page: MobileEntryEditorPage
private let focusedField: UInt64? private let focusedField: UInt64?
private var addingOtp: Bool
private let saved: (String) -> Void private let saved: (String) -> Void
private var states: [UInt64: FieldState] = [:] private var states: [UInt64: FieldState] = [:]
private var localDirty = false private var localDirty = false
@@ -5334,12 +5390,14 @@ private final class MobileEntryEditorViewController: UITableViewController,
authentication: MobileAuthentication, authentication: MobileAuthentication,
session: MobileEntryEditorSession, session: MobileEntryEditorSession,
focusedField: UInt64?, focusedField: UInt64?,
addingOtp: Bool = false,
saved: @escaping (String) -> Void saved: @escaping (String) -> Void
) { ) {
self.authentication = authentication self.authentication = authentication
editorID = session.id editorID = session.id
page = session.page page = session.page
self.focusedField = focusedField self.focusedField = focusedField
self.addingOtp = addingOtp
self.saved = saved self.saved = saved
super.init(style: .insetGrouped) super.init(style: .insetGrouped)
resetStates() resetStates()
@@ -5404,6 +5462,10 @@ private final class MobileEntryEditorViewController: UITableViewController,
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
navigationController?.presentationController?.delegate = self navigationController?.presentationController?.delegate = self
if addingOtp {
addingOtp = false
promptForOtpUri()
}
guard !focused, let focusedField, guard !focused, let focusedField,
let index = page.fields.firstIndex(where: { $0.id == focusedField }) let index = page.fields.firstIndex(where: { $0.id == focusedField })
else { return } else { return }
@@ -5590,7 +5652,7 @@ private final class MobileEntryEditorViewController: UITableViewController,
sheet.addAction(UIAlertAction(title: "Notes", style: .default) { [weak self] _ in sheet.addAction(UIAlertAction(title: "Notes", style: .default) { [weak self] _ in
self?.addField(kind: .note, name: nil, value: "") 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() self?.promptForOtpUri()
}) })
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
@@ -5619,23 +5681,16 @@ private final class MobileEntryEditorViewController: UITableViewController,
} }
private func promptForOtpUri() { private func promptForOtpUri() {
let alert = UIAlertController( do {
title: "OTP URI", try synchronizeDraft()
message: "Enter the complete otpauth URI.", let importer = OtpImportViewController(authentication: authentication, editor: editorID) { [weak self] page in
preferredStyle: .alert guard let self else { return }
) self.page = page
alert.addTextField { field in resetStates()
field.placeholder = "otpauth://…" tableView.reloadData()
field.keyboardType = .URL }
field.autocapitalizationType = .none navigationController?.pushViewController(importer, animated: true)
field.autocorrectionType = .no } catch { presentEditorFailure(error) }
}
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)
} }
private func addField(kind: MobileEntryEditorFieldKind, name: String?, value: String) { 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 @MainActor
private final class MobileEntryEditorCell: UITableViewCell, UITextFieldDelegate, UITextViewDelegate { private final class MobileEntryEditorCell: UITableViewCell, UITextFieldDelegate, UITextViewDelegate {
private let iconView = UIImageView() private let iconView = UIImageView()
+13
View File
@@ -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 | | 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 | | 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 | | 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 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 | | 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 | | 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 ## 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 Record the device model/OS, build commit, and pass/fail result for each manual
row above. Simulator validation is required during implementation. The final row above. Simulator validation is required during implementation. The final
distribution issue remains open until the approved App Store build is installed distribution issue remains open until the approved App Store build is installed
+1 -1
View File
@@ -27,7 +27,7 @@ targets:
CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
NSFaceIDUsageDescription: Unlock your GPG key for protected password operations. 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: {} UILaunchScreen: {}
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
+11
View File
@@ -1901,6 +1901,17 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn add_entry_editor_otp_image(
&self,
editor: u64,
image: Vec<u8>,
) -> Result<MobileEntryEditorPage, MobileAuthenticationFfiError> {
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( pub fn remove_entry_editor_field(
&self, &self,
editor: u64, editor: u64,
@@ -1173,6 +1173,18 @@ impl MobileAuthentication {
Ok(draft.page()) Ok(draft.page())
} }
pub fn add_entry_editor_otp_image(
&self,
editor: u64,
image: SecretBytes,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
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( pub fn remove_entry_editor_field(
&self, &self,
editor: u64, editor: u64,
+33
View File
@@ -7,6 +7,8 @@ use crate::document::{
EntryFieldKind, EntrySensitivity, EntryFieldKind, EntrySensitivity,
}; };
use crate::generate::{GenerateError, GeneratorConfig, MAX_PASSWORD_LENGTH}; use crate::generate::{GenerateError, GeneratorConfig, MAX_PASSWORD_LENGTH};
use crate::presentation::{QrError, QrMatrix};
use crate::repository::SecretBytes;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileEntrySectionKind { pub enum MobileEntrySectionKind {
@@ -330,6 +332,15 @@ impl MobileEntryDraft {
name: Option<String>, name: Option<String>,
value: String, value: String,
) -> Result<(), MobileEntryEditorError> { ) -> 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 { let draft = match kind {
MobileEntryEditorFieldKind::Password => { MobileEntryEditorFieldKind::Password => {
return Err(MobileEntryEditorError::ProtectedPasswordField); return Err(MobileEntryEditorError::ProtectedPasswordField);
@@ -343,10 +354,26 @@ impl MobileEntryDraft {
} }
MobileEntryEditorFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?, 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)?; self.document.add(self.document.fields().len(), draft)?;
Ok(()) 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> { pub fn remove(&mut self, id: u64) -> Result<(), MobileEntryEditorError> {
let id = EntryFieldId::from_value(id); let id = EntryFieldId::from_value(id);
if self if self
@@ -420,6 +447,8 @@ pub enum MobileEntryEditorError {
MissingFieldName, MissingFieldName,
ProtectedPasswordField, ProtectedPasswordField,
NonUtf8, NonUtf8,
ExistingOtp,
Qr(QrError),
} }
impl fmt::Display for MobileEntryEditorError { 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") 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::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),
} }
} }
} }
+13 -1
View File
@@ -242,7 +242,19 @@ impl QrMatrix {
/// Decode the first QR symbol from an encoded image without exposing the /// Decode the first QR symbol from an encoded image without exposing the
/// secret-derived payload to a presentation adapter. /// secret-derived payload to a presentation adapter.
pub fn decode_image(image: &SecretBytes) -> Result<SecretBytes, QrError> { pub fn decode_image(image: &SecretBytes) -> Result<SecretBytes, QrError> {
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)? .map_err(|_| QrError::InvalidImage)?
.into_luma8(); .into_luma8();
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale( let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
+81
View File
@@ -801,6 +801,87 @@ fn mobile_editor_creates_edits_generates_and_preserves_dynamic_fields() -> TestR
Ok(()) 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] #[test]
fn every_compatibility_entry_round_trips_as_a_document() -> TestResult { fn every_compatibility_entry_round_trips_as_a_document() -> TestResult {
let fixture = FixtureSet::load()?; let fixture = FixtureSet::load()?;
+6
View File
@@ -260,6 +260,12 @@ fn qr_image_import_returns_secret_bytes_and_rejects_invalid_input() -> TestResul
image.write_to(&mut encoded, image::ImageFormat::Png)?; image.write_to(&mut encoded, image::ImageFormat::Png)?;
let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?; let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?;
assert_eq!(decoded.expose(), payload.expose()); 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!( assert!(matches!(
QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())), QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())),
Err(QrError::InvalidImage) Err(QrError::InvalidImage)