diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift
index f3c5447..e518cbe 100644
--- a/apple/Generated/ironstorage_apple.swift
+++ b/apple/Generated/ironstorage_apple.swift
@@ -580,6 +580,24 @@ fileprivate struct FfiConverterString: FfiConverter {
}
}
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+fileprivate struct FfiConverterData: FfiConverterRustBuffer {
+ typealias SwiftType = Data
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Data {
+ let len: Int32 = try readInt(&buf)
+ return Data(try readBytes(&buf, count: Int(len)))
+ }
+
+ public static func write(_ value: Data, into buf: inout [UInt8]) {
+ let len = Int32(value.count)
+ writeInt(&buf, len)
+ writeBytes(&buf, value)
+ }
+}
+
@@ -1172,6 +1190,279 @@ public func FfiConverterTypeMobileHomeOperation_lower(_ value: MobileHomeOperati
+public protocol MobileKeyTransferProtocol: AnyObject, Sendable {
+
+ func export(fingerprint: String, kind: MobileKeyTransferKind, passphrase: String?) throws -> MobileKeyTransferExport
+
+ func importer() -> MobileKeyTransferImport
+
+ func keys() -> [MobileKeyTransferKey]
+
+}
+open class MobileKeyTransfer: MobileKeyTransferProtocol, @unchecked Sendable {
+ fileprivate let handle: UInt64
+
+ /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly.
+#if swift(>=5.8)
+ @_documentation(visibility: private)
+#endif
+ public struct NoHandle {
+ public init() {}
+ }
+
+ // TODO: We'd like this to be `private` but for Swifty reasons,
+ // we can't implement `FfiConverter` without making this `required` and we can't
+ // make it `required` without making it `public`.
+#if swift(>=5.8)
+ @_documentation(visibility: private)
+#endif
+ required public init(unsafeFromHandle handle: UInt64) {
+ self.handle = handle
+ }
+
+ // This constructor can be used to instantiate a fake object.
+ // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject].
+ //
+ // - Warning:
+ // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash.
+#if swift(>=5.8)
+ @_documentation(visibility: private)
+#endif
+ public init(noHandle: NoHandle) {
+ self.handle = 0
+ }
+
+#if swift(>=5.8)
+ @_documentation(visibility: private)
+#endif
+ public func uniffiCloneHandle() -> UInt64 {
+ return try! rustCall { uniffi_ironstorage_apple_fn_clone_mobilekeytransfer(self.handle, $0) }
+ }
+ // No primary constructor declared for this class.
+
+ deinit {
+ if handle == 0 {
+ // Mock objects have handle=0 don't try to free them
+ return
+ }
+
+ try! rustCall { uniffi_ironstorage_apple_fn_free_mobilekeytransfer(handle, $0) }
+ }
+
+
+
+
+open func export(fingerprint: String, kind: MobileKeyTransferKind, passphrase: String?)throws -> MobileKeyTransferExport {
+ return try FfiConverterTypeMobileKeyTransferExport_lift(try rustCallWithError(FfiConverterTypeMobileKeyTransferFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobilekeytransfer_export(
+ self.uniffiCloneHandle(),
+ FfiConverterString.lower(fingerprint),
+ FfiConverterTypeMobileKeyTransferKind_lower(kind),
+ FfiConverterOptionString.lower(passphrase),uniffiCallStatus
+ )
+})
+}
+
+open func importer() -> MobileKeyTransferImport {
+ return try! FfiConverterTypeMobileKeyTransferImport_lift(try! rustCall() {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobilekeytransfer_importer(
+ self.uniffiCloneHandle(),uniffiCallStatus
+ )
+})
+}
+
+open func keys() -> [MobileKeyTransferKey] {
+ return try! FfiConverterSequenceTypeMobileKeyTransferKey.lift(try! rustCall() {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobilekeytransfer_keys(
+ self.uniffiCloneHandle(),uniffiCallStatus
+ )
+})
+}
+
+
+
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileKeyTransfer: FfiConverter {
+ typealias FfiType = UInt64
+ typealias SwiftType = MobileKeyTransfer
+
+ public static func lift(_ handle: UInt64) throws -> MobileKeyTransfer {
+ return MobileKeyTransfer(unsafeFromHandle: handle)
+ }
+
+ public static func lower(_ value: MobileKeyTransfer) -> UInt64 {
+ return value.uniffiCloneHandle()
+ }
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransfer {
+ let handle: UInt64 = try readInt(&buf)
+ return try lift(handle)
+ }
+
+ public static func write(_ value: MobileKeyTransfer, into buf: inout [UInt8]) {
+ writeInt(&buf, lower(value))
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransfer_lift(_ handle: UInt64) throws -> MobileKeyTransfer {
+ return try FfiConverterTypeMobileKeyTransfer.lift(handle)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransfer_lower(_ value: MobileKeyTransfer) -> UInt64 {
+ return FfiConverterTypeMobileKeyTransfer.lower(value)
+}
+
+
+
+
+
+
+public protocol MobileKeyTransferImportProtocol: AnyObject, Sendable {
+
+ func addFrame(payload: String) throws -> MobileKeyTransferProgress
+
+ func `import`(passphrase: String?, makeDefault: Bool) throws -> MobileKeyTransferOutcome
+
+}
+open class MobileKeyTransferImport: MobileKeyTransferImportProtocol, @unchecked Sendable {
+ fileprivate let handle: UInt64
+
+ /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly.
+#if swift(>=5.8)
+ @_documentation(visibility: private)
+#endif
+ public struct NoHandle {
+ public init() {}
+ }
+
+ // TODO: We'd like this to be `private` but for Swifty reasons,
+ // we can't implement `FfiConverter` without making this `required` and we can't
+ // make it `required` without making it `public`.
+#if swift(>=5.8)
+ @_documentation(visibility: private)
+#endif
+ required public init(unsafeFromHandle handle: UInt64) {
+ self.handle = handle
+ }
+
+ // This constructor can be used to instantiate a fake object.
+ // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject].
+ //
+ // - Warning:
+ // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash.
+#if swift(>=5.8)
+ @_documentation(visibility: private)
+#endif
+ public init(noHandle: NoHandle) {
+ self.handle = 0
+ }
+
+#if swift(>=5.8)
+ @_documentation(visibility: private)
+#endif
+ public func uniffiCloneHandle() -> UInt64 {
+ return try! rustCall { uniffi_ironstorage_apple_fn_clone_mobilekeytransferimport(self.handle, $0) }
+ }
+ // No primary constructor declared for this class.
+
+ deinit {
+ if handle == 0 {
+ // Mock objects have handle=0 don't try to free them
+ return
+ }
+
+ try! rustCall { uniffi_ironstorage_apple_fn_free_mobilekeytransferimport(handle, $0) }
+ }
+
+
+
+
+open func addFrame(payload: String)throws -> MobileKeyTransferProgress {
+ return try FfiConverterTypeMobileKeyTransferProgress_lift(try rustCallWithError(FfiConverterTypeMobileKeyTransferFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_add_frame(
+ self.uniffiCloneHandle(),
+ FfiConverterString.lower(payload),uniffiCallStatus
+ )
+})
+}
+
+open func `import`(passphrase: String?, makeDefault: Bool)throws -> MobileKeyTransferOutcome {
+ return try FfiConverterTypeMobileKeyTransferOutcome_lift(try rustCallWithError(FfiConverterTypeMobileKeyTransferFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_import(
+ self.uniffiCloneHandle(),
+ FfiConverterOptionString.lower(passphrase),
+ FfiConverterBool.lower(makeDefault),uniffiCallStatus
+ )
+})
+}
+
+
+
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileKeyTransferImport: FfiConverter {
+ typealias FfiType = UInt64
+ typealias SwiftType = MobileKeyTransferImport
+
+ public static func lift(_ handle: UInt64) throws -> MobileKeyTransferImport {
+ return MobileKeyTransferImport(unsafeFromHandle: handle)
+ }
+
+ public static func lower(_ value: MobileKeyTransferImport) -> UInt64 {
+ return value.uniffiCloneHandle()
+ }
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransferImport {
+ let handle: UInt64 = try readInt(&buf)
+ return try lift(handle)
+ }
+
+ public static func write(_ value: MobileKeyTransferImport, into buf: inout [UInt8]) {
+ writeInt(&buf, lower(value))
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferImport_lift(_ handle: UInt64) throws -> MobileKeyTransferImport {
+ return try FfiConverterTypeMobileKeyTransferImport.lift(handle)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferImport_lower(_ value: MobileKeyTransferImport) -> UInt64 {
+ return FfiConverterTypeMobileKeyTransferImport.lower(value)
+}
+
+
+
+
+
+
public protocol MobileOnboardingOperationProtocol: AnyObject, Sendable {
func cancel()
@@ -2319,6 +2610,304 @@ public func FfiConverterTypeMobileHomeSummaryRow_lower(_ value: MobileHomeSummar
}
+public struct MobileKeyTransferExport: Equatable, Hashable {
+ public var key: MobileKeyTransferKey
+ public var frames: [MobileKeyTransferFrame]
+
+ // Default memberwise initializers are never public by default, so we
+ // declare one manually.
+ public init(key: MobileKeyTransferKey, frames: [MobileKeyTransferFrame]) {
+ self.key = key
+ self.frames = frames
+ }
+
+
+
+
+}
+
+#if compiler(>=6)
+extension MobileKeyTransferExport: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileKeyTransferExport: FfiConverterRustBuffer {
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransferExport {
+ return
+ try MobileKeyTransferExport(
+ key: FfiConverterTypeMobileKeyTransferKey.read(from: &buf),
+ frames: FfiConverterSequenceTypeMobileKeyTransferFrame.read(from: &buf)
+ )
+ }
+
+ public static func write(_ value: MobileKeyTransferExport, into buf: inout [UInt8]) {
+ FfiConverterTypeMobileKeyTransferKey.write(value.key, into: &buf)
+ FfiConverterSequenceTypeMobileKeyTransferFrame.write(value.frames, into: &buf)
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferExport_lift(_ buf: RustBuffer) throws -> MobileKeyTransferExport {
+ return try FfiConverterTypeMobileKeyTransferExport.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferExport_lower(_ value: MobileKeyTransferExport) -> RustBuffer {
+ return FfiConverterTypeMobileKeyTransferExport.lower(value)
+}
+
+
+public struct MobileKeyTransferFrame: Equatable, Hashable {
+ public var sequence: UInt32
+ public var total: UInt32
+ public var width: UInt32
+ public var modules: Data
+
+ // Default memberwise initializers are never public by default, so we
+ // declare one manually.
+ public init(sequence: UInt32, total: UInt32, width: UInt32, modules: Data) {
+ self.sequence = sequence
+ self.total = total
+ self.width = width
+ self.modules = modules
+ }
+
+
+
+
+}
+
+#if compiler(>=6)
+extension MobileKeyTransferFrame: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileKeyTransferFrame: FfiConverterRustBuffer {
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransferFrame {
+ return
+ try MobileKeyTransferFrame(
+ sequence: FfiConverterUInt32.read(from: &buf),
+ total: FfiConverterUInt32.read(from: &buf),
+ width: FfiConverterUInt32.read(from: &buf),
+ modules: FfiConverterData.read(from: &buf)
+ )
+ }
+
+ public static func write(_ value: MobileKeyTransferFrame, into buf: inout [UInt8]) {
+ FfiConverterUInt32.write(value.sequence, into: &buf)
+ FfiConverterUInt32.write(value.total, into: &buf)
+ FfiConverterUInt32.write(value.width, into: &buf)
+ FfiConverterData.write(value.modules, into: &buf)
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferFrame_lift(_ buf: RustBuffer) throws -> MobileKeyTransferFrame {
+ return try FfiConverterTypeMobileKeyTransferFrame.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferFrame_lower(_ value: MobileKeyTransferFrame) -> RustBuffer {
+ return FfiConverterTypeMobileKeyTransferFrame.lower(value)
+}
+
+
+public struct MobileKeyTransferKey: Equatable, Hashable {
+ public var fingerprint: String
+ public var title: String
+ public var detail: String
+ public var kind: MobileKeyTransferKind
+ public var requiresPassphrase: Bool
+
+ // Default memberwise initializers are never public by default, so we
+ // declare one manually.
+ public init(fingerprint: String, title: String, detail: String, kind: MobileKeyTransferKind, requiresPassphrase: Bool) {
+ self.fingerprint = fingerprint
+ self.title = title
+ self.detail = detail
+ self.kind = kind
+ self.requiresPassphrase = requiresPassphrase
+ }
+
+
+
+
+}
+
+#if compiler(>=6)
+extension MobileKeyTransferKey: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileKeyTransferKey: FfiConverterRustBuffer {
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransferKey {
+ return
+ try MobileKeyTransferKey(
+ fingerprint: FfiConverterString.read(from: &buf),
+ title: FfiConverterString.read(from: &buf),
+ detail: FfiConverterString.read(from: &buf),
+ kind: FfiConverterTypeMobileKeyTransferKind.read(from: &buf),
+ requiresPassphrase: FfiConverterBool.read(from: &buf)
+ )
+ }
+
+ public static func write(_ value: MobileKeyTransferKey, into buf: inout [UInt8]) {
+ FfiConverterString.write(value.fingerprint, into: &buf)
+ FfiConverterString.write(value.title, into: &buf)
+ FfiConverterString.write(value.detail, into: &buf)
+ FfiConverterTypeMobileKeyTransferKind.write(value.kind, into: &buf)
+ FfiConverterBool.write(value.requiresPassphrase, into: &buf)
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferKey_lift(_ buf: RustBuffer) throws -> MobileKeyTransferKey {
+ return try FfiConverterTypeMobileKeyTransferKey.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferKey_lower(_ value: MobileKeyTransferKey) -> RustBuffer {
+ return FfiConverterTypeMobileKeyTransferKey.lower(value)
+}
+
+
+public struct MobileKeyTransferOutcome: Equatable, Hashable {
+ public var title: String
+ public var detail: String
+
+ // Default memberwise initializers are never public by default, so we
+ // declare one manually.
+ public init(title: String, detail: String) {
+ self.title = title
+ self.detail = detail
+ }
+
+
+
+
+}
+
+#if compiler(>=6)
+extension MobileKeyTransferOutcome: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileKeyTransferOutcome: FfiConverterRustBuffer {
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransferOutcome {
+ return
+ try MobileKeyTransferOutcome(
+ title: FfiConverterString.read(from: &buf),
+ detail: FfiConverterString.read(from: &buf)
+ )
+ }
+
+ public static func write(_ value: MobileKeyTransferOutcome, into buf: inout [UInt8]) {
+ FfiConverterString.write(value.title, into: &buf)
+ FfiConverterString.write(value.detail, into: &buf)
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferOutcome_lift(_ buf: RustBuffer) throws -> MobileKeyTransferOutcome {
+ return try FfiConverterTypeMobileKeyTransferOutcome.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferOutcome_lower(_ value: MobileKeyTransferOutcome) -> RustBuffer {
+ return FfiConverterTypeMobileKeyTransferOutcome.lower(value)
+}
+
+
+public struct MobileKeyTransferProgress: Equatable, Hashable {
+ public var received: UInt32
+ public var total: UInt32
+ public var duplicate: Bool
+ public var key: MobileKeyTransferKey?
+
+ // Default memberwise initializers are never public by default, so we
+ // declare one manually.
+ public init(received: UInt32, total: UInt32, duplicate: Bool, key: MobileKeyTransferKey?) {
+ self.received = received
+ self.total = total
+ self.duplicate = duplicate
+ self.key = key
+ }
+
+
+
+
+}
+
+#if compiler(>=6)
+extension MobileKeyTransferProgress: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileKeyTransferProgress: FfiConverterRustBuffer {
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransferProgress {
+ return
+ try MobileKeyTransferProgress(
+ received: FfiConverterUInt32.read(from: &buf),
+ total: FfiConverterUInt32.read(from: &buf),
+ duplicate: FfiConverterBool.read(from: &buf),
+ key: FfiConverterOptionTypeMobileKeyTransferKey.read(from: &buf)
+ )
+ }
+
+ public static func write(_ value: MobileKeyTransferProgress, into buf: inout [UInt8]) {
+ FfiConverterUInt32.write(value.received, into: &buf)
+ FfiConverterUInt32.write(value.total, into: &buf)
+ FfiConverterBool.write(value.duplicate, into: &buf)
+ FfiConverterOptionTypeMobileKeyTransferKey.write(value.key, into: &buf)
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferProgress_lift(_ buf: RustBuffer) throws -> MobileKeyTransferProgress {
+ return try FfiConverterTypeMobileKeyTransferProgress.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferProgress_lower(_ value: MobileKeyTransferProgress) -> RustBuffer {
+ return FfiConverterTypeMobileKeyTransferProgress.lower(value)
+}
+
+
public struct MobileOnboardingDiscovery: Equatable, Hashable {
public var branches: [String]
public var selectedBranch: UInt32
@@ -3880,6 +4469,147 @@ public func FfiConverterTypeMobileHomePhase_lower(_ value: MobileHomePhase) -> R
+public
+enum MobileKeyTransferFfiError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
+
+
+
+ case Failed(message: String
+ )
+
+
+
+
+
+
+ public var errorDescription: String? {
+ String(reflecting: self)
+ }
+
+}
+
+#if compiler(>=6)
+extension MobileKeyTransferFfiError: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileKeyTransferFfiError: FfiConverterRustBuffer {
+ typealias SwiftType = MobileKeyTransferFfiError
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransferFfiError {
+ let variant: Int32 = try readInt(&buf)
+ switch variant {
+
+
+
+
+ case 1: return .Failed(
+ message: try FfiConverterString.read(from: &buf)
+ )
+
+ default: throw UniffiInternalError.unexpectedEnumCase
+ }
+ }
+
+ public static func write(_ value: MobileKeyTransferFfiError, into buf: inout [UInt8]) {
+ switch value {
+
+
+
+
+
+ case let .Failed(message):
+ writeInt(&buf, Int32(1))
+ FfiConverterString.write(message, into: &buf)
+
+ }
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferFfiError_lift(_ buf: RustBuffer) throws -> MobileKeyTransferFfiError {
+ return try FfiConverterTypeMobileKeyTransferFfiError.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferFfiError_lower(_ value: MobileKeyTransferFfiError) -> RustBuffer {
+ return FfiConverterTypeMobileKeyTransferFfiError.lower(value)
+}
+
+
+
+public enum MobileKeyTransferKind: Equatable, Hashable {
+
+ case `public`
+ case `private`
+
+
+
+
+
+}
+
+#if compiler(>=6)
+extension MobileKeyTransferKind: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileKeyTransferKind: FfiConverterRustBuffer {
+ typealias SwiftType = MobileKeyTransferKind
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileKeyTransferKind {
+ let variant: Int32 = try readInt(&buf)
+ switch variant {
+
+ case 1: return .`public`
+
+ case 2: return .`private`
+
+ default: throw UniffiInternalError.unexpectedEnumCase
+ }
+ }
+
+ public static func write(_ value: MobileKeyTransferKind, into buf: inout [UInt8]) {
+ switch value {
+
+
+ case .`public`:
+ writeInt(&buf, Int32(1))
+
+
+ case .`private`:
+ writeInt(&buf, Int32(2))
+
+ }
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferKind_lift(_ buf: RustBuffer) throws -> MobileKeyTransferKind {
+ return try FfiConverterTypeMobileKeyTransferKind.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileKeyTransferKind_lower(_ value: MobileKeyTransferKind) -> RustBuffer {
+ return FfiConverterTypeMobileKeyTransferKind.lower(value)
+}
+
+
+
public enum MobileOnboardingErrorKind: Equatable, Hashable {
@@ -4796,6 +5526,30 @@ fileprivate struct FfiConverterOptionTypeMobileHomeNotice: FfiConverterRustBuffe
}
}
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+fileprivate struct FfiConverterOptionTypeMobileKeyTransferKey: FfiConverterRustBuffer {
+ typealias SwiftType = MobileKeyTransferKey?
+
+ public static func write(_ value: SwiftType, into buf: inout [UInt8]) {
+ guard let value = value else {
+ writeInt(&buf, Int8(0))
+ return
+ }
+ writeInt(&buf, Int8(1))
+ FfiConverterTypeMobileKeyTransferKey.write(value, into: &buf)
+ }
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType {
+ switch try readInt(&buf) as Int8 {
+ case 0: return nil
+ case 1: return try FfiConverterTypeMobileKeyTransferKey.read(from: &buf)
+ default: throw UniffiInternalError.unexpectedOptionalTag
+ }
+ }
+}
+
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -4996,6 +5750,56 @@ fileprivate struct FfiConverterSequenceTypeMobileHomeSummaryRow: FfiConverterRus
}
}
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+fileprivate struct FfiConverterSequenceTypeMobileKeyTransferFrame: FfiConverterRustBuffer {
+ typealias SwiftType = [MobileKeyTransferFrame]
+
+ public static func write(_ value: [MobileKeyTransferFrame], into buf: inout [UInt8]) {
+ let len = Int32(value.count)
+ writeInt(&buf, len)
+ for item in value {
+ FfiConverterTypeMobileKeyTransferFrame.write(item, into: &buf)
+ }
+ }
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileKeyTransferFrame] {
+ let len: Int32 = try readInt(&buf)
+ var seq = [MobileKeyTransferFrame]()
+ seq.reserveCapacity(Int(len))
+ for _ in 0 ..< len {
+ seq.append(try FfiConverterTypeMobileKeyTransferFrame.read(from: &buf))
+ }
+ return seq
+ }
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+fileprivate struct FfiConverterSequenceTypeMobileKeyTransferKey: FfiConverterRustBuffer {
+ typealias SwiftType = [MobileKeyTransferKey]
+
+ public static func write(_ value: [MobileKeyTransferKey], into buf: inout [UInt8]) {
+ let len = Int32(value.count)
+ writeInt(&buf, len)
+ for item in value {
+ FfiConverterTypeMobileKeyTransferKey.write(item, into: &buf)
+ }
+ }
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileKeyTransferKey] {
+ let len: Int32 = try readInt(&buf)
+ var seq = [MobileKeyTransferKey]()
+ seq.reserveCapacity(Int(len))
+ for _ in 0 ..< len {
+ seq.append(try FfiConverterTypeMobileKeyTransferKey.read(from: &buf))
+ }
+ return seq
+ }
+}
+
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -5084,6 +5888,13 @@ public func mobileHomeOperation() -> MobileHomeOperation {
)
})
}
+public func mobileKeyTransfer()throws -> MobileKeyTransfer {
+ return try FfiConverterTypeMobileKeyTransfer_lift(try rustCallWithError(FfiConverterTypeMobileKeyTransferFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_func_mobile_key_transfer(uniffiCallStatus
+ )
+})
+}
public func mobileOnboardingOperation(serverUrl: String, account: String, repositoryPath: String, applicationToken: String)throws -> MobileOnboardingOperation {
return try FfiConverterTypeMobileOnboardingOperation_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
uniffiCallStatus in
@@ -5162,6 +5973,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_func_mobile_home_operation() != 10595) {
return InitializationResult.apiChecksumMismatch
}
+ if (uniffi_ironstorage_apple_checksum_func_mobile_key_transfer() != 57389) {
+ return InitializationResult.apiChecksumMismatch
+ }
if (uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation() != 8354) {
return InitializationResult.apiChecksumMismatch
}
@@ -5276,6 +6090,21 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale() != 27818) {
return InitializationResult.apiChecksumMismatch
}
+ if (uniffi_ironstorage_apple_checksum_method_mobilekeytransfer_export() != 50467) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_ironstorage_apple_checksum_method_mobilekeytransfer_importer() != 55825) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_ironstorage_apple_checksum_method_mobilekeytransfer_keys() != 1261) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_add_frame() != 27319) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_import() != 37403) {
+ return InitializationResult.apiChecksumMismatch
+ }
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel() != 49755) {
return InitializationResult.apiChecksumMismatch
}
diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h
index d126f9d..eed466a 100644
--- a/apple/Generated/ironstorage_appleFFI.h
+++ b/apple/Generated/ironstorage_appleFFI.h
@@ -418,6 +418,51 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh(uint64
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh_if_stale(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER
+uint64_t uniffi_ironstorage_apple_fn_clone_mobilekeytransfer(uint64_t handle, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEKEYTRANSFER
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEKEYTRANSFER
+void uniffi_ironstorage_apple_fn_free_mobilekeytransfer(uint64_t handle, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFER_EXPORT
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFER_EXPORT
+RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransfer_export(uint64_t ptr, RustBuffer fingerprint, RustBuffer kind, RustBuffer passphrase, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFER_IMPORTER
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFER_IMPORTER
+uint64_t uniffi_ironstorage_apple_fn_method_mobilekeytransfer_importer(uint64_t ptr, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFER_KEYS
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFER_KEYS
+RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransfer_keys(uint64_t ptr, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFERIMPORT
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFERIMPORT
+uint64_t uniffi_ironstorage_apple_fn_clone_mobilekeytransferimport(uint64_t handle, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEKEYTRANSFERIMPORT
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEKEYTRANSFERIMPORT
+void uniffi_ironstorage_apple_fn_free_mobilekeytransferimport(uint64_t handle, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_FRAME
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_FRAME
+RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_add_frame(uint64_t ptr, RustBuffer payload, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
+RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_import(uint64_t ptr, RustBuffer passphrase, int8_t make_default, RustCallStatus *_Nonnull out_status
+);
+#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
uint64_t uniffi_ironstorage_apple_fn_clone_mobileonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
@@ -458,6 +503,12 @@ uint64_t uniffi_ironstorage_apple_fn_func_mobile_authentication(RustCallStatus *
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER
+uint64_t uniffi_ironstorage_apple_fn_func_mobile_key_transfer(RustCallStatus *_Nonnull out_status
+
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
@@ -767,6 +818,12 @@ uint16_t uniffi_ironstorage_apple_checksum_func_mobile_authentication(void
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_home_operation(void
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_KEY_TRANSFER
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_KEY_TRANSFER
+uint16_t uniffi_ironstorage_apple_checksum_func_mobile_key_transfer(void
+
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
@@ -995,6 +1052,36 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh(vo
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale(void
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_EXPORT
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_EXPORT
+uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransfer_export(void
+
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_IMPORTER
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_IMPORTER
+uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransfer_importer(void
+
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_KEYS
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_KEYS
+uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransfer_keys(void
+
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_FRAME
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_FRAME
+uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_add_frame(void
+
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
+uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_import(void
+
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
diff --git a/apple/Info.plist b/apple/Info.plist
index df4f349..fb09502 100644
--- a/apple/Info.plist
+++ b/apple/Info.plist
@@ -24,6 +24,8 @@
NSFaceIDUsageDescription
Unlock your GPG key for protected password operations.
+ NSCameraUsageDescription
+ Scan GPG key transfer QR codes that you choose to import.
UILaunchScreen
UISupportedInterfaceOrientations
diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift
index d0ac5d5..b68429c 100644
--- a/apple/Sources/App/IronStorageApp.swift
+++ b/apple/Sources/App/IronStorageApp.swift
@@ -1,4 +1,7 @@
import UIKit
+import AVFoundation
+import Vision
+import VisionKit
extension Notification.Name {
static let ironStorageLocalStoreDidChange = Notification.Name(
@@ -10,6 +13,9 @@ extension Notification.Name {
static let ironStorageWatchSnapshotDidChange = Notification.Name(
"de.rfc1437.ironstorage.watch-snapshot-did-change"
)
+ static let ironStorageKeyMaterialDidChange = Notification.Name(
+ "de.rfc1437.ironstorage.key-material-did-change"
+ )
}
@main
@@ -17,6 +23,20 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var context: AppContext?
+ override init() {
+ super.init()
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(keyMaterialDidChange),
+ name: .ironStorageKeyMaterialDidChange,
+ object: nil
+ )
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
+
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
@@ -33,6 +53,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidEnterBackground(_ application: UIApplication) {
context?.lockForBackground()
}
+
+ @objc private func keyMaterialDidChange() {
+ guard let window else { return }
+ context?.lockForBackground()
+ let context = AppContext()
+ self.context = context
+ window.rootViewController = context.makeRootController()
+ }
}
@MainActor
@@ -676,6 +704,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
fileprivate let shellTab = MobileTab.preferences
private var page: MobilePage
private let authentication: MobileAuthentication?
+ private let keyTransfer = try? mobileKeyTransfer()
private var state: MobileAuthenticationState?
private var preferenceTask: Task?
private var loadTask: Task?
@@ -718,21 +747,25 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
}
override func numberOfSections(in tableView: UITableView) -> Int {
- page.state == .ready ? 2 : 0
+ page.state == .ready ? 3 : 0
}
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
- section == 0 ? 1 : 2
+ section == 0 ? 2 : (section == 1 ? 1 : 2)
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
- section == 0 ? "Secure Unlock" : "Authentication Session"
+ switch section {
+ case 0: "GPG Key Transfer"
+ case 1: "Secure Unlock"
+ default: "Authentication Session"
+ }
}
override func tableView(
@@ -740,6 +773,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
titleForFooterInSection section: Int
) -> String? {
if section == 0 {
+ return "Scan or display ASCII-armored GPG keys. Private-key transfers require explicit confirmation and passphrase validation."
+ }
+ if section == 1 {
return "When enabled, the GPG passphrase is device-only, requires a device passcode, and is invalidated when enrolled biometrics change."
}
return "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
@@ -752,6 +788,16 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
if indexPath.section == 0 {
+ let importing = indexPath.row == 0
+ content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode")
+ content.text = importing ? "Import GPG Key" : "Export GPG Key"
+ content.secondaryText = importing
+ ? "Scan one or more transfer QR codes"
+ : "Display public or private key armor"
+ cell.accessoryType = .disclosureIndicator
+ cell.isUserInteractionEnabled = keyTransfer != nil
+ cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1
+ } else if indexPath.section == 1 {
content.image = UIImage(systemName: "faceid")
content.text = "Biometric Unlock"
content.secondaryText = state?.biometricUnlockEnabled == true
@@ -788,7 +834,19 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
- guard indexPath.section == 1, indexPath.row == 1, let authentication else { return }
+ if indexPath.section == 0 {
+ guard let keyTransfer else { return }
+ if indexPath.row == 0 {
+ requestKeyScanner(keyTransfer)
+ } else {
+ navigationController?.pushViewController(
+ KeyExportListViewController(transfer: keyTransfer),
+ animated: true
+ )
+ }
+ return
+ }
+ guard indexPath.section == 2, indexPath.row == 1, let authentication else { return }
do {
try authentication.manualLock()
refreshState()
@@ -845,6 +903,67 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
}
+ private func requestKeyScanner(_ transfer: MobileKeyTransfer) {
+ guard DataScannerViewController.isSupported else {
+ presentMessage(
+ title: "Key Scanning Is Unavailable",
+ detail: "This device does not support live QR scanning."
+ )
+ return
+ }
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized:
+ showKeyScanner(transfer)
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
+ Task { @MainActor in
+ guard let self else { return }
+ if granted {
+ self.showKeyScanner(transfer)
+ } else {
+ self.showCameraDenied()
+ }
+ }
+ }
+ default:
+ showCameraDenied()
+ }
+ }
+
+ private func showKeyScanner(_ transfer: MobileKeyTransfer) {
+ guard DataScannerViewController.isAvailable else {
+ presentMessage(
+ title: "Camera Is Unavailable",
+ detail: "Close other camera apps and try again."
+ )
+ return
+ }
+ navigationController?.pushViewController(
+ KeyImportScannerViewController(transfer: transfer),
+ animated: true
+ )
+ }
+
+ private func showCameraDenied() {
+ let alert = UIAlertController(
+ title: "Camera Access Is Off",
+ message: "Allow camera access in Settings to scan GPG key QR codes.",
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+ alert.addAction(UIAlertAction(title: "Open Settings", style: .default) { _ in
+ guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+ UIApplication.shared.open(url)
+ })
+ present(alert, animated: true)
+ }
+
+ private func presentMessage(title: String, detail: String) {
+ let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
+ present(alert, animated: true)
+ }
+
@objc private func authenticationDidChange() {
refreshState()
}
@@ -893,6 +1012,569 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
}
}
+@MainActor
+private final class KeyImportScannerViewController: UIViewController,
+ DataScannerViewControllerDelegate
+{
+ private let importer: MobileKeyTransferImport
+ private let scanner = DataScannerViewController(
+ recognizedDataTypes: [.barcode(symbologies: [.qr])],
+ qualityLevel: .balanced,
+ recognizesMultipleItems: true,
+ isHighFrameRateTrackingEnabled: false,
+ isPinchToZoomEnabled: true,
+ isGuidanceEnabled: true,
+ isHighlightingEnabled: true
+ )
+ private let progressLabel = UILabel()
+ private let shield = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
+ private var completing = false
+
+ init(transfer: MobileKeyTransfer) {
+ importer = transfer.importer()
+ super.init(nibName: nil, bundle: nil)
+ title = "Import GPG Key"
+ navigationItem.largeTitleDisplayMode = .never
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is not supported")
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .systemBackground
+ scanner.delegate = self
+ addChild(scanner)
+ scanner.view.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(scanner.view)
+ scanner.didMove(toParent: self)
+
+ let material = UIBlurEffect(style: .systemMaterial)
+ let status = UIVisualEffectView(effect: material)
+ status.layer.cornerRadius = 12
+ status.clipsToBounds = true
+ status.translatesAutoresizingMaskIntoConstraints = false
+ progressLabel.text = "Point the camera at a key-transfer QR code"
+ progressLabel.font = .preferredFont(forTextStyle: .callout)
+ progressLabel.adjustsFontForContentSizeCategory = true
+ progressLabel.numberOfLines = 0
+ progressLabel.textAlignment = .center
+ progressLabel.translatesAutoresizingMaskIntoConstraints = false
+ status.contentView.addSubview(progressLabel)
+ view.addSubview(status)
+
+ let shieldLabel = UILabel()
+ shieldLabel.text = "Camera hidden while IronStorage is inactive or the screen is captured"
+ shieldLabel.font = .preferredFont(forTextStyle: .headline)
+ shieldLabel.adjustsFontForContentSizeCategory = true
+ shieldLabel.numberOfLines = 0
+ shieldLabel.textAlignment = .center
+ shieldLabel.translatesAutoresizingMaskIntoConstraints = false
+ shield.contentView.addSubview(shieldLabel)
+ shield.translatesAutoresizingMaskIntoConstraints = false
+ shield.isHidden = true
+ shield.accessibilityViewIsModal = true
+ view.addSubview(shield)
+
+ NSLayoutConstraint.activate([
+ scanner.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ scanner.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ scanner.view.topAnchor.constraint(equalTo: view.topAnchor),
+ scanner.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ status.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
+ status.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20),
+ status.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
+ progressLabel.leadingAnchor.constraint(equalTo: status.contentView.leadingAnchor, constant: 16),
+ progressLabel.trailingAnchor.constraint(equalTo: status.contentView.trailingAnchor, constant: -16),
+ progressLabel.topAnchor.constraint(equalTo: status.contentView.topAnchor, constant: 12),
+ progressLabel.bottomAnchor.constraint(equalTo: status.contentView.bottomAnchor, constant: -12),
+ shield.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ shield.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ shield.topAnchor.constraint(equalTo: view.topAnchor),
+ shield.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ shieldLabel.leadingAnchor.constraint(equalTo: shield.contentView.leadingAnchor, constant: 32),
+ shieldLabel.trailingAnchor.constraint(equalTo: shield.contentView.trailingAnchor, constant: -32),
+ shieldLabel.centerYAnchor.constraint(equalTo: shield.contentView.centerYAnchor),
+ ])
+
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(appWillResignActive),
+ name: UIApplication.willResignActiveNotification,
+ object: nil
+ )
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(appDidBecomeActive),
+ name: UIApplication.didBecomeActiveNotification,
+ object: nil
+ )
+ registerForTraitChanges([UITraitSceneCaptureState.self]) {
+ (controller: KeyImportScannerViewController, _: UITraitCollection) in
+ controller.updateCaptureShield()
+ }
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ if !completing {
+ do {
+ try scanner.startScanning()
+ } catch {
+ presentFailure(error.localizedDescription)
+ }
+ }
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ scanner.stopScanning()
+ super.viewWillDisappear(animated)
+ }
+
+ func dataScanner(
+ _ dataScanner: DataScannerViewController,
+ didAdd addedItems: [RecognizedItem],
+ allItems: [RecognizedItem]
+ ) {
+ guard !completing else { return }
+ for item in addedItems {
+ guard case let .barcode(barcode) = item, let payload = barcode.payloadStringValue else {
+ continue
+ }
+ do {
+ let progress = try importer.addFrame(payload: payload)
+ if progress.duplicate {
+ progressLabel.text = "Already scanned — show the next QR code"
+ } else {
+ progressLabel.text = progress.key == nil
+ ? "Scanned \(progress.received) of \(progress.total)"
+ : "Key transfer complete"
+ }
+ UIAccessibility.post(notification: .announcement, argument: progressLabel.text)
+ if let key = progress.key {
+ completing = true
+ scanner.stopScanning()
+ confirmImport(key)
+ return
+ }
+ } catch {
+ progressLabel.text = "That QR code could not be added"
+ UIAccessibility.post(notification: .announcement, argument: progressLabel.text)
+ presentFailure(error.localizedDescription)
+ }
+ }
+ }
+
+ func dataScanner(
+ _ dataScanner: DataScannerViewController,
+ becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable
+ ) {
+ presentFailure("Live scanning became unavailable. Try again when the camera is free.")
+ }
+
+ @objc private func appWillResignActive() {
+ shield.isHidden = false
+ scanner.stopScanning()
+ }
+
+ @objc private func appDidBecomeActive() {
+ updateCaptureShield()
+ }
+
+ private func updateCaptureShield() {
+ let captured = traitCollection.sceneCaptureState == .active
+ shield.isHidden = !captured
+ if captured || completing || view.window == nil {
+ scanner.stopScanning()
+ } else {
+ try? scanner.startScanning()
+ }
+ }
+
+ private func confirmImport(_ key: MobileKeyTransferKey) {
+ let privateKey = key.kind == .private
+ let warning = privateKey
+ ? "Import this private key only if you trust the device that displayed it."
+ : "Import this public key?"
+ let alert = UIAlertController(
+ title: privateKey ? "Import Private GPG Key?" : "Import Public GPG Key?",
+ message: "\(warning)\n\n\(key.title)\n\(key.detail)",
+ preferredStyle: .alert
+ )
+ if privateKey && key.requiresPassphrase {
+ alert.addTextField { field in
+ field.placeholder = "GPG key passphrase"
+ field.isSecureTextEntry = true
+ field.textContentType = .password
+ field.clearButtonMode = .whileEditing
+ }
+ }
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
+ self?.navigationController?.popViewController(animated: true)
+ })
+ alert.addAction(UIAlertAction(title: "Import", style: .default) { [weak self, weak alert] _ in
+ self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: false)
+ })
+ if privateKey {
+ alert.addAction(UIAlertAction(title: "Import as Default", style: .default) {
+ [weak self, weak alert] _ in
+ self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: true)
+ })
+ }
+ present(alert, animated: true)
+ }
+
+ private func finishImport(passphrase: String?, makeDefault: Bool) {
+ do {
+ let outcome = try importer.import(passphrase: passphrase, makeDefault: makeDefault)
+ let alert = UIAlertController(
+ title: outcome.title,
+ message: outcome.detail,
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
+ guard self != nil else { return }
+ NotificationCenter.default.post(name: .ironStorageKeyMaterialDidChange, object: nil)
+ })
+ present(alert, animated: true)
+ } catch {
+ completing = false
+ presentFailure(error.localizedDescription) { [weak self] in
+ guard let self else { return }
+ try? scanner.startScanning()
+ }
+ }
+ }
+
+ private func presentFailure(_ detail: String, completion: (() -> Void)? = nil) {
+ guard presentedViewController == nil else { return }
+ let alert = UIAlertController(
+ title: "GPG Key Transfer Failed",
+ message: detail,
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completion?() })
+ present(alert, animated: true)
+ }
+}
+
+@MainActor
+private final class KeyExportListViewController: UITableViewController {
+ private let transfer: MobileKeyTransfer
+ private let keys: [MobileKeyTransferKey]
+
+ init(transfer: MobileKeyTransfer) {
+ self.transfer = transfer
+ keys = transfer.keys()
+ super.init(style: .insetGrouped)
+ title = "Export GPG Key"
+ navigationItem.largeTitleDisplayMode = .never
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is not supported")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ if keys.isEmpty {
+ var configuration = UIContentUnavailableConfiguration.empty()
+ configuration.image = UIImage(systemName: "key.slash")
+ configuration.text = "No GPG Keys"
+ configuration.secondaryText = "Import a key before exporting one."
+ contentUnavailableConfiguration = configuration
+ }
+ }
+
+ override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+ keys.count
+ }
+
+ override func tableView(
+ _ tableView: UITableView,
+ cellForRowAt indexPath: IndexPath
+ ) -> UITableViewCell {
+ let key = keys[indexPath.row]
+ let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
+ var content = cell.defaultContentConfiguration()
+ content.image = UIImage(systemName: key.kind == .private ? "key.fill" : "key")
+ content.text = key.title
+ content.secondaryText = key.detail
+ content.secondaryTextProperties.numberOfLines = 0
+ cell.contentConfiguration = content
+ cell.accessoryType = .disclosureIndicator
+ return cell
+ }
+
+ override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ tableView.deselectRow(at: indexPath, animated: true)
+ let key = keys[indexPath.row]
+ let sheet = UIAlertController(title: key.title, message: key.detail, preferredStyle: .actionSheet)
+ sheet.addAction(UIAlertAction(title: "Export Public Key", style: .default) {
+ [weak self] _ in self?.export(key, kind: .public, passphrase: nil)
+ })
+ if key.kind == .private {
+ sheet.addAction(UIAlertAction(title: "Export Private Key…", style: .destructive) {
+ [weak self] _ in self?.confirmPrivateExport(key)
+ })
+ }
+ sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+ if let popover = sheet.popoverPresentationController {
+ popover.sourceView = tableView.cellForRow(at: indexPath)
+ popover.sourceRect = tableView.cellForRow(at: indexPath)?.bounds ?? .zero
+ }
+ present(sheet, animated: true)
+ }
+
+ private func confirmPrivateExport(_ key: MobileKeyTransferKey) {
+ let alert = UIAlertController(
+ title: "Display Private GPG Key?",
+ message: "Anyone who scans these QR codes can use this private key.\n\n\(key.title)\n\(key.detail)",
+ preferredStyle: .alert
+ )
+ if key.requiresPassphrase {
+ alert.addTextField { field in
+ field.placeholder = "GPG key passphrase"
+ field.isSecureTextEntry = true
+ field.textContentType = .password
+ field.clearButtonMode = .whileEditing
+ }
+ }
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+ alert.addAction(UIAlertAction(title: "Display", style: .destructive) {
+ [weak self, weak alert] _ in
+ self?.export(key, kind: .private, passphrase: alert?.textFields?.first?.text)
+ })
+ present(alert, animated: true)
+ }
+
+ private func export(
+ _ key: MobileKeyTransferKey,
+ kind: MobileKeyTransferKind,
+ passphrase: String?
+ ) {
+ do {
+ let exported = try transfer.export(
+ fingerprint: key.fingerprint,
+ kind: kind,
+ passphrase: passphrase
+ )
+ navigationController?.pushViewController(
+ KeyQrExportViewController(exported: exported),
+ animated: true
+ )
+ } catch {
+ let alert = UIAlertController(
+ title: "GPG Key Could Not Be Exported",
+ message: error.localizedDescription,
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
+ present(alert, animated: true)
+ }
+ }
+}
+
+@MainActor
+private final class KeyQrExportViewController: UIViewController {
+ private let exported: MobileKeyTransferExport
+ private let qrView = KeyQrView()
+ private let pageControl = UIPageControl()
+ private let progressLabel = UILabel()
+ private let shield = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
+ private var index = 0
+
+ init(exported: MobileKeyTransferExport) {
+ self.exported = exported
+ super.init(nibName: nil, bundle: nil)
+ title = exported.key.kind == .private ? "Private GPG Key" : "Public GPG Key"
+ navigationItem.largeTitleDisplayMode = .never
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is not supported")
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .systemBackground
+ qrView.translatesAutoresizingMaskIntoConstraints = false
+ qrView.layer.cornerRadius = 16
+ qrView.clipsToBounds = true
+
+ let identity = UILabel()
+ identity.text = exported.key.title
+ identity.font = .preferredFont(forTextStyle: .headline)
+ identity.adjustsFontForContentSizeCategory = true
+ identity.numberOfLines = 0
+ identity.textAlignment = .center
+
+ let fingerprint = UILabel()
+ fingerprint.text = exported.key.detail
+ fingerprint.font = .preferredFont(forTextStyle: .footnote)
+ fingerprint.adjustsFontForContentSizeCategory = true
+ fingerprint.numberOfLines = 0
+ fingerprint.textAlignment = .center
+ fingerprint.textColor = .secondaryLabel
+
+ progressLabel.font = .preferredFont(forTextStyle: .callout)
+ progressLabel.adjustsFontForContentSizeCategory = true
+ progressLabel.textAlignment = .center
+ pageControl.numberOfPages = exported.frames.count
+ pageControl.addTarget(self, action: #selector(pageChanged), for: .valueChanged)
+ pageControl.isHidden = exported.frames.count == 1
+
+ let stack = UIStackView(arrangedSubviews: [identity, fingerprint, qrView, progressLabel, pageControl])
+ stack.axis = .vertical
+ stack.alignment = .fill
+ stack.spacing = 14
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(stack)
+
+ let shieldLabel = UILabel()
+ shieldLabel.text = "Private key hidden while IronStorage is inactive or the screen is captured"
+ shieldLabel.font = .preferredFont(forTextStyle: .headline)
+ shieldLabel.adjustsFontForContentSizeCategory = true
+ shieldLabel.numberOfLines = 0
+ shieldLabel.textAlignment = .center
+ shieldLabel.translatesAutoresizingMaskIntoConstraints = false
+ shield.contentView.addSubview(shieldLabel)
+ shield.translatesAutoresizingMaskIntoConstraints = false
+ shield.isHidden = true
+ shield.accessibilityViewIsModal = true
+ view.addSubview(shield)
+
+ NSLayoutConstraint.activate([
+ stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24),
+ stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24),
+ stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
+ qrView.widthAnchor.constraint(equalTo: qrView.heightAnchor),
+ qrView.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -48),
+ shield.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ shield.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ shield.topAnchor.constraint(equalTo: view.topAnchor),
+ shield.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ shieldLabel.leadingAnchor.constraint(equalTo: shield.contentView.leadingAnchor, constant: 32),
+ shieldLabel.trailingAnchor.constraint(equalTo: shield.contentView.trailingAnchor, constant: -32),
+ shieldLabel.centerYAnchor.constraint(equalTo: shield.contentView.centerYAnchor),
+ ])
+
+ let left = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft))
+ left.direction = .left
+ let right = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight))
+ right.direction = .right
+ qrView.addGestureRecognizer(left)
+ qrView.addGestureRecognizer(right)
+ qrView.isUserInteractionEnabled = true
+
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(appWillResignActive),
+ name: UIApplication.willResignActiveNotification,
+ object: nil
+ )
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(appDidBecomeActive),
+ name: UIApplication.didBecomeActiveNotification,
+ object: nil
+ )
+ registerForTraitChanges([UITraitSceneCaptureState.self]) {
+ (controller: KeyQrExportViewController, _: UITraitCollection) in
+ controller.updateCaptureShield()
+ }
+ showFrame(0)
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ updateCaptureShield()
+ }
+
+ @objc private func pageChanged() {
+ showFrame(pageControl.currentPage)
+ }
+
+ @objc private func swipedLeft() {
+ showFrame(min(index + 1, exported.frames.count - 1))
+ }
+
+ @objc private func swipedRight() {
+ showFrame(max(index - 1, 0))
+ }
+
+ private func showFrame(_ index: Int) {
+ self.index = index
+ pageControl.currentPage = index
+ qrView.transferFrame = exported.frames[index]
+ progressLabel.text = exported.frames.count == 1
+ ? "Scan this QR code"
+ : "QR code \(index + 1) of \(exported.frames.count)"
+ qrView.accessibilityLabel = progressLabel.text
+ UIAccessibility.post(notification: .pageScrolled, argument: progressLabel.text)
+ }
+
+ @objc private func appWillResignActive() {
+ if exported.key.kind == .private {
+ shield.isHidden = false
+ }
+ }
+
+ @objc private func appDidBecomeActive() {
+ updateCaptureShield()
+ }
+
+ private func updateCaptureShield() {
+ shield.isHidden = exported.key.kind != .private || traitCollection.sceneCaptureState != .active
+ }
+}
+
+private final class KeyQrView: UIView {
+ var transferFrame: MobileKeyTransferFrame? {
+ didSet { setNeedsDisplay() }
+ }
+
+ override func draw(_ rect: CGRect) {
+ UIColor.white.setFill()
+ UIRectFill(rect)
+ guard let transferFrame, transferFrame.width > 0 else { return }
+ let width = Int(transferFrame.width)
+ let padded = width + 8
+ let scale = floor(min(rect.width, rect.height) / CGFloat(padded))
+ guard scale >= 1 else { return }
+ let symbolSize = CGFloat(padded) * scale
+ let origin = CGPoint(
+ x: rect.midX - symbolSize / 2 + 4 * scale,
+ y: rect.midY - symbolSize / 2 + 4 * scale
+ )
+ guard let context = UIGraphicsGetCurrentContext() else { return }
+ context.setFillColor(UIColor.black.cgColor)
+ context.interpolationQuality = .none
+ for y in 0.. for MobileAuthenticationState {
}
}
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum MobileKeyTransferKind {
+ Public,
+ Private,
+}
+
+impl From for MobileKeyTransferKind {
+ fn from(kind: StorageKeyTransferKind) -> Self {
+ match kind {
+ StorageKeyTransferKind::Public => Self::Public,
+ StorageKeyTransferKind::Private => Self::Private,
+ }
+ }
+}
+
+impl From for StorageKeyTransferKind {
+ fn from(kind: MobileKeyTransferKind) -> Self {
+ match kind {
+ MobileKeyTransferKind::Public => Self::Public,
+ MobileKeyTransferKind::Private => Self::Private,
+ }
+ }
+}
+
+#[derive(Clone, uniffi::Record)]
+pub struct MobileKeyTransferKey {
+ pub fingerprint: String,
+ pub title: String,
+ pub detail: String,
+ pub kind: MobileKeyTransferKind,
+ pub requires_passphrase: bool,
+}
+
+impl From<&StorageKeyTransferKey> for MobileKeyTransferKey {
+ fn from(key: &StorageKeyTransferKey) -> Self {
+ Self {
+ fingerprint: key.fingerprint().to_owned(),
+ title: key.title().to_owned(),
+ detail: key.detail().to_owned(),
+ kind: key.kind().into(),
+ requires_passphrase: key.requires_passphrase(),
+ }
+ }
+}
+
+#[derive(Clone, uniffi::Record)]
+pub struct MobileKeyTransferFrame {
+ pub sequence: u32,
+ pub total: u32,
+ pub width: u32,
+ pub modules: Vec,
+}
+
+#[derive(Clone, uniffi::Record)]
+pub struct MobileKeyTransferExport {
+ pub key: MobileKeyTransferKey,
+ pub frames: Vec,
+}
+
+#[derive(Clone, uniffi::Record)]
+pub struct MobileKeyTransferProgress {
+ pub received: u32,
+ pub total: u32,
+ pub duplicate: bool,
+ pub key: Option,
+}
+
+impl From for MobileKeyTransferProgress {
+ fn from(progress: StorageKeyTransferProgress) -> Self {
+ Self {
+ received: u32::try_from(progress.received()).unwrap_or(u32::MAX),
+ total: u32::try_from(progress.total()).unwrap_or(u32::MAX),
+ duplicate: progress.duplicate(),
+ key: progress.key().map(Into::into),
+ }
+ }
+}
+
+#[derive(Clone, uniffi::Record)]
+pub struct MobileKeyTransferOutcome {
+ pub title: String,
+ pub detail: String,
+}
+
+#[derive(Debug, uniffi::Error)]
+pub enum MobileKeyTransferFfiError {
+ Failed { message: String },
+}
+
+impl fmt::Display for MobileKeyTransferFfiError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Failed { message } => formatter.write_str(message),
+ }
+ }
+}
+
+impl Error for MobileKeyTransferFfiError {}
+
+impl From for MobileKeyTransferFfiError {
+ fn from(error: StorageKeyTransferError) -> Self {
+ Self::Failed {
+ message: error.to_string(),
+ }
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct MobileKeyTransfer {
+ service: ironstorage::mobile_key_transfer::MobileKeyTransferService,
+}
+
+#[uniffi::export]
+impl MobileKeyTransfer {
+ pub fn keys(&self) -> Vec {
+ self.service.keys().iter().map(Into::into).collect()
+ }
+
+ pub fn export(
+ &self,
+ fingerprint: String,
+ kind: MobileKeyTransferKind,
+ passphrase: Option,
+ ) -> Result {
+ let exported = self.service.export(
+ &fingerprint,
+ kind.into(),
+ passphrase.map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
+ )?;
+ let frames = exported
+ .frames()
+ .iter()
+ .map(|frame| {
+ let matrix = frame.matrix();
+ let mut modules = Vec::with_capacity(matrix.width() * matrix.width());
+ for y in 0..matrix.width() {
+ for x in 0..matrix.width() {
+ modules.push(u8::from(matrix.is_dark(x, y).unwrap_or(false)));
+ }
+ }
+ MobileKeyTransferFrame {
+ sequence: u32::try_from(frame.sequence()).unwrap_or(u32::MAX),
+ total: u32::try_from(frame.total()).unwrap_or(u32::MAX),
+ width: u32::try_from(matrix.width()).unwrap_or(u32::MAX),
+ modules,
+ }
+ })
+ .collect();
+ Ok(MobileKeyTransferExport {
+ key: exported.key().into(),
+ frames,
+ })
+ }
+
+ pub fn importer(&self) -> Arc {
+ Arc::new(MobileKeyTransferImport {
+ importer: Mutex::new(self.service.importer()),
+ })
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct MobileKeyTransferImport {
+ importer: Mutex,
+}
+
+#[uniffi::export]
+impl MobileKeyTransferImport {
+ pub fn add_frame(
+ &self,
+ payload: String,
+ ) -> Result {
+ self.importer
+ .lock()
+ .map_err(|_| MobileKeyTransferFfiError::Failed {
+ message: "the key-transfer session is unavailable".to_owned(),
+ })?
+ .add_frame(ironstorage::repository::SecretBytes::new(
+ payload.into_bytes(),
+ ))
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub fn import(
+ &self,
+ passphrase: Option,
+ make_default: bool,
+ ) -> Result {
+ let outcome = self
+ .importer
+ .lock()
+ .map_err(|_| MobileKeyTransferFfiError::Failed {
+ message: "the key-transfer session is unavailable".to_owned(),
+ })?
+ .import(
+ passphrase
+ .map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
+ make_default,
+ )?;
+ Ok(MobileKeyTransferOutcome {
+ title: outcome.title().to_owned(),
+ detail: outcome.detail().to_owned(),
+ })
+ }
+}
+
#[derive(Debug, uniffi::Error)]
pub enum MobileAuthenticationFfiError {
Failed {
@@ -1388,6 +1605,13 @@ pub fn mobile_authentication() -> Result, MobileAuthen
}))
}
+#[uniffi::export]
+pub fn mobile_key_transfer() -> Result, MobileKeyTransferFfiError> {
+ Ok(Arc::new(MobileKeyTransfer {
+ service: ironstorage::mobile_key_transfer::MobileKeyTransferService::load()?,
+ }))
+}
+
#[uniffi::export]
pub fn mobile_onboarding_operation(
server_url: String,
diff --git a/crates/storage/src/crypto.rs b/crates/storage/src/crypto.rs
index 06e817d..84de370 100644
--- a/crates/storage/src/crypto.rs
+++ b/crates/storage/src/crypto.rs
@@ -265,6 +265,52 @@ impl KeyStore {
self.keys.values().map(key_info)
}
+ /// Serialize one verified certificate as transferable ASCII armor.
+ pub fn export_armored(
+ &self,
+ identity: &str,
+ include_secret: bool,
+ passphrase: Option<&SecretBytes>,
+ ) -> Result {
+ let handle = self.resolve(identity)?;
+ let material = self.material(&handle)?;
+ let bytes = if include_secret {
+ let secret = material
+ .secret
+ .as_ref()
+ .ok_or(CryptoError::MissingSecretKey)?;
+ let password = Password::from(passphrase.map_or(&[][..], |value| value.expose()));
+ if !secret_unlocks(secret, &password) {
+ return Err(CryptoError::InvalidKeyPassphrase);
+ }
+ secret.to_armored_bytes(ArmorOptions::default())
+ } else {
+ material.public.to_armored_bytes(ArmorOptions::default())
+ }
+ .map_err(|_| CryptoError::CorruptKeyMaterial)?;
+ Ok(SecretBytes::new(bytes))
+ }
+
+ /// Prove that a supplied passphrase unlocks every protected packet in a secret key.
+ pub fn validate_secret_passphrase(
+ &self,
+ identity: &str,
+ passphrase: Option<&SecretBytes>,
+ ) -> Result<(), CryptoError> {
+ let handle = self.resolve(identity)?;
+ let secret = self
+ .material(&handle)?
+ .secret
+ .as_ref()
+ .ok_or(CryptoError::MissingSecretKey)?;
+ let password = Password::from(passphrase.map_or(&[][..], |value| value.expose()));
+ if secret_unlocks(secret, &password) {
+ Ok(())
+ } else {
+ Err(CryptoError::InvalidKeyPassphrase)
+ }
+ }
+
/// Resolve a full primary/subkey fingerprint, 8/16-digit key ID, or exact UTF-8 user ID.
pub fn resolve(&self, identity: &str) -> Result {
let hex_identity = normalize_hex_identity(identity);
@@ -646,6 +692,7 @@ pub enum CryptoError {
reason: SecretProviderError,
},
DecryptionFailed,
+ InvalidKeyPassphrase,
MissingSigningKey {
fingerprint: KeyFingerprint,
},
@@ -704,6 +751,9 @@ impl fmt::Display for CryptoError {
"secret provider could not unlock OpenPGP key {fingerprint}: {reason:?}"
),
Self::DecryptionFailed => formatter.write_str("OpenPGP decryption failed"),
+ Self::InvalidKeyPassphrase => {
+ formatter.write_str("the OpenPGP key passphrase is incorrect")
+ }
Self::MissingSigningKey { fingerprint } => {
write!(formatter, "OpenPGP key cannot sign: {fingerprint}")
}
@@ -875,6 +925,14 @@ fn secret_requires_password(key: &SignedSecretKey) -> bool {
.any(|subkey| subkey.secret_params().is_encrypted())
}
+fn secret_unlocks(key: &SignedSecretKey, password: &Password) -> bool {
+ matches!(key.primary_key.unlock(password, |_, _| Ok(())), Ok(Ok(())))
+ && key
+ .secret_subkeys
+ .iter()
+ .all(|subkey| matches!(subkey.key.unlock(password, |_, _| Ok(())), Ok(Ok(()))))
+}
+
fn signing_target_requires_password(target: SigningTarget<'_>) -> bool {
match target {
SigningTarget::Primary(key) => key.secret_params().is_encrypted(),
diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs
index 9a6ee5d..500e000 100644
--- a/crates/storage/src/lib.rs
+++ b/crates/storage/src/lib.rs
@@ -18,6 +18,7 @@ pub mod mobile;
pub mod mobile_authentication;
pub mod mobile_entry;
pub mod mobile_home;
+pub mod mobile_key_transfer;
pub mod mobile_onboarding;
pub mod mobile_passwords;
pub mod mobile_totp;
diff --git a/crates/storage/src/mobile_key_transfer.rs b/crates/storage/src/mobile_key_transfer.rs
new file mode 100644
index 0000000..e6525f9
--- /dev/null
+++ b/crates/storage/src/mobile_key_transfer.rs
@@ -0,0 +1,764 @@
+//! Storage-owned OpenPGP key transfer for native mobile QR adapters.
+
+use std::{collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path};
+
+use cap_std::{ambient_authority, fs::Dir};
+use cap_tempfile::TempFile;
+use data_encoding::{BASE64URL_NOPAD, HEXLOWER};
+use sha2::{Digest as _, Sha256};
+
+use crate::{
+ config::{Config, ConfigError},
+ crypto::{CryptoError, KeyInfo, KeyStore},
+ presentation::{QrError, QrMatrix},
+ repository::SecretBytes,
+};
+
+const FRAME_PREFIX: &str = "ISKT1";
+const FRAME_CHUNK_BYTES: usize = 1_600;
+const MAX_TRANSFER_FRAMES: usize = 11_000;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum MobileKeyTransferKind {
+ Public,
+ Private,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MobileKeyTransferKey {
+ fingerprint: String,
+ title: String,
+ detail: String,
+ kind: MobileKeyTransferKind,
+ requires_passphrase: bool,
+}
+
+impl MobileKeyTransferKey {
+ pub fn fingerprint(&self) -> &str {
+ &self.fingerprint
+ }
+
+ pub fn title(&self) -> &str {
+ &self.title
+ }
+
+ pub fn detail(&self) -> &str {
+ &self.detail
+ }
+
+ pub fn kind(&self) -> MobileKeyTransferKind {
+ self.kind
+ }
+
+ pub fn requires_passphrase(&self) -> bool {
+ self.requires_passphrase
+ }
+}
+
+pub struct MobileKeyTransferFrame {
+ sequence: usize,
+ total: usize,
+ payload: SecretBytes,
+ matrix: QrMatrix,
+}
+
+impl MobileKeyTransferFrame {
+ pub fn sequence(&self) -> usize {
+ self.sequence
+ }
+
+ pub fn total(&self) -> usize {
+ self.total
+ }
+
+ pub fn payload(&self) -> &[u8] {
+ self.payload.expose()
+ }
+
+ pub fn matrix(&self) -> &QrMatrix {
+ &self.matrix
+ }
+}
+
+pub struct MobileKeyTransferExport {
+ key: MobileKeyTransferKey,
+ frames: Vec,
+}
+
+impl MobileKeyTransferExport {
+ pub fn key(&self) -> &MobileKeyTransferKey {
+ &self.key
+ }
+
+ pub fn frames(&self) -> &[MobileKeyTransferFrame] {
+ &self.frames
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MobileKeyTransferProgress {
+ received: usize,
+ total: usize,
+ duplicate: bool,
+ key: Option,
+}
+
+impl MobileKeyTransferProgress {
+ pub fn received(&self) -> usize {
+ self.received
+ }
+
+ pub fn total(&self) -> usize {
+ self.total
+ }
+
+ pub fn duplicate(&self) -> bool {
+ self.duplicate
+ }
+
+ pub fn key(&self) -> Option<&MobileKeyTransferKey> {
+ self.key.as_ref()
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MobileKeyTransferOutcome {
+ title: String,
+ detail: String,
+}
+
+impl MobileKeyTransferOutcome {
+ pub fn title(&self) -> &str {
+ &self.title
+ }
+
+ pub fn detail(&self) -> &str {
+ &self.detail
+ }
+}
+
+pub struct MobileKeyTransferService {
+ config: Config,
+ keys: KeyStore,
+}
+
+impl MobileKeyTransferService {
+ pub fn load() -> Result {
+ let config = Config::load(None)?;
+ let keys = KeyStore::load(config.key_material())?;
+ Ok(Self { config, keys })
+ }
+
+ pub fn keys(&self) -> Vec {
+ self.keys.infos().map(|key| transfer_key(&key)).collect()
+ }
+
+ pub fn export(
+ &self,
+ fingerprint: &str,
+ kind: MobileKeyTransferKind,
+ passphrase: Option,
+ ) -> Result {
+ let include_secret = kind == MobileKeyTransferKind::Private;
+ let armor = self
+ .keys
+ .export_armored(fingerprint, include_secret, passphrase.as_ref())?;
+ let info = self
+ .keys
+ .infos()
+ .find(|key| key.fingerprint().as_str() == fingerprint)
+ .ok_or(MobileKeyTransferError::KeyUnavailable)?;
+ let frames = encode_frames(armor)?;
+ Ok(MobileKeyTransferExport {
+ key: transfer_key_with_kind(&info, kind),
+ frames,
+ })
+ }
+
+ pub fn importer(&self) -> MobileKeyTransferImport {
+ MobileKeyTransferImport::new(self.config.clone())
+ }
+}
+
+pub struct MobileKeyTransferImport {
+ config: Config,
+ digest: Option,
+ total: Option,
+ chunks: BTreeMap>,
+ complete: Option,
+ key: Option,
+}
+
+impl MobileKeyTransferImport {
+ fn new(config: Config) -> Self {
+ Self {
+ config,
+ digest: None,
+ total: None,
+ chunks: BTreeMap::new(),
+ complete: None,
+ key: None,
+ }
+ }
+
+ pub fn add_frame(
+ &mut self,
+ payload: SecretBytes,
+ ) -> Result {
+ if self.complete.is_some() {
+ return Err(MobileKeyTransferError::AlreadyComplete);
+ }
+ if payload.expose().starts_with(b"-----BEGIN PGP ") {
+ if self.total.is_some() {
+ return Err(MobileKeyTransferError::MixedTransfers);
+ }
+ self.finish(payload)?;
+ return Ok(self.progress(false));
+ }
+
+ let text = std::str::from_utf8(payload.expose())
+ .map_err(|_| MobileKeyTransferError::InvalidFrame)?;
+ let mut fields = text.splitn(5, ':');
+ if fields.next() != Some(FRAME_PREFIX) {
+ return Err(MobileKeyTransferError::InvalidFrame);
+ }
+ let digest = fields.next().ok_or(MobileKeyTransferError::InvalidFrame)?;
+ if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(MobileKeyTransferError::InvalidFrame);
+ }
+ let sequence = parse_positive(fields.next())?;
+ let total = parse_positive(fields.next())?;
+ if sequence > total || total > MAX_TRANSFER_FRAMES {
+ return Err(MobileKeyTransferError::InvalidFrame);
+ }
+ let chunk = BASE64URL_NOPAD
+ .decode(
+ fields
+ .next()
+ .ok_or(MobileKeyTransferError::InvalidFrame)?
+ .as_bytes(),
+ )
+ .map_err(|_| MobileKeyTransferError::InvalidFrame)?;
+ if chunk.is_empty() || chunk.len() > FRAME_CHUNK_BYTES {
+ return Err(MobileKeyTransferError::InvalidFrame);
+ }
+
+ if self.digest.as_deref().is_some_and(|value| value != digest)
+ || self.total.is_some_and(|value| value != total)
+ {
+ return Err(MobileKeyTransferError::MixedTransfers);
+ }
+ self.digest
+ .get_or_insert_with(|| digest.to_ascii_lowercase());
+ self.total.get_or_insert(total);
+ if let Some(existing) = self.chunks.get(&sequence) {
+ if existing == &chunk {
+ return Ok(self.progress(true));
+ }
+ return Err(MobileKeyTransferError::ConflictingFrame);
+ }
+ self.chunks.insert(sequence, chunk);
+ if self.chunks.len() == total {
+ let mut armor = Vec::new();
+ for sequence in 1..=total {
+ armor.extend(
+ self.chunks
+ .get(&sequence)
+ .ok_or(MobileKeyTransferError::IncompleteTransfer)?,
+ );
+ }
+ let actual = HEXLOWER.encode(&Sha256::digest(&armor));
+ if self.digest.as_deref() != Some(actual.as_str()) {
+ return Err(MobileKeyTransferError::ChecksumMismatch);
+ }
+ self.finish(SecretBytes::new(armor))?;
+ }
+ Ok(self.progress(false))
+ }
+
+ pub fn import(
+ &mut self,
+ passphrase: Option,
+ make_default: bool,
+ ) -> Result {
+ let armor = self
+ .complete
+ .as_ref()
+ .ok_or(MobileKeyTransferError::IncompleteTransfer)?;
+ let key = self
+ .key
+ .as_ref()
+ .ok_or(MobileKeyTransferError::IncompleteTransfer)?;
+ let mut imported = KeyStore::new();
+ imported.import(armor.expose())?;
+ if key.kind == MobileKeyTransferKind::Private {
+ imported.validate_secret_passphrase(&key.fingerprint, passphrase.as_ref())?;
+ }
+ if make_default && key.kind != MobileKeyTransferKind::Private {
+ return Err(MobileKeyTransferError::PublicDefault);
+ }
+
+ let existing = KeyStore::load(self.config.key_material())?;
+ if let Some(found) = existing
+ .infos()
+ .find(|info| info.fingerprint().as_str() == key.fingerprint)
+ && (found.has_secret() || key.kind == MobileKeyTransferKind::Public)
+ {
+ return Err(MobileKeyTransferError::DuplicateKey);
+ }
+
+ persist_key_material(self.config.key_material(), key, armor.expose())?;
+ if make_default {
+ let mut settings = self.config.settings();
+ settings.set_default_key(key.fingerprint.clone());
+ self.config.with_settings(settings)?.persist()?;
+ }
+ let kind = if key.kind == MobileKeyTransferKind::Private {
+ "private"
+ } else {
+ "public"
+ };
+ Ok(MobileKeyTransferOutcome {
+ title: "GPG Key Imported".to_owned(),
+ detail: format!("Imported the {kind} key {}.", key.fingerprint),
+ })
+ }
+
+ fn finish(&mut self, armor: SecretBytes) -> Result<(), MobileKeyTransferError> {
+ validate_transfer_armor(armor.expose())?;
+ let mut store = KeyStore::new();
+ let infos = store.import(armor.expose())?;
+ if infos.len() != 1 {
+ return Err(MobileKeyTransferError::MismatchedKeys);
+ }
+ self.key = infos.first().map(transfer_key);
+ self.complete = Some(armor);
+ Ok(())
+ }
+
+ fn progress(&self, duplicate: bool) -> MobileKeyTransferProgress {
+ MobileKeyTransferProgress {
+ received: self.chunks.len().max(usize::from(self.complete.is_some())),
+ total: self.total.unwrap_or(1),
+ duplicate,
+ key: self.key.clone(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum MobileKeyTransferError {
+ Configuration,
+ KeyUnavailable,
+ InvalidFrame,
+ MixedTransfers,
+ ConflictingFrame,
+ IncompleteTransfer,
+ AlreadyComplete,
+ ChecksumMismatch,
+ MismatchedKeys,
+ DuplicateKey,
+ PublicDefault,
+ InvalidKeyMaterial,
+ IncorrectPassphrase,
+ QrPayload,
+ Write,
+}
+
+impl fmt::Display for MobileKeyTransferError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::Configuration => "the IronStorage configuration is unavailable",
+ Self::KeyUnavailable => "the selected GPG key is unavailable",
+ Self::InvalidFrame => "this is not a valid IronStorage key-transfer QR code",
+ Self::MixedTransfers => "the scanned QR codes belong to different key transfers",
+ Self::ConflictingFrame => "a scanned QR frame conflicts with an earlier frame",
+ Self::IncompleteTransfer => {
+ "more QR frames are required before this key can be imported"
+ }
+ Self::AlreadyComplete => "this key transfer is already complete",
+ Self::ChecksumMismatch => {
+ "the reconstructed key transfer did not pass its integrity check"
+ }
+ Self::MismatchedKeys => "the transfer contains multiple or mismatched GPG keys",
+ Self::DuplicateKey => "this GPG key is already installed",
+ Self::PublicDefault => "a public-only key cannot become the default GPG key",
+ Self::InvalidKeyMaterial => "the GPG key material is invalid",
+ Self::IncorrectPassphrase => "the GPG key passphrase is incorrect",
+ Self::QrPayload => "the GPG key could not be represented as QR codes",
+ Self::Write => "the GPG key could not be stored safely",
+ })
+ }
+}
+
+impl Error for MobileKeyTransferError {}
+
+impl From for MobileKeyTransferError {
+ fn from(_: ConfigError) -> Self {
+ Self::Configuration
+ }
+}
+
+impl From for MobileKeyTransferError {
+ fn from(error: CryptoError) -> Self {
+ match error {
+ CryptoError::InvalidKeyPassphrase => Self::IncorrectPassphrase,
+ CryptoError::MissingIdentity { .. } | CryptoError::MissingSecretKey => {
+ Self::KeyUnavailable
+ }
+ _ => Self::InvalidKeyMaterial,
+ }
+ }
+}
+
+impl From for MobileKeyTransferError {
+ fn from(_: QrError) -> Self {
+ Self::QrPayload
+ }
+}
+
+fn transfer_key(info: &KeyInfo) -> MobileKeyTransferKey {
+ transfer_key_with_kind(
+ info,
+ if info.has_secret() {
+ MobileKeyTransferKind::Private
+ } else {
+ MobileKeyTransferKind::Public
+ },
+ )
+}
+
+fn transfer_key_with_kind(info: &KeyInfo, kind: MobileKeyTransferKind) -> MobileKeyTransferKey {
+ let title = info
+ .user_ids()
+ .first()
+ .cloned()
+ .unwrap_or_else(|| format!("GPG key {}", info.key_id()));
+ MobileKeyTransferKey {
+ fingerprint: info.fingerprint().as_str().to_owned(),
+ title,
+ detail: format!("Fingerprint {}", info.fingerprint()),
+ kind,
+ requires_passphrase: info.requires_passphrase(),
+ }
+}
+
+fn encode_frames(
+ armor: SecretBytes,
+) -> Result, MobileKeyTransferError> {
+ if let Ok(matrix) = QrMatrix::encode(&armor) {
+ return Ok(vec![MobileKeyTransferFrame {
+ sequence: 1,
+ total: 1,
+ payload: armor,
+ matrix,
+ }]);
+ }
+ encode_multipart(armor, FRAME_CHUNK_BYTES)
+}
+
+fn encode_multipart(
+ armor: SecretBytes,
+ chunk_bytes: usize,
+) -> Result, MobileKeyTransferError> {
+ if chunk_bytes == 0 {
+ return Err(MobileKeyTransferError::QrPayload);
+ }
+ let digest = HEXLOWER.encode(&Sha256::digest(armor.expose()));
+ let total = armor.expose().len().div_ceil(chunk_bytes);
+ if total == 0 || total > MAX_TRANSFER_FRAMES {
+ return Err(MobileKeyTransferError::QrPayload);
+ }
+ let mut frames = Vec::with_capacity(total);
+ for (index, chunk) in armor.expose().chunks(chunk_bytes).enumerate() {
+ let payload = SecretBytes::new(
+ format!(
+ "{FRAME_PREFIX}:{digest}:{}:{total}:{}",
+ index + 1,
+ BASE64URL_NOPAD.encode(chunk)
+ )
+ .into_bytes(),
+ );
+ let matrix = QrMatrix::encode(&payload)?;
+ frames.push(MobileKeyTransferFrame {
+ sequence: index + 1,
+ total,
+ payload,
+ matrix,
+ });
+ }
+ Ok(frames)
+}
+
+fn parse_positive(value: Option<&str>) -> Result {
+ value
+ .ok_or(MobileKeyTransferError::InvalidFrame)?
+ .parse::()
+ .ok()
+ .filter(|value| *value > 0)
+ .ok_or(MobileKeyTransferError::InvalidFrame)
+}
+
+fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
+ let text =
+ std::str::from_utf8(bytes).map_err(|_| MobileKeyTransferError::InvalidKeyMaterial)?;
+ let end = if text.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
+ "-----END PGP PUBLIC KEY BLOCK-----"
+ } else if text.starts_with("-----BEGIN PGP PRIVATE KEY BLOCK-----") {
+ "-----END PGP PRIVATE KEY BLOCK-----"
+ } else {
+ return Err(MobileKeyTransferError::InvalidKeyMaterial);
+ };
+ if text.matches("-----BEGIN PGP ").count() != 1 {
+ return Err(MobileKeyTransferError::MismatchedKeys);
+ }
+ let end_offset = text
+ .find(end)
+ .map(|offset| offset + end.len())
+ .ok_or(MobileKeyTransferError::InvalidKeyMaterial)?;
+ if !text[end_offset..].trim().is_empty() {
+ return Err(MobileKeyTransferError::InvalidKeyMaterial);
+ }
+ Ok(())
+}
+
+fn persist_key_material(
+ configured: &Path,
+ key: &MobileKeyTransferKey,
+ armor: &[u8],
+) -> Result<(), MobileKeyTransferError> {
+ if configured.is_dir() {
+ let suffix = if key.kind == MobileKeyTransferKind::Private {
+ "secret"
+ } else {
+ "public"
+ };
+ let name = format!("{}-{suffix}.asc", key.fingerprint.to_ascii_lowercase());
+ atomic_replace(configured, Path::new(&name), armor)
+ } else {
+ let existing = fs::read(configured).map_err(|_| MobileKeyTransferError::Write)?;
+ let mut combined = Vec::with_capacity(existing.len() + armor.len() + 1);
+ combined.extend_from_slice(&existing);
+ if !combined.ends_with(b"\n") {
+ combined.push(b'\n');
+ }
+ combined.extend_from_slice(armor);
+ let parent = configured.parent().ok_or(MobileKeyTransferError::Write)?;
+ let name = configured
+ .file_name()
+ .ok_or(MobileKeyTransferError::Write)?;
+ atomic_replace(parent, Path::new(name), &combined)
+ }
+}
+
+fn atomic_replace(
+ parent: &Path,
+ name: &Path,
+ contents: &[u8],
+) -> Result<(), MobileKeyTransferError> {
+ let directory = Dir::open_ambient_dir(parent, ambient_authority())
+ .map_err(|_| MobileKeyTransferError::Write)?;
+ if let Ok(metadata) = directory.symlink_metadata(name)
+ && (metadata.file_type().is_symlink() || !metadata.is_file())
+ {
+ return Err(MobileKeyTransferError::Write);
+ }
+ let mut temporary = TempFile::new(&directory).map_err(|_| MobileKeyTransferError::Write)?;
+ set_private_permissions(&temporary)?;
+ temporary
+ .write_all(contents)
+ .and_then(|()| temporary.as_file().sync_all())
+ .and_then(|()| temporary.replace(name))
+ .and_then(|()| directory.open(".").and_then(|file| file.sync_all()))
+ .map_err(|_| MobileKeyTransferError::Write)
+}
+
+#[cfg(unix)]
+fn set_private_permissions(temporary: &TempFile<'_>) -> Result<(), MobileKeyTransferError> {
+ use cap_std::fs::{Permissions, PermissionsExt as _};
+
+ temporary
+ .as_file()
+ .set_permissions(Permissions::from_mode(0o600))
+ .map_err(|_| MobileKeyTransferError::Write)
+}
+
+#[cfg(not(unix))]
+fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), MobileKeyTransferError> {
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ MobileKeyTransferError, MobileKeyTransferImport, MobileKeyTransferKind,
+ MobileKeyTransferService, encode_multipart,
+ };
+ use crate::{config::Config, crypto::KeyStore, repository::SecretBytes};
+ use tempfile::tempdir;
+
+ const ALICE_PUBLIC: &[u8] =
+ include_bytes!("../tests/fixtures/compatibility/keys/alice-public.asc");
+ const ALICE_SECRET: &[u8] =
+ include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc");
+ const ALICE_FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
+
+ #[test]
+ fn multipart_frames_reconstruct_exact_armor_out_of_order()
+ -> Result<(), Box> {
+ let fixture = configured_public_key()?;
+ let mut importer = MobileKeyTransferImport::new(fixture.config);
+ let mut frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?;
+ frames.reverse();
+ for frame in frames {
+ importer.add_frame(SecretBytes::new(frame.payload().to_vec()))?;
+ }
+ assert_eq!(
+ importer.complete.as_ref().map(SecretBytes::expose),
+ Some(ALICE_SECRET)
+ );
+ assert_eq!(
+ importer.key.as_ref().map(|key| key.kind()),
+ Some(MobileKeyTransferKind::Private)
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn single_frame_export_validates_private_passphrase_and_reconstructs_exactly()
+ -> Result<(), Box> {
+ let fixture = configured_secret_key()?;
+ let service = MobileKeyTransferService {
+ config: fixture.config.clone(),
+ keys: KeyStore::load(&fixture.key_path)?,
+ };
+ assert!(matches!(
+ service.export(
+ ALICE_FINGERPRINT,
+ MobileKeyTransferKind::Private,
+ Some(SecretBytes::new(b"wrong".to_vec())),
+ ),
+ Err(MobileKeyTransferError::IncorrectPassphrase)
+ ));
+ let exported = service.export(
+ ALICE_FINGERPRINT,
+ MobileKeyTransferKind::Private,
+ Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
+ )?;
+ assert_eq!(exported.frames().len(), 1);
+ let expected = exported.frames()[0].payload().to_vec();
+ let mut importer = service.importer();
+ let progress = importer.add_frame(SecretBytes::new(expected.clone()))?;
+ assert!(progress.key().is_some());
+ assert_eq!(
+ importer.complete.as_ref().map(SecretBytes::expose),
+ Some(expected.as_slice())
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn mismatched_public_keys_are_rejected_as_one_transfer()
+ -> Result<(), Box> {
+ let fixture = configured_public_key()?;
+ let mut importer = MobileKeyTransferImport::new(fixture.config);
+ let mut mismatched = ALICE_PUBLIC.to_vec();
+ mismatched.extend_from_slice(include_bytes!(
+ "../tests/fixtures/compatibility/keys/bob-public.asc"
+ ));
+ assert_eq!(
+ importer.add_frame(SecretBytes::new(mismatched)),
+ Err(MobileKeyTransferError::MismatchedKeys)
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn invalid_duplicate_and_incomplete_frames_do_not_replace_keys()
+ -> Result<(), Box> {
+ let fixture = configured_public_key()?;
+ let before = std::fs::read(fixture.key_path.join("alice-public.asc"))?;
+ let mut importer = MobileKeyTransferImport::new(fixture.config);
+ let frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?;
+ let first = frames[0].payload();
+ let progress = importer.add_frame(SecretBytes::new(first.to_vec()))?;
+ assert!(
+ importer
+ .add_frame(SecretBytes::new(first.to_vec()))?
+ .duplicate()
+ );
+ assert!(progress.received() < progress.total());
+ assert_eq!(
+ importer.import(None, false),
+ Err(MobileKeyTransferError::IncompleteTransfer)
+ );
+ assert_eq!(
+ std::fs::read(fixture.key_path.join("alice-public.asc"))?,
+ before
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn private_import_validates_passphrase_before_atomic_persistence()
+ -> Result<(), Box> {
+ let fixture = configured_public_key()?;
+ let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
+ importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
+ assert_eq!(
+ importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true),
+ Err(MobileKeyTransferError::IncorrectPassphrase)
+ );
+ assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
+ importer.import(
+ Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
+ true,
+ )?;
+ let keys = KeyStore::load(&fixture.key_path)?;
+ assert!(keys.infos().any(|key| key.has_secret()));
+ assert_eq!(
+ Config::load(Some(fixture.config.source()))?
+ .default_key()
+ .as_str(),
+ ALICE_FINGERPRINT
+ );
+ Ok(())
+ }
+
+ struct Fixture {
+ _root: tempfile::TempDir,
+ config: Config,
+ key_path: std::path::PathBuf,
+ }
+
+ fn configured_public_key() -> Result> {
+ configured_key("alice-public.asc", ALICE_PUBLIC)
+ }
+
+ fn configured_secret_key() -> Result> {
+ configured_key("alice-secret.asc", ALICE_SECRET)
+ }
+
+ fn configured_key(name: &str, bytes: &[u8]) -> Result> {
+ let root = tempdir()?;
+ let vault = root.path().join("vault");
+ let keys = root.path().join("keys");
+ std::fs::create_dir(&vault)?;
+ std::fs::create_dir(&keys)?;
+ std::fs::write(keys.join(name), bytes)?;
+ let config_path = root.path().join("config.toml");
+ std::fs::write(
+ &config_path,
+ format!(
+ "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n",
+ vault, ALICE_FINGERPRINT, keys
+ ),
+ )?;
+ let config = Config::load(Some(&config_path))?;
+ Ok(Fixture {
+ _root: root,
+ config,
+ key_path: keys,
+ })
+ }
+}