Implement iPhone TOTP tab and Watch sharing
This commit is contained in:
@@ -595,6 +595,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
||||
|
||||
func copyEntryField(path: String, field: UInt64) throws -> MobileEntryCopy
|
||||
|
||||
func copyTotpCode(path: String, unixSeconds: UInt64) throws -> MobileEntryCopy
|
||||
|
||||
func discardEntryEditor(editor: UInt64) throws
|
||||
|
||||
func entryEditor(editor: UInt64) throws -> MobileEntryEditorPage
|
||||
@@ -617,12 +619,20 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
||||
|
||||
func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState
|
||||
|
||||
func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64) throws -> MobileTotpDetail
|
||||
|
||||
func state() throws -> MobileAuthenticationState
|
||||
|
||||
func totpDetail(path: String, unixSeconds: UInt64) throws -> MobileTotpDetail
|
||||
|
||||
func totpPage() throws -> MobileTotpPage
|
||||
|
||||
func touchUserActivity() throws
|
||||
|
||||
func unlockEntry(path: String, passphrase: String?) throws -> MobileAuthenticationState
|
||||
|
||||
func unlockTotp(passphrase: String?) throws -> MobileAuthenticationState
|
||||
|
||||
func updateEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput]) throws -> MobileEntryEditorPage
|
||||
|
||||
}
|
||||
@@ -733,6 +743,17 @@ open func copyEntryField(path: String, field: UInt64)throws -> MobileEntryCopy
|
||||
})
|
||||
}
|
||||
|
||||
open func copyTotpCode(path: String, unixSeconds: UInt64)throws -> MobileEntryCopy {
|
||||
return try FfiConverterTypeMobileEntryCopy_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_totp_code(
|
||||
self.uniffiCloneHandle(),
|
||||
FfiConverterString.lower(path),
|
||||
FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
open func discardEntryEditor(editor: UInt64)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor(
|
||||
@@ -849,6 +870,18 @@ open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState
|
||||
})
|
||||
}
|
||||
|
||||
open func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64)throws -> MobileTotpDetail {
|
||||
return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(
|
||||
self.uniffiCloneHandle(),
|
||||
FfiConverterString.lower(path),
|
||||
FfiConverterBool.lower(shared),
|
||||
FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
open func state()throws -> MobileAuthenticationState {
|
||||
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
@@ -858,6 +891,26 @@ open func state()throws -> MobileAuthenticationState {
|
||||
})
|
||||
}
|
||||
|
||||
open func totpDetail(path: String, unixSeconds: UInt64)throws -> MobileTotpDetail {
|
||||
return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_detail(
|
||||
self.uniffiCloneHandle(),
|
||||
FfiConverterString.lower(path),
|
||||
FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
open func totpPage()throws -> MobileTotpPage {
|
||||
return try FfiConverterTypeMobileTotpPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_page(
|
||||
self.uniffiCloneHandle(),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
open func touchUserActivity()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity(
|
||||
@@ -877,6 +930,16 @@ open func unlockEntry(path: String, passphrase: String?)throws -> MobileAuthent
|
||||
})
|
||||
}
|
||||
|
||||
open func unlockTotp(passphrase: String?)throws -> MobileAuthenticationState {
|
||||
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_totp(
|
||||
self.uniffiCloneHandle(),
|
||||
FfiConverterOptionString.lower(passphrase),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
open func updateEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput])throws -> MobileEntryEditorPage {
|
||||
return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
@@ -2682,6 +2745,266 @@ public func FfiConverterTypeMobileShell_lower(_ value: MobileShell) -> RustBuffe
|
||||
}
|
||||
|
||||
|
||||
public struct MobileTotpDetail: Equatable, Hashable {
|
||||
public var path: String
|
||||
public var issuer: String?
|
||||
public var account: String
|
||||
public var code: String
|
||||
public var validUntil: UInt64
|
||||
public var period: UInt64
|
||||
public var sharedWithWatch: Bool
|
||||
public var watch: MobileWatchSnapshotStatus
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// declare one manually.
|
||||
public init(path: String, issuer: String?, account: String, code: String, validUntil: UInt64, period: UInt64, sharedWithWatch: Bool, watch: MobileWatchSnapshotStatus) {
|
||||
self.path = path
|
||||
self.issuer = issuer
|
||||
self.account = account
|
||||
self.code = code
|
||||
self.validUntil = validUntil
|
||||
self.period = period
|
||||
self.sharedWithWatch = sharedWithWatch
|
||||
self.watch = watch
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileTotpDetail: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileTotpDetail: FfiConverterRustBuffer {
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpDetail {
|
||||
return
|
||||
try MobileTotpDetail(
|
||||
path: FfiConverterString.read(from: &buf),
|
||||
issuer: FfiConverterOptionString.read(from: &buf),
|
||||
account: FfiConverterString.read(from: &buf),
|
||||
code: FfiConverterString.read(from: &buf),
|
||||
validUntil: FfiConverterUInt64.read(from: &buf),
|
||||
period: FfiConverterUInt64.read(from: &buf),
|
||||
sharedWithWatch: FfiConverterBool.read(from: &buf),
|
||||
watch: FfiConverterTypeMobileWatchSnapshotStatus.read(from: &buf)
|
||||
)
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileTotpDetail, into buf: inout [UInt8]) {
|
||||
FfiConverterString.write(value.path, into: &buf)
|
||||
FfiConverterOptionString.write(value.issuer, into: &buf)
|
||||
FfiConverterString.write(value.account, into: &buf)
|
||||
FfiConverterString.write(value.code, into: &buf)
|
||||
FfiConverterUInt64.write(value.validUntil, into: &buf)
|
||||
FfiConverterUInt64.write(value.period, into: &buf)
|
||||
FfiConverterBool.write(value.sharedWithWatch, into: &buf)
|
||||
FfiConverterTypeMobileWatchSnapshotStatus.write(value.watch, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileTotpDetail_lift(_ buf: RustBuffer) throws -> MobileTotpDetail {
|
||||
return try FfiConverterTypeMobileTotpDetail.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileTotpDetail_lower(_ value: MobileTotpDetail) -> RustBuffer {
|
||||
return FfiConverterTypeMobileTotpDetail.lower(value)
|
||||
}
|
||||
|
||||
|
||||
public struct MobileTotpPage: Equatable, Hashable {
|
||||
public var rows: [MobileTotpRow]
|
||||
public var unavailableEntries: UInt32
|
||||
public var watch: MobileWatchSnapshotStatus
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// declare one manually.
|
||||
public init(rows: [MobileTotpRow], unavailableEntries: UInt32, watch: MobileWatchSnapshotStatus) {
|
||||
self.rows = rows
|
||||
self.unavailableEntries = unavailableEntries
|
||||
self.watch = watch
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileTotpPage: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileTotpPage: FfiConverterRustBuffer {
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpPage {
|
||||
return
|
||||
try MobileTotpPage(
|
||||
rows: FfiConverterSequenceTypeMobileTotpRow.read(from: &buf),
|
||||
unavailableEntries: FfiConverterUInt32.read(from: &buf),
|
||||
watch: FfiConverterTypeMobileWatchSnapshotStatus.read(from: &buf)
|
||||
)
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileTotpPage, into buf: inout [UInt8]) {
|
||||
FfiConverterSequenceTypeMobileTotpRow.write(value.rows, into: &buf)
|
||||
FfiConverterUInt32.write(value.unavailableEntries, into: &buf)
|
||||
FfiConverterTypeMobileWatchSnapshotStatus.write(value.watch, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileTotpPage_lift(_ buf: RustBuffer) throws -> MobileTotpPage {
|
||||
return try FfiConverterTypeMobileTotpPage.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileTotpPage_lower(_ value: MobileTotpPage) -> RustBuffer {
|
||||
return FfiConverterTypeMobileTotpPage.lower(value)
|
||||
}
|
||||
|
||||
|
||||
public struct MobileTotpRow: Equatable, Hashable {
|
||||
public var path: String
|
||||
public var issuer: String?
|
||||
public var account: String
|
||||
public var title: String
|
||||
public var detail: String
|
||||
public var sharedWithWatch: Bool
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// declare one manually.
|
||||
public init(path: String, issuer: String?, account: String, title: String, detail: String, sharedWithWatch: Bool) {
|
||||
self.path = path
|
||||
self.issuer = issuer
|
||||
self.account = account
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
self.sharedWithWatch = sharedWithWatch
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileTotpRow: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileTotpRow: FfiConverterRustBuffer {
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpRow {
|
||||
return
|
||||
try MobileTotpRow(
|
||||
path: FfiConverterString.read(from: &buf),
|
||||
issuer: FfiConverterOptionString.read(from: &buf),
|
||||
account: FfiConverterString.read(from: &buf),
|
||||
title: FfiConverterString.read(from: &buf),
|
||||
detail: FfiConverterString.read(from: &buf),
|
||||
sharedWithWatch: FfiConverterBool.read(from: &buf)
|
||||
)
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileTotpRow, into buf: inout [UInt8]) {
|
||||
FfiConverterString.write(value.path, into: &buf)
|
||||
FfiConverterOptionString.write(value.issuer, into: &buf)
|
||||
FfiConverterString.write(value.account, into: &buf)
|
||||
FfiConverterString.write(value.title, into: &buf)
|
||||
FfiConverterString.write(value.detail, into: &buf)
|
||||
FfiConverterBool.write(value.sharedWithWatch, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileTotpRow_lift(_ buf: RustBuffer) throws -> MobileTotpRow {
|
||||
return try FfiConverterTypeMobileTotpRow.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileTotpRow_lower(_ value: MobileTotpRow) -> RustBuffer {
|
||||
return FfiConverterTypeMobileTotpRow.lower(value)
|
||||
}
|
||||
|
||||
|
||||
public struct MobileWatchSnapshotStatus: Equatable, Hashable {
|
||||
public var state: MobileWatchSnapshotState
|
||||
public var detail: String
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// declare one manually.
|
||||
public init(state: MobileWatchSnapshotState, detail: String) {
|
||||
self.state = state
|
||||
self.detail = detail
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileWatchSnapshotStatus: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileWatchSnapshotStatus: FfiConverterRustBuffer {
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileWatchSnapshotStatus {
|
||||
return
|
||||
try MobileWatchSnapshotStatus(
|
||||
state: FfiConverterTypeMobileWatchSnapshotState.read(from: &buf),
|
||||
detail: FfiConverterString.read(from: &buf)
|
||||
)
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileWatchSnapshotStatus, into buf: inout [UInt8]) {
|
||||
FfiConverterTypeMobileWatchSnapshotState.write(value.state, into: &buf)
|
||||
FfiConverterString.write(value.detail, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileWatchSnapshotStatus_lift(_ buf: RustBuffer) throws -> MobileWatchSnapshotStatus {
|
||||
return try FfiConverterTypeMobileWatchSnapshotStatus.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileWatchSnapshotStatus_lower(_ value: MobileWatchSnapshotStatus) -> RustBuffer {
|
||||
return FfiConverterTypeMobileWatchSnapshotStatus.lower(value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
public enum MobileAuthenticationErrorKind: Equatable, Hashable {
|
||||
|
||||
@@ -4311,6 +4634,72 @@ public func FfiConverterTypeMobileTab_lower(_ value: MobileTab) -> RustBuffer {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public enum MobileWatchSnapshotState: Equatable, Hashable {
|
||||
|
||||
case unavailable
|
||||
case pending
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileWatchSnapshotState: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileWatchSnapshotState: FfiConverterRustBuffer {
|
||||
typealias SwiftType = MobileWatchSnapshotState
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileWatchSnapshotState {
|
||||
let variant: Int32 = try readInt(&buf)
|
||||
switch variant {
|
||||
|
||||
case 1: return .unavailable
|
||||
|
||||
case 2: return .pending
|
||||
|
||||
default: throw UniffiInternalError.unexpectedEnumCase
|
||||
}
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileWatchSnapshotState, into buf: inout [UInt8]) {
|
||||
switch value {
|
||||
|
||||
|
||||
case .unavailable:
|
||||
writeInt(&buf, Int32(1))
|
||||
|
||||
|
||||
case .pending:
|
||||
writeInt(&buf, Int32(2))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileWatchSnapshotState_lift(_ buf: RustBuffer) throws -> MobileWatchSnapshotState {
|
||||
return try FfiConverterTypeMobileWatchSnapshotState.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileWatchSnapshotState_lower(_ value: MobileWatchSnapshotState) -> RustBuffer {
|
||||
return FfiConverterTypeMobileWatchSnapshotState.lower(value)
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
@@ -4656,6 +5045,31 @@ fileprivate struct FfiConverterSequenceTypeMobilePasswordRow: FfiConverterRustBu
|
||||
return seq
|
||||
}
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
fileprivate struct FfiConverterSequenceTypeMobileTotpRow: FfiConverterRustBuffer {
|
||||
typealias SwiftType = [MobileTotpRow]
|
||||
|
||||
public static func write(_ value: [MobileTotpRow], into buf: inout [UInt8]) {
|
||||
let len = Int32(value.count)
|
||||
writeInt(&buf, len)
|
||||
for item in value {
|
||||
FfiConverterTypeMobileTotpRow.write(item, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileTotpRow] {
|
||||
let len: Int32 = try readInt(&buf)
|
||||
var seq = [MobileTotpRow]()
|
||||
seq.reserveCapacity(Int(len))
|
||||
for _ in 0 ..< len {
|
||||
seq.append(try FfiConverterTypeMobileTotpRow.read(from: &buf))
|
||||
}
|
||||
return seq
|
||||
}
|
||||
}
|
||||
public func mobileAuthentication()throws -> MobileAuthentication {
|
||||
return try FfiConverterTypeMobileAuthentication_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
@@ -4784,6 +5198,9 @@ private let initializationResult: InitializationResult = {
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entry_field() != 17773) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_totp_code() != 8344) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_discard_entry_editor() != 28195) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
@@ -4817,15 +5234,27 @@ private let initializationResult: InitializationResult = {
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared() != 57472) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_state() != 60826) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_detail() != 42762) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_page() != 18529) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_touch_user_activity() != 18402) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_entry() != 28139) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_totp() != 816) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_update_entry_editor() != 30139) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
|
||||
@@ -278,6 +278,11 @@ void uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(uint64_t ptr
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_totp_code(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor(uint64_t ptr, uint64_t editor, RustCallStatus *_Nonnull out_status
|
||||
@@ -333,11 +338,26 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_ed
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(uint64_t ptr, RustBuffer path, int8_t shared, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_state(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_detail(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_PAGE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_PAGE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_page(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
@@ -348,6 +368,11 @@ void uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_entry(uint64_t ptr, RustBuffer path, RustBuffer passphrase, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UNLOCK_TOTP
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UNLOCK_TOTP
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_totp(uint64_t ptr, RustBuffer passphrase, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_update_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status
|
||||
@@ -814,6 +839,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel(vo
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entry_field(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_totp_code(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
|
||||
@@ -880,12 +911,30 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entr
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_state(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_detail(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_PAGE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_PAGE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_page(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY
|
||||
@@ -898,6 +947,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_touch_use
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_entry(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_TOTP
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_TOTP
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_totp(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR
|
||||
|
||||
@@ -7,6 +7,9 @@ extension Notification.Name {
|
||||
static let ironStorageAuthenticationDidChange = Notification.Name(
|
||||
"de.rfc1437.ironstorage.authentication-did-change"
|
||||
)
|
||||
static let ironStorageWatchSnapshotDidChange = Notification.Name(
|
||||
"de.rfc1437.ironstorage.watch-snapshot-did-change"
|
||||
)
|
||||
}
|
||||
|
||||
@main
|
||||
@@ -56,6 +59,8 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
let root: UIViewController = switch page.tab {
|
||||
case .passwords:
|
||||
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
|
||||
case .totp:
|
||||
TotpListViewController(shellPage: page, authentication: authentication)
|
||||
case .preferences:
|
||||
PreferencesViewController(page: page, authentication: authentication)
|
||||
default:
|
||||
@@ -888,6 +893,738 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TotpListViewController: UITableViewController, MobileTabRoot {
|
||||
fileprivate let shellTab = MobileTab.totp
|
||||
private let authentication: MobileAuthentication?
|
||||
private var shellPage: MobilePage
|
||||
private var page: MobileTotpPage?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var unlockTask: Task<Void, Never>?
|
||||
|
||||
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
|
||||
self.shellPage = shellPage
|
||||
self.authentication = authentication
|
||||
super.init(style: .insetGrouped)
|
||||
title = shellPage.title
|
||||
navigationItem.largeTitleDisplayMode = .always
|
||||
refreshControl = UIRefreshControl()
|
||||
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
unlockTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(authenticationDidChange),
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(localStoreDidChange),
|
||||
name: .ironStorageLocalStoreDidChange,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(localStoreDidChange),
|
||||
name: .ironStorageWatchSnapshotDidChange,
|
||||
object: nil
|
||||
)
|
||||
refreshState()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
refreshState()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
page?.rows.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
guard let page else { return nil }
|
||||
let unavailable = page.unavailableEntries == 0
|
||||
? ""
|
||||
: " \(page.unavailableEntries) entries could not be inspected with the active key."
|
||||
return page.watch.detail + unavailable
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let row = page?.rows[indexPath.row]
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.image = UIImage(systemName: "timer")
|
||||
content.text = row?.title
|
||||
content.secondaryText = row?.detail
|
||||
content.secondaryTextProperties.numberOfLines = 2
|
||||
if row?.sharedWithWatch == true {
|
||||
content.secondaryText = [row?.detail, "Apple Watch selected"]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " • ")
|
||||
}
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.accessibilityLabel = [row?.title, row?.detail].compactMap { $0 }.joined(separator: ", ")
|
||||
cell.accessibilityValue = row?.sharedWithWatch == true ? "Selected for Apple Watch" : nil
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let row = page?.rows[indexPath.row], let authentication else { return }
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
return Result<MobileTotpDetail, AuthenticationFailure>.success(
|
||||
try authentication.totpDetail(
|
||||
path: row.path,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} 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(detail):
|
||||
navigationController?.pushViewController(
|
||||
TotpDetailViewController(authentication: authentication, detail: detail),
|
||||
animated: true
|
||||
)
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshRequested() {
|
||||
refreshState(force: true)
|
||||
}
|
||||
|
||||
@objc private func authenticationDidChange() {
|
||||
refreshState()
|
||||
}
|
||||
|
||||
@objc private func localStoreDidChange() {
|
||||
guard (try? authentication?.state().unlocked) == true else { return }
|
||||
loadPage()
|
||||
}
|
||||
|
||||
@objc private func unlockRequested() {
|
||||
unlock(passphrase: nil)
|
||||
}
|
||||
|
||||
@objc private func lockRequested() {
|
||||
guard let authentication else { return }
|
||||
do {
|
||||
try authentication.manualLock()
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
presentAuthenticationFailure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
presentAuthenticationFailure(.unexpected)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshState(force: Bool = false) {
|
||||
guard shellPage.state == .ready else {
|
||||
page = nil
|
||||
showUnavailable(
|
||||
title: shellPage.stateTitle,
|
||||
detail: shellPage.stateDetail,
|
||||
image: shellPage.systemImage
|
||||
)
|
||||
return
|
||||
}
|
||||
if (try? authentication?.state().unlocked) == true {
|
||||
if page == nil || force { loadPage() }
|
||||
} else {
|
||||
loadTask?.cancel()
|
||||
page = nil
|
||||
tableView.reloadData()
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "lock.fill")
|
||||
configuration.text = "TOTP Is Locked"
|
||||
configuration.secondaryText =
|
||||
"Authenticate to scan password entries for time-based one-time passwords."
|
||||
configuration.button = .filled()
|
||||
configuration.button.title = "Unlock"
|
||||
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
|
||||
self?.unlockRequested()
|
||||
}
|
||||
contentUnavailableConfiguration = configuration
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private func unlock(passphrase: String?) {
|
||||
guard let authentication else {
|
||||
presentAuthenticationFailure(.unavailable)
|
||||
return
|
||||
}
|
||||
unlockTask?.cancel()
|
||||
showLoading("Unlocking TOTP")
|
||||
unlockTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
|
||||
try authentication.unlockTotp(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 .success:
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
loadPage()
|
||||
UIAccessibility.post(notification: .announcement, argument: "TOTP unlocked")
|
||||
case let .failure(failure)
|
||||
where passphrase == nil
|
||||
&& (failure.kind == .passphraseRequired
|
||||
|| failure.kind == .biometryUnavailable):
|
||||
promptForPassphrase(message: failure.detail)
|
||||
case let .failure(failure):
|
||||
if failure.kind != .cancelled { handle(failure) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func promptForPassphrase(message: String) {
|
||||
let alert = UIAlertController(
|
||||
title: "GPG Key Passphrase",
|
||||
message: message,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addTextField { field in
|
||||
field.isSecureTextEntry = true
|
||||
field.textContentType = .password
|
||||
field.placeholder = "Passphrase"
|
||||
field.returnKeyType = .go
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
||||
self?.refreshState()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
|
||||
guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return }
|
||||
alert?.textFields?.first?.text = nil
|
||||
self?.unlock(passphrase: value)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func loadPage() {
|
||||
guard let authentication else {
|
||||
presentAuthenticationFailure(.unavailable)
|
||||
return
|
||||
}
|
||||
loadTask?.cancel()
|
||||
showLoading("Loading TOTP")
|
||||
loadTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileTotpPage, AuthenticationFailure>.success(
|
||||
try authentication.totpPage()
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
refreshControl?.endRefreshing()
|
||||
switch result {
|
||||
case let .success(page):
|
||||
self.page = page
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "lock.fill"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(lockRequested)
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
|
||||
tableView.reloadData()
|
||||
if page.rows.isEmpty {
|
||||
showUnavailable(
|
||||
title: "No TOTP Codes",
|
||||
detail: "No valid time-based OTP entries were found in the password store.",
|
||||
image: "timer"
|
||||
)
|
||||
} else {
|
||||
contentUnavailableConfiguration = nil
|
||||
}
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ failure: AuthenticationFailure) {
|
||||
if failure.kind == .expired {
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
refreshState()
|
||||
} else {
|
||||
showUnavailable(title: failure.title, detail: failure.detail, image: "exclamationmark.triangle")
|
||||
}
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
|
||||
private func showLoading(_ title: String) {
|
||||
var configuration = UIContentUnavailableConfiguration.loading()
|
||||
configuration.text = title
|
||||
configuration.secondaryText = "Reading OTP metadata in secure storage."
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
private func showUnavailable(title: String, detail: String, image: String) {
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: image)
|
||||
configuration.text = title
|
||||
configuration.secondaryText = detail
|
||||
contentUnavailableConfiguration = configuration
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TotpDetailViewController: UITableViewController {
|
||||
private let authentication: MobileAuthentication
|
||||
private var detail: MobileTotpDetail
|
||||
private let codeContainer = UIView()
|
||||
private let issuerLabel = UILabel()
|
||||
private let accountLabel = UILabel()
|
||||
private let codeLabel = UILabel()
|
||||
private let countdownLabel = UILabel()
|
||||
private let progress = UIProgressView(progressViewStyle: .default)
|
||||
private let watchSwitch = UISwitch()
|
||||
private var timerTask: Task<Void, Never>?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var clipboardTask: Task<Void, Never>?
|
||||
private var copiedValue: String?
|
||||
|
||||
init(authentication: MobileAuthentication, detail: MobileTotpDetail) {
|
||||
self.authentication = authentication
|
||||
self.detail = detail
|
||||
super.init(style: .insetGrouped)
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
timerTask?.cancel()
|
||||
loadTask?.cancel()
|
||||
clipboardTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
configureHeader()
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(copyRequested)
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Copy current TOTP code"
|
||||
watchSwitch.addTarget(self, action: #selector(watchSwitchChanged(_:)), for: .valueChanged)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(authenticationDidChange),
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(entryDidChange),
|
||||
name: .ironStorageLocalStoreDidChange,
|
||||
object: nil
|
||||
)
|
||||
apply(detail)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
guard let header = tableView.tableHeaderView else { return }
|
||||
let height = header.systemLayoutSizeFitting(
|
||||
CGSize(width: tableView.bounds.width, height: 0),
|
||||
withHorizontalFittingPriority: .required,
|
||||
verticalFittingPriority: .fittingSizeLevel
|
||||
).height
|
||||
guard abs(header.frame.height - height) > 0.5 else { return }
|
||||
header.frame.size.height = height
|
||||
tableView.tableHeaderView = header
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
"Apple Watch"
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
detail.watch.detail
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = "Share with Apple Watch"
|
||||
content.secondaryText = "Time-based codes only"
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryView = watchSwitch
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
|
||||
@objc private func authenticationDidChange() {
|
||||
guard (try? authentication.state().unlocked) == true else {
|
||||
lockDetail()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func entryDidChange() {
|
||||
refreshCode()
|
||||
}
|
||||
|
||||
@objc private func copyRequested() {
|
||||
loadTask?.cancel()
|
||||
let authentication = authentication
|
||||
let path = detail.path
|
||||
loadTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
return Result<MobileEntryCopy, AuthenticationFailure>.success(
|
||||
try authentication.copyTotpCode(
|
||||
path: path,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
loadTask = nil
|
||||
switch result {
|
||||
case let .success(copy):
|
||||
UIPasteboard.general.string = copy.value
|
||||
copiedValue = copy.value
|
||||
flashCopied()
|
||||
clipboardTask?.cancel()
|
||||
clipboardTask = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(copy.timeoutSeconds))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if UIPasteboard.general.string == copy.value {
|
||||
UIPasteboard.general.items = []
|
||||
}
|
||||
self.copiedValue = nil
|
||||
}
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func watchSwitchChanged(_ sender: UISwitch) {
|
||||
let requested = sender.isOn
|
||||
sender.isEnabled = false
|
||||
loadTask?.cancel()
|
||||
let authentication = authentication
|
||||
let path = detail.path
|
||||
loadTask = Task { [weak self, weak sender] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
return Result<MobileTotpDetail, AuthenticationFailure>.success(
|
||||
try authentication.setTotpWatchShared(
|
||||
path: path,
|
||||
shared: requested,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
loadTask = nil
|
||||
sender?.isEnabled = true
|
||||
switch result {
|
||||
case let .success(detail):
|
||||
apply(detail)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageWatchSnapshotDidChange,
|
||||
object: nil
|
||||
)
|
||||
case let .failure(failure):
|
||||
sender?.setOn(!requested, animated: true)
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureHeader() {
|
||||
issuerLabel.font = .preferredFont(forTextStyle: .title2)
|
||||
issuerLabel.adjustsFontForContentSizeCategory = true
|
||||
issuerLabel.textAlignment = .center
|
||||
accountLabel.font = .preferredFont(forTextStyle: .body)
|
||||
accountLabel.textColor = .secondaryLabel
|
||||
accountLabel.adjustsFontForContentSizeCategory = true
|
||||
accountLabel.textAlignment = .center
|
||||
accountLabel.numberOfLines = 0
|
||||
codeLabel.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(
|
||||
for: .monospacedDigitSystemFont(ofSize: 48, weight: .semibold)
|
||||
)
|
||||
codeLabel.adjustsFontForContentSizeCategory = true
|
||||
codeLabel.textAlignment = .center
|
||||
codeLabel.minimumScaleFactor = 0.55
|
||||
codeLabel.adjustsFontSizeToFitWidth = true
|
||||
codeLabel.layer.cornerRadius = 12
|
||||
codeLabel.layer.masksToBounds = true
|
||||
codeLabel.isAccessibilityElement = true
|
||||
codeLabel.isUserInteractionEnabled = true
|
||||
codeLabel.accessibilityTraits.insert(.button)
|
||||
codeLabel.accessibilityHint = "Copies the current code"
|
||||
countdownLabel.font = .preferredFont(forTextStyle: .footnote)
|
||||
countdownLabel.textColor = .secondaryLabel
|
||||
countdownLabel.adjustsFontForContentSizeCategory = true
|
||||
countdownLabel.textAlignment = .center
|
||||
let stack = UIStackView(arrangedSubviews: [issuerLabel, accountLabel, codeLabel, progress, countdownLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
codeContainer.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: codeContainer.topAnchor, constant: 20),
|
||||
stack.bottomAnchor.constraint(equalTo: codeContainer.bottomAnchor, constant: -20),
|
||||
])
|
||||
codeContainer.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1)
|
||||
tableView.tableHeaderView = codeContainer
|
||||
codeLabel.addGestureRecognizer(
|
||||
UITapGestureRecognizer(target: self, action: #selector(copyRequested))
|
||||
)
|
||||
}
|
||||
|
||||
private func apply(_ detail: MobileTotpDetail) {
|
||||
self.detail = detail
|
||||
title = detail.issuer ?? detail.account
|
||||
issuerLabel.text = detail.issuer ?? "TOTP"
|
||||
accountLabel.text = detail.account
|
||||
codeLabel.text = groupedCode(detail.code)
|
||||
codeLabel.accessibilityLabel = "Current code \(detail.code.map(String.init).joined(separator: " "))"
|
||||
watchSwitch.setOn(detail.sharedWithWatch, animated: false)
|
||||
tableView.reloadData()
|
||||
updateCountdown()
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timerTask?.cancel()
|
||||
timerTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard let self else { return }
|
||||
if currentUnixSeconds() >= detail.validUntil {
|
||||
refreshCode()
|
||||
} else {
|
||||
updateCountdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCountdown() {
|
||||
let remaining = detail.validUntil.saturatingSubtracting(currentUnixSeconds())
|
||||
let fraction = detail.period == 0 ? 0 : Float(remaining) / Float(detail.period)
|
||||
progress.setProgress(min(max(fraction, 0), 1), animated: true)
|
||||
countdownLabel.text = "\(remaining) seconds remaining"
|
||||
progress.accessibilityLabel = "Code validity"
|
||||
progress.accessibilityValue = countdownLabel.text
|
||||
}
|
||||
|
||||
private func refreshCode() {
|
||||
guard loadTask == nil else { return }
|
||||
let authentication = authentication
|
||||
let path = detail.path
|
||||
loadTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileTotpDetail, AuthenticationFailure>.success(
|
||||
try authentication.totpDetail(
|
||||
path: path,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard let self else { return }
|
||||
loadTask = nil
|
||||
guard !Task.isCancelled else { return }
|
||||
switch result {
|
||||
case let .success(detail): apply(detail)
|
||||
case let .failure(failure): showDetailUnavailable(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flashCopied() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
UIAccessibility.post(notification: .announcement, argument: "TOTP code copied")
|
||||
UIView.animate(withDuration: 0.12, animations: {
|
||||
self.codeLabel.backgroundColor = .systemGreen.withAlphaComponent(0.28)
|
||||
}) { _ in
|
||||
UIView.animate(withDuration: 0.55) { self.codeLabel.backgroundColor = .clear }
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ failure: AuthenticationFailure) {
|
||||
if failure.kind == .expired {
|
||||
lockDetail()
|
||||
}
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
|
||||
private func showDetailUnavailable(_ failure: AuthenticationFailure) {
|
||||
if failure.kind == .expired {
|
||||
lockDetail()
|
||||
} else {
|
||||
timerTask?.cancel()
|
||||
detail.code = ""
|
||||
codeLabel.text = nil
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
watchSwitch.isEnabled = false
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "exclamationmark.triangle")
|
||||
configuration.text = failure.title
|
||||
configuration.secondaryText = failure.detail
|
||||
configuration.button = .plain()
|
||||
configuration.button.title = "Back to TOTP"
|
||||
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
|
||||
private func lockDetail() {
|
||||
timerTask?.cancel()
|
||||
loadTask?.cancel()
|
||||
clipboardTask?.cancel()
|
||||
detail.code = ""
|
||||
codeLabel.text = nil
|
||||
if UIPasteboard.general.string == copiedValue {
|
||||
UIPasteboard.general.items = []
|
||||
}
|
||||
copiedValue = nil
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
watchSwitch.isEnabled = false
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "lock.fill")
|
||||
configuration.text = "TOTP Is Locked"
|
||||
configuration.secondaryText = "The code was removed when IronStorage locked."
|
||||
configuration.button = .plain()
|
||||
configuration.button.title = "Back to TOTP"
|
||||
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
contentUnavailableConfiguration = configuration
|
||||
UIAccessibility.post(notification: .announcement, argument: "TOTP locked")
|
||||
}
|
||||
}
|
||||
|
||||
private func currentUnixSeconds() -> UInt64 {
|
||||
UInt64(max(Date().timeIntervalSince1970, 0))
|
||||
}
|
||||
|
||||
private func groupedCode(_ code: String) -> String {
|
||||
let midpoint = code.index(code.startIndex, offsetBy: code.count / 2)
|
||||
return String(code[..<midpoint]) + " " + String(code[midpoint...])
|
||||
}
|
||||
|
||||
private extension UInt64 {
|
||||
func saturatingSubtracting(_ value: UInt64) -> UInt64 {
|
||||
self > value ? self - value : 0
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
|
||||
fileprivate let shellTab = MobileTab.passwords
|
||||
|
||||
@@ -37,6 +37,11 @@ use ironstorage::{
|
||||
MobilePasswordErrorKind as StoragePasswordErrorKind,
|
||||
MobilePasswordRowKind as StoragePasswordRowKind,
|
||||
},
|
||||
mobile_totp::{
|
||||
MobileTotpDetail as StorageTotpDetail, MobileTotpPage as StorageTotpPage,
|
||||
MobileWatchSnapshotState as StorageWatchSnapshotState,
|
||||
MobileWatchSnapshotStatus as StorageWatchSnapshotStatus,
|
||||
},
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
@@ -630,6 +635,102 @@ pub struct MobileEntryCopy {
|
||||
pub timeout_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileWatchSnapshotState {
|
||||
Unavailable,
|
||||
Pending,
|
||||
}
|
||||
|
||||
impl From<StorageWatchSnapshotState> for MobileWatchSnapshotState {
|
||||
fn from(state: StorageWatchSnapshotState) -> Self {
|
||||
match state {
|
||||
StorageWatchSnapshotState::Unavailable => Self::Unavailable,
|
||||
StorageWatchSnapshotState::Pending => Self::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileWatchSnapshotStatus {
|
||||
pub state: MobileWatchSnapshotState,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl From<&StorageWatchSnapshotStatus> for MobileWatchSnapshotStatus {
|
||||
fn from(status: &StorageWatchSnapshotStatus) -> Self {
|
||||
Self {
|
||||
state: status.state().into(),
|
||||
detail: status.detail().to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileTotpRow {
|
||||
pub path: String,
|
||||
pub issuer: Option<String>,
|
||||
pub account: String,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
pub shared_with_watch: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileTotpPage {
|
||||
pub rows: Vec<MobileTotpRow>,
|
||||
pub unavailable_entries: u32,
|
||||
pub watch: MobileWatchSnapshotStatus,
|
||||
}
|
||||
|
||||
impl From<StorageTotpPage> for MobileTotpPage {
|
||||
fn from(page: StorageTotpPage) -> Self {
|
||||
Self {
|
||||
rows: page
|
||||
.rows()
|
||||
.iter()
|
||||
.map(|row| MobileTotpRow {
|
||||
path: row.path().to_owned(),
|
||||
issuer: row.issuer().map(str::to_owned),
|
||||
account: row.account().to_owned(),
|
||||
title: row.title().to_owned(),
|
||||
detail: row.detail().to_owned(),
|
||||
shared_with_watch: row.shared_with_watch(),
|
||||
})
|
||||
.collect(),
|
||||
unavailable_entries: page.unavailable_entries(),
|
||||
watch: page.watch().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileTotpDetail {
|
||||
pub path: String,
|
||||
pub issuer: Option<String>,
|
||||
pub account: String,
|
||||
pub code: String,
|
||||
pub valid_until: u64,
|
||||
pub period: u64,
|
||||
pub shared_with_watch: bool,
|
||||
pub watch: MobileWatchSnapshotStatus,
|
||||
}
|
||||
|
||||
impl From<StorageTotpDetail> for MobileTotpDetail {
|
||||
fn from(detail: StorageTotpDetail) -> Self {
|
||||
Self {
|
||||
path: detail.path().to_owned(),
|
||||
issuer: detail.issuer().map(str::to_owned),
|
||||
account: detail.account().to_owned(),
|
||||
code: String::from_utf8(detail.code().expose().to_vec())
|
||||
.expect("storage-generated TOTP codes are ASCII"),
|
||||
valid_until: detail.valid_until(),
|
||||
period: detail.period(),
|
||||
shared_with_watch: detail.shared_with_watch(),
|
||||
watch: detail.watch().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileEntryEditorFieldKind {
|
||||
Password,
|
||||
@@ -894,6 +995,60 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn unlock_totp(
|
||||
&self,
|
||||
passphrase: Option<String>,
|
||||
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.unlock_totp(
|
||||
passphrase
|
||||
.map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
|
||||
)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn totp_page(&self) -> Result<MobileTotpPage, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.totp_page()
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn totp_detail(
|
||||
&self,
|
||||
path: String,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileTotpDetail, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.totp_detail(&path, unix_seconds)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn copy_totp_code(
|
||||
&self,
|
||||
path: String,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.copy_totp_code(&path, unix_seconds)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_totp_watch_shared(
|
||||
&self,
|
||||
path: String,
|
||||
shared: bool,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileTotpDetail, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.set_totp_watch_shared(&path, shared, unix_seconds)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn begin_create_entry(
|
||||
&self,
|
||||
directory: String,
|
||||
|
||||
@@ -19,6 +19,7 @@ use url::Url;
|
||||
use crate::authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT};
|
||||
use crate::mobile::MobileTab;
|
||||
use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT};
|
||||
use crate::repository::EntryPath;
|
||||
|
||||
const APPLICATION_DIRECTORY: &str = "ironstorage";
|
||||
const CONFIG_FILE: &str = "config.toml";
|
||||
@@ -38,6 +39,7 @@ pub struct Config {
|
||||
biometric_unlock_enabled: bool,
|
||||
mobile_tab: MobileTab,
|
||||
mobile_home_refreshed_at: Option<i64>,
|
||||
watch_shared_totp_entries: BTreeSet<EntryPath>,
|
||||
git_remotes: Vec<GitRemote>,
|
||||
}
|
||||
|
||||
@@ -135,6 +137,10 @@ impl Config {
|
||||
self.mobile_home_refreshed_at
|
||||
}
|
||||
|
||||
pub fn watch_shared_totp_entries(&self) -> &BTreeSet<EntryPath> {
|
||||
&self.watch_shared_totp_entries
|
||||
}
|
||||
|
||||
pub fn git_remotes(&self) -> &[GitRemote] {
|
||||
&self.git_remotes
|
||||
}
|
||||
@@ -154,7 +160,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn update_mobile_tab(&self, tab: MobileTab) -> Result<(), ConfigError> {
|
||||
let mut document = self.document.clone();
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
@@ -178,13 +184,46 @@ impl Config {
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub fn update_watch_shared_totp_entries(
|
||||
&self,
|
||||
entries: &BTreeSet<EntryPath>,
|
||||
) -> Result<(), ConfigError> {
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let ui = root
|
||||
.entry("ui")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
|
||||
.as_table_mut()
|
||||
.ok_or(ConfigError::InvalidField { field: "ui" })?;
|
||||
ui.insert(
|
||||
"watch_shared_totp_entries".to_owned(),
|
||||
toml::Value::Array(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| toml::Value::String(entry.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
let raw = document
|
||||
.clone()
|
||||
.try_into::<RawConfig>()
|
||||
.map_err(|_| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub(crate) fn update_mobile_home_refresh(&self, unix_seconds: i64) -> Result<(), ConfigError> {
|
||||
if unix_seconds <= 0 {
|
||||
return Err(ConfigError::InvalidField {
|
||||
field: "ui.home_remote_refreshed_at_unix_seconds",
|
||||
});
|
||||
}
|
||||
let mut document = self.document.clone();
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
@@ -209,7 +248,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn update_biometric_unlock(&self, enabled: bool) -> Result<(), ConfigError> {
|
||||
let mut document = self.document.clone();
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
@@ -233,6 +272,10 @@ impl Config {
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
fn current_document(&self) -> Result<toml::Value, ConfigError> {
|
||||
Self::load(Some(&self.source)).map(|config| config.document)
|
||||
}
|
||||
|
||||
pub(crate) fn create_mobile_clone(
|
||||
source: PathBuf,
|
||||
vault: &Path,
|
||||
@@ -873,6 +916,8 @@ struct RawSecurity {
|
||||
struct RawUi {
|
||||
selected_mobile_tab: Option<String>,
|
||||
home_remote_refreshed_at_unix_seconds: Option<i64>,
|
||||
#[serde(default)]
|
||||
watch_shared_totp_entries: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -964,6 +1009,16 @@ fn validate_config(
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let watch_shared_totp_entries = raw
|
||||
.ui
|
||||
.watch_shared_totp_entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
EntryPath::parse(&entry).map_err(|_| ConfigError::InvalidField {
|
||||
field: "ui.watch_shared_totp_entries",
|
||||
})
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?;
|
||||
let git_remotes = validate_remotes(raw.git.remotes)?;
|
||||
|
||||
Ok(Config {
|
||||
@@ -978,6 +1033,7 @@ fn validate_config(
|
||||
biometric_unlock_enabled,
|
||||
mobile_tab,
|
||||
mobile_home_refreshed_at,
|
||||
watch_shared_totp_entries,
|
||||
git_remotes,
|
||||
})
|
||||
}
|
||||
@@ -1162,6 +1218,7 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
|
||||
&[
|
||||
"selected_mobile_tab",
|
||||
"home_remote_refreshed_at_unix_seconds",
|
||||
"watch_shared_totp_entries",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod mobile_entry;
|
||||
pub mod mobile_home;
|
||||
pub mod mobile_onboarding;
|
||||
pub mod mobile_passwords;
|
||||
pub mod mobile_totp;
|
||||
pub mod mutation;
|
||||
pub mod otp;
|
||||
pub mod presentation;
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage,
|
||||
MobileEntryValueError, field_value,
|
||||
},
|
||||
mobile_totp::{MobileTotpDetail, MobileTotpError, MobileTotpPage, MobileTotpService},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
|
||||
@@ -161,6 +162,7 @@ struct MobileAuthenticationStatus {
|
||||
active: Option<ActiveMobileLease>,
|
||||
next_editor_id: u64,
|
||||
editors: BTreeMap<u64, MobileEntryDraft>,
|
||||
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
||||
}
|
||||
|
||||
/// One process-wide mobile authentication lease shared by every tab and viewer.
|
||||
@@ -194,6 +196,7 @@ impl MobileAuthentication {
|
||||
active: None,
|
||||
next_editor_id: 0,
|
||||
editors: BTreeMap::new(),
|
||||
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
||||
}),
|
||||
config,
|
||||
repository,
|
||||
@@ -396,6 +399,95 @@ impl MobileAuthentication {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unlock_totp(
|
||||
&self,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||
let path = self
|
||||
.repository
|
||||
.snapshot()
|
||||
.map_err(entry_error)?
|
||||
.entries()
|
||||
.next()
|
||||
.map(|entry| entry.path().clone())
|
||||
.ok_or_else(|| entry_detail("TOTP Is Unavailable", "the password store is empty"))?;
|
||||
let ciphertext = self.repository.read_entry(&path).map_err(entry_error)?;
|
||||
self.unlock_ciphertext(&ciphertext, passphrase)
|
||||
}
|
||||
|
||||
pub fn totp_page(&self) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let (handle, key, shared) = {
|
||||
let status = self.status()?;
|
||||
let active = status.active.as_ref().ok_or_else(locked_error)?;
|
||||
(
|
||||
active.handle.clone(),
|
||||
active.key.clone(),
|
||||
status.watch_shared_totp_entries.clone(),
|
||||
)
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
MobileTotpService::new(&self.repository, &self.keys)
|
||||
.page(&shared, &mut provider)
|
||||
.map_err(totp_error)
|
||||
}
|
||||
|
||||
pub fn totp_detail(
|
||||
&self,
|
||||
path: &str,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileTotpDetail, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let (handle, key, shared) = {
|
||||
let status = self.status()?;
|
||||
let active = status.active.as_ref().ok_or_else(locked_error)?;
|
||||
(
|
||||
active.handle.clone(),
|
||||
active.key.clone(),
|
||||
status.watch_shared_totp_entries.clone(),
|
||||
)
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
MobileTotpService::new(&self.repository, &self.keys)
|
||||
.detail(path, unix_seconds, &shared, &mut provider)
|
||||
.map_err(totp_error)
|
||||
}
|
||||
|
||||
pub fn copy_totp_code(
|
||||
&self,
|
||||
path: &str,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
|
||||
let detail = self.totp_detail(path, unix_seconds)?;
|
||||
let value = String::from_utf8(detail.code().expose().to_vec())
|
||||
.map_err(|_| entry_detail("TOTP Code Is Unavailable", "the code is not UTF-8"))?;
|
||||
Ok(MobileEntryCopy {
|
||||
value,
|
||||
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_totp_watch_shared(
|
||||
&self,
|
||||
path: &str,
|
||||
shared: bool,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileTotpDetail, MobileAuthenticationError> {
|
||||
self.totp_detail(path, unix_seconds)?;
|
||||
let entry = EntryPath::parse(path).map_err(entry_error)?;
|
||||
let mut selected = self.status()?.watch_shared_totp_entries.clone();
|
||||
if shared {
|
||||
selected.insert(entry);
|
||||
} else {
|
||||
selected.remove(&entry);
|
||||
}
|
||||
self.config
|
||||
.update_watch_shared_totp_entries(&selected)
|
||||
.map_err(config_error)?;
|
||||
self.status()?.watch_shared_totp_entries = selected;
|
||||
self.totp_detail(path, unix_seconds)
|
||||
}
|
||||
|
||||
pub fn replace_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -786,6 +878,10 @@ fn editor_error(error: MobileEntryEditorError) -> MobileAuthenticationError {
|
||||
entry_detail("Entry Draft Is Invalid", error)
|
||||
}
|
||||
|
||||
fn totp_error(error: MobileTotpError) -> MobileAuthenticationError {
|
||||
entry_detail("TOTP Is Unavailable", error)
|
||||
}
|
||||
|
||||
fn editor_missing() -> MobileAuthenticationError {
|
||||
entry_detail(
|
||||
"Entry Draft Is Unavailable",
|
||||
|
||||
301
crates/storage/src/mobile_totp.rs
Normal file
301
crates/storage/src/mobile_totp.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
//! Storage-owned TOTP catalog and detail state for native mobile frontends.
|
||||
|
||||
use std::{collections::BTreeSet, error::Error, fmt};
|
||||
|
||||
use crate::{
|
||||
crypto::{KeyStore, SecretProvider},
|
||||
otp::{OtpError, OtpKind, OtpService},
|
||||
repository::{EntryPath, Repository, RepositoryError, SecretBytes},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileWatchSnapshotState {
|
||||
Unavailable,
|
||||
Pending,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl MobileWatchSnapshotStatus {
|
||||
pub fn state(&self) -> MobileWatchSnapshotState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &str {
|
||||
&self.detail
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileTotpRow {
|
||||
path: String,
|
||||
issuer: Option<String>,
|
||||
account: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
shared_with_watch: bool,
|
||||
}
|
||||
|
||||
impl MobileTotpRow {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn issuer(&self) -> Option<&str> {
|
||||
self.issuer.as_deref()
|
||||
}
|
||||
|
||||
pub fn account(&self) -> &str {
|
||||
&self.account
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &str {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn shared_with_watch(&self) -> bool {
|
||||
self.shared_with_watch
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileTotpPage {
|
||||
rows: Vec<MobileTotpRow>,
|
||||
unavailable_entries: u32,
|
||||
watch: MobileWatchSnapshotStatus,
|
||||
}
|
||||
|
||||
impl MobileTotpPage {
|
||||
pub fn rows(&self) -> &[MobileTotpRow] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub fn unavailable_entries(&self) -> u32 {
|
||||
self.unavailable_entries
|
||||
}
|
||||
|
||||
pub fn watch(&self) -> &MobileWatchSnapshotStatus {
|
||||
&self.watch
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileTotpDetail {
|
||||
path: String,
|
||||
issuer: Option<String>,
|
||||
account: String,
|
||||
code: SecretBytes,
|
||||
valid_until: u64,
|
||||
period: u64,
|
||||
shared_with_watch: bool,
|
||||
watch: MobileWatchSnapshotStatus,
|
||||
}
|
||||
|
||||
impl MobileTotpDetail {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn issuer(&self) -> Option<&str> {
|
||||
self.issuer.as_deref()
|
||||
}
|
||||
|
||||
pub fn account(&self) -> &str {
|
||||
&self.account
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &SecretBytes {
|
||||
&self.code
|
||||
}
|
||||
|
||||
pub fn valid_until(&self) -> u64 {
|
||||
self.valid_until
|
||||
}
|
||||
|
||||
pub fn period(&self) -> u64 {
|
||||
self.period
|
||||
}
|
||||
|
||||
pub fn shared_with_watch(&self) -> bool {
|
||||
self.shared_with_watch
|
||||
}
|
||||
|
||||
pub fn watch(&self) -> &MobileWatchSnapshotStatus {
|
||||
&self.watch
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MobileTotpDetail {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("MobileTotpDetail")
|
||||
.field("path", &self.path)
|
||||
.field("issuer", &self.issuer)
|
||||
.field("account", &self.account)
|
||||
.field("code", &"[REDACTED]")
|
||||
.field("valid_until", &self.valid_until)
|
||||
.field("period", &self.period)
|
||||
.field("shared_with_watch", &self.shared_with_watch)
|
||||
.field("watch", &self.watch)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileTotpService<'a> {
|
||||
repository: &'a Repository,
|
||||
keys: &'a KeyStore,
|
||||
}
|
||||
|
||||
impl<'a> MobileTotpService<'a> {
|
||||
pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self {
|
||||
Self { repository, keys }
|
||||
}
|
||||
|
||||
pub fn page(
|
||||
&self,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
provider: &mut impl SecretProvider,
|
||||
) -> Result<MobileTotpPage, MobileTotpError> {
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let mut rows = Vec::new();
|
||||
let mut unavailable_entries = 0_u32;
|
||||
for entry in snapshot.entries() {
|
||||
match OtpService::new(self.repository, self.keys)
|
||||
.uri(&entry.path().to_string(), provider)
|
||||
{
|
||||
Ok(uri) if uri.kind() == OtpKind::Totp => rows.push(row(
|
||||
entry.path(),
|
||||
uri.issuer(),
|
||||
uri.account(),
|
||||
shared.contains(entry.path()),
|
||||
)),
|
||||
Ok(_) | Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => {}
|
||||
Err(OtpError::Crypto(_)) => {
|
||||
unavailable_entries = unavailable_entries.saturating_add(1);
|
||||
}
|
||||
Err(OtpError::Repository(error)) => return Err(error.into()),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
rows.sort_by(|left, right| {
|
||||
left.title
|
||||
.to_lowercase()
|
||||
.cmp(&right.title.to_lowercase())
|
||||
.then_with(|| {
|
||||
left.account
|
||||
.to_lowercase()
|
||||
.cmp(&right.account.to_lowercase())
|
||||
})
|
||||
.then_with(|| left.path.cmp(&right.path))
|
||||
});
|
||||
let selected = rows.iter().filter(|row| row.shared_with_watch).count();
|
||||
Ok(MobileTotpPage {
|
||||
rows,
|
||||
unavailable_entries,
|
||||
watch: snapshot_status(selected),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn detail(
|
||||
&self,
|
||||
entry: &str,
|
||||
unix_seconds: u64,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
provider: &mut impl SecretProvider,
|
||||
) -> Result<MobileTotpDetail, MobileTotpError> {
|
||||
let path = EntryPath::parse(entry)?;
|
||||
let uri = OtpService::new(self.repository, self.keys).uri(entry, provider)?;
|
||||
if uri.kind() != OtpKind::Totp {
|
||||
return Err(OtpError::NotTotp.into());
|
||||
}
|
||||
let period = uri.period().ok_or(OtpError::NotTotp)?;
|
||||
let valid_until = (unix_seconds / period)
|
||||
.checked_add(1)
|
||||
.and_then(|counter| counter.checked_mul(period))
|
||||
.ok_or(OtpError::CounterOverflow)?;
|
||||
let shared_with_watch = shared.contains(&path);
|
||||
Ok(MobileTotpDetail {
|
||||
path: path.to_string(),
|
||||
issuer: uri.issuer().map(str::to_owned),
|
||||
account: uri.account().to_owned(),
|
||||
code: uri.code_at(unix_seconds)?,
|
||||
valid_until,
|
||||
period,
|
||||
shared_with_watch,
|
||||
watch: snapshot_status(shared.len()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn row(
|
||||
path: &EntryPath,
|
||||
issuer: Option<&str>,
|
||||
account: &str,
|
||||
shared_with_watch: bool,
|
||||
) -> MobileTotpRow {
|
||||
MobileTotpRow {
|
||||
path: path.to_string(),
|
||||
issuer: issuer.map(str::to_owned),
|
||||
account: account.to_owned(),
|
||||
title: issuer.unwrap_or(account).to_owned(),
|
||||
detail: issuer.map_or_else(|| path.to_string(), |_| account.to_owned()),
|
||||
shared_with_watch,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus {
|
||||
if selected == 0 {
|
||||
MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState::Unavailable,
|
||||
detail: "No TOTP codes are selected for Apple Watch.".to_owned(),
|
||||
}
|
||||
} else {
|
||||
MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState::Pending,
|
||||
detail: format!(
|
||||
"{selected} selected TOTP {} pending Apple Watch synchronization.",
|
||||
if selected == 1 {
|
||||
"code is"
|
||||
} else {
|
||||
"codes are"
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MobileTotpError {
|
||||
Repository(RepositoryError),
|
||||
Otp(OtpError),
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileTotpError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Repository(error) => error.fmt(formatter),
|
||||
Self::Otp(error) => error.fmt(formatter),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobileTotpError {}
|
||||
|
||||
impl From<RepositoryError> for MobileTotpError {
|
||||
fn from(error: RepositoryError) -> Self {
|
||||
Self::Repository(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OtpError> for MobileTotpError {
|
||||
fn from(error: OtpError) -> Self {
|
||||
Self::Otp(error)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::{error::Error, ffi::OsStr, fs, path::Path, time::Duration};
|
||||
use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time::Duration};
|
||||
|
||||
use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
|
||||
use ironstorage::{
|
||||
@@ -8,6 +8,7 @@ use ironstorage::{
|
||||
config::{ConfigError, ConfigLoader, EditorSource},
|
||||
desktop::DesktopStorage,
|
||||
mobile::MobileTab,
|
||||
repository::EntryPath,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -64,7 +65,6 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
|
||||
let config = fixture
|
||||
.loader()
|
||||
.load(Some(Path::new("config/config.toml")))?;
|
||||
|
||||
assert_eq!(config.source(), fs::canonicalize(fixture.explicit_path())?);
|
||||
assert_eq!(
|
||||
config.vault(),
|
||||
@@ -98,6 +98,33 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_totp_selection_persists_only_in_application_configuration() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fixture.write_explicit(fixture.valid_contents())?;
|
||||
let config = fixture
|
||||
.loader()
|
||||
.load(Some(Path::new("config/config.toml")))?;
|
||||
let stale = fixture
|
||||
.loader()
|
||||
.load(Some(Path::new("config/config.toml")))?;
|
||||
let selected = BTreeSet::from([
|
||||
EntryPath::parse("otp/personal")?,
|
||||
EntryPath::parse("otp/work")?,
|
||||
]);
|
||||
|
||||
config.update_watch_shared_totp_entries(&selected)?;
|
||||
stale.update_mobile_tab(MobileTab::Totp)?;
|
||||
let reloaded = fixture
|
||||
.loader()
|
||||
.load(Some(Path::new("config/config.toml")))?;
|
||||
|
||||
assert_eq!(reloaded.watch_shared_totp_entries(), &selected);
|
||||
assert_eq!(reloaded.mobile_tab(), MobileTab::Totp);
|
||||
assert!(!fixture.temporary.path().join("cwd/vault").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_timeout_defaults_overrides_and_rejects_invalid_values() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
|
||||
117
crates/storage/tests/mobile_totp.rs
Normal file
117
crates/storage/tests/mobile_totp.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod support;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use ironstorage::{
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
mobile_totp::{MobileTotpService, MobileWatchSnapshotState},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{EntryPath, Repository, SecretBytes},
|
||||
};
|
||||
use support::compatibility::{FixtureSet, TestResult};
|
||||
|
||||
struct FixtureSecrets(BTreeMap<String, Vec<u8>>);
|
||||
|
||||
impl FixtureSecrets {
|
||||
fn all(fixture: &FixtureSet) -> Self {
|
||||
Self(
|
||||
fixture
|
||||
.generated
|
||||
.keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
(
|
||||
key.primary_fingerprint.clone(),
|
||||
key.passphrase.as_bytes().to_vec(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretProvider for FixtureSecrets {
|
||||
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
self.0
|
||||
.get(key.fingerprint().as_str())
|
||||
.cloned()
|
||||
.map(SecretBytes::new)
|
||||
.ok_or(SecretProviderError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let store = fixture.materialize_store("basic")?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
write_plaintext(
|
||||
&repository,
|
||||
&keys,
|
||||
"otp/alice",
|
||||
b"password\notpauth://totp/Acme:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n",
|
||||
)?;
|
||||
write_plaintext(
|
||||
&repository,
|
||||
&keys,
|
||||
"otp/counter",
|
||||
b"otpauth://hotp/Counter?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0\n",
|
||||
)?;
|
||||
write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?;
|
||||
|
||||
let service = MobileTotpService::new(&repository, &keys);
|
||||
let mut secrets = FixtureSecrets::all(&fixture);
|
||||
let page = service.page(&BTreeSet::new(), &mut secrets)?;
|
||||
let alice = page
|
||||
.rows()
|
||||
.iter()
|
||||
.find(|row| row.path() == "otp/alice")
|
||||
.expect("inserted TOTP row");
|
||||
assert_eq!(alice.issuer(), Some("Acme"));
|
||||
assert_eq!(alice.account(), "alice@example.com");
|
||||
assert!(!alice.shared_with_watch());
|
||||
assert!(page.rows().iter().all(|row| row.path() != "otp/counter"));
|
||||
assert_eq!(page.watch().state(), MobileWatchSnapshotState::Unavailable);
|
||||
assert!(!format!("{page:?}").contains("94287082"));
|
||||
|
||||
let detail = service.detail("otp/alice", 59, &BTreeSet::new(), &mut secrets)?;
|
||||
assert_eq!(detail.code().expose(), b"94287082");
|
||||
assert_eq!(detail.valid_until(), 60);
|
||||
assert_eq!(detail.period(), 30);
|
||||
assert!(!format!("{detail:?}").contains("94287082"));
|
||||
|
||||
let selected = BTreeSet::from([EntryPath::parse("otp/alice")?]);
|
||||
let page = service.page(&selected, &mut secrets)?;
|
||||
assert!(
|
||||
page.rows()
|
||||
.iter()
|
||||
.find(|row| row.path() == "otp/alice")
|
||||
.expect("selected TOTP row")
|
||||
.shared_with_watch()
|
||||
);
|
||||
assert_eq!(page.watch().state(), MobileWatchSnapshotState::Pending);
|
||||
let detail = service.detail("otp/alice", 60, &selected, &mut secrets)?;
|
||||
assert!(detail.shared_with_watch());
|
||||
assert_eq!(detail.valid_until(), 90);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_plaintext(
|
||||
repository: &Repository,
|
||||
keys: &KeyStore,
|
||||
path: &str,
|
||||
plaintext: &[u8],
|
||||
) -> TestResult {
|
||||
let path = EntryPath::parse(path)?;
|
||||
let recipients =
|
||||
RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?;
|
||||
let encrypted = keys.encrypt(
|
||||
SecretBytes::new(plaintext.to_vec()),
|
||||
recipients.recipients(),
|
||||
)?;
|
||||
repository.write_entry(&path, &encrypted)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user