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, + }) + } +}