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
@@ -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
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()?;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user