From 72f82004c6f9b9a7daf5d30a4d620c75ab15ae95 Mon Sep 17 00:00:00 2001 From: Abhay Kumar Date: Thu, 9 Jul 2026 11:34:45 -0700 Subject: [PATCH] Remove hardware signers; refresh the marketing homepage Two related changes: hardware signers are deferred, and the marketing site is refreshed to match the product it now describes. Hardware signers: - Remove all Ledger (BLE / APDU) and YubiKey (NFC / PIV) signer code, the add-flows, onboarding UI, and their tests. Hot-wallet signers (generated, recovery-phrase import, secret-key import) are the only signer type. A hardware wallet can't display Squads context, so it adds custody without independent verification, which cuts against clear signing; deferred until it can be validated on real hardware and positioned honestly. Git history preserves the implementation. - Ledger and YubiKey remain as disabled "Coming soon" rows in the add-signer chooser. - Drop the YubiKit dependency and the Bluetooth + NFC permission prompts and entitlements the app no longer needs. Marketing homepage (core/static): - Rebuild the landing page: a kinetic decode hero ("Know what you sign"), a raw-vs-decoded before/after, an on-device-decode section, a sticky how-it-works narrative, a device/relay/chain trust diagram, and a verifiable-builds section tied to the public GitHub Release. - Decoding is framed honestly as on-device (the relay transports data and offers optional simulation). Hardware is not featured on the page. - Responsive + reduced-motion behavior per the design handoff; the privacy page drops the hardware-signer clause. - Align the in-app About tagline to the same framing: it now notes that every proposal is decoded on your device. --- App/Resources/Cosign.entitlements | 9 +- App/Resources/Info.plist | 12 - Modules/Core/Sources/Signer.swift | 2 - .../CoreBluetoothLedgerTransport.swift | 398 ---------------- Modules/Signers/Sources/LedgerAPDU.swift | 225 --------- .../Signers/Sources/LedgerBLEFraming.swift | 108 ----- Modules/Signers/Sources/LedgerBLETypes.swift | 42 -- .../Sources/LedgerErrorDescriptions.swift | 66 --- .../Sources/LedgerOnboardingFailure.swift | 71 --- Modules/Signers/Sources/LedgerSigner.swift | 53 --- .../Sources/LedgerSignerPreflight.swift | 25 - Modules/Signers/Sources/YubiKeyAPDU.swift | 313 ------------- .../Signers/Sources/YubiKeyEnrollment.swift | 106 ----- .../Sources/YubiKeyErrorDescriptions.swift | 36 -- .../Sources/YubiKeyPIVRegistration.swift | 176 ------- Modules/Signers/Sources/YubiKeySigner.swift | 57 --- .../Sources/YubiKitYubiKeyAPDUTransport.swift | 70 --- Modules/Signers/Tests/LedgerSignerTests.swift | 182 -------- .../Signers/Tests/YubiKeySignerTests.swift | 205 -------- Modules/UI/Sources/AddLedgerView+Flow.swift | 115 ----- Modules/UI/Sources/AddLedgerView+Steps.swift | 201 -------- Modules/UI/Sources/AddLedgerView.swift | 77 --- Modules/UI/Sources/AddYubiKeyView+Flow.swift | 88 ---- Modules/UI/Sources/AddYubiKeyView+Steps.swift | 154 ------ Modules/UI/Sources/AddYubiKeyView.swift | 77 --- Modules/UI/Sources/CosignCopy.swift | 5 +- Modules/UI/Sources/CosignDemoSigners.swift | 32 -- .../Sources/CosignLedgerOnboardingCopy.swift | 115 ----- .../Sources/CosignProposalCopy+Creation.swift | 39 -- Modules/UI/Sources/CosignProposalCopy.swift | 35 +- .../UI/Sources/CosignSignerCopy+Signers.swift | 36 +- Modules/UI/Sources/CosignSignerCopy.swift | 126 ----- .../Sources/CosignYubiKeyOnboardingCopy.swift | 102 ---- .../CreateTransferProposalView+Logic.swift | 3 - .../Sources/CreateTransferProposalView.swift | 2 - .../Sources/LedgerOnboardingComponents.swift | 203 -------- .../UI/Sources/LedgerOnboardingRecovery.swift | 166 ------- Modules/UI/Sources/ProposalActionSheets.swift | 71 +-- Modules/UI/Sources/ProposalActionsView.swift | 2 - .../UI/Sources/ProposalCreationSheets.swift | 87 +--- .../ProposalDetailView+BroadcastAction.swift | 1 - Modules/UI/Sources/ProposalDetailView.swift | 3 - .../UI/Sources/ProposalSignerHelpers.swift | 123 +---- .../SignerDetailView+Diagnostics.swift | 117 +---- Modules/UI/Sources/SignerDetailView.swift | 5 - Modules/UI/Sources/SignerHomeView.swift | 20 - Modules/UI/Sources/SignerListCard.swift | 12 - Modules/UI/Sources/SignerTypeDisplay.swift | 4 - .../UI/Sources/SignersListComponents.swift | 103 ++-- Modules/UI/Sources/SignersListView.swift | 4 - .../Sources/YubiKeyOnboardingComponents.swift | 196 -------- .../Sources/YubiKeyOnboardingRecovery.swift | 213 --------- Modules/UI/Sources/YubiKeySetupNotice.swift | 57 --- .../UI/Sources/YubiKeySigningOptions.swift | 139 ------ Project.swift | 12 +- .../CosignDemoDesignWalkthroughUITests.swift | 3 +- core/static/index.html | 442 +++++++++++++----- core/static/privacy.html | 4 +- 58 files changed, 452 insertions(+), 4898 deletions(-) delete mode 100644 Modules/Signers/Sources/CoreBluetoothLedgerTransport.swift delete mode 100644 Modules/Signers/Sources/LedgerAPDU.swift delete mode 100644 Modules/Signers/Sources/LedgerBLEFraming.swift delete mode 100644 Modules/Signers/Sources/LedgerBLETypes.swift delete mode 100644 Modules/Signers/Sources/LedgerErrorDescriptions.swift delete mode 100644 Modules/Signers/Sources/LedgerOnboardingFailure.swift delete mode 100644 Modules/Signers/Sources/LedgerSigner.swift delete mode 100644 Modules/Signers/Sources/LedgerSignerPreflight.swift delete mode 100644 Modules/Signers/Sources/YubiKeyAPDU.swift delete mode 100644 Modules/Signers/Sources/YubiKeyEnrollment.swift delete mode 100644 Modules/Signers/Sources/YubiKeyErrorDescriptions.swift delete mode 100644 Modules/Signers/Sources/YubiKeyPIVRegistration.swift delete mode 100644 Modules/Signers/Sources/YubiKeySigner.swift delete mode 100644 Modules/Signers/Sources/YubiKitYubiKeyAPDUTransport.swift delete mode 100644 Modules/Signers/Tests/LedgerSignerTests.swift delete mode 100644 Modules/Signers/Tests/YubiKeySignerTests.swift delete mode 100644 Modules/UI/Sources/AddLedgerView+Flow.swift delete mode 100644 Modules/UI/Sources/AddLedgerView+Steps.swift delete mode 100644 Modules/UI/Sources/AddLedgerView.swift delete mode 100644 Modules/UI/Sources/AddYubiKeyView+Flow.swift delete mode 100644 Modules/UI/Sources/AddYubiKeyView+Steps.swift delete mode 100644 Modules/UI/Sources/AddYubiKeyView.swift delete mode 100644 Modules/UI/Sources/CosignLedgerOnboardingCopy.swift delete mode 100644 Modules/UI/Sources/CosignYubiKeyOnboardingCopy.swift delete mode 100644 Modules/UI/Sources/LedgerOnboardingComponents.swift delete mode 100644 Modules/UI/Sources/LedgerOnboardingRecovery.swift delete mode 100644 Modules/UI/Sources/YubiKeyOnboardingComponents.swift delete mode 100644 Modules/UI/Sources/YubiKeyOnboardingRecovery.swift delete mode 100644 Modules/UI/Sources/YubiKeySetupNotice.swift delete mode 100644 Modules/UI/Sources/YubiKeySigningOptions.swift diff --git a/App/Resources/Cosign.entitlements b/App/Resources/Cosign.entitlements index 35e2fe8..0c67376 100644 --- a/App/Resources/Cosign.entitlements +++ b/App/Resources/Cosign.entitlements @@ -1,12 +1,5 @@ - - com.apple.developer.nfc.readersession.formats - - TAG - - com.apple.security.smartcard - - + diff --git a/App/Resources/Info.plist b/App/Resources/Info.plist index 562614e..d7dcf51 100644 --- a/App/Resources/Info.plist +++ b/App/Resources/Info.plist @@ -41,18 +41,6 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS - NSBluetoothAlwaysUsageDescription - Cosign uses Bluetooth to connect to Ledger hardware wallets for transaction signing. - NFCReaderUsageDescription - Cosign uses NFC to connect to YubiKeys for transaction signing. - UISupportedExternalAccessoryProtocols - - com.yubico.ylp - - com.apple.developer.nfc.readersession.iso7816.select-identifiers - - A000000308 - UILaunchScreen UISupportedInterfaceOrientations diff --git a/Modules/Core/Sources/Signer.swift b/Modules/Core/Sources/Signer.swift index 3f47ed1..c1f29b0 100644 --- a/Modules/Core/Sources/Signer.swift +++ b/Modules/Core/Sources/Signer.swift @@ -5,8 +5,6 @@ public typealias SolanaSignature = Data public enum SignerType: String, Codable, Sendable { case hotWallet - case ledger - case yubikey } public protocol Signer: Sendable { diff --git a/Modules/Signers/Sources/CoreBluetoothLedgerTransport.swift b/Modules/Signers/Sources/CoreBluetoothLedgerTransport.swift deleted file mode 100644 index 8eed957..0000000 --- a/Modules/Signers/Sources/CoreBluetoothLedgerTransport.swift +++ /dev/null @@ -1,398 +0,0 @@ -import CoreBluetooth -import Foundation - -public final class CoreBluetoothLedgerTransport: NSObject, LedgerAPDUTransport, @unchecked Sendable { - private let queue = DispatchQueue(label: "com.hackshare.cosign.ledger-ble") - - private var central: CBCentralManager! - private var discovered: [UUID: CBPeripheral] = [:] - private var discoveredDevices: [UUID: LedgerBLEDevice] = [:] - - private var connectedPeripheral: CBPeripheral? - private var writeCharacteristic: CBCharacteristic? - private var notifyCharacteristic: CBCharacteristic? - - private var scanContinuation: CheckedContinuation<[LedgerBLEDevice], Error>? - private var connectContinuation: CheckedContinuation? - private var pendingConnectID: UUID? - - // A scan requested before the central manager finished powering on (first - // launch / permission grant). Held until centralManagerDidUpdateState settles. - private var scanAwaitingPowerOn = false - private var pendingScanTimeout: TimeInterval? - - private var exchangeContinuation: CheckedContinuation? - private var pendingFrames: [Data] = [] - private var receivedFrames: [Data] = [] - private var nextFrameIndex = 0 - - override public init() { - super.init() - central = CBCentralManager(delegate: self, queue: queue) - } - - public func scan(timeout: TimeInterval = 8) async throws -> [LedgerBLEDevice] { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[LedgerBLEDevice], any Error>) in - queue.async { - guard self.scanContinuation == nil else { - continuation.resume(throwing: LedgerBLETransportError.scanInProgress) - return - } - - switch self.central.state { - case .poweredOn: - self.scanContinuation = continuation - self.beginScan(timeout: timeout) - case .unknown, .resetting: - // The manager is still coming up (first launch or just after - // the permission grant). Hold the scan until it settles - // rather than failing with "Bluetooth is not ready yet". - self.scanContinuation = continuation - self.scanAwaitingPowerOn = true - self.pendingScanTimeout = timeout - self.queue.asyncAfter(deadline: .now() + .seconds(5)) { - guard self.scanAwaitingPowerOn, let pending = self.scanContinuation else { - return - } - self.scanAwaitingPowerOn = false - self.scanContinuation = nil - pending.resume(throwing: self.unavailableError(for: self.central.state)) - } - default: - continuation.resume(throwing: self.unavailableError(for: self.central.state)) - } - } - } - } - - /// Starts the actual peripheral scan. `scanContinuation` must already be set; - /// must be called on `queue` with the central powered on. - private func beginScan(timeout: TimeInterval) { - scanAwaitingPowerOn = false - discovered.removeAll() - discoveredDevices.removeAll() - central.scanForPeripherals(withServices: [LedgerBLEUUID.service]) - - queue.asyncAfter(deadline: .now() + .milliseconds(Int(timeout * 1000))) { - guard let continuation = self.scanContinuation else { - return - } - self.central.stopScan() - self.scanContinuation = nil - continuation.resume(returning: self.discoveredDevices.values.sorted { lhs, rhs in - lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - }) - } - } - - public func connect(to device: LedgerBLEDevice, timeout: TimeInterval = 15) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - queue.async { - guard self.connectContinuation == nil else { - continuation.resume(throwing: LedgerBLETransportError.connectInProgress) - return - } - guard self.central.state == .poweredOn else { - continuation.resume(throwing: self.unavailableError(for: self.central.state)) - return - } - guard let peripheral = self.discovered[device.id] else { - continuation.resume(throwing: LedgerBLETransportError.unknownDevice(device.id)) - return - } - - self.connectContinuation = continuation - self.pendingConnectID = device.id - self.connectedPeripheral = nil - self.writeCharacteristic = nil - self.notifyCharacteristic = nil - peripheral.delegate = self - self.central.connect(peripheral) - - self.queue.asyncAfter(deadline: .now() + .milliseconds(Int(timeout * 1000))) { - guard let continuation = self.connectContinuation, - self.pendingConnectID == device.id - else { - return - } - self.central.cancelPeripheralConnection(peripheral) - self.connectContinuation = nil - self.pendingConnectID = nil - continuation.resume(throwing: LedgerBLETransportError.connectionFailed("Connection timed out.")) - } - } - } - } - - public func disconnect() { - queue.async { - guard let connectedPeripheral = self.connectedPeripheral else { - return - } - self.central.cancelPeripheralConnection(connectedPeripheral) - } - } - - public func exchange(_ command: LedgerAPDUCommand) async throws -> LedgerAPDUResponse { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< - LedgerAPDUResponse, - any Error - >) in - queue.async { - guard self.exchangeContinuation == nil else { - continuation.resume(throwing: LedgerBLETransportError.exchangeInProgress) - return - } - guard let peripheral = self.connectedPeripheral, - let writeCharacteristic = self.writeCharacteristic - else { - continuation.resume(throwing: LedgerBLETransportError.notConnected) - return - } - - do { - self.pendingFrames = try LedgerBLEFraming.encodeAPDU( - command.encoded, - mtu: peripheral.maximumWriteValueLength(for: .withResponse) - ) - self.receivedFrames = [] - self.nextFrameIndex = 0 - self.exchangeContinuation = continuation - self.writeNextFrame(to: peripheral, characteristic: writeCharacteristic) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - private func writeNextFrame(to peripheral: CBPeripheral, characteristic: CBCharacteristic) { - guard nextFrameIndex < pendingFrames.count else { - return - } - - let frame = pendingFrames[nextFrameIndex] - nextFrameIndex += 1 - peripheral.writeValue(frame, for: characteristic, type: .withResponse) - } - - private func finishExchange(_ result: Result) { - guard let continuation = exchangeContinuation else { - return - } - - exchangeContinuation = nil - pendingFrames = [] - receivedFrames = [] - nextFrameIndex = 0 - - switch result { - case let .success(response): - continuation.resume(returning: response) - case let .failure(error): - continuation.resume(throwing: error) - } - } - - private func finishConnect(_ result: Result) { - guard let continuation = connectContinuation else { - return - } - - connectContinuation = nil - pendingConnectID = nil - - switch result { - case .success: - continuation.resume() - case let .failure(error): - continuation.resume(throwing: error) - } - } - - private func unavailableError(for state: CBManagerState) -> LedgerBLETransportError { - switch state { - case .poweredOff: - .bluetoothUnavailable(.off) - case .unauthorized: - .bluetoothUnavailable(.unauthorized) - case .unsupported: - .bluetoothUnavailable(.unsupported) - case .resetting: - .bluetoothUnavailable(.resetting) - case .unknown: - .bluetoothUnavailable(.notReady) - case .poweredOn: - .bluetoothUnavailable(.unavailable) - @unknown default: - .bluetoothUnavailable(.unavailable) - } - } -} - -extension CoreBluetoothLedgerTransport: CBCentralManagerDelegate { - public func centralManagerDidUpdateState(_ central: CBCentralManager) { - switch central.state { - case .poweredOn: - // Start a scan that was requested before the manager finished - // powering on. - if scanAwaitingPowerOn, scanContinuation != nil { - beginScan(timeout: pendingScanTimeout ?? 8) - } - case .unknown, .resetting: - // Still transient — keep any pending scan waiting; the scan() - // safety timeout gives up if it never settles. - break - default: - // Terminal-unavailable (off / unauthorized / unsupported): fail - // pending work with a specific reason. - if let scanContinuation { - self.scanContinuation = nil - scanAwaitingPowerOn = false - scanContinuation.resume(throwing: unavailableError(for: central.state)) - } - finishConnect(.failure(unavailableError(for: central.state))) - finishExchange(.failure(unavailableError(for: central.state))) - } - } - - public func centralManager( - _: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi RSSI: NSNumber - ) { - let name = peripheral.name - ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String - ?? "Ledger" - discovered[peripheral.identifier] = peripheral - discoveredDevices[peripheral.identifier] = LedgerBLEDevice( - id: peripheral.identifier, - name: name, - rssi: RSSI.intValue - ) - } - - public func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) { - connectedPeripheral = peripheral - peripheral.delegate = self - peripheral.discoverServices([LedgerBLEUUID.service]) - } - - public func centralManager( - _: CBCentralManager, - didFailToConnect _: CBPeripheral, - error: (any Error)? - ) { - finishConnect(.failure(LedgerBLETransportError - .connectionFailed(error?.localizedDescription ?? "Connection failed."))) - } - - public func centralManager( - _: CBCentralManager, - didDisconnectPeripheral _: CBPeripheral, - error _: (any Error)? - ) { - connectedPeripheral = nil - writeCharacteristic = nil - notifyCharacteristic = nil - finishConnect(.failure(LedgerBLETransportError.disconnected)) - finishExchange(.failure(LedgerBLETransportError.disconnected)) - } -} - -extension CoreBluetoothLedgerTransport: CBPeripheralDelegate { - public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: (any Error)?) { - if let error { - finishConnect(.failure(LedgerBLETransportError.connectionFailed(error.localizedDescription))) - return - } - - guard let service = peripheral.services?.first(where: { $0.uuid == LedgerBLEUUID.service }) else { - finishConnect(.failure(LedgerBLETransportError.missingService)) - return - } - - peripheral.discoverCharacteristics( - [LedgerBLEUUID.notifyCharacteristic, LedgerBLEUUID.writeCharacteristic], - for: service - ) - } - - public func peripheral( - _ peripheral: CBPeripheral, - didDiscoverCharacteristicsFor service: CBService, - error: (any Error)? - ) { - if let error { - finishConnect(.failure(LedgerBLETransportError.connectionFailed(error.localizedDescription))) - return - } - - notifyCharacteristic = service.characteristics?.first { $0.uuid == LedgerBLEUUID.notifyCharacteristic } - writeCharacteristic = service.characteristics?.first { $0.uuid == LedgerBLEUUID.writeCharacteristic } - - guard let notifyCharacteristic, writeCharacteristic != nil else { - finishConnect(.failure(LedgerBLETransportError.missingCharacteristic)) - return - } - - peripheral.setNotifyValue(true, for: notifyCharacteristic) - } - - public func peripheral( - _: CBPeripheral, - didUpdateNotificationStateFor characteristic: CBCharacteristic, - error: (any Error)? - ) { - if let error { - finishConnect(.failure(LedgerBLETransportError.connectionFailed(error.localizedDescription))) - return - } - - if characteristic.uuid == LedgerBLEUUID.notifyCharacteristic, characteristic.isNotifying { - finishConnect(.success(())) - } - } - - public func peripheral( - _ peripheral: CBPeripheral, - didWriteValueFor _: CBCharacteristic, - error: (any Error)? - ) { - if let error { - finishExchange(.failure(LedgerBLETransportError.connectionFailed(error.localizedDescription))) - return - } - - if let writeCharacteristic { - writeNextFrame(to: peripheral, characteristic: writeCharacteristic) - } - } - - public func peripheral( - _: CBPeripheral, - didUpdateValueFor characteristic: CBCharacteristic, - error: (any Error)? - ) { - if let error { - finishExchange(.failure(LedgerBLETransportError.connectionFailed(error.localizedDescription))) - return - } - guard characteristic.uuid == LedgerBLEUUID.notifyCharacteristic, - let value = characteristic.value - else { - return - } - - receivedFrames.append(value) - do { - let apdu = try LedgerBLEFraming.decodeAPDU(receivedFrames) - try finishExchange(.success(LedgerAPDUResponse(encoded: apdu))) - } catch LedgerBLEFramingError.incompletePayload { - return - } catch { - finishExchange(.failure(error)) - } - } -} diff --git a/Modules/Signers/Sources/LedgerAPDU.swift b/Modules/Signers/Sources/LedgerAPDU.swift deleted file mode 100644 index 9f7b75d..0000000 --- a/Modules/Signers/Sources/LedgerAPDU.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation - -public struct LedgerDerivationPath: Equatable, Sendable { - public let components: [UInt32] - - public static let defaultSolana = LedgerDerivationPath(components: [ - hardened(44), - hardened(501), - hardened(0), - hardened(0) - ]) - - public init(components: [UInt32]) { - precondition(components.count <= Int(UInt8.max), "Ledger derivation paths support at most 255 components.") - self.components = components - } - - public static func hardened(_ component: UInt32) -> UInt32 { - component | 0x8000_0000 - } - - public var serialized: Data { - var data = Data([UInt8(components.count)]) - for component in components { - data.appendBigEndian(component) - } - return data - } -} - -public struct LedgerAPDUCommand: Equatable, Sendable { - public let cla: UInt8 - public let instruction: UInt8 - public let parameter1: UInt8 - public let parameter2: UInt8 - public let data: Data - - public init( - cla: UInt8, - instruction: UInt8, - parameter1: UInt8, - parameter2: UInt8, - data: Data = Data() - ) { - self.cla = cla - self.instruction = instruction - self.parameter1 = parameter1 - self.parameter2 = parameter2 - self.data = data - } - - public var encoded: Data { - precondition(data.count <= Int(UInt8.max), "APDU data length must fit in one byte.") - var encoded = Data([cla, instruction, parameter1, parameter2, UInt8(data.count)]) - encoded.append(data) - return encoded - } -} - -public struct LedgerAPDUResponse: Equatable, Sendable { - public let data: Data - public let status: UInt16 - - public init(data: Data = Data(), status: UInt16) { - self.data = data - self.status = status - } - - public init(encoded: Data) throws { - guard encoded.count >= 2 else { - throw LedgerSignerError.malformedResponse - } - - data = encoded.dropLast(2) - status = encoded.readBigEndianUInt16(at: encoded.count - 2) - } - - public func successfulData() throws -> Data { - switch status { - case LedgerSolanaAPDU.statusOK: - return data - case LedgerSolanaAPDU.statusBlindSignatureRequired: - throw LedgerSignerError.blindSigningRequired - default: - throw LedgerSignerError.deviceStatus(status) - } - } -} - -public struct LedgerSolanaAppConfiguration: Equatable, Sendable { - public let blindSigningEnabled: Bool - public let pubkeyDisplayMode: UInt8 - public let version: String -} - -public enum LedgerSolanaAPDU { - public static let cla: UInt8 = 0xE0 - public static let instructionGetConfiguration: UInt8 = 0x04 - public static let instructionGetAddress: UInt8 = 0x05 - public static let instructionSignTransaction: UInt8 = 0x06 - - public static let p1NonConfirm: UInt8 = 0x00 - public static let p1Confirm: UInt8 = 0x01 - public static let p2Extend: UInt8 = 0x01 - public static let p2More: UInt8 = 0x02 - - public static let statusOK: UInt16 = 0x9000 - public static let statusBlindSignatureRequired: UInt16 = 0x6808 - - static let maxPayloadLength = 255 - - public static func appConfigurationCommand() -> LedgerAPDUCommand { - LedgerAPDUCommand( - cla: cla, - instruction: instructionGetConfiguration, - parameter1: p1NonConfirm, - parameter2: 0 - ) - } - - public static func addressCommand( - path: LedgerDerivationPath = .defaultSolana, - displayOnDevice: Bool = false - ) -> LedgerAPDUCommand { - LedgerAPDUCommand( - cla: cla, - instruction: instructionGetAddress, - parameter1: displayOnDevice ? p1Confirm : p1NonConfirm, - parameter2: 0, - data: path.serialized - ) - } - - public static func signTransactionCommands( - path: LedgerDerivationPath = .defaultSolana, - message: Data - ) -> [LedgerAPDUCommand] { - var payload = Data([1]) - payload.append(path.serialized) - payload.append(message) - return chunkedCommands( - instruction: instructionSignTransaction, - parameter1: p1Confirm, - payload: payload - ) - } - - public static func parseAppConfiguration(_ responseData: Data) throws -> LedgerSolanaAppConfiguration { - guard responseData.count >= 5 else { - throw LedgerSignerError.malformedResponse - } - - return LedgerSolanaAppConfiguration( - blindSigningEnabled: responseData[responseData.startIndex] != 0, - pubkeyDisplayMode: responseData[responseData.startIndex + 1], - version: [ - responseData[responseData.startIndex + 2], - responseData[responseData.startIndex + 3], - responseData[responseData.startIndex + 4] - ].map(String.init).joined(separator: ".") - ) - } - - public static func parseAddress(_ responseData: Data) throws -> Data { - guard responseData.count == 32 else { - throw LedgerSignerError.invalidAddressLength(responseData.count) - } - - return responseData - } - - static func chunkedCommands( - instruction: UInt8, - parameter1: UInt8, - payload: Data - ) -> [LedgerAPDUCommand] { - var commands: [LedgerAPDUCommand] = [] - var offset = payload.startIndex - var parameter2: UInt8 = 0 - - while payload.distance(from: offset, to: payload.endIndex) > maxPayloadLength { - let end = payload.index(offset, offsetBy: maxPayloadLength) - commands.append(LedgerAPDUCommand( - cla: cla, - instruction: instruction, - parameter1: parameter1, - parameter2: parameter2 | p2More, - data: payload[offset ..< end] - )) - offset = end - parameter2 |= p2Extend - } - - commands.append(LedgerAPDUCommand( - cla: cla, - instruction: instruction, - parameter1: parameter1, - parameter2: parameter2, - data: payload[offset ..< payload.endIndex] - )) - return commands - } -} - -enum LedgerSignerError: Error, Equatable { - case malformedResponse - case blindSigningRequired - case deviceStatus(UInt16) - case addressMismatch(expected: Data, actual: Data) - case invalidAddressLength(Int) - case invalidSignatureLength(Int) -} - -private extension Data { - mutating func appendBigEndian(_ value: UInt32) { - append(UInt8((value >> 24) & 0xFF)) - append(UInt8((value >> 16) & 0xFF)) - append(UInt8((value >> 8) & 0xFF)) - append(UInt8(value & 0xFF)) - } - - func readBigEndianUInt16(at offset: Int) -> UInt16 { - (UInt16(self[startIndex + offset]) << 8) | UInt16(self[startIndex + offset + 1]) - } -} diff --git a/Modules/Signers/Sources/LedgerBLEFraming.swift b/Modules/Signers/Sources/LedgerBLEFraming.swift deleted file mode 100644 index a6ca4c5..0000000 --- a/Modules/Signers/Sources/LedgerBLEFraming.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation - -enum LedgerBLEFraming { - static let apduTag: UInt8 = 0x05 - - static func encodeAPDU(_ apdu: Data, mtu: Int) throws -> [Data] { - guard mtu >= 5 else { - throw LedgerBLEFramingError.invalidMTU(mtu) - } - guard apdu.count <= Int(UInt16.max) else { - throw LedgerBLEFramingError.payloadTooLarge(apdu.count) - } - - var frames: [Data] = [] - var sequence: UInt16 = 0 - var offset = apdu.startIndex - var firstFrame = Data([apduTag]) - firstFrame.appendBigEndian(sequence) - firstFrame.appendBigEndian(UInt16(apdu.count)) - - let firstPayloadLength = min(mtu - 5, apdu.count) - if firstPayloadLength > 0 { - let end = apdu.index(offset, offsetBy: firstPayloadLength) - firstFrame.append(apdu[offset ..< end]) - offset = end - } - frames.append(firstFrame) - sequence += 1 - - while offset < apdu.endIndex { - var frame = Data([apduTag]) - frame.appendBigEndian(sequence) - let payloadLength = min(mtu - 3, apdu.distance(from: offset, to: apdu.endIndex)) - let end = apdu.index(offset, offsetBy: payloadLength) - frame.append(apdu[offset ..< end]) - frames.append(frame) - sequence += 1 - offset = end - } - - return frames - } - - static func decodeAPDU(_ frames: [Data]) throws -> Data { - var expectedSequence: UInt16 = 0 - var expectedLength: Int? - var apdu = Data() - - for frame in frames { - guard frame.count >= 3 else { - throw LedgerBLEFramingError.malformedFrame - } - guard frame[frame.startIndex] == apduTag else { - throw LedgerBLEFramingError.invalidTag(frame[frame.startIndex]) - } - - let sequence = frame.readBigEndianUInt16(at: 1) - guard sequence == expectedSequence else { - throw LedgerBLEFramingError.invalidSequence(expected: expectedSequence, actual: sequence) - } - - var payload = frame.dropFirst(3) - if sequence == 0 { - guard payload.count >= 2 else { - throw LedgerBLEFramingError.malformedFrame - } - expectedLength = Int(payload.readBigEndianUInt16(at: 0)) - payload = payload.dropFirst(2) - } - - apdu.append(payload) - if let expectedLength, apdu.count > expectedLength { - throw LedgerBLEFramingError.payloadTooLarge(apdu.count) - } - - expectedSequence += 1 - } - - guard let expectedLength else { - throw LedgerBLEFramingError.incompletePayload(expected: 0, actual: 0) - } - guard apdu.count == expectedLength else { - throw LedgerBLEFramingError.incompletePayload(expected: expectedLength, actual: apdu.count) - } - - return apdu - } -} - -enum LedgerBLEFramingError: Error, Equatable { - case invalidMTU(Int) - case payloadTooLarge(Int) - case malformedFrame - case invalidTag(UInt8) - case invalidSequence(expected: UInt16, actual: UInt16) - case incompletePayload(expected: Int, actual: Int) -} - -private extension Data { - mutating func appendBigEndian(_ value: UInt16) { - append(UInt8((value >> 8) & 0xFF)) - append(UInt8(value & 0xFF)) - } - - func readBigEndianUInt16(at offset: Int) -> UInt16 { - (UInt16(self[startIndex + offset]) << 8) | UInt16(self[startIndex + offset + 1]) - } -} diff --git a/Modules/Signers/Sources/LedgerBLETypes.swift b/Modules/Signers/Sources/LedgerBLETypes.swift deleted file mode 100644 index 88d3c8b..0000000 --- a/Modules/Signers/Sources/LedgerBLETypes.swift +++ /dev/null @@ -1,42 +0,0 @@ -import CoreBluetooth -import Foundation - -public struct LedgerBLEDevice: Equatable, Identifiable, Sendable { - public let id: UUID - public let name: String - public let rssi: Int - - public init(id: UUID, name: String, rssi: Int) { - self.id = id - self.name = name - self.rssi = rssi - } -} - -enum LedgerBLEUUID { - static let service = CBUUID(string: "13D63400-2C97-6004-0000-4C6564676572") - static let notifyCharacteristic = CBUUID(string: "13D63400-2C97-6004-0001-4C6564676572") - static let writeCharacteristic = CBUUID(string: "13D63400-2C97-6004-0002-4C6564676572") -} - -public enum LedgerBluetoothUnavailableReason: Equatable, Sendable { - case off - case unauthorized - case unsupported - case resetting - case notReady - case unavailable -} - -enum LedgerBLETransportError: Error, Equatable { - case bluetoothUnavailable(LedgerBluetoothUnavailableReason) - case scanInProgress - case connectInProgress - case exchangeInProgress - case unknownDevice(UUID) - case notConnected - case disconnected - case missingService - case missingCharacteristic - case connectionFailed(String) -} diff --git a/Modules/Signers/Sources/LedgerErrorDescriptions.swift b/Modules/Signers/Sources/LedgerErrorDescriptions.swift deleted file mode 100644 index c29a270..0000000 --- a/Modules/Signers/Sources/LedgerErrorDescriptions.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation - -extension LedgerBluetoothUnavailableReason { - var message: String { - switch self { - case .off: - "Bluetooth is turned off." - case .unauthorized: - "Bluetooth permission is not available." - case .unsupported: - "Bluetooth is not supported on this device." - case .resetting: - "Bluetooth is resetting." - case .notReady: - "Bluetooth is not ready yet." - case .unavailable: - "Bluetooth is unavailable." - } - } -} - -extension LedgerBLETransportError: LocalizedError { - public var errorDescription: String? { - switch self { - case let .bluetoothUnavailable(reason): - reason.message - case .scanInProgress: - "A Ledger scan is already running." - case .connectInProgress: - "A Ledger connection is already in progress." - case .exchangeInProgress: - "A Ledger request is already in progress." - case .unknownDevice: - "Select a scanned Ledger device." - case .notConnected: - "The Ledger is not connected." - case .disconnected: - "The Ledger disconnected." - case .missingService: - "The Ledger Bluetooth service was not found." - case .missingCharacteristic: - "The Ledger Bluetooth channel was not found." - case let .connectionFailed(message): - message - } - } -} - -extension LedgerSignerError: LocalizedError { - public var errorDescription: String? { - switch self { - case .malformedResponse: - "The Ledger returned a malformed response." - case .blindSigningRequired: - "Blind signing is required on the Ledger device." - case let .deviceStatus(status): - "The Ledger returned status 0x\(String(status, radix: 16, uppercase: true))." - case .addressMismatch: - "The connected Ledger address does not match the saved signer." - case let .invalidAddressLength(length): - "The Ledger returned an address with \(length) bytes." - case let .invalidSignatureLength(length): - "The Ledger returned a signature with \(length) bytes." - } - } -} diff --git a/Modules/Signers/Sources/LedgerOnboardingFailure.swift b/Modules/Signers/Sources/LedgerOnboardingFailure.swift deleted file mode 100644 index 45c073a..0000000 --- a/Modules/Signers/Sources/LedgerOnboardingFailure.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation - -/// A coarse, UI-facing classification of why a Ledger onboarding attempt failed. -/// The concrete transport/APDU error types are internal to this module, so this -/// enum is the bridge the UI uses to pick a recovery path. -public enum LedgerOnboardingFailure: Equatable, Sendable { - case bluetoothOff - case bluetoothPermissionDenied - case bluetoothUnsupported - case deviceLocked - case solanaAppNotOpen - case userRejected - case lostConnection - case timedOut - case addressMismatch - case other(String) - - public static func classify(_ error: any Error) -> LedgerOnboardingFailure { - if let transportError = error as? LedgerBLETransportError { - return classify(transportError) - } - if let signerError = error as? LedgerSignerError { - return classify(signerError) - } - return .other((error as? LocalizedError)?.errorDescription ?? String(describing: error)) - } - - private static func classify(_ error: LedgerBLETransportError) -> LedgerOnboardingFailure { - switch error { - case let .bluetoothUnavailable(reason): - switch reason { - case .unauthorized: - .bluetoothPermissionDenied - case .unsupported: - .bluetoothUnsupported - case .off, .resetting, .notReady, .unavailable: - .bluetoothOff - } - case let .connectionFailed(message): - message.localizedCaseInsensitiveContains("timed out") ? .timedOut : .lostConnection - case .disconnected, .notConnected, .missingService, .missingCharacteristic, .unknownDevice: - .lostConnection - case .scanInProgress, .connectInProgress, .exchangeInProgress: - .timedOut - } - } - - private static func classify(_ error: LedgerSignerError) -> LedgerOnboardingFailure { - switch error { - case .addressMismatch: - .addressMismatch - case let .deviceStatus(status): - classify(status: status) - case .blindSigningRequired, .malformedResponse, .invalidAddressLength, .invalidSignatureLength: - .other(error.errorDescription ?? String(describing: error)) - } - } - - private static func classify(status: UInt16) -> LedgerOnboardingFailure { - switch status { - case 0x5515, 0x6804: - .deviceLocked - case 0x6985, 0x5501: - .userRejected - case 0x6E00, 0x6D00, 0x6807, 0x6511, 0x6A82, 0x6F00: - .solanaAppNotOpen - default: - .other("The Ledger returned status 0x\(String(status, radix: 16, uppercase: true)).") - } - } -} diff --git a/Modules/Signers/Sources/LedgerSigner.swift b/Modules/Signers/Sources/LedgerSigner.swift deleted file mode 100644 index f32c54e..0000000 --- a/Modules/Signers/Sources/LedgerSigner.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Core -import Foundation - -public protocol LedgerAPDUTransport: Sendable { - func exchange(_ command: LedgerAPDUCommand) async throws -> LedgerAPDUResponse -} - -public struct LedgerSigner: Signer { - public let label: String - public let pubkey: Pubkey - public let type: SignerType = .ledger - public let derivationPath: LedgerDerivationPath - - private let transport: (any LedgerAPDUTransport)? - - public init( - label: String, - pubkey: Pubkey, - derivationPath: LedgerDerivationPath = .defaultSolana, - transport: (any LedgerAPDUTransport)? = nil - ) { - self.label = label - self.pubkey = pubkey - self.derivationPath = derivationPath - self.transport = transport - } - - public func sign(message: Data) async throws -> SolanaSignature { - guard let transport else { - throw SignerError.deviceUnavailable - } - - let commands = LedgerSolanaAPDU.signTransactionCommands( - path: derivationPath, - message: message - ) - var signature = Data() - - for (index, command) in commands.enumerated() { - let response = try await transport.exchange(command) - let data = try response.successfulData() - if index == commands.indices.last { - signature = data - } - } - - guard signature.count == 64 else { - throw SignerError.underlying(LedgerSignerError.invalidSignatureLength(signature.count)) - } - - return signature - } -} diff --git a/Modules/Signers/Sources/LedgerSignerPreflight.swift b/Modules/Signers/Sources/LedgerSignerPreflight.swift deleted file mode 100644 index 2d962cf..0000000 --- a/Modules/Signers/Sources/LedgerSignerPreflight.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Core -import Foundation - -public enum LedgerSignerPreflight { - @discardableResult - public static func verifySolanaAppAndAddress( - expectedPubkey: Pubkey, - transport: any LedgerAPDUTransport, - displayAddressOnDevice: Bool = false - ) async throws -> LedgerSolanaAppConfiguration { - let configurationResponse = try await transport.exchange(LedgerSolanaAPDU.appConfigurationCommand()) - let configuration = try LedgerSolanaAPDU.parseAppConfiguration(configurationResponse.successfulData()) - - let addressResponse = try await transport.exchange( - LedgerSolanaAPDU.addressCommand(displayOnDevice: displayAddressOnDevice) - ) - let actualPubkey = try LedgerSolanaAPDU.parseAddress(addressResponse.successfulData()) - - guard actualPubkey == expectedPubkey else { - throw LedgerSignerError.addressMismatch(expected: expectedPubkey, actual: actualPubkey) - } - - return configuration - } -} diff --git a/Modules/Signers/Sources/YubiKeyAPDU.swift b/Modules/Signers/Sources/YubiKeyAPDU.swift deleted file mode 100644 index 09c22f9..0000000 --- a/Modules/Signers/Sources/YubiKeyAPDU.swift +++ /dev/null @@ -1,313 +0,0 @@ -import Foundation - -public struct YubiKeyAPDUCommand: Equatable, Sendable { - public let cla: UInt8 - public let instruction: UInt8 - public let parameter1: UInt8 - public let parameter2: UInt8 - public let data: Data - - public init( - cla: UInt8, - instruction: UInt8, - parameter1: UInt8, - parameter2: UInt8, - data: Data = Data() - ) { - self.cla = cla - self.instruction = instruction - self.parameter1 = parameter1 - self.parameter2 = parameter2 - self.data = data - } - - public var encoded: Data { - precondition(data.count <= Int(UInt16.max), "YubiKey APDU data length must fit in two bytes.") - - var encoded = Data([cla, instruction, parameter1, parameter2]) - if data.isEmpty { - return encoded - } - - if data.count <= Int(UInt8.max) { - encoded.append(UInt8(data.count)) - } else { - encoded.append(0x00) - encoded.appendBigEndian(UInt16(data.count)) - } - encoded.append(data) - return encoded - } -} - -public struct YubiKeyAPDUResponse: Equatable, Sendable { - public let data: Data - public let status: UInt16 - - public init(data: Data = Data(), status: UInt16) { - self.data = data - self.status = status - } - - public init(encoded: Data) throws { - guard encoded.count >= 2 else { - throw YubiKeySignerError.malformedResponse - } - - data = encoded.dropLast(2) - status = encoded.readBigEndianUInt16(at: encoded.count - 2) - } - - public func successfulData() throws -> Data { - if status == YubiKeyPIV.statusOK { - return data - } - if status & 0xFFF0 == YubiKeyPIV.statusPINRetriesRemainingMask { - throw YubiKeySignerError.invalidPIN(retriesRemaining: Int(status & 0x000F)) - } - - switch status { - case YubiKeyPIV.statusAuthenticationRequired: - throw YubiKeySignerError.authenticationRequired - case YubiKeyPIV.statusPINBlocked: - throw YubiKeySignerError.pinBlocked - default: - throw YubiKeySignerError.deviceStatus(status) - } - } -} - -public enum YubiKeyPIVSlot: UInt8, Sendable { - case authentication = 0x9A - case signature = 0x9C - case keyManagement = 0x9D - case cardAuthentication = 0x9E -} - -enum YubiKeyPIVAlgorithm: UInt8 { - case ed25519 = 0xE0 -} - -enum YubiKeyPIV { - static let cla: UInt8 = 0x00 - static let claCommandChaining: UInt8 = 0x10 - static let aid = Data([0xA0, 0x00, 0x00, 0x03, 0x08, 0x00, 0x00, 0x10, 0x00]) - - static let instructionSelect: UInt8 = 0xA4 - static let instructionVerify: UInt8 = 0x20 - static let instructionAuthenticate: UInt8 = 0x87 - - static let statusOK: UInt16 = 0x9000 - static let statusAuthenticationRequired: UInt16 = 0x6982 - static let statusPINBlocked: UInt16 = 0x6983 - static let statusPINRetriesRemainingMask: UInt16 = 0x63C0 - - static let maxCommandDataLength = 255 - - static func selectCommand() -> YubiKeyAPDUCommand { - YubiKeyAPDUCommand( - cla: cla, - instruction: instructionSelect, - parameter1: 0x04, - parameter2: 0x00, - data: aid - ) - } - - static func verifyPINCommand(_ pin: String) throws -> YubiKeyAPDUCommand { - var pinBytes = Data(pin.utf8) - guard (6 ... 8).contains(pinBytes.count) else { - throw YubiKeySignerError.invalidPINLength - } - - while pinBytes.count < 8 { - pinBytes.append(0xFF) - } - - return YubiKeyAPDUCommand( - cla: cla, - instruction: instructionVerify, - parameter1: 0x00, - parameter2: 0x80, - data: pinBytes - ) - } - - static func ed25519SignCommands( - message: Data, - slot: YubiKeyPIVSlot = .signature - ) -> [YubiKeyAPDUCommand] { - chunkedAuthenticateCommands( - data: authenticateSignData(message), - slot: slot - ) - } - - static func authenticateCommand( - data: Data, - slot: YubiKeyPIVSlot = .signature - ) -> YubiKeyAPDUCommand { - YubiKeyAPDUCommand( - cla: cla, - instruction: instructionAuthenticate, - parameter1: YubiKeyPIVAlgorithm.ed25519.rawValue, - parameter2: slot.rawValue, - data: data - ) - } - - static func parseEd25519Signature(_ responseData: Data) throws -> Data { - var parser = YubiKeyTLVParser(data: responseData) - let template = try parser.readExpected(tag: 0x7C) - try parser.requireEnd() - - var templateParser = YubiKeyTLVParser(data: template) - let signature = try templateParser.readExpected(tag: 0x82) - try templateParser.requireEnd() - - guard signature.count == 64 else { - throw YubiKeySignerError.invalidSignatureLength(signature.count) - } - - return signature - } - - private static func authenticateSignData(_ message: Data) -> Data { - var responseTemplate = Data([0x82, 0x00, 0x81]) - responseTemplate.appendDERLength(message.count) - responseTemplate.append(message) - - var data = Data([0x7C]) - data.appendDERLength(responseTemplate.count) - data.append(responseTemplate) - return data - } - - private static func chunkedAuthenticateCommands( - data: Data, - slot: YubiKeyPIVSlot - ) -> [YubiKeyAPDUCommand] { - if data.isEmpty { - return [authenticateCommand(data: data, slot: slot)] - } - - var commands: [YubiKeyAPDUCommand] = [] - var offset = data.startIndex - - while offset < data.endIndex { - let remaining = data.distance(from: offset, to: data.endIndex) - let chunkLength = min(maxCommandDataLength, remaining) - let end = data.index(offset, offsetBy: chunkLength) - let isLast = end == data.endIndex - - commands.append(YubiKeyAPDUCommand( - cla: isLast ? cla : claCommandChaining, - instruction: instructionAuthenticate, - parameter1: YubiKeyPIVAlgorithm.ed25519.rawValue, - parameter2: slot.rawValue, - data: data[offset ..< end] - )) - offset = end - } - - return commands - } -} - -public enum YubiKeySignerError: Error, Equatable, Sendable { - case malformedResponse - case malformedTLV - case unexpectedTLVTag(expected: UInt8, actual: UInt8) - case invalidPINLength - case invalidPIN(retriesRemaining: Int) - case pinBlocked - case authenticationRequired - case deviceStatus(UInt16) - case invalidSignatureLength(Int) - case missingPINProvider -} - -private struct YubiKeyTLVParser { - private let data: Data - private var offset = 0 - - init(data: Data) { - self.data = data - } - - mutating func readExpected(tag expectedTag: UInt8) throws -> Data { - guard offset < data.count else { - throw YubiKeySignerError.malformedTLV - } - let tag = data[data.startIndex + offset] - guard tag == expectedTag else { - throw YubiKeySignerError.unexpectedTLVTag(expected: expectedTag, actual: tag) - } - offset += 1 - - let length = try readLength() - guard offset + length <= data.count else { - throw YubiKeySignerError.malformedTLV - } - - let start = data.startIndex + offset - let end = start + length - offset += length - return data[start ..< end] - } - - func requireEnd() throws { - if offset != data.count { - throw YubiKeySignerError.malformedTLV - } - } - - private mutating func readLength() throws -> Int { - guard offset < data.count else { - throw YubiKeySignerError.malformedTLV - } - - let first = data[data.startIndex + offset] - offset += 1 - if first < 0x80 { - return Int(first) - } - - let lengthByteCount = Int(first & 0x7F) - guard lengthByteCount > 0, lengthByteCount <= 2, offset + lengthByteCount <= data.count else { - throw YubiKeySignerError.malformedTLV - } - - var length = 0 - for _ in 0 ..< lengthByteCount { - length = (length << 8) | Int(data[data.startIndex + offset]) - offset += 1 - } - return length - } -} - -private extension Data { - mutating func appendDERLength(_ length: Int) { - precondition(length <= Int(UInt16.max), "DER length must fit in two bytes.") - - if length < 0x80 { - append(UInt8(length)) - } else if length <= Int(UInt8.max) { - append(0x81) - append(UInt8(length)) - } else { - append(0x82) - appendBigEndian(UInt16(length)) - } - } - - mutating func appendBigEndian(_ value: UInt16) { - append(UInt8((value >> 8) & 0xFF)) - append(UInt8(value & 0xFF)) - } - - func readBigEndianUInt16(at offset: Int) -> UInt16 { - (UInt16(self[startIndex + offset]) << 8) | UInt16(self[startIndex + offset + 1]) - } -} diff --git a/Modules/Signers/Sources/YubiKeyEnrollment.swift b/Modules/Signers/Sources/YubiKeyEnrollment.swift deleted file mode 100644 index 5f2b6ee..0000000 --- a/Modules/Signers/Sources/YubiKeyEnrollment.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Core -import Foundation -import YubiKit - -/// Result of a guided YubiKey enrollment: the Ed25519 public key read from the -/// PIV signature slot plus where it came from, after the PIN was verified and a -/// presence (touch) check was satisfied in the same session. -public struct YubiKeyEnrollment: Equatable, Sendable { - public let pubkey: Pubkey - public let source: YubiKeyPIVPublicKeySource - public let generatedOnYubiKey: Bool? - - public init(pubkey: Pubkey, source: YubiKeyPIVPublicKeySource, generatedOnYubiKey: Bool?) { - self.pubkey = pubkey - self.source = source - self.generatedOnYubiKey = generatedOnYubiKey - } -} - -public enum YubiKeyEnrollmentError: LocalizedError, Sendable, Equatable { - case wrongPIN(retriesRemaining: Int) - case pinLocked - - public var errorDescription: String? { - switch self { - case let .wrongPIN(retriesRemaining): - "Incorrect PIN. \(retriesRemaining) attempt\(retriesRemaining == 1 ? "" : "s") remaining before the key locks." - case .pinLocked: - "This YubiKey is locked. Reset the PIN with your PUK using YubiKey Manager." - } - } -} - -public extension YubiKeyPIVRegistration { - /// Drives the full onboarding handshake over a single connection: - /// open (tap/insert) → verify PIN → read the Ed25519 public key → sign a - /// random challenge to require an on-device touch and prove key control. - static func enroll( - pin: String, - preference: YubiKeyConnectionPreference, - slot: YubiKeyPIVSlot = .signature - ) async throws -> YubiKeyEnrollment { - switch preference { - case .wired: - let connection = try await WiredSmartCardConnection.makeConnection() - return try await enrollAndClose(connection, pin: pin, slot: slot) { - await connection.close(error: nil) - } - case let .nfc(alertMessage): - let connection = try await NFCSmartCardConnection(alertMessage: alertMessage) - return try await enrollAndClose(connection, pin: pin, slot: slot) { - await connection.close(message: "YubiKey connected.") - } - } - } - - private static func enrollAndClose( - _ connection: some SmartCardConnection, - pin: String, - slot: YubiKeyPIVSlot, - closeSuccess: () async -> Void - ) async throws -> YubiKeyEnrollment { - do { - let result = try await enroll(over: connection, pin: pin, slot: slot) - await closeSuccess() - return result - } catch { - await connection.close(error: error) - throw error - } - } - - private static func enroll( - over connection: some SmartCardConnection, - pin: String, - slot: YubiKeyPIVSlot - ) async throws -> YubiKeyEnrollment { - let session = try await PIVSession.makeSession(connection: connection) - - switch try await session.verifyPin(pin) { - case .success: - break - case let .fail(retriesLeft): - throw YubiKeyEnrollmentError.wrongPIN(retriesRemaining: retriesLeft) - case .pinLocked: - throw YubiKeyEnrollmentError.pinLocked - } - - let slotKey = try await readEd25519PublicKey(from: session, slot: slot) - - // A private-key operation forces the gold-disc touch (when the key has a - // touch policy) and proves the holder controls the slot, not just the PIN. - _ = try await session.sign(presenceChallenge(), in: slot.yubiKitSlot, keyType: .ed25519) - - return YubiKeyEnrollment( - pubkey: slotKey.pubkey, - source: slotKey.source, - generatedOnYubiKey: slotKey.generatedOnYubiKey - ) - } - - private static func presenceChallenge() -> Data { - var generator = SystemRandomNumberGenerator() - return Data((0 ..< 32).map { _ in UInt8.random(in: .min ... .max, using: &generator) }) - } -} diff --git a/Modules/Signers/Sources/YubiKeyErrorDescriptions.swift b/Modules/Signers/Sources/YubiKeyErrorDescriptions.swift deleted file mode 100644 index 07a52bd..0000000 --- a/Modules/Signers/Sources/YubiKeyErrorDescriptions.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -extension YubiKeySignerError: LocalizedError { - public var errorDescription: String? { - switch self { - case .malformedResponse: - "The YubiKey returned a malformed response." - case .malformedTLV: - "The YubiKey returned malformed TLV data." - case let .unexpectedTLVTag(expected, actual): - "The YubiKey returned TLV tag 0x\(Self.hex(actual)) instead of 0x\(Self.hex(expected))." - case .invalidPINLength: - "YubiKey PIV PINs must be 6 to 8 characters." - case let .invalidPIN(retriesRemaining): - "The YubiKey PIV PIN was rejected. \(retriesRemaining) retries remain." - case .pinBlocked: - "The YubiKey PIV PIN is blocked. Reset or unblock PIV with YubiKey Manager before using this signer." - case .authenticationRequired: - "The YubiKey requires PIV PIN verification." - case let .deviceStatus(status): - "The YubiKey returned status 0x\(Self.hex(status))." - case let .invalidSignatureLength(length): - "The YubiKey returned a signature with \(length) bytes." - case .missingPINProvider: - "YubiKey signing requires a PIN prompt." - } - } - - private static func hex(_ value: UInt8) -> String { - String(value, radix: 16, uppercase: true) - } - - private static func hex(_ value: UInt16) -> String { - String(value, radix: 16, uppercase: true) - } -} diff --git a/Modules/Signers/Sources/YubiKeyPIVRegistration.swift b/Modules/Signers/Sources/YubiKeyPIVRegistration.swift deleted file mode 100644 index 300a8d1..0000000 --- a/Modules/Signers/Sources/YubiKeyPIVRegistration.swift +++ /dev/null @@ -1,176 +0,0 @@ -import Core -import Foundation -import YubiKit - -public enum YubiKeyPIVPublicKeySource: String, Sendable { - case metadata - case certificate -} - -public struct YubiKeyPIVSlotPublicKey: Equatable, Sendable { - public let pubkey: Pubkey - public let slot: YubiKeyPIVSlot - public let source: YubiKeyPIVPublicKeySource - public let generatedOnYubiKey: Bool? - - public init( - pubkey: Pubkey, - slot: YubiKeyPIVSlot, - source: YubiKeyPIVPublicKeySource, - generatedOnYubiKey: Bool? - ) { - self.pubkey = pubkey - self.slot = slot - self.source = source - self.generatedOnYubiKey = generatedOnYubiKey - } -} - -public enum YubiKeyPIVRegistration { - public static func readEd25519PublicKey( - preference: YubiKeyConnectionPreference, - slot: YubiKeyPIVSlot = .signature - ) async throws -> YubiKeyPIVSlotPublicKey { - switch preference { - case .wired: - let connection = try await WiredSmartCardConnection.makeConnection() - return try await readAndClose( - connection, - slot: slot, - closeSuccess: { - await connection.close(error: nil) - } - ) - case let .nfc(alertMessage): - let connection = try await NFCSmartCardConnection(alertMessage: alertMessage) - return try await readAndClose( - connection, - slot: slot, - closeSuccess: { - await connection.close(message: "YubiKey address read.") - } - ) - } - } - - private static func readAndClose( - _ connection: some SmartCardConnection, - slot: YubiKeyPIVSlot, - closeSuccess: () async -> Void - ) async throws -> YubiKeyPIVSlotPublicKey { - do { - let session = try await PIVSession.makeSession(connection: connection) - let result = try await readEd25519PublicKey(from: session, slot: slot) - await closeSuccess() - return result - } catch { - await connection.close(error: error) - throw error - } - } - - static func readEd25519PublicKey( - from session: PIVSession, - slot: YubiKeyPIVSlot - ) async throws -> YubiKeyPIVSlotPublicKey { - let pivSlot = slot.yubiKitSlot - - let metadataError: Error - do { - let metadata = try await session.getMetadata(in: pivSlot) - return try YubiKeyPIVSlotPublicKey( - pubkey: ed25519Pubkey(from: metadata.publicKey, slot: slot), - slot: slot, - source: .metadata, - generatedOnYubiKey: metadata.generated - ) - } catch let error as YubiKeyPIVRegistrationError { - throw error - } catch { - metadataError = error - } - - do { - let certificate = try await session.getCertificate(in: pivSlot) - guard let publicKey = certificate.publicKey else { - throw YubiKeyPIVRegistrationError.noEd25519PublicKey(slot: slot) - } - return try YubiKeyPIVSlotPublicKey( - pubkey: ed25519Pubkey(from: publicKey, slot: slot), - slot: slot, - source: .certificate, - generatedOnYubiKey: nil - ) - } catch let error as YubiKeyPIVRegistrationError { - throw error - } catch { - throw YubiKeyPIVRegistrationError.noUsablePublicKey( - slot: slot, - metadataError: Self.describe(metadataError), - certificateError: Self.describe(error) - ) - } - } - - private static func ed25519Pubkey(from publicKey: PublicKey, slot: YubiKeyPIVSlot) throws -> Pubkey { - guard case let .ed25519(key) = publicKey else { - throw YubiKeyPIVRegistrationError.unsupportedPublicKey(slot: slot) - } - return key.keyData - } - - private static func describe(_ error: any Error) -> String { - if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription { - return description - } - - return String(describing: error) - } -} - -public enum YubiKeyPIVRegistrationError: LocalizedError, Sendable { - case unsupportedPublicKey(slot: YubiKeyPIVSlot) - case noEd25519PublicKey(slot: YubiKeyPIVSlot) - case noUsablePublicKey(slot: YubiKeyPIVSlot, metadataError: String, certificateError: String) - - public var errorDescription: String? { - switch self { - case let .unsupportedPublicKey(slot): - "The \(slot.displayName) contains a key, but it is not an Ed25519 signing key." - case let .noEd25519PublicKey(slot): - "No Ed25519 public key was found in the \(slot.displayName)." - case let .noUsablePublicKey(slot, metadataError, certificateError): - "No usable Ed25519 public key was found in the \(slot.displayName). Metadata read failed: \(metadataError). Certificate read failed: \(certificateError)." - } - } -} - -public extension YubiKeyPIVSlot { - var displayName: String { - switch self { - case .authentication: - "PIV authentication slot (9A)" - case .signature: - "PIV signature slot (9C)" - case .keyManagement: - "PIV key management slot (9D)" - case .cardAuthentication: - "PIV card authentication slot (9E)" - } - } -} - -extension YubiKeyPIVSlot { - var yubiKitSlot: PIV.Slot { - switch self { - case .authentication: - .authentication - case .signature: - .signature - case .keyManagement: - .keyManagement - case .cardAuthentication: - .cardAuth - } - } -} diff --git a/Modules/Signers/Sources/YubiKeySigner.swift b/Modules/Signers/Sources/YubiKeySigner.swift deleted file mode 100644 index cf4433b..0000000 --- a/Modules/Signers/Sources/YubiKeySigner.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Core -import Foundation - -public protocol YubiKeyAPDUTransport: Sendable { - func exchange(_ command: YubiKeyAPDUCommand) async throws -> YubiKeyAPDUResponse -} - -public typealias YubiKeyPINProvider = @Sendable () async throws -> String - -public struct YubiKeySigner: Signer { - public let label: String - public let pubkey: Pubkey - public let type: SignerType = .yubikey - public let slot: YubiKeyPIVSlot - - private let transport: (any YubiKeyAPDUTransport)? - private let pinProvider: YubiKeyPINProvider? - - public init( - label: String, - pubkey: Pubkey, - slot: YubiKeyPIVSlot = .signature, - transport: (any YubiKeyAPDUTransport)? = nil, - pinProvider: YubiKeyPINProvider? = nil - ) { - self.label = label - self.pubkey = pubkey - self.slot = slot - self.transport = transport - self.pinProvider = pinProvider - } - - public func sign(message: Data) async throws -> SolanaSignature { - guard let transport else { - throw SignerError.deviceUnavailable - } - guard let pinProvider else { - throw SignerError.underlying(YubiKeySignerError.missingPINProvider) - } - - let pin = try await pinProvider() - - _ = try await transport.exchange(YubiKeyPIV.selectCommand()).successfulData() - _ = try await transport.exchange(YubiKeyPIV.verifyPINCommand(pin)).successfulData() - - var signatureResponseData = Data() - let commands = YubiKeyPIV.ed25519SignCommands(message: message, slot: slot) - for (index, command) in commands.enumerated() { - let responseData = try await transport.exchange(command).successfulData() - if index == commands.indices.last { - signatureResponseData = responseData - } - } - - return try YubiKeyPIV.parseEd25519Signature(signatureResponseData) - } -} diff --git a/Modules/Signers/Sources/YubiKitYubiKeyAPDUTransport.swift b/Modules/Signers/Sources/YubiKitYubiKeyAPDUTransport.swift deleted file mode 100644 index 52b41f4..0000000 --- a/Modules/Signers/Sources/YubiKitYubiKeyAPDUTransport.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import YubiKit - -public enum YubiKeyConnectionPreference: Equatable, Sendable { - case wired - case nfc(alertMessage: String? = nil) -} - -public final class YubiKitYubiKeyAPDUTransport: YubiKeyAPDUTransport, Sendable { - private let exchangeData: @Sendable (Data) async throws -> Data - private let closeConnection: @Sendable (Error?) async -> Void - - init( - exchangeData: @escaping @Sendable (Data) async throws -> Data, - closeConnection: @escaping @Sendable (Error?) async -> Void - ) { - self.exchangeData = exchangeData - self.closeConnection = closeConnection - } - - public static func open( - _ preference: YubiKeyConnectionPreference - ) async throws -> YubiKitYubiKeyAPDUTransport { - switch preference { - case .wired: - try await wired() - case let .nfc(alertMessage): - try await nfc(alertMessage: alertMessage) - } - } - - public static func wired() async throws -> YubiKitYubiKeyAPDUTransport { - let connection = try await WiredSmartCardConnection.makeConnection() - return YubiKitYubiKeyAPDUTransport( - exchangeData: { data in - try await connection.send(data: data) - }, - closeConnection: { error in - await connection.close(error: error) - } - ) - } - - public static func nfc( - alertMessage: String? = "Hold your YubiKey near this iPhone." - ) async throws -> YubiKitYubiKeyAPDUTransport { - let connection = try await NFCSmartCardConnection(alertMessage: alertMessage) - return YubiKitYubiKeyAPDUTransport( - exchangeData: { data in - try await connection.send(data: data) - }, - closeConnection: { error in - if let error { - await connection.close(error: error) - } else { - await connection.close(message: "YubiKey signing complete.") - } - } - ) - } - - public func exchange(_ command: YubiKeyAPDUCommand) async throws -> YubiKeyAPDUResponse { - let responseData = try await exchangeData(command.encoded) - return try YubiKeyAPDUResponse(encoded: responseData) - } - - public func close(error: Error? = nil) async { - await closeConnection(error) - } -} diff --git a/Modules/Signers/Tests/LedgerSignerTests.swift b/Modules/Signers/Tests/LedgerSignerTests.swift deleted file mode 100644 index 60b3e6a..0000000 --- a/Modules/Signers/Tests/LedgerSignerTests.swift +++ /dev/null @@ -1,182 +0,0 @@ -import Core -import Foundation -import Testing -@testable import Signers - -struct LedgerSignerTests { - @Test func defaultSolanaPathSerializesForLedger() { - #expect(LedgerDerivationPath.defaultSolana.serialized == Data([ - 0x04, - 0x80, 0x00, 0x00, 0x2C, - 0x80, 0x00, 0x01, 0xF5, - 0x80, 0x00, 0x00, 0x00, - 0x80, 0x00, 0x00, 0x00 - ])) - } - - @Test func buildsAddressAndConfigurationAPDUs() { - let configuration = LedgerSolanaAPDU.appConfigurationCommand() - #expect(configuration.encoded == Data([0xE0, 0x04, 0x00, 0x00, 0x00])) - - let address = LedgerSolanaAPDU.addressCommand(displayOnDevice: true) - #expect(address.cla == 0xE0) - #expect(address.instruction == 0x05) - #expect(address.parameter1 == 0x01) - #expect(address.parameter2 == 0x00) - #expect(address.data == LedgerDerivationPath.defaultSolana.serialized) - } - - @Test func chunksLongSolanaSignPayloads() { - let message = Data(repeating: 0xAB, count: 600) - let commands = LedgerSolanaAPDU.signTransactionCommands(message: message) - - #expect(commands.count == 3) - #expect(commands.map(\.parameter2) == [0x02, 0x03, 0x01]) - #expect(commands.map(\.data.count) == [255, 255, 108]) - #expect(commands[0].data.first == 1) - } - - @Test func parsesAppConfigurationResponse() throws { - let configuration = try LedgerSolanaAPDU.parseAppConfiguration(Data([1, 0, 1, 2, 3])) - - #expect(configuration.blindSigningEnabled) - #expect(configuration.pubkeyDisplayMode == 0) - #expect(configuration.version == "1.2.3") - } - - @Test func parsesAddressResponse() throws { - let pubkey = Data(repeating: 0xAB, count: 32) - - #expect(try LedgerSolanaAPDU.parseAddress(pubkey) == pubkey) - #expect(throws: LedgerSignerError.invalidAddressLength(31)) { - try LedgerSolanaAPDU.parseAddress(Data(repeating: 0xAB, count: 31)) - } - } - - @Test func ledgerPreflightVerifiesAddress() async throws { - let pubkey = Data(repeating: 0xAB, count: 32) - let transport = RecordingLedgerTransport(responses: [ - LedgerAPDUResponse(data: Data([1, 0, 1, 2, 3]), status: LedgerSolanaAPDU.statusOK), - LedgerAPDUResponse(data: pubkey, status: LedgerSolanaAPDU.statusOK) - ]) - - let configuration = try await LedgerSignerPreflight.verifySolanaAppAndAddress( - expectedPubkey: pubkey, - transport: transport - ) - let commands = await transport.recordedCommands - - #expect(configuration.version == "1.2.3") - #expect(commands.map(\.instruction) == [ - LedgerSolanaAPDU.instructionGetConfiguration, - LedgerSolanaAPDU.instructionGetAddress - ]) - #expect(commands[1].parameter1 == LedgerSolanaAPDU.p1NonConfirm) - } - - @Test func ledgerPreflightRejectsAddressMismatch() async throws { - let expectedPubkey = Data(repeating: 0xAB, count: 32) - let actualPubkey = Data(repeating: 0xCD, count: 32) - let transport = RecordingLedgerTransport(responses: [ - LedgerAPDUResponse(data: Data([1, 0, 1, 2, 3]), status: LedgerSolanaAPDU.statusOK), - LedgerAPDUResponse(data: actualPubkey, status: LedgerSolanaAPDU.statusOK) - ]) - - await #expect( - throws: LedgerSignerError.addressMismatch(expected: expectedPubkey, actual: actualPubkey) - ) { - try await LedgerSignerPreflight.verifySolanaAppAndAddress( - expectedPubkey: expectedPubkey, - transport: transport - ) - } - } - - @Test func parsesLedgerResponses() throws { - let response = try LedgerAPDUResponse(encoded: Data([0xAA, 0xBB, 0x90, 0x00])) - - #expect(try response.successfulData() == Data([0xAA, 0xBB])) - } - - @Test func surfacesBlindSigningResponse() throws { - let response = try LedgerAPDUResponse(encoded: Data([0x68, 0x08])) - - #expect(throws: LedgerSignerError.blindSigningRequired) { - try response.successfulData() - } - } - - @Test func encodesAndDecodesBLEFrames() throws { - let apdu = Data([0, 1, 2, 3, 4, 5, 6, 7]) - let frames = try LedgerBLEFraming.encodeAPDU(apdu, mtu: 8) - - #expect(frames == [ - Data([0x05, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x02]), - Data([0x05, 0x00, 0x01, 0x03, 0x04, 0x05, 0x06, 0x07]) - ]) - #expect(try LedgerBLEFraming.decodeAPDU(frames) == apdu) - } - - @Test func exposesLedgerBLEUUIDs() { - #expect(LedgerBLEUUID.service.uuidString == "13D63400-2C97-6004-0000-4C6564676572") - #expect(LedgerBLEUUID.notifyCharacteristic.uuidString == "13D63400-2C97-6004-0001-4C6564676572") - #expect(LedgerBLEUUID.writeCharacteristic.uuidString == "13D63400-2C97-6004-0002-4C6564676572") - } - - @Test func rejectsInvalidBLEFrameSequence() throws { - let frames = [ - Data([0x05, 0x00, 0x00, 0x00, 0x04, 0x00]), - Data([0x05, 0x00, 0x02, 0x01, 0x02, 0x03]) - ] - - #expect(throws: LedgerBLEFramingError.invalidSequence(expected: 1, actual: 2)) { - try LedgerBLEFraming.decodeAPDU(frames) - } - } - - @Test func ledgerSignerRequiresTransport() async throws { - let signer = LedgerSigner(label: "Ledger", pubkey: Data(repeating: 1, count: 32)) - - await #expect(throws: SignerError.self) { - _ = try await signer.sign(message: Data([1, 2, 3])) - } - } - - @Test func ledgerSignerSubmitsChunkedSignCommands() async throws { - let signature = Data(repeating: 0xCC, count: 64) - let transport = RecordingLedgerTransport(responses: [ - LedgerAPDUResponse(status: LedgerSolanaAPDU.statusOK), - LedgerAPDUResponse(data: signature, status: LedgerSolanaAPDU.statusOK) - ]) - let signer = LedgerSigner( - label: "Ledger", - pubkey: Data(repeating: 1, count: 32), - transport: transport - ) - - let submittedSignature = try await signer.sign(message: Data(repeating: 0xAB, count: 300)) - let commands = await transport.recordedCommands - - #expect(submittedSignature == signature) - #expect(commands.count == 2) - #expect(commands.map(\.parameter2) == [0x02, 0x01]) - } -} - -private actor RecordingLedgerTransport: LedgerAPDUTransport { - private var responses: [LedgerAPDUResponse] - private var commands: [LedgerAPDUCommand] = [] - - var recordedCommands: [LedgerAPDUCommand] { - commands - } - - init(responses: [LedgerAPDUResponse]) { - self.responses = responses - } - - func exchange(_ command: LedgerAPDUCommand) throws -> LedgerAPDUResponse { - commands.append(command) - return responses.removeFirst() - } -} diff --git a/Modules/Signers/Tests/YubiKeySignerTests.swift b/Modules/Signers/Tests/YubiKeySignerTests.swift deleted file mode 100644 index 1c1bccb..0000000 --- a/Modules/Signers/Tests/YubiKeySignerTests.swift +++ /dev/null @@ -1,205 +0,0 @@ -import Core -import Foundation -import Testing -@testable import Signers - -struct YubiKeySignerTests { - @Test func buildsSelectAndVerifyPINCommands() throws { - #expect(YubiKeyPIV.selectCommand().encoded == Data([ - 0x00, 0xA4, 0x04, 0x00, 0x09, - 0xA0, 0x00, 0x00, 0x03, 0x08, 0x00, 0x00, 0x10, 0x00 - ])) - - #expect(try YubiKeyPIV.verifyPINCommand("123456").encoded == Data([ - 0x00, 0x20, 0x00, 0x80, 0x08, - 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xFF, 0xFF - ])) - #expect(try YubiKeyPIV.verifyPINCommand("12345678").data == Data("12345678".utf8)) - #expect(throws: YubiKeySignerError.invalidPINLength) { - try YubiKeyPIV.verifyPINCommand("12345") - } - } - - @Test func chainsLongEd25519SignCommands() { - let commands = YubiKeyPIV.ed25519SignCommands(message: Data(repeating: 0xAB, count: 300)) - - #expect(commands.count == 2) - #expect(commands.map(\.cla) == [YubiKeyPIV.claCommandChaining, YubiKeyPIV.cla]) - #expect(commands.map(\.data.count) == [255, 55]) - #expect(commands[0].data.prefix(10) == Data([ - 0x7C, 0x82, 0x01, 0x32, - 0x82, 0x00, - 0x81, 0x82, 0x01, 0x2C - ])) - } - - @Test func parsesYubiKeyResponses() throws { - let response = try YubiKeyAPDUResponse(encoded: Data([0xAA, 0xBB, 0x90, 0x00])) - - #expect(try response.successfulData() == Data([0xAA, 0xBB])) - - let invalidPINResponse = try YubiKeyAPDUResponse(encoded: Data([0x63, 0xC3])) - #expect(throws: YubiKeySignerError.invalidPIN(retriesRemaining: 3)) { - try invalidPINResponse.successfulData() - } - - let blockedPINResponse = try YubiKeyAPDUResponse(encoded: Data([0x69, 0x83])) - #expect(throws: YubiKeySignerError.pinBlocked) { - try blockedPINResponse.successfulData() - } - } - - @Test func parsesEd25519SignatureResponse() throws { - let signature = Data(repeating: 0xCC, count: 64) - - #expect(try YubiKeyPIV.parseEd25519Signature(Self.signatureResponse(signature)) == signature) - #expect(throws: YubiKeySignerError.invalidSignatureLength(63)) { - try YubiKeyPIV.parseEd25519Signature(Self.signatureResponse(Data(repeating: 0xCC, count: 63))) - } - } - - @Test func yubiKeySignerRequiresTransport() async throws { - let signer = YubiKeySigner( - label: "YubiKey", - pubkey: Data(repeating: 1, count: 32), - pinProvider: { "123456" } - ) - - await #expect(throws: SignerError.self) { - _ = try await signer.sign(message: Data([1, 2, 3])) - } - } - - @Test func yubiKeySignerRequiresPINProvider() async throws { - let transport = RecordingYubiKeyTransport(responses: []) - let signer = YubiKeySigner( - label: "YubiKey", - pubkey: Data(repeating: 1, count: 32), - transport: transport - ) - - await #expect(throws: SignerError.self) { - _ = try await signer.sign(message: Data([1, 2, 3])) - } - } - - @Test func yubiKeySignerSubmitsPIVSignCommands() async throws { - let signature = Data(repeating: 0xCC, count: 64) - let transport = RecordingYubiKeyTransport(responses: [ - YubiKeyAPDUResponse(status: YubiKeyPIV.statusOK), - YubiKeyAPDUResponse(status: YubiKeyPIV.statusOK), - YubiKeyAPDUResponse(status: YubiKeyPIV.statusOK), - YubiKeyAPDUResponse(data: Self.signatureResponse(signature), status: YubiKeyPIV.statusOK) - ]) - let signer = YubiKeySigner( - label: "YubiKey", - pubkey: Data(repeating: 1, count: 32), - transport: transport, - pinProvider: { "123456" } - ) - - let submittedSignature = try await signer.sign(message: Data(repeating: 0xAB, count: 300)) - let commands = await transport.recordedCommands - - #expect(submittedSignature == signature) - #expect(commands.map(\.instruction) == [ - YubiKeyPIV.instructionSelect, - YubiKeyPIV.instructionVerify, - YubiKeyPIV.instructionAuthenticate, - YubiKeyPIV.instructionAuthenticate - ]) - #expect(commands[2].cla == YubiKeyPIV.claCommandChaining) - #expect(commands[3].cla == YubiKeyPIV.cla) - #expect(commands[3].parameter2 == YubiKeyPIVSlot.signature.rawValue) - } - - @Test func yubiKitTransportExchangesRawAPDUs() async throws { - let recorder = RecordingYubiKitConnection() - let transport = YubiKitYubiKeyAPDUTransport( - exchangeData: { data in - await recorder.record(data) - return Data([0xCA, 0xFE, 0x90, 0x00]) - }, - closeConnection: { error in - await recorder.close(error: error) - } - ) - - let response = try await transport.exchange(YubiKeyPIV.selectCommand()) - await transport.close() - - #expect(response == YubiKeyAPDUResponse(data: Data([0xCA, 0xFE]), status: YubiKeyPIV.statusOK)) - #expect(await recorder.requests == [YubiKeyPIV.selectCommand().encoded]) - #expect(await recorder.closeCount == 1) - #expect(await recorder.closeError == nil) - } - - @Test func describesPIVRegistrationFailures() { - #expect( - YubiKeyPIVRegistrationError.unsupportedPublicKey(slot: .signature).errorDescription - == "The PIV signature slot (9C) contains a key, but it is not an Ed25519 signing key." - ) - #expect( - YubiKeyPIVRegistrationError.noEd25519PublicKey(slot: .signature).errorDescription - == "No Ed25519 public key was found in the PIV signature slot (9C)." - ) - #expect(YubiKeyPIVSlot.signature.displayName == "PIV signature slot (9C)") - } - - private static func signatureResponse(_ signature: Data) -> Data { - var response = Data([0x7C]) - response.appendDERLength(signature.count + 2) - response.append(0x82) - response.appendDERLength(signature.count) - response.append(signature) - return response - } -} - -private actor RecordingYubiKitConnection { - private(set) var requests: [Data] = [] - private(set) var closeCount = 0 - private(set) var closeError: String? - - func record(_ data: Data) { - requests.append(data) - } - - func close(error: Error?) { - closeCount += 1 - closeError = error.map { String(describing: $0) } - } -} - -private actor RecordingYubiKeyTransport: YubiKeyAPDUTransport { - private var responses: [YubiKeyAPDUResponse] - private var commands: [YubiKeyAPDUCommand] = [] - - var recordedCommands: [YubiKeyAPDUCommand] { - commands - } - - init(responses: [YubiKeyAPDUResponse]) { - self.responses = responses - } - - func exchange(_ command: YubiKeyAPDUCommand) throws -> YubiKeyAPDUResponse { - commands.append(command) - return responses.removeFirst() - } -} - -private extension Data { - mutating func appendDERLength(_ length: Int) { - if length < 0x80 { - append(UInt8(length)) - } else if length <= Int(UInt8.max) { - append(0x81) - append(UInt8(length)) - } else { - append(0x82) - append(UInt8((length >> 8) & 0xFF)) - append(UInt8(length & 0xFF)) - } - } -} diff --git a/Modules/UI/Sources/AddLedgerView+Flow.swift b/Modules/UI/Sources/AddLedgerView+Flow.swift deleted file mode 100644 index 9b72257..0000000 --- a/Modules/UI/Sources/AddLedgerView+Flow.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Core -import CosignCore -import Persistence -import Signers -import SwiftUI -import UIKit - -extension AddLedgerView { - @MainActor - func startScan() async { - recovery = nil - devices = [] - selectedDeviceID = nil - pairedDevice = nil - derivedAddress = nil - phase = .searching - - do { - let found = try await transport.scan(timeout: 8) - guard !found.isEmpty else { - recovery = .noDevices - phase = .recovery - return - } - devices = found - selectedDeviceID = found.first?.id - phase = .found - } catch { - present(error) - } - } - - @MainActor - func connectAndVerify(_ device: LedgerBLEDevice) async { - recovery = nil - connectingDeviceName = device.name - phase = .connecting - - do { - try await transport.connect(to: device) - - let configuration = try await transport.exchange(LedgerSolanaAPDU.appConfigurationCommand()) - _ = try LedgerSolanaAPDU.parseAppConfiguration(configuration.successfulData()) - - let lookup = try await transport.exchange(LedgerSolanaAPDU.addressCommand(displayOnDevice: false)) - let pubkey = try LedgerSolanaAPDU.parseAddress(lookup.successfulData()) - - guard !signers.contains(where: { $0.pubkey == pubkey }) else { - recovery = .alreadyAdded - phase = .recovery - return - } - - pairedDevice = device - derivedAddress = CosignCore.base58(pubkey) - phase = .verifying - - let confirmation = try await transport.exchange(LedgerSolanaAPDU.addressCommand(displayOnDevice: true)) - let confirmedPubkey = try LedgerSolanaAPDU.parseAddress(confirmation.successfulData()) - guard confirmedPubkey == pubkey else { - recovery = LedgerRecovery(failure: .addressMismatch, hasSelectedDevice: true) - phase = .recovery - return - } - - try persist(pubkey: pubkey) - pairedAddress = CosignCore.base58(pubkey) - phase = .ready - } catch { - present(error) - } - } - - @MainActor - func performRecovery(_ recovery: LedgerRecovery) async { - switch recovery.action { - case .openSettings: - openSettings() - case .rescan: - await startScan() - case .reconnect: - if let device = selectedDevice ?? pairedDevice { - await connectAndVerify(device) - } else { - await startScan() - } - case .dismiss: - dismiss() - } - } - - private func persist(pubkey: Pubkey) throws { - let registered = RegisteredSigner( - label: trimmedLabel, - type: .ledger, - pubkey: pubkey, - backedUp: true - ) - context.insert(registered) - try context.save() - } - - private func present(_ error: any Error) { - recovery = LedgerRecovery( - failure: LedgerOnboardingFailure.classify(error), - hasSelectedDevice: selectedDevice != nil - ) - phase = .recovery - } - - private func openSettings() { - guard let url = URL(string: UIApplication.openSettingsURLString) else { return } - UIApplication.shared.open(url) - } -} diff --git a/Modules/UI/Sources/AddLedgerView+Steps.swift b/Modules/UI/Sources/AddLedgerView+Steps.swift deleted file mode 100644 index 7cf1899..0000000 --- a/Modules/UI/Sources/AddLedgerView+Steps.swift +++ /dev/null @@ -1,201 +0,0 @@ -import CosignCore -import SwiftUI - -extension AddLedgerView { - var checklistStep: some View { - CosignAnchoredFooterScreen { - header - - VStack(alignment: .leading, spacing: 8) { - Text(CosignCopy.Ledger.checklistEyebrow.uppercased()) - .font(CosignTheme.FontStyle.eyebrow) - .foregroundStyle(CosignTheme.inkFaint) - Text(CosignCopy.Ledger.checklistTitle) - .font(CosignTheme.FontStyle.display) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.Ledger.checklistSubtitle) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(CosignTheme.inkDim) - } - - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.Ledger.labelFieldTitle) - CosignCard { - TextField(CosignCopy.Ledger.defaultLabel, text: $label) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .cosignField() - } - } - - LedgerChecklistCard(items: checklistItems) - - CosignInlineBanner(tone: .neutral) { - Text(CosignCopy.Ledger.privacyNote) - } - } footer: { - Button(CosignCopy.Ledger.startScanButton) { - Task { await startScan() } - } - .buttonStyle(CosignButtonStyle(kind: .accent)) - .disabled(trimmedLabel.isEmpty) - .accessibilityIdentifier("ledger-start-scan") - } - } - - private var checklistItems: [LedgerChecklistItem] { - [ - LedgerChecklistItem(title: CosignCopy.Ledger.checklistStepUnlock, state: .done), - LedgerChecklistItem(title: CosignCopy.Ledger.checklistStepSolanaApp, state: .done), - LedgerChecklistItem(title: CosignCopy.Ledger.checklistStepBluetooth, state: .active), - LedgerChecklistItem(title: CosignCopy.Ledger.checklistStepProximity, state: .pending) - ] - } - - var searchingStep: some View { - CosignAnchoredFooterScreen { - header - - VStack(spacing: 18) { - LedgerRadarView() - VStack(spacing: 6) { - Text(CosignCopy.Ledger.searchingTitle) - .font(CosignTheme.FontStyle.titleL) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.Ledger.searchingSubtitle) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - } - } - .frame(maxWidth: .infinity) - .padding(.top, 12) - - if phase == .found { - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.Ledger.foundSectionTitle(count: devices.count)) - ForEach(devices) { device in - LedgerFoundDeviceRow(device: device, isSelected: selectedDeviceID == device.id) { - selectedDeviceID = device.id - } - } - } - } - } footer: { - Button(CosignCopy.Ledger.connectButton) { - guard let selectedDevice else { return } - Task { await connectAndVerify(selectedDevice) } - } - .buttonStyle(CosignButtonStyle(kind: .accent, isLoading: phase == .searching)) - .disabled(selectedDevice == nil) - .accessibilityIdentifier("ledger-connect") - } - } - - var connectingStep: some View { - CosignScreen { - header - VStack(spacing: 18) { - LedgerRadarView() - Text(CosignCopy.Ledger.connectingTitle(deviceName: connectingDeviceName ?? "")) - .font(CosignTheme.FontStyle.titleL) - .foregroundStyle(CosignTheme.ink) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.top, 24) - } - } - - var verifyStep: some View { - CosignScreen { - header - - VStack(alignment: .leading, spacing: 8) { - Text(CosignCopy.Ledger.verifyEyebrow.uppercased()) - .font(CosignTheme.FontStyle.eyebrow) - .foregroundStyle(CosignTheme.inkFaint) - Text(CosignCopy.Ledger.verifyTitle) - .font(CosignTheme.FontStyle.display) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.Ledger.verifySubtitle) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(CosignTheme.inkDim) - } - - CosignCard { - VStack(alignment: .leading, spacing: 8) { - Text(CosignCopy.Ledger.addressFieldTitle.uppercased()) - .font(CosignTheme.FontStyle.eyebrow) - .foregroundStyle(CosignTheme.inkFaint) - Text(derivedAddress ?? "") - .font(CosignTheme.FontStyle.mono) - .foregroundStyle(CosignTheme.ink) - .fixedSize(horizontal: false, vertical: true) - } - } - - VStack(spacing: 8) { - LedgerDeviceMark(size: 110) - HStack(spacing: 8) { - Circle().fill(CosignTheme.riskAmber).frame(width: 8, height: 8) - Text(CosignCopy.Ledger.waitingForApproval) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.riskAmber) - } - } - .frame(maxWidth: .infinity) - .padding(.top, 4) - - CosignInlineBanner(tone: .amber) { - Text(CosignCopy.Ledger.verifyCautionNote) - } - } - } - - var readyStep: some View { - CosignAnchoredFooterScreen { - header - - VStack(spacing: 18) { - CosignGlyphView(glyph: .check, size: 30, color: CosignTheme.mint) - .frame(width: 72, height: 72) - .background(CosignTheme.mintWash, in: .circle) - .overlay { Circle().stroke(CosignTheme.mint.opacity(0.40), lineWidth: 1) } - - VStack(spacing: 8) { - Text(CosignCopy.Ledger.readyTitle) - .font(CosignTheme.FontStyle.display) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.Ledger.readySubtitle) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(CosignTheme.inkDim) - .multilineTextAlignment(.center) - } - } - .frame(maxWidth: .infinity) - .padding(.top, 8) - - LedgerSignerSummaryCard( - deviceName: pairedDevice?.name ?? trimmedLabel, - address: pairedAddress ?? "" - ) - } footer: { - Button(CosignCopy.Ledger.doneButtonTitle) { - dismiss() - } - .buttonStyle(CosignButtonStyle(kind: .primary)) - .accessibilityIdentifier("ledger-done") - } - } - - var recoveryStep: some View { - CosignScreen { - header - if let recovery { - LedgerRecoveryCard(recovery: recovery) { - Task { await performRecovery(recovery) } - } - } - } - } -} diff --git a/Modules/UI/Sources/AddLedgerView.swift b/Modules/UI/Sources/AddLedgerView.swift deleted file mode 100644 index b42578f..0000000 --- a/Modules/UI/Sources/AddLedgerView.swift +++ /dev/null @@ -1,77 +0,0 @@ -import CosignCore -import Persistence -import Signers -import SwiftData -import SwiftUI - -struct AddLedgerView: View { - @Environment(\.dismiss) var dismiss - @Environment(\.modelContext) var context - @Query(sort: \RegisteredSigner.createdAt, order: .forward) - var signers: [RegisteredSigner] - - @State var label = CosignCopy.Ledger.defaultLabel - @State var transport = CoreBluetoothLedgerTransport() - @State var devices: [LedgerBLEDevice] = [] - @State var selectedDeviceID: UUID? - @State var phase: Phase = .checklist - @State var connectingDeviceName: String? - @State var derivedAddress: String? - @State var pairedDevice: LedgerBLEDevice? - @State var pairedAddress: String? - @State var recovery: LedgerRecovery? - - enum Phase: Equatable { - case checklist - case searching - case found - case connecting - case verifying - case ready - case recovery - } - - init() {} - - var body: some View { - NavigationStack { - phaseContent - .toolbar(.hidden, for: .navigationBar) - .cosignScreenIdentifier("screen.add-ledger") - .onDisappear { transport.disconnect() } - } - } - - @ViewBuilder - private var phaseContent: some View { - switch phase { - case .checklist: - checklistStep - case .searching, .found: - searchingStep - case .connecting: - connectingStep - case .verifying: - verifyStep - case .ready: - readyStep - case .recovery: - recoveryStep - } - } - - var trimmedLabel: String { - label.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var selectedDevice: LedgerBLEDevice? { - devices.first { $0.id == selectedDeviceID } - } - - var header: some View { - CosignCompactPageHeader(title: CosignCopy.Ledger.connectChromeTitle) { - transport.disconnect() - dismiss() - } - } -} diff --git a/Modules/UI/Sources/AddYubiKeyView+Flow.swift b/Modules/UI/Sources/AddYubiKeyView+Flow.swift deleted file mode 100644 index 9c7e94b..0000000 --- a/Modules/UI/Sources/AddYubiKeyView+Flow.swift +++ /dev/null @@ -1,88 +0,0 @@ -import Core -import CosignCore -import Persistence -import Signers -import SwiftUI - -extension AddYubiKeyView { - func beginPINEntry() { - recovery = nil - pin = "" - phase = .pin - } - - func submitPIN() { - guard hasValidPIN else { return } - phase = .touch - Task { await enroll() } - } - - @MainActor - func enroll() async { - recovery = nil - let pinToVerify = pin.trimmingCharacters(in: .whitespacesAndNewlines) - - do { - let result = try await YubiKeyPIVRegistration.enroll( - pin: pinToVerify, - preference: transport.connectionPreference( - alertMessage: CosignCopy.YubiKey.tapPrompt - ) - ) - - guard !signers.contains(where: { $0.pubkey == result.pubkey }) else { - recovery = .alreadyAdded - phase = .recovery - return - } - - try persist(pubkey: result.pubkey) - addedAddress = CosignCore.base58(result.pubkey) - phase = .ready - } catch { - present(error) - } - } - - @MainActor - func performRecovery(_ recovery: YubiKeyRecovery) async { - switch recovery.action { - case .reEnterPIN: - pin = "" - self.recovery = nil - phase = .pin - case .retryConnect: - self.recovery = nil - phase = .touch - await enroll() - case .useWired: - transport = .wired - self.recovery = nil - phase = .tapOrInsert - case .startOver: - resetToStart() - case .dismiss: - dismiss() - } - } - - private func persist(pubkey: Pubkey) throws { - let registered = RegisteredSigner( - label: trimmedLabel, - type: .yubikey, - pubkey: pubkey, - backedUp: true - ) - context.insert(registered) - try context.save() - } - - private func present(_ error: any Error) { - let failure = YubiKeyOnboardingFailure.classify(error) - if case let .wrongPIN(retriesRemaining) = failure { - pinAttemptsRemaining = retriesRemaining - } - recovery = YubiKeyRecovery(failure: failure) - phase = .recovery - } -} diff --git a/Modules/UI/Sources/AddYubiKeyView+Steps.swift b/Modules/UI/Sources/AddYubiKeyView+Steps.swift deleted file mode 100644 index 4efec4f..0000000 --- a/Modules/UI/Sources/AddYubiKeyView+Steps.swift +++ /dev/null @@ -1,154 +0,0 @@ -import CosignCore -import SwiftUI - -extension AddYubiKeyView { - var tapOrInsertStep: some View { - CosignAnchoredFooterScreen { - header(title: CosignCopy.YubiKey.connectChromeTitle) - - VStack(alignment: .leading, spacing: 8) { - Text(CosignCopy.YubiKey.tapEyebrow.uppercased()) - .font(CosignTheme.FontStyle.eyebrow) - .foregroundStyle(CosignTheme.inkFaint) - Text(CosignCopy.YubiKey.tapTitle) - .font(CosignTheme.FontStyle.display) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.YubiKey.tapSubtitle) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(CosignTheme.inkDim) - } - - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.YubiKey.labelFieldTitle) - CosignCard { - TextField(CosignCopy.YubiKey.defaultLabel, text: $label) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .cosignField() - } - } - - VStack(spacing: 16) { - YubiKeyHaloView(pulses: transport == .nfc) - YubiKeyStatusPill( - text: transport == .nfc ? CosignCopy.YubiKey.listeningNFC : CosignCopy.YubiKey.listeningWired - ) - } - .frame(maxWidth: .infinity) - .padding(.top, 8) - - Button(transport == .nfc ? CosignCopy.YubiKey.useWiredButton : CosignCopy.YubiKey.useNFCButton) { - transport = transport == .nfc ? .wired : .nfc - } - .buttonStyle(CosignButtonStyle(kind: .secondary)) - .accessibilityIdentifier("yubikey-toggle-transport") - } footer: { - Button(CosignCopy.YubiKey.continueButton) { - beginPINEntry() - } - .buttonStyle(CosignButtonStyle(kind: .accent)) - .disabled(trimmedLabel.isEmpty) - .accessibilityIdentifier("yubikey-continue") - } - } - - var pinStep: some View { - CosignAnchoredFooterScreen { - header(title: CosignCopy.YubiKey.connectChromeTitle) - - VStack(alignment: .leading, spacing: 8) { - Text(CosignCopy.YubiKey.pinEyebrow.uppercased()) - .font(CosignTheme.FontStyle.eyebrow) - .foregroundStyle(CosignTheme.inkFaint) - Text(CosignCopy.YubiKey.pinTitle) - .font(CosignTheme.FontStyle.display) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.YubiKey.pinAttemptsRemaining(pinAttemptsRemaining)) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - } - - YubiKeyPINDots(count: pin.count) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - - YubiKeyPINPad(pin: $pin) - } footer: { - Button(CosignCopy.YubiKey.continueButton) { - submitPIN() - } - .buttonStyle(CosignButtonStyle(kind: .accent)) - .disabled(!hasValidPIN) - .accessibilityIdentifier("yubikey-pin-continue") - } - } - - var touchStep: some View { - CosignScreen { - header(title: CosignCopy.YubiKey.confirmChromeTitle) - - VStack(alignment: .leading, spacing: 8) { - Text(CosignCopy.YubiKey.touchEyebrow.uppercased()) - .font(CosignTheme.FontStyle.eyebrow) - .foregroundStyle(CosignTheme.inkFaint) - Text(CosignCopy.YubiKey.touchTitle) - .font(CosignTheme.FontStyle.display) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.YubiKey.touchSubtitle) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(CosignTheme.inkDim) - } - - VStack(spacing: 16) { - YubiKeyHaloView(pulses: true) - YubiKeyStatusPill(text: CosignCopy.YubiKey.waitingForTouch) - } - .frame(maxWidth: .infinity) - .padding(.top, 12) - } - } - - var readyStep: some View { - CosignAnchoredFooterScreen { - header(title: CosignCopy.YubiKey.connectChromeTitle) - - VStack(spacing: 18) { - CosignGlyphView(glyph: .check, size: 30, color: CosignTheme.mint) - .frame(width: 72, height: 72) - .background(CosignTheme.mintWash, in: .circle) - .overlay { Circle().stroke(CosignTheme.mint.opacity(0.40), lineWidth: 1) } - - VStack(spacing: 8) { - Text(CosignCopy.YubiKey.readyTitle) - .font(CosignTheme.FontStyle.display) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.YubiKey.readySubtitle) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(CosignTheme.inkDim) - .multilineTextAlignment(.center) - } - } - .frame(maxWidth: .infinity) - .padding(.top, 8) - - YubiKeySignerSummaryCard(name: trimmedLabel, address: addedAddress ?? "") - } footer: { - Button(CosignCopy.YubiKey.doneButtonTitle) { - dismiss() - } - .buttonStyle(CosignButtonStyle(kind: .primary)) - .accessibilityIdentifier("yubikey-done") - } - } - - var recoveryStep: some View { - CosignScreen { - header(title: CosignCopy.YubiKey.connectChromeTitle) - if let recovery { - YubiKeyRecoveryCard(recovery: recovery) { - Task { await performRecovery(recovery) } - } - } - } - } -} diff --git a/Modules/UI/Sources/AddYubiKeyView.swift b/Modules/UI/Sources/AddYubiKeyView.swift deleted file mode 100644 index 008d237..0000000 --- a/Modules/UI/Sources/AddYubiKeyView.swift +++ /dev/null @@ -1,77 +0,0 @@ -import CosignCore -import Persistence -import SwiftData -import SwiftUI - -struct AddYubiKeyView: View { - @Environment(\.dismiss) var dismiss - @Environment(\.modelContext) var context - @Query(sort: \RegisteredSigner.createdAt, order: .forward) - var signers: [RegisteredSigner] - - @State var label = CosignCopy.YubiKey.defaultLabel - @State var transport = YubiKeyTransportChoice.nfc - @State var pin = "" - @State var phase: Phase = .tapOrInsert - @State var pinAttemptsRemaining = Self.defaultPINAttempts - @State var addedAddress: String? - @State var recovery: YubiKeyRecovery? - - static let defaultPINAttempts = 3 - - enum Phase: Equatable { - case tapOrInsert - case pin - case touch - case ready - case recovery - } - - init() {} - - var body: some View { - NavigationStack { - phaseContent - .toolbar(.hidden, for: .navigationBar) - .cosignScreenIdentifier("screen.add-yubikey") - } - } - - @ViewBuilder - private var phaseContent: some View { - switch phase { - case .tapOrInsert: - tapOrInsertStep - case .pin: - pinStep - case .touch: - touchStep - case .ready: - readyStep - case .recovery: - recoveryStep - } - } - - var trimmedLabel: String { - label.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var hasValidPIN: Bool { - (6 ... 8).contains(pin.trimmingCharacters(in: .whitespacesAndNewlines).utf8.count) - } - - func header(title: String) -> some View { - CosignCompactPageHeader(title: title) { - dismiss() - } - } - - func resetToStart() { - pin = "" - addedAddress = nil - recovery = nil - pinAttemptsRemaining = Self.defaultPINAttempts - phase = .tapOrInsert - } -} diff --git a/Modules/UI/Sources/CosignCopy.swift b/Modules/UI/Sources/CosignCopy.swift index 2b37595..fd66071 100644 --- a/Modules/UI/Sources/CosignCopy.swift +++ b/Modules/UI/Sources/CosignCopy.swift @@ -157,7 +157,10 @@ extension CosignCopy { enum About { static let appName = String(localized: "Cosign", bundle: .module) - static let tagline = String(localized: "A verifiable signer for Squads v4 multisigs.", bundle: .module) + static let tagline = String( + localized: "A verifiable signer for Solana Squads v4 multisigs. Every proposal is decoded on your device.", + bundle: .module + ) static let versionLabel = String(localized: "Version", bundle: .module) static let buildLabel = String(localized: "Build", bundle: .module) static let emptyValue = String(localized: "—", bundle: .module) diff --git a/Modules/UI/Sources/CosignDemoSigners.swift b/Modules/UI/Sources/CosignDemoSigners.swift index d407bb9..1498b6a 100644 --- a/Modules/UI/Sources/CosignDemoSigners.swift +++ b/Modules/UI/Sources/CosignDemoSigners.swift @@ -20,22 +20,6 @@ public enum CosignDemoSigners { pubkey: Data((0 ..< 32).map { UInt8($0 + 1) }), keychainItemRef: "cosign-demo-operations", createdAt: Date(timeIntervalSince1970: 1_779_200_000) - ), - CosignDemoSignerSeed( - id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, - label: CosignCopy.Demo.treasurySignerLabel, - type: .ledger, - pubkey: Data((0 ..< 32).map { UInt8($0 + 33) }), - keychainItemRef: "cosign-demo-treasury", - createdAt: Date(timeIntervalSince1970: 1_779_200_060) - ), - CosignDemoSignerSeed( - id: UUID(uuidString: "33333333-3333-3333-3333-333333333333")!, - label: CosignCopy.Demo.localDevnetSignerLabel, - type: .yubikey, - pubkey: Data((0 ..< 32).map { UInt8($0 + 65) }), - keychainItemRef: "cosign-demo-local-devnet", - createdAt: Date(timeIntervalSince1970: 1_779_200_120) ) ] @@ -47,22 +31,6 @@ public enum CosignDemoSigners { pubkey: Data((0 ..< 32).map { UInt8($0 + 97) }), keychainItemRef: "cosign-demo-empty-portfolio", createdAt: Date(timeIntervalSince1970: 1_779_300_000) - ), - CosignDemoSignerSeed( - id: UUID(uuidString: "55555555-5555-5555-5555-555555555555")!, - label: CosignCopy.Demo.noVaultsSignerLabel, - type: .ledger, - pubkey: Data((0 ..< 32).map { UInt8($0 + 129) }), - keychainItemRef: "cosign-demo-no-vaults", - createdAt: Date(timeIntervalSince1970: 1_779_300_060) - ), - CosignDemoSignerSeed( - id: UUID(uuidString: "66666666-6666-6666-6666-666666666666")!, - label: CosignCopy.Demo.detachedSignerLabel, - type: .yubikey, - pubkey: Data((0 ..< 32).map { UInt8($0 + 161) }), - keychainItemRef: "cosign-demo-detached", - createdAt: Date(timeIntervalSince1970: 1_779_300_120) ) ] diff --git a/Modules/UI/Sources/CosignLedgerOnboardingCopy.swift b/Modules/UI/Sources/CosignLedgerOnboardingCopy.swift deleted file mode 100644 index d78bc71..0000000 --- a/Modules/UI/Sources/CosignLedgerOnboardingCopy.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Foundation - -extension CosignCopy.Ledger { - static let connectChromeTitle = String(localized: "Connect Ledger", bundle: .module) - - static let checklistEyebrow = String(localized: "Hardware signer", bundle: .module) - static let checklistTitle = String(localized: "Before we scan", bundle: .module) - static let checklistSubtitle = String( - localized: "Get your Ledger ready, then start the Bluetooth scan.", - bundle: .module - ) - static let checklistStepUnlock = String(localized: "Unlock your Ledger with its PIN", bundle: .module) - static let checklistStepSolanaApp = String(localized: "Open the Solana app on the device", bundle: .module) - static let checklistStepBluetooth = String(localized: "Turn Bluetooth on", bundle: .module) - static let checklistStepProximity = String(localized: "Hold the device near your phone", bundle: .module) - static let privacyNote = - String( - localized: "Cosign never sees your recovery phrase — the Ledger signs on-device and returns only the signature.", - bundle: .module - ) - static let startScanButton = String(localized: "Start scan", bundle: .module) - static let labelFieldTitle = String(localized: "Label", bundle: .module) - - static let searchingTitle = String(localized: "Searching for devices…", bundle: .module) - static let searchingSubtitle = String(localized: "Keep the Ledger unlocked and nearby", bundle: .module) - static let connectButton = String(localized: "Connect", bundle: .module) - - static let verifyEyebrow = String(localized: "Step 4 of 4", bundle: .module) - static let verifyTitle = String(localized: "Confirm on your Ledger", bundle: .module) - static let verifySubtitle = String( - localized: "Check the address on the device screen matches the one below, then approve.", - bundle: .module - ) - static let addressFieldTitle = String(localized: "Address", bundle: .module) - static let waitingForApproval = String(localized: "Waiting for approval on device", bundle: .module) - static let verifyCautionNote = - String( - localized: "Reject on the device if the address does not match exactly — never approve a mismatch.", - bundle: .module - ) - - static let readyTitle = String(localized: "Ledger connected", bundle: .module) - static let readySubtitle = String(localized: "This signer is ready to approve proposals.", bundle: .module) - static let hardwareTag = String(localized: "HARDWARE", bundle: .module) - - static func foundSectionTitle(count: Int) -> String { - String(localized: "Found · \(count)", bundle: .module) - } - - static func connectingTitle(deviceName: String) -> String { - String(localized: "Connecting to \(deviceName)…", bundle: .module) - } - - static func deviceSubtitle(rssi: Int) -> String { - String(localized: "Bluetooth signer · \(rssi) dBm", bundle: .module) - } - - enum Recovery { - static let bluetoothOffTitle = String(localized: "Bluetooth is off", bundle: .module) - static let bluetoothOffMessage = String( - localized: "Cosign needs Bluetooth to reach the Ledger.", - bundle: .module - ) - static let bluetoothOffAction = String(localized: "Open Settings", bundle: .module) - - static let permissionDeniedTitle = String(localized: "Bluetooth permission denied", bundle: .module) - static let permissionDeniedMessage = String(localized: "The app was denied Bluetooth access.", bundle: .module) - static let permissionDeniedAction = String(localized: "Allow in Settings", bundle: .module) - - static let deviceLockedTitle = String(localized: "Device is locked", bundle: .module) - static let deviceLockedMessage = String( - localized: "Unlock the Ledger with its PIN to continue.", - bundle: .module - ) - static let deviceLockedAction = String(localized: "Retry scan", bundle: .module) - - static let solanaAppTitle = String(localized: "Solana app not open", bundle: .module) - static let solanaAppMessage = String( - localized: "Open the Solana app on the device, not the dashboard or another app.", - bundle: .module - ) - static let solanaAppAction = String(localized: "Retry", bundle: .module) - - static let lostConnectionTitle = String(localized: "Out of range / lost connection", bundle: .module) - static let lostConnectionMessage = String( - localized: "The device moved too far or went to sleep.", - bundle: .module - ) - static let lostConnectionAction = String(localized: "Reconnect", bundle: .module) - - static let timedOutTitle = String(localized: "Timed out", bundle: .module) - static let timedOutMessage = String(localized: "No response from the device in time.", bundle: .module) - static let timedOutAction = String(localized: "Try again", bundle: .module) - - static let noDevicesTitle = String(localized: "No Ledger found", bundle: .module) - static let noDevicesMessage = String( - localized: "No Ledger was in range. Keep it unlocked and nearby, then try again.", - bundle: .module - ) - static let noDevicesAction = String(localized: "Try again", bundle: .module) - - static let mismatchTitle = String(localized: "Address mismatch", bundle: .module) - static let mismatchMessage = String( - localized: "The device returned a different address. Never approve a mismatch.", - bundle: .module - ) - static let mismatchAction = String(localized: "Start over", bundle: .module) - - static let alreadyAddedTitle = String(localized: "Already added", bundle: .module) - static let alreadyAddedMessage = String( - localized: "This Ledger address is already a signer on this device.", - bundle: .module - ) - } -} diff --git a/Modules/UI/Sources/CosignProposalCopy+Creation.swift b/Modules/UI/Sources/CosignProposalCopy+Creation.swift index 0657d99..f42dbcd 100644 --- a/Modules/UI/Sources/CosignProposalCopy+Creation.swift +++ b/Modules/UI/Sources/CosignProposalCopy+Creation.swift @@ -129,44 +129,5 @@ extension CosignCopy { static func vaultDisplayName(index: UInt8) -> String { String(localized: "Vault \(index)", bundle: .module) } - - static func signButtonTitle(for signerType: SignerType) -> String { - switch signerType { - case .hotWallet: - hotWalletSignTitle - case .ledger: - ProposalSigning.buttonTitle(for: .approve, signerType: .ledger) - case .yubikey: - ProposalSigning.buttonTitle(for: .approve, signerType: .yubikey) - } - } - - static func hardwareTitle(for signerType: SignerType) -> String { - switch signerType { - case .hotWallet: - ProposalSigning.deviceCheckTitle - case .ledger: - ProposalSigning.ledgerTitle - case .yubikey: - ProposalSigning.yubiKeyTitle - } - } - - static func hardwareContext(for signerType: SignerType) -> String { - switch signerType { - case .hotWallet: - ProposalSigning.deviceContext(for: .hotWallet) - case .ledger: - String( - localized: "Review the action above before approving on your Ledger. The device may not show full Squads proposal context.", - bundle: .module - ) - case .yubikey: - String( - localized: "Review the action above before tapping your YubiKey. The key signs the proposal transaction; Cosign provides the transfer context.", - bundle: .module - ) - } - } } } diff --git a/Modules/UI/Sources/CosignProposalCopy.swift b/Modules/UI/Sources/CosignProposalCopy.swift index f5c9a17..f7db869 100644 --- a/Modules/UI/Sources/CosignProposalCopy.swift +++ b/Modules/UI/Sources/CosignProposalCopy.swift @@ -39,22 +39,8 @@ extension CosignCopy { static let approvedByUnknownValue = String(localized: "Threshold met", bundle: .module) static let localHotWalletTitle = String(localized: "Local hot wallet", bundle: .module) static let deviceCheckTitle = String(localized: "Device check", bundle: .module) - static let ledgerTitle = String(localized: "Ledger", bundle: .module) - static let yubiKeyTitle = String(localized: "YubiKey", bundle: .module) - static let scanningLedgerStatus = String(localized: "Scanning for Ledger devices...", bundle: .module) - static let verifyingLedgerAddressStatus = String(localized: "Verifying Ledger address...", bundle: .module) - static let confirmLedgerStatus = String(localized: "Confirm the transaction on your Ledger.", bundle: .module) - static let noLedgerDevicesError = String(localized: "No Ledger devices were found.", bundle: .module) - static let missingYubiKeyOptionsError = String( - localized: "YubiKey signing needs a connection choice and PIN.", - bundle: .module - ) static let notBackedUpError = String(localized: "Back up this wallet before it can sign.", bundle: .module) - static func connectingLedgerStatus(deviceName: String) -> String { - String(localized: "Connecting to \(deviceName)...", bundle: .module) - } - static func actionTitle(for action: SquadProposalAction, actionTitle: String) -> String { switch action { case .approveAndExecute: @@ -120,10 +106,6 @@ extension CosignCopy { switch signerType { case .hotWallet: hotWalletButtonTitle(for: action) - case .ledger: - String(localized: "Continue on Ledger", bundle: .module) - case .yubikey: - String(localized: "Sign with YubiKey", bundle: .module) } } @@ -196,21 +178,8 @@ extension CosignCopy { } } - static func deviceContext(for signerType: SignerType) -> String { - switch signerType { - case .hotWallet: - String(localized: "The private key stays in the device Keychain.", bundle: .module) - case .ledger: - String( - localized: "Review the action above before approving on your Ledger. The device may not show full Squads proposal context.", - bundle: .module - ) - case .yubikey: - String( - localized: "Review the action above before tapping your YubiKey. The key signs the transaction message; Cosign provides the proposal context.", - bundle: .module - ) - } + static var deviceContext: String { + String(localized: "The private key stays in the device Keychain.", bundle: .module) } private static func hotWalletButtonTitle(for action: SquadProposalAction) -> String { diff --git a/Modules/UI/Sources/CosignSignerCopy+Signers.swift b/Modules/UI/Sources/CosignSignerCopy+Signers.swift index fd8213d..18cae11 100644 --- a/Modules/UI/Sources/CosignSignerCopy+Signers.swift +++ b/Modules/UI/Sources/CosignSignerCopy+Signers.swift @@ -50,6 +50,12 @@ extension CosignCopy { ) } + static let comingSoonTag = String(localized: "Coming soon", bundle: .module) + static let ledgerComingSoonTitle = String(localized: "Ledger", bundle: .module) + static let ledgerComingSoonSubtitle = String(localized: "Bluetooth or USB hardware signer", bundle: .module) + static let yubiKeyComingSoonTitle = String(localized: "YubiKey", bundle: .module) + static let yubiKeyComingSoonSubtitle = String(localized: "NFC tap or USB security key", bundle: .module) + static func removeMessage(for type: SignerType) -> String { switch type { case .hotWallet: @@ -57,16 +63,6 @@ extension CosignCopy { localized: "This removes the signer from this device and deletes its private key from the Keychain. You will need the recovery phrase to add it again.", bundle: .module ) - case .ledger: - String( - localized: "This removes the Ledger signer from this device. The Ledger itself is not changed.", - bundle: .module - ) - case .yubikey: - String( - localized: "This removes the YubiKey signer from this device. The YubiKey itself is not changed.", - bundle: .module - ) } } @@ -74,10 +70,6 @@ extension CosignCopy { switch type { case .hotWallet: String(localized: "Hot wallet", bundle: .module) - case .ledger: - String(localized: "Ledger", bundle: .module) - case .yubikey: - String(localized: "YubiKey", bundle: .module) } } @@ -85,10 +77,6 @@ extension CosignCopy { switch type { case .hotWallet: String(localized: "Ready on this device", bundle: .module) - case .ledger: - String(localized: "Hardware approval required", bundle: .module) - case .yubikey: - String(localized: "Tap or plug in to sign", bundle: .module) } } @@ -96,10 +84,6 @@ extension CosignCopy { switch type { case .hotWallet: String(localized: "KEYCHAIN", bundle: .module) - case .ledger: - String(localized: "LEDGER", bundle: .module) - case .yubikey: - String(localized: "YUBIKEY", bundle: .module) } } @@ -107,10 +91,6 @@ extension CosignCopy { switch sheet { case .hotWallet: String(localized: "Hot Wallet", bundle: .module) - case .ledger: - String(localized: "Ledger", bundle: .module) - case .yubikey: - String(localized: "YubiKey", bundle: .module) } } @@ -118,10 +98,6 @@ extension CosignCopy { switch sheet { case .hotWallet: String(localized: "Create a key stored in iOS Keychain", bundle: .module) - case .ledger: - String(localized: "Bluetooth or USB hardware signer", bundle: .module) - case .yubikey: - String(localized: "NFC tap or USB security key", bundle: .module) } } diff --git a/Modules/UI/Sources/CosignSignerCopy.swift b/Modules/UI/Sources/CosignSignerCopy.swift index 5f3b4a2..27e115d 100644 --- a/Modules/UI/Sources/CosignSignerCopy.swift +++ b/Modules/UI/Sources/CosignSignerCopy.swift @@ -54,8 +54,6 @@ extension CosignCopy { switch type { case .hotWallet: storedOnDeviceHeader - case .ledger, .yubikey: - hardwareHeader } } } @@ -119,12 +117,6 @@ extension CosignCopy { extension CosignCopy { enum SignerDiagnostics { - static let yubiKeySectionTitle = String(localized: "YubiKey", bundle: .module) - static let yubiKeyMessage = - String( - localized: "The connected YubiKey must expose the saved Ed25519 key from the PIV signature slot (9C).", - bundle: .module - ) static let diagnosticsSectionTitle = String(localized: "Diagnostics", bundle: .module) static let testFailedTitle = String(localized: "Test failed", bundle: .module) static let checkingKeychainStatus = String(localized: "Checking Keychain item...", bundle: .module) @@ -134,15 +126,6 @@ extension CosignCopy { localized: "The Keychain item loaded and produced a valid signature.", bundle: .module ) - static let verifyingAddressStatus = String(localized: "Verifying saved address...", bundle: .module) - static let blindSigningEnabled = String(localized: "enabled", bundle: .module) - static let blindSigningDisabled = String(localized: "disabled", bundle: .module) - static let ledgerReadyTitle = String(localized: "Ledger ready", bundle: .module) - static let yubiKeyTestPrompt = String( - localized: "Hold your YubiKey near this iPhone to test it.", - bundle: .module - ) - static let yubiKeyReadyTitle = String(localized: "YubiKey ready", bundle: .module) static let missingHotWalletKeychainReference = String(localized: "This hot wallet is missing its Keychain reference.", bundle: .module) static let invalidHotWalletSignature = @@ -150,47 +133,13 @@ extension CosignCopy { localized: "The hot wallet produced a signature that did not verify against the saved address.", bundle: .module ) - static let noLedgerDevices = String(localized: "No Ledger devices were found.", bundle: .module) static func buttonTitle(for type: SignerType) -> String { switch type { case .hotWallet: String(localized: "Test Hot Wallet", bundle: .module) - case .ledger: - String(localized: "Test Ledger Connection", bundle: .module) - case .yubikey: - String(localized: "Test YubiKey Connection", bundle: .module) } } - - static func ledgerReadyMessage(deviceName: String, version: String, blindSigning: String) -> String { - String( - localized: "Connected to \(deviceName). Solana app \(version) matched the saved address. Blind signing is \(blindSigning).", - bundle: .module - ) - } - - static func yubiKeyReadyMessage(details: String) -> String { - String(localized: "The PIV signature slot matched the saved address. \(details)", bundle: .module) - } - - static func sourceDetail(_ source: String) -> String { - String(localized: "Source: \(source).", bundle: .module) - } - - static func keyOriginDetail(generatedOnYubiKey: Bool) -> String { - String( - localized: "Key origin: \(generatedOnYubiKey ? "generated on YubiKey" : "imported").", - bundle: .module - ) - } - - static func addressMismatch(expected: String, actual: String) -> String { - String( - localized: "The connected signer address does not match the saved signer. Expected \(expected), got \(actual).", - bundle: .module - ) - } } } @@ -211,78 +160,3 @@ extension CosignCopy { } } } - -extension CosignCopy { - enum Ledger { - static let defaultLabel = String(localized: "My Ledger", bundle: .module) - static let doneButtonTitle = String(localized: "Done", bundle: .module) - - static func scanningStatus() -> String { - String(localized: "Scanning for Ledger devices...", bundle: .module) - } - - static func connectingStatus(deviceName: String) -> String { - String(localized: "Connecting to \(deviceName)...", bundle: .module) - } - } -} - -extension CosignCopy { - enum YubiKey { - static let defaultLabel = String(localized: "My YubiKey", bundle: .module) - static let doneButtonTitle = String(localized: "Done", bundle: .module) - static let tapPrompt = String(localized: "Hold your YubiKey near this iPhone to add it.", bundle: .module) - - static func connectingStatus(transport: String) -> String { - String(localized: "Connecting to \(transport)...", bundle: .module) - } - } -} - -extension CosignCopy { - enum YubiKeySigning { - static let pluggedInTransportTitle = String(localized: "Plugged In", bundle: .module) - static let tapTransportTitle = String(localized: "Tap", bundle: .module) - static let pluggedInTransportStatus = String(localized: "plugged-in YubiKey", bundle: .module) - static let nfcTransportStatus = String(localized: "NFC YubiKey", bundle: .module) - static let sectionTitle = String(localized: "YubiKey", bundle: .module) - static let pinPlaceholder = String(localized: "PIN", bundle: .module) - static let pinLengthMessage = String(localized: "YubiKey PIV PINs must be 6 to 8 characters.", bundle: .module) - static let tapPrompt = String(localized: "Hold your YubiKey near this iPhone to sign.", bundle: .module) - static let connectingStatusPrefix = String(localized: "Connecting to", bundle: .module) - static let signStatus = String(localized: "Sign with your YubiKey.", bundle: .module) - static let checkFailedTitle = String(localized: "YubiKey check failed", bundle: .module) - static let unsupportedKeyTitle = String(localized: "Unsupported YubiKey key", bundle: .module) - static let keyNotFoundTitle = String(localized: "YubiKey key not found", bundle: .module) - static let keyNotReadableTitle = String(localized: "YubiKey key not readable", bundle: .module) - - static func connectingStatus(transport: String) -> String { - String(localized: "\(connectingStatusPrefix) \(transport)...", bundle: .module) - } - - static func unsupportedKeyMessage(slotName: String) -> String { - String( - localized: "The \(slotName) has a key, but it is not Ed25519. Provision or import an Ed25519 key in slot 9C with YubiKey Manager, then try again. Cosign will not overwrite hardware keys.", - bundle: .module - ) - } - - static func keyNotFoundMessage(slotName: String) -> String { - String( - localized: "Cosign could not find an Ed25519 public key in the \(slotName). Provision or import an Ed25519 key in slot 9C with YubiKey Manager, then try again.", - bundle: .module - ) - } - - static func keyNotReadableMessage( - slotName: String, - metadataError: String, - certificateError: String - ) -> String { - String( - localized: "Cosign could not read an Ed25519 public key from the \(slotName). Confirm the PIV app is enabled and slot 9C contains an Ed25519 key. Metadata: \(metadataError) Certificate: \(certificateError)", - bundle: .module - ) - } - } -} diff --git a/Modules/UI/Sources/CosignYubiKeyOnboardingCopy.swift b/Modules/UI/Sources/CosignYubiKeyOnboardingCopy.swift deleted file mode 100644 index 12cc16f..0000000 --- a/Modules/UI/Sources/CosignYubiKeyOnboardingCopy.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation - -extension CosignCopy.YubiKey { - static let connectChromeTitle = String(localized: "Connect YubiKey", bundle: .module) - static let confirmChromeTitle = String(localized: "Confirm", bundle: .module) - - static let tapEyebrow = String(localized: "Hardware signer", bundle: .module) - static let tapTitle = String(localized: "Tap your YubiKey", bundle: .module) - static let tapSubtitle = String( - localized: "Hold the key to the top of your phone, or insert it into the port.", - bundle: .module - ) - static let listeningNFC = String(localized: "Listening for NFC…", bundle: .module) - static let listeningWired = String(localized: "Insert and hold your key", bundle: .module) - static let useWiredButton = String(localized: "Use USB instead", bundle: .module) - static let useNFCButton = String(localized: "Tap over NFC instead", bundle: .module) - static let continueButton = String(localized: "Continue", bundle: .module) - static let labelFieldTitle = String(localized: "Label", bundle: .module) - - static let pinEyebrow = String(localized: "Step 2 of 3", bundle: .module) - static let pinTitle = String(localized: "Enter PIN", bundle: .module) - static let pinDeleteLabel = String(localized: "Delete", bundle: .module) - static let pinDeleteAccessibility = String(localized: "Delete digit", bundle: .module) - - static let touchEyebrow = String(localized: "Step 3 of 3", bundle: .module) - static let touchTitle = String(localized: "Touch your YubiKey", bundle: .module) - static let touchSubtitle = String( - localized: "Touch the gold disc to prove you are present and approve the address.", - bundle: .module - ) - static let waitingForTouch = String(localized: "Waiting for touch…", bundle: .module) - - static let readyTitle = String(localized: "YubiKey connected", bundle: .module) - static let readySubtitle = String(localized: "This signer is ready to approve proposals.", bundle: .module) - static let hardwareTag = String(localized: "HARDWARE", bundle: .module) - - static func pinAttemptsRemaining(_ count: Int) -> String { - String(localized: "\(count) attempt\(count == 1 ? "" : "s") remaining before the key locks.", bundle: .module) - } - - enum Recovery { - static let wrongPINTitle = String(localized: "Wrong PIN", bundle: .module) - static let wrongPINAction = String(localized: "Re-enter PIN", bundle: .module) - - static let pinLockedTitle = String(localized: "Key locked", bundle: .module) - static let pinLockedMessage = String( - localized: "Too many wrong PINs. Reset the PIN with your PUK using YubiKey Manager.", - bundle: .module - ) - static let pinLockedAction = String(localized: "Start over", bundle: .module) - - static let noKeyTitle = String(localized: "No key detected", bundle: .module) - static let noKeyMessage = String(localized: "NFC found no key, or the port is empty.", bundle: .module) - static let noKeyAction = String(localized: "Tap again", bundle: .module) - - static let nfcUnavailableTitle = String(localized: "NFC unavailable", bundle: .module) - static let nfcUnavailableMessage = String( - localized: "This device can't reach the key over NFC. Insert it over USB instead.", - bundle: .module - ) - static let nfcUnavailableAction = String(localized: "Use USB", bundle: .module) - - static let touchTimedOutTitle = String(localized: "Touch timed out", bundle: .module) - static let touchTimedOutMessage = String(localized: "No touch registered in time.", bundle: .module) - static let touchTimedOutAction = String(localized: "Retry", bundle: .module) - - static let lostConnectionTitle = String(localized: "Connection lost", bundle: .module) - static let lostConnectionMessage = String( - localized: "The key moved away or the session ended before it finished.", - bundle: .module - ) - static let lostConnectionAction = String(localized: "Reconnect", bundle: .module) - - static let notProvisionedTitle = String(localized: "No signing key on this YubiKey", bundle: .module) - static let notProvisionedMessage = - String( - localized: "Slot 9C has no Ed25519 key. Provision one with YubiKey Manager, then start over.", - bundle: .module - ) - static let notProvisionedAction = String(localized: "Start over", bundle: .module) - - static let mismatchTitle = String(localized: "Address mismatch", bundle: .module) - static let mismatchMessage = String( - localized: "The key returned a different address. Never approve a mismatch.", - bundle: .module - ) - static let mismatchAction = String(localized: "Start over", bundle: .module) - - static let alreadyAddedTitle = String(localized: "Already added", bundle: .module) - static let alreadyAddedMessage = String( - localized: "This YubiKey address is already a signer on this device.", - bundle: .module - ) - - static func wrongPINMessage(retriesRemaining: Int) -> String { - String( - localized: "\(retriesRemaining) attempt\(retriesRemaining == 1 ? "" : "s") remaining before the key locks itself.", - bundle: .module - ) - } - } -} diff --git a/Modules/UI/Sources/CreateTransferProposalView+Logic.swift b/Modules/UI/Sources/CreateTransferProposalView+Logic.swift index 1fa3e69..6407941 100644 --- a/Modules/UI/Sources/CreateTransferProposalView+Logic.swift +++ b/Modules/UI/Sources/CreateTransferProposalView+Logic.swift @@ -173,7 +173,6 @@ extension CreateTransferProposalView { } do { - yubiKeySigningOptions = YubiKeySigningOptions() signingRequest = try makeSigningRequest() errorMessage = nil } catch { @@ -200,7 +199,6 @@ extension CreateTransferProposalView { do { let submission = try await withResolvedProposalSigner( request.signer, - yubiKeyOptions: request.signer.type == .yubikey ? yubiKeySigningOptions : nil, deviceStatus: { deviceStatusMessage = $0 }, operation: { signer in try await squadsService.submitTransferProposal( @@ -293,7 +291,6 @@ extension CreateTransferProposalView { memo = "" errorMessage = nil deviceStatusMessage = nil - yubiKeySigningOptions = YubiKeySigningOptions() } var actionSigners: [ProposalActionSigner] { diff --git a/Modules/UI/Sources/CreateTransferProposalView.swift b/Modules/UI/Sources/CreateTransferProposalView.swift index 0355235..617b72c 100644 --- a/Modules/UI/Sources/CreateTransferProposalView.swift +++ b/Modules/UI/Sources/CreateTransferProposalView.swift @@ -29,7 +29,6 @@ public struct CreateTransferProposalView: View { @State var isSubmitting = false @State var errorMessage: String? @State var deviceStatusMessage: String? - @State var yubiKeySigningOptions = YubiKeySigningOptions() @State var signingRequest: ProposalCreationSigningRequest? @State var submittedResult: ProposalCreationResult? @State var proposalCreationCompletion: ProposalCreationCompletion? @@ -95,7 +94,6 @@ public struct CreateTransferProposalView: View { isSubmitting: isSubmitting, errorMessage: errorMessage, deviceStatusMessage: deviceStatusMessage, - yubiKeyOptions: $yubiKeySigningOptions, onCancel: { if !isSubmitting { signingRequest = nil diff --git a/Modules/UI/Sources/LedgerOnboardingComponents.swift b/Modules/UI/Sources/LedgerOnboardingComponents.swift deleted file mode 100644 index bdba174..0000000 --- a/Modules/UI/Sources/LedgerOnboardingComponents.swift +++ /dev/null @@ -1,203 +0,0 @@ -import Signers -import SwiftUI - -enum LedgerChecklistState { - case done - case active - case pending -} - -struct LedgerChecklistItem: Identifiable { - let id = UUID() - let title: String - let state: LedgerChecklistState -} - -struct LedgerChecklistCard: View { - let items: [LedgerChecklistItem] - - var body: some View { - CosignCard(padding: 4) { - VStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in - LedgerChecklistRow(item: item) - if index < items.count - 1 { - Divider().overlay(CosignTheme.line) - } - } - } - .padding(.horizontal, 12) - } - } -} - -private struct LedgerChecklistRow: View { - let item: LedgerChecklistItem - - var body: some View { - HStack(spacing: 12) { - indicator - Text(item.title) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(item.state == .pending ? CosignTheme.inkFaint : CosignTheme.ink) - Spacer(minLength: 0) - } - .padding(.vertical, 13) - } - - @ViewBuilder - private var indicator: some View { - switch item.state { - case .done: - CosignGlyphView(glyph: .check, size: 12, color: CosignTheme.mint) - .frame(width: 22, height: 22) - .background(CosignTheme.mintWash, in: .circle) - case .active: - ZStack { - Circle().stroke(CosignTheme.accent, lineWidth: 2) - Circle().fill(CosignTheme.accent).frame(width: 7, height: 7) - } - .frame(width: 22, height: 22) - case .pending: - Circle() - .stroke(CosignTheme.lineStrong, lineWidth: 2) - .frame(width: 22, height: 22) - } - } -} - -struct LedgerDeviceMark: View { - var size: CGFloat = 120 - var color: Color = CosignTheme.accent - - var body: some View { - Canvas { context, canvasSize in - let scale = min(canvasSize.width, canvasSize.height) / 120 - func rect(_ originX: CGFloat, _ originY: CGFloat, _ width: CGFloat, _ height: CGFloat) -> CGRect { - CGRect(x: originX * scale, y: originY * scale, width: width * scale, height: height * scale) - } - - let band = Path(roundedRect: rect(28, 34, 26, 52), cornerRadius: 9 * scale) - context.fill(band, with: .color(color.opacity(0.12))) - - let bodyPath = Path(roundedRect: rect(28, 34, 64, 52), cornerRadius: 9 * scale) - context.stroke(bodyPath, with: .color(color), lineWidth: 3 * scale) - - let button = Path(ellipseIn: rect(63, 51, 18, 18)) - context.stroke(button, with: .color(color), lineWidth: 3 * scale) - } - .frame(width: size, height: size) - } -} - -struct LedgerRadarView: View { - @State private var pulsing = false - - var body: some View { - ZStack { - ForEach(Array([140.0, 96.0, 52.0].enumerated()), id: \.offset) { index, diameter in - Circle() - .stroke(CosignTheme.accent.opacity(0.30 - Double(index) * 0.08), lineWidth: 1) - .frame(width: diameter, height: diameter) - } - Circle() - .stroke(CosignTheme.accent.opacity(0.45), lineWidth: 1) - .frame(width: 140, height: 140) - .scaleEffect(pulsing ? 1.0 : 0.4) - .opacity(pulsing ? 0 : 0.7) - LedgerDeviceMark(size: 110) - } - .frame(width: 140, height: 140) - .onAppear { - withAnimation(.easeOut(duration: 1.8).repeatForever(autoreverses: false)) { - pulsing = true - } - } - } -} - -struct LedgerFoundDeviceRow: View { - let device: LedgerBLEDevice - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - LedgerDeviceMark(size: 22) - .frame(width: 38, height: 38) - .background(CosignTheme.accentWash, in: .rect(cornerRadius: 11)) - - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(CosignTheme.FontStyle.titleM) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.Ledger.deviceSubtitle(rssi: device.rssi)) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkFaint) - } - - Spacer(minLength: 8) - - if isSelected { - CosignGlyphView(glyph: .check, size: 16, color: CosignTheme.accentDeep) - } - Circle() - .fill(CosignTheme.mint) - .frame(width: 8, height: 8) - } - .padding(.horizontal, 16) - .frame(minHeight: 64) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - isSelected ? CosignTheme.accentWash : CosignTheme.surface, - in: .rect(cornerRadius: CosignTheme.Radius.card) - ) - .overlay { - RoundedRectangle(cornerRadius: CosignTheme.Radius.card) - .stroke(isSelected ? CosignTheme.accent.opacity(0.40) : CosignTheme.line, lineWidth: 1) - } - } - .buttonStyle(.plain) - } -} - -struct LedgerSignerSummaryCard: View { - let deviceName: String - let address: String - - var body: some View { - CosignCard { - HStack(spacing: 14) { - RoundedRectangle(cornerRadius: 13) - .fill( - LinearGradient( - colors: [CosignTheme.accent, Color(hex: 0x241808)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 44, height: 44) - - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text(deviceName) - .font(CosignTheme.FontStyle.titleM) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.Ledger.hardwareTag) - .font(CosignTheme.FontStyle.eyebrow) - .foregroundStyle(CosignTheme.mint) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(CosignTheme.mintWash, in: .rect(cornerRadius: 4)) - } - Text(cosignShortAddress(address)) - .font(CosignTheme.FontStyle.mono) - .foregroundStyle(CosignTheme.inkDim) - } - - Spacer(minLength: 0) - } - } - } -} diff --git a/Modules/UI/Sources/LedgerOnboardingRecovery.swift b/Modules/UI/Sources/LedgerOnboardingRecovery.swift deleted file mode 100644 index 1a99d3b..0000000 --- a/Modules/UI/Sources/LedgerOnboardingRecovery.swift +++ /dev/null @@ -1,166 +0,0 @@ -import Signers -import SwiftUI - -enum LedgerRecoveryAction { - case openSettings - case rescan - case reconnect - case dismiss -} - -struct LedgerRecovery: Equatable { - enum Kind { - case bluetoothOff - case permissionDenied - case deviceLocked - case solanaAppNotOpen - case lostConnection - case timedOut - case noDevices - case addressMismatch - case alreadyAdded - } - - let kind: Kind - let action: LedgerRecoveryAction - - static let noDevices = LedgerRecovery(kind: .noDevices, action: .rescan) - static let alreadyAdded = LedgerRecovery(kind: .alreadyAdded, action: .dismiss) - - init(kind: Kind, action: LedgerRecoveryAction) { - self.kind = kind - self.action = action - } - - init(failure: LedgerOnboardingFailure, hasSelectedDevice: Bool) { - let reconnectOrRescan: LedgerRecoveryAction = hasSelectedDevice ? .reconnect : .rescan - switch failure { - case .bluetoothOff, .bluetoothUnsupported: - self.init(kind: .bluetoothOff, action: .openSettings) - case .bluetoothPermissionDenied: - self.init(kind: .permissionDenied, action: .openSettings) - case .deviceLocked: - self.init(kind: .deviceLocked, action: .rescan) - case .solanaAppNotOpen: - self.init(kind: .solanaAppNotOpen, action: reconnectOrRescan) - case .lostConnection: - self.init(kind: .lostConnection, action: reconnectOrRescan) - case .timedOut, .userRejected, .other: - self.init(kind: .timedOut, action: reconnectOrRescan) - case .addressMismatch: - self.init(kind: .addressMismatch, action: .rescan) - } - } -} - -extension LedgerRecovery { - typealias Copy = CosignCopy.Ledger.Recovery - - var title: String { - switch kind { - case .bluetoothOff: Copy.bluetoothOffTitle - case .permissionDenied: Copy.permissionDeniedTitle - case .deviceLocked: Copy.deviceLockedTitle - case .solanaAppNotOpen: Copy.solanaAppTitle - case .lostConnection: Copy.lostConnectionTitle - case .timedOut: Copy.timedOutTitle - case .noDevices: Copy.noDevicesTitle - case .addressMismatch: Copy.mismatchTitle - case .alreadyAdded: Copy.alreadyAddedTitle - } - } - - var message: String { - switch kind { - case .bluetoothOff: Copy.bluetoothOffMessage - case .permissionDenied: Copy.permissionDeniedMessage - case .deviceLocked: Copy.deviceLockedMessage - case .solanaAppNotOpen: Copy.solanaAppMessage - case .lostConnection: Copy.lostConnectionMessage - case .timedOut: Copy.timedOutMessage - case .noDevices: Copy.noDevicesMessage - case .addressMismatch: Copy.mismatchMessage - case .alreadyAdded: Copy.alreadyAddedMessage - } - } - - var actionTitle: String { - switch kind { - case .bluetoothOff: Copy.bluetoothOffAction - case .permissionDenied: Copy.permissionDeniedAction - case .deviceLocked: Copy.deviceLockedAction - case .solanaAppNotOpen: Copy.solanaAppAction - case .lostConnection: Copy.lostConnectionAction - case .timedOut: Copy.timedOutAction - case .noDevices: Copy.noDevicesAction - case .addressMismatch: Copy.mismatchAction - case .alreadyAdded: CosignCopy.Ledger.doneButtonTitle - } - } - - var isDanger: Bool { - switch kind { - case .permissionDenied, .addressMismatch: true - default: false - } - } - - var accent: Color { - isDanger ? CosignTheme.riskRed : CosignTheme.riskAmber - } - - var glyph: CosignGlyph { - switch kind { - case .bluetoothOff, .lostConnection: .wave - case .permissionDenied: .xmark - case .deviceLocked: .lock - case .solanaAppNotOpen, .addressMismatch: .warning - case .timedOut: .clock - case .noDevices: .search - case .alreadyAdded: .check - } - } -} - -struct LedgerRecoveryCard: View { - let recovery: LedgerRecovery - let action: () -> Void - - var body: some View { - CosignCard { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top, spacing: 12) { - CosignGlyphView(glyph: recovery.glyph, size: 18, color: recovery.accent) - .frame(width: 38, height: 38) - .background(recovery.accent.opacity(0.12), in: .rect(cornerRadius: CosignTheme.Radius.medium)) - - VStack(alignment: .leading, spacing: 4) { - Text(recovery.title) - .font(CosignTheme.FontStyle.titleM) - .foregroundStyle(CosignTheme.ink) - Text(recovery.message) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - Button(action: action) { - Text(recovery.actionTitle) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(recovery.accent) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CosignTheme.surface2, in: .rect(cornerRadius: CosignTheme.Radius.control)) - .overlay { - RoundedRectangle(cornerRadius: CosignTheme.Radius.control) - .stroke(CosignTheme.line, lineWidth: 1) - } - } - .buttonStyle(.plain) - .accessibilityIdentifier("ledger-recovery-action") - } - } - } -} diff --git a/Modules/UI/Sources/ProposalActionSheets.swift b/Modules/UI/Sources/ProposalActionSheets.swift index 113b5de..4961a4f 100644 --- a/Modules/UI/Sources/ProposalActionSheets.swift +++ b/Modules/UI/Sources/ProposalActionSheets.swift @@ -8,7 +8,6 @@ struct ProposalSigningSheet: View { let isSubmitting: Bool let errorMessage: String? let deviceStatusMessage: String? - @Binding var yubiKeyOptions: YubiKeySigningOptions let onCancel: () -> Void let onConfirm: () -> Void @Environment(\.squadsService) private var squadsService @@ -55,13 +54,6 @@ struct ProposalSigningSheet: View { } } - if request.signer.type == .yubikey { - YubiKeySigningControls( - options: $yubiKeyOptions, - isDisabled: isSubmitting - ) - } - if let deviceStatusMessage { VStack(alignment: .leading, spacing: 10) { CosignSectionTitle(title: deviceStatusTitle) @@ -112,9 +104,7 @@ struct ProposalSigningSheet: View { } private var canConfirm: Bool { - !isSubmitting && - (request.signer.type != .yubikey || yubiKeyOptions.hasValidPINLength) && - (!requiresTypedConfirmation || confirmationText == confirmationPhrase) + !isSubmitting && (!requiresTypedConfirmation || confirmationText == confirmationPhrase) } private var requiresTypedConfirmation: Bool { @@ -317,58 +307,29 @@ private extension ProposalSigningSheet { ) } - @ViewBuilder var hardwareContext: some View { - switch request.signer.type { - case .hotWallet: - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.ProposalSigning.deviceCheckTitle) - CosignCard { - HStack(spacing: 12) { - CosignGlyphView(glyph: .faceID, size: 22, color: CosignTheme.accentDeep) - .frame(width: 38, height: 38) - .background(CosignTheme.accentWash, in: .rect(cornerRadius: CosignTheme.Radius.medium)) - VStack(alignment: .leading, spacing: 3) { - Text(CosignCopy.ProposalSigning.localHotWalletTitle) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.ProposalSigning.deviceContext(for: request.signer.type)) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - } + VStack(alignment: .leading, spacing: 10) { + CosignSectionTitle(title: CosignCopy.ProposalSigning.deviceCheckTitle) + CosignCard { + HStack(spacing: 12) { + CosignGlyphView(glyph: .faceID, size: 22, color: CosignTheme.accentDeep) + .frame(width: 38, height: 38) + .background(CosignTheme.accentWash, in: .rect(cornerRadius: CosignTheme.Radius.medium)) + VStack(alignment: .leading, spacing: 3) { + Text(CosignCopy.ProposalSigning.localHotWalletTitle) + .font(CosignTheme.FontStyle.body) + .foregroundStyle(CosignTheme.ink) + Text(CosignCopy.ProposalSigning.deviceContext) + .font(CosignTheme.FontStyle.caption) + .foregroundStyle(CosignTheme.inkDim) } } } - case .ledger: - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.ProposalSigning.ledgerTitle) - CosignCard { - Text(CosignCopy.ProposalSigning.deviceContext(for: request.signer.type)) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - } - } - case .yubikey: - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.ProposalSigning.yubiKeyTitle) - CosignCard { - Text(CosignCopy.ProposalSigning.deviceContext(for: request.signer.type)) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - } - } } } var deviceStatusTitle: String { - switch request.signer.type { - case .hotWallet: - CosignCopy.ProposalSigning.signerLabel - case .ledger: - CosignCopy.ProposalSigning.ledgerTitle - case .yubikey: - CosignCopy.ProposalSigning.yubiKeyTitle - } + CosignCopy.ProposalSigning.signerLabel } } diff --git a/Modules/UI/Sources/ProposalActionsView.swift b/Modules/UI/Sources/ProposalActionsView.swift index 543da02..4560fe5 100644 --- a/Modules/UI/Sources/ProposalActionsView.swift +++ b/Modules/UI/Sources/ProposalActionsView.swift @@ -17,8 +17,6 @@ struct ProposalActionSigner: Identifiable { enum ProposalActionSignerStorage: Equatable { case hotWallet(keychainAccount: String) - case ledger - case yubikey } struct ProposalSigningRequest: Identifiable { diff --git a/Modules/UI/Sources/ProposalCreationSheets.swift b/Modules/UI/Sources/ProposalCreationSheets.swift index 63d68a8..c91b281 100644 --- a/Modules/UI/Sources/ProposalCreationSheets.swift +++ b/Modules/UI/Sources/ProposalCreationSheets.swift @@ -42,7 +42,6 @@ struct ProposalCreationReviewSheet: View { let isSubmitting: Bool let errorMessage: String? let deviceStatusMessage: String? - @Binding var yubiKeyOptions: YubiKeySigningOptions let onCancel: () -> Void let onConfirm: () -> Void @State private var footerHeight = CosignLayout.estimatedSheetStickyFooterHeight @@ -177,13 +176,6 @@ struct ProposalCreationReviewSheet: View { } } - if request.signer.type == .yubikey { - YubiKeySigningControls( - options: $yubiKeyOptions, - isDisabled: isSubmitting - ) - } - if let deviceStatusMessage { VStack(alignment: .leading, spacing: 10) { CosignSectionTitle(title: deviceStatusTitle) @@ -214,79 +206,36 @@ struct ProposalCreationReviewSheet: View { @ViewBuilder private var signControl: some View { - if request.signer.type == .hotWallet { - CosignHoldActionButton( - title: CosignCopy.ProposalCreation.holdToSign, - glyph: .lock, - kind: .accent, - isLoading: isSubmitting, - isDisabled: !canConfirm, - onCommit: onConfirm - ) - .accessibilityIdentifier("proposal-creation-hold-button") - Text(CosignCopy.ProposalCreation.holdHelpText) - .font(CosignTheme.FontStyle.monoSmall) - .foregroundStyle(CosignTheme.inkFaint) - .frame(maxWidth: .infinity, alignment: .center) - .lineLimit(1) - .minimumScaleFactor(0.78) - } else { - Button { - onConfirm() - } label: { - HStack { - if isSubmitting { - ProgressView() - .tint(CosignTheme.accentInk) - } - Text(signButtonTitle) - } - } - .buttonStyle(CosignButtonStyle(kind: .accent)) - .disabled(!canConfirm) - .accessibilityIdentifier("proposal-creation-sign-button") - } + CosignHoldActionButton( + title: CosignCopy.ProposalCreation.holdToSign, + glyph: .lock, + kind: .accent, + isLoading: isSubmitting, + isDisabled: !canConfirm, + onCommit: onConfirm + ) + .accessibilityIdentifier("proposal-creation-hold-button") + Text(CosignCopy.ProposalCreation.holdHelpText) + .font(CosignTheme.FontStyle.monoSmall) + .foregroundStyle(CosignTheme.inkFaint) + .frame(maxWidth: .infinity, alignment: .center) + .lineLimit(1) + .minimumScaleFactor(0.78) } private var canConfirm: Bool { - !isSubmitting && (request.signer.type != .yubikey || yubiKeyOptions.hasValidPINLength) + !isSubmitting } private var destinationTokenAccountCopyLabel: String { CosignCopy.ProposalCreation.copyDestinationTokenAccountAccessibilityLabel() } - @ViewBuilder private var hardwareContext: some View { - switch request.signer.type { - case .hotWallet: - EmptyView() - case .ledger: - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.ProposalCreation.hardwareTitle(for: request.signer.type)) - CosignCard { - Text(CosignCopy.ProposalCreation.hardwareContext(for: request.signer.type)) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - } - } - case .yubikey: - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.ProposalCreation.hardwareTitle(for: request.signer.type)) - CosignCard { - Text(CosignCopy.ProposalCreation.hardwareContext(for: request.signer.type)) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - } - } - } + EmptyView() } private var deviceStatusTitle: String { - CosignCopy.ProposalCreation.hardwareTitle(for: request.signer.type) - } - - private var signButtonTitle: String { - CosignCopy.ProposalCreation.signButtonTitle(for: request.signer.type) + CosignCopy.ProposalSigning.signerLabel } } diff --git a/Modules/UI/Sources/ProposalDetailView+BroadcastAction.swift b/Modules/UI/Sources/ProposalDetailView+BroadcastAction.swift index 9fd722e..05bc914 100644 --- a/Modules/UI/Sources/ProposalDetailView+BroadcastAction.swift +++ b/Modules/UI/Sources/ProposalDetailView+BroadcastAction.swift @@ -25,7 +25,6 @@ extension ProposalDetailView { do { let broadcaster = try await withResolvedProposalSigner( request.signer, - yubiKeyOptions: request.signer.type == .yubikey ? actionYubiKeyOptions : nil, deviceStatus: { actionDeviceStatusMessage = $0 }, operation: { signer in try await squadsService.makeBroadcaster( diff --git a/Modules/UI/Sources/ProposalDetailView.swift b/Modules/UI/Sources/ProposalDetailView.swift index 309b669..720726e 100644 --- a/Modules/UI/Sources/ProposalDetailView.swift +++ b/Modules/UI/Sources/ProposalDetailView.swift @@ -30,7 +30,6 @@ public struct ProposalDetailView: View { @State var actionBroadcaster: ProposalActionBroadcaster? @State var broadcastFailure: BroadcastFailure? @State var pendingBroadcastRequest: ProposalSigningRequest? - @State var actionYubiKeyOptions = YubiKeySigningOptions() @State var submittedResult: ProposalSubmissionResult? @State var pendingExecuteSigner: ProposalActionSigner? @State var executionSignature: String? @@ -99,7 +98,6 @@ public struct ProposalDetailView: View { isSubmitting: isSubmittingAction, errorMessage: actionErrorMessage, deviceStatusMessage: actionDeviceStatusMessage, - yubiKeyOptions: $actionYubiKeyOptions, onCancel: { if !isSubmittingAction { signingRequest = nil @@ -365,7 +363,6 @@ extension ProposalDetailView { func beginSigning(action: SquadProposalAction, signer: ProposalActionSigner) { actionErrorMessage = nil actionDeviceStatusMessage = nil - actionYubiKeyOptions = YubiKeySigningOptions() signingRequest = ProposalSigningRequest( action: action, signer: signer, diff --git a/Modules/UI/Sources/ProposalSignerHelpers.swift b/Modules/UI/Sources/ProposalSignerHelpers.swift index a487865..0eae1f3 100644 --- a/Modules/UI/Sources/ProposalSignerHelpers.swift +++ b/Modules/UI/Sources/ProposalSignerHelpers.swift @@ -5,17 +5,8 @@ import Persistence import Signers func makeProposalActionSigner(from signer: RegisteredSigner) -> ProposalActionSigner? { - let storage: ProposalActionSignerStorage - switch signer.type { - case .hotWallet: - guard let account = signer.keychainItemRef else { - return nil - } - storage = .hotWallet(keychainAccount: account) - case .ledger: - storage = .ledger - case .yubikey: - storage = .yubikey + guard let account = signer.keychainItemRef else { + return nil } return ProposalActionSigner( @@ -24,7 +15,7 @@ func makeProposalActionSigner(from signer: RegisteredSigner) -> ProposalActionSi type: signer.type, pubkey: signer.pubkey, address: CosignCore.base58(signer.pubkey), - storage: storage, + storage: .hotWallet(keychainAccount: account), backedUp: signer.backedUp ) } @@ -32,125 +23,29 @@ func makeProposalActionSigner(from signer: RegisteredSigner) -> ProposalActionSi @MainActor func withResolvedProposalSigner( _ signer: ProposalActionSigner, - yubiKeyOptions: YubiKeySigningOptions? = nil, deviceStatus: @escaping @MainActor (String?) -> Void, operation: (any Signer) async throws -> T ) async throws -> T { deviceStatus(nil) - switch signer.storage { - case let .hotWallet(keychainAccount): - guard signer.backedUp else { - throw ProposalSignerResolutionError.notBackedUp - } - return try await operation(HotWalletSigner( - label: signer.label, - pubkey: signer.pubkey, - keychainAccount: keychainAccount - )) - case .ledger: - return try await withResolvedLedgerSigner( - signer, - deviceStatus: deviceStatus, - operation: operation - ) - case .yubikey: - return try await withResolvedYubiKeySigner( - signer, - options: yubiKeyOptions, - deviceStatus: deviceStatus, - operation: operation - ) + guard case let .hotWallet(keychainAccount) = signer.storage else { + throw ProposalSignerResolutionError.notBackedUp } -} - -@MainActor -private func withResolvedLedgerSigner( - _ signer: ProposalActionSigner, - deviceStatus: @escaping @MainActor (String?) -> Void, - operation: (any Signer) async throws -> T -) async throws -> T { - let transport = CoreBluetoothLedgerTransport() - defer { - transport.disconnect() - deviceStatus(nil) - } - - deviceStatus(CosignCopy.ProposalSigning.scanningLedgerStatus) - let devices = try await transport.scan(timeout: 8) - guard let device = devices.first else { - throw ProposalSignerResolutionError.noLedgerDevices + guard signer.backedUp else { + throw ProposalSignerResolutionError.notBackedUp } - - deviceStatus(CosignCopy.ProposalSigning.connectingLedgerStatus(deviceName: device.name)) - try await transport.connect(to: device) - - deviceStatus(CosignCopy.ProposalSigning.verifyingLedgerAddressStatus) - try await LedgerSignerPreflight.verifySolanaAppAndAddress( - expectedPubkey: signer.pubkey, - transport: transport - ) - - deviceStatus(CosignCopy.ProposalSigning.confirmLedgerStatus) - return try await operation(LedgerSigner( + return try await operation(HotWalletSigner( label: signer.label, pubkey: signer.pubkey, - transport: transport + keychainAccount: keychainAccount )) } -@MainActor -private func withResolvedYubiKeySigner( - _ signer: ProposalActionSigner, - options: YubiKeySigningOptions?, - deviceStatus: @escaping @MainActor (String?) -> Void, - operation: (any Signer) async throws -> T -) async throws -> T { - guard let options else { - throw ProposalSignerResolutionError.missingYubiKeyOptions - } - - let pin = options.trimmedPIN - guard options.hasValidPINLength else { - throw YubiKeySignerError.invalidPINLength - } - - deviceStatus(CosignCopy.YubiKeySigning.connectingStatus(transport: options.transport.statusLabel)) - let transport = try await YubiKitYubiKeyAPDUTransport.open( - options.transport.connectionPreference( - alertMessage: CosignCopy.YubiKeySigning.tapPrompt - ) - ) - - do { - deviceStatus(CosignCopy.YubiKeySigning.signStatus) - let result = try await operation(YubiKeySigner( - label: signer.label, - pubkey: signer.pubkey, - transport: transport, - pinProvider: { pin } - )) - await transport.close() - deviceStatus(nil) - return result - } catch { - await transport.close(error: error) - deviceStatus(nil) - throw error - } -} - enum ProposalSignerResolutionError: LocalizedError { - case noLedgerDevices - case missingYubiKeyOptions case notBackedUp var errorDescription: String? { switch self { - case .noLedgerDevices: - CosignCopy.ProposalSigning.noLedgerDevicesError - case .missingYubiKeyOptions: - CosignCopy.ProposalSigning.missingYubiKeyOptionsError case .notBackedUp: CosignCopy.ProposalSigning.notBackedUpError } diff --git a/Modules/UI/Sources/SignerDetailView+Diagnostics.swift b/Modules/UI/Sources/SignerDetailView+Diagnostics.swift index 72420af..db9221b 100644 --- a/Modules/UI/Sources/SignerDetailView+Diagnostics.swift +++ b/Modules/UI/Sources/SignerDetailView+Diagnostics.swift @@ -6,20 +6,6 @@ import Signers import SwiftUI extension SignerDetailView { - var yubiKeySection: some View { - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.SignerDiagnostics.yubiKeySectionTitle) - CosignCard { - YubiKeyTransportSelector(selection: $yubiKeyTransport, isDisabled: isTesting) - - Text(CosignCopy.SignerDiagnostics.yubiKeyMessage) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - .padding(.top, 8) - } - } - } - func diagnosticSection(_ signer: RegisteredSigner) -> some View { VStack(alignment: .leading, spacing: 10) { CosignSectionTitle(title: CosignCopy.SignerDiagnostics.diagnosticsSectionTitle) @@ -30,7 +16,7 @@ extension SignerDetailView { } } label: { HStack { - Text(diagnosticButtonTitle(for: signer.type)) + Text(CosignCopy.SignerDiagnostics.buttonTitle(for: signer.type)) Spacer() if isTesting { ProgressView() @@ -94,7 +80,7 @@ extension SignerDetailView { } do { - diagnosticResult = try await runDiagnostic(profile) + diagnosticResult = try await testHotWallet(profile) } catch { diagnosticResult = SignerDiagnosticResult( title: CosignCopy.SignerDiagnostics.testFailedTitle, @@ -104,18 +90,6 @@ extension SignerDetailView { } } - @MainActor - func runDiagnostic(_ profile: SignerDiagnosticProfile) async throws -> SignerDiagnosticResult { - switch profile.type { - case .hotWallet: - try await testHotWallet(profile) - case .ledger: - try await testLedger(profile) - case .yubikey: - try await testYubiKey(profile) - } - } - @MainActor func testHotWallet(_ profile: SignerDiagnosticProfile) async throws -> SignerDiagnosticResult { guard let keychainAccount = profile.keychainAccount else { @@ -140,93 +114,12 @@ extension SignerDetailView { isSuccess: true ) } - - @MainActor - func testLedger(_ profile: SignerDiagnosticProfile) async throws -> SignerDiagnosticResult { - let transport = CoreBluetoothLedgerTransport() - defer { - transport.disconnect() - } - - diagnosticStatusMessage = CosignCopy.Ledger.scanningStatus() - let devices = try await transport.scan(timeout: 8) - guard let device = devices.first else { - throw SignerDiagnosticError.noLedgerDevices - } - - diagnosticStatusMessage = CosignCopy.Ledger.connectingStatus(deviceName: device.name) - try await transport.connect(to: device) - - diagnosticStatusMessage = CosignCopy.SignerDiagnostics.verifyingAddressStatus - let configuration = try await LedgerSignerPreflight.verifySolanaAppAndAddress( - expectedPubkey: profile.pubkey, - transport: transport - ) - let blindSigning = configuration.blindSigningEnabled - ? CosignCopy.SignerDiagnostics.blindSigningEnabled - : CosignCopy.SignerDiagnostics.blindSigningDisabled - - return SignerDiagnosticResult( - title: CosignCopy.SignerDiagnostics.ledgerReadyTitle, - message: CosignCopy.SignerDiagnostics.ledgerReadyMessage( - deviceName: device.name, - version: configuration.version, - blindSigning: blindSigning - ), - isSuccess: true - ) - } - - @MainActor - func testYubiKey(_ profile: SignerDiagnosticProfile) async throws -> SignerDiagnosticResult { - diagnosticStatusMessage = CosignCopy.YubiKey.connectingStatus(transport: yubiKeyTransport.statusLabel) - let publicKey = try await YubiKeyPIVRegistration.readEd25519PublicKey( - preference: yubiKeyTransport.connectionPreference( - alertMessage: CosignCopy.SignerDiagnostics.yubiKeyTestPrompt - ) - ) - guard publicKey.pubkey == profile.pubkey else { - throw SignerDiagnosticError.addressMismatch( - expected: profile.address, - actual: CosignCore.base58(publicKey.pubkey) - ) - } - - var details = [ - CosignCopy.SignerDiagnostics.sourceDetail(displayLabel(publicKey.source.rawValue)) - ] - if let generatedOnYubiKey = publicKey.generatedOnYubiKey { - details.append(CosignCopy.SignerDiagnostics.keyOriginDetail(generatedOnYubiKey: generatedOnYubiKey)) - } - - return SignerDiagnosticResult( - title: CosignCopy.SignerDiagnostics.yubiKeyReadyTitle, - message: CosignCopy.SignerDiagnostics.yubiKeyReadyMessage(details: details.joined(separator: " ")), - isSuccess: true - ) - } -} - -func diagnosticButtonTitle(for type: SignerType) -> String { - switch type { - case .hotWallet: - CosignCopy.SignerDiagnostics.buttonTitle(for: .hotWallet) - case .ledger: - CosignCopy.SignerDiagnostics.buttonTitle(for: .ledger) - case .yubikey: - CosignCopy.SignerDiagnostics.buttonTitle(for: .yubikey) - } } func diagnosticMessage(for error: any Error) -> String { - if error is YubiKeyPIVRegistrationError || error is YubiKeySignerError { - return yubiKeySetupNotice(for: error).message - } - if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription { return description } - return String(describing: error) } @@ -247,8 +140,6 @@ struct SignerDiagnosticResult { enum SignerDiagnosticError: LocalizedError { case missingHotWalletKeychainReference case invalidHotWalletSignature - case noLedgerDevices - case addressMismatch(expected: String, actual: String) var errorDescription: String? { switch self { @@ -256,10 +147,6 @@ enum SignerDiagnosticError: LocalizedError { CosignCopy.SignerDiagnostics.missingHotWalletKeychainReference case .invalidHotWalletSignature: CosignCopy.SignerDiagnostics.invalidHotWalletSignature - case .noLedgerDevices: - CosignCopy.SignerDiagnostics.noLedgerDevices - case let .addressMismatch(expected, actual): - CosignCopy.SignerDiagnostics.addressMismatch(expected: expected, actual: actual) } } } diff --git a/Modules/UI/Sources/SignerDetailView.swift b/Modules/UI/Sources/SignerDetailView.swift index edcf4f8..2145648 100644 --- a/Modules/UI/Sources/SignerDetailView.swift +++ b/Modules/UI/Sources/SignerDetailView.swift @@ -17,7 +17,6 @@ public struct SignerDetailView: View { private let signerID: UUID - @State var yubiKeyTransport = YubiKeyTransportChoice.wired @State var isTesting = false @State var diagnosticStatusMessage: String? @State var diagnosticResult: SignerDiagnosticResult? @@ -100,10 +99,6 @@ public struct SignerDetailView: View { squadsSection } - if signer.type == .yubikey { - yubiKeySection - } - diagnosticSection(signer) removeSection } diff --git a/Modules/UI/Sources/SignerHomeView.swift b/Modules/UI/Sources/SignerHomeView.swift index 4580ca7..1e19757 100644 --- a/Modules/UI/Sources/SignerHomeView.swift +++ b/Modules/UI/Sources/SignerHomeView.swift @@ -342,18 +342,6 @@ private func signerAvatarGradient(for type: SignerType) -> LinearGradient { startPoint: .topLeading, endPoint: .bottomTrailing ) - case .ledger: - LinearGradient( - colors: [Color(hex: 0x8AA8FF), Color(hex: 0x0B142B)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - case .yubikey: - LinearGradient( - colors: [Color(hex: 0x7CF2B0), Color(hex: 0x0F2018)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) } } @@ -361,10 +349,6 @@ private func statusHint(for type: SignerType) -> String { switch type { case .hotWallet: CosignCopy.Signers.statusHint(for: .hotWallet) - case .ledger: - CosignCopy.Signers.statusHint(for: .ledger) - case .yubikey: - CosignCopy.Signers.statusHint(for: .yubikey) } } @@ -372,9 +356,5 @@ private func keyKind(for type: SignerType) -> String { switch type { case .hotWallet: CosignCopy.Signers.keyKind(for: .hotWallet) - case .ledger: - CosignCopy.Signers.keyKind(for: .ledger) - case .yubikey: - CosignCopy.Signers.keyKind(for: .yubikey) } } diff --git a/Modules/UI/Sources/SignerListCard.swift b/Modules/UI/Sources/SignerListCard.swift index 24fdffe..a80bfc0 100644 --- a/Modules/UI/Sources/SignerListCard.swift +++ b/Modules/UI/Sources/SignerListCard.swift @@ -163,18 +163,6 @@ struct SignerListCard: View { startPoint: .topLeading, endPoint: .bottomTrailing ) - case .ledger: - return LinearGradient( - colors: [Color(hex: 0x8AA8FF), Color(hex: 0x0B142B)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - case .yubikey: - return LinearGradient( - colors: [Color(hex: 0x7CF2B0), Color(hex: 0x0F2018)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) } } } diff --git a/Modules/UI/Sources/SignerTypeDisplay.swift b/Modules/UI/Sources/SignerTypeDisplay.swift index 5982f52..2ccbeda 100644 --- a/Modules/UI/Sources/SignerTypeDisplay.swift +++ b/Modules/UI/Sources/SignerTypeDisplay.swift @@ -5,10 +5,6 @@ extension SignerType { switch self { case .hotWallet: "Hot wallet" - case .ledger: - "Ledger" - case .yubikey: - "YubiKey" } } } diff --git a/Modules/UI/Sources/SignersListComponents.swift b/Modules/UI/Sources/SignersListComponents.swift index 53c620b..c297c7f 100644 --- a/Modules/UI/Sources/SignersListComponents.swift +++ b/Modules/UI/Sources/SignersListComponents.swift @@ -50,8 +50,6 @@ struct PendingApprovalsBanner: View { enum AddSignerSheet: Hashable, Identifiable { case hotWallet - case ledger - case yubikey var id: Self { self @@ -69,58 +67,93 @@ enum AddSignerSheet: Hashable, Identifiable { switch self { case .hotWallet: .key - case .ledger: - .shield - case .yubikey: - .wave } } } +private struct ComingSoonSignerRow: View { + let title: String + let subtitle: String + let glyph: CosignGlyph + + var body: some View { + HStack(spacing: 0) { + CosignObjectRow( + title: title, + subtitle: subtitle, + style: .plain, + leading: { + CosignGlyphView(glyph: glyph, size: 18, color: CosignTheme.inkDim) + .frame(width: 36, height: 36) + .background( + CosignTheme.surface3, + in: .rect(cornerRadius: CosignTheme.Radius.medium) + ) + } + ) + Text(CosignCopy.Signers.comingSoonTag) + .font(CosignTheme.FontStyle.caption) + .foregroundStyle(CosignTheme.inkFaint) + .padding(.trailing, 14) + } + .opacity(0.6) + } +} + struct AddSignerChooserSheet: View { @Environment(\.dismiss) private var dismiss let onSelect: (AddSignerSheet) -> Void - private let options: [AddSignerSheet] = [.hotWallet, .ledger, .yubikey] - var body: some View { CosignScreen { header CosignCard(padding: 0) { VStack(spacing: 0) { - ForEach(Array(options.enumerated()), id: \.element.id) { index, option in - Button { - onSelect(option) - } label: { - CosignObjectRow( - title: option.title, - subtitle: option.subtitle, - style: .plain, - leading: { - CosignGlyphView(glyph: option.glyph, size: 18, color: CosignTheme.accentDeep) - .frame(width: 36, height: 36) - .background( - CosignTheme.accentWash, - in: .rect(cornerRadius: CosignTheme.Radius.medium) - ) - } - ) - } - .buttonStyle(.plain) - .accessibilityIdentifier("signer-option-\(option)") - - if index < options.count - 1 { - Divider() - .overlay(CosignTheme.line) - .padding(.leading, 14) - } + Button { + onSelect(.hotWallet) + } label: { + CosignObjectRow( + title: AddSignerSheet.hotWallet.title, + subtitle: AddSignerSheet.hotWallet.subtitle, + style: .plain, + leading: { + CosignGlyphView(glyph: .key, size: 18, color: CosignTheme.accentDeep) + .frame(width: 36, height: 36) + .background( + CosignTheme.accentWash, + in: .rect(cornerRadius: CosignTheme.Radius.medium) + ) + } + ) } + .buttonStyle(.plain) + .accessibilityIdentifier("signer-option-hotWallet") + + Divider() + .overlay(CosignTheme.line) + .padding(.leading, 14) + + ComingSoonSignerRow( + title: CosignCopy.Signers.ledgerComingSoonTitle, + subtitle: CosignCopy.Signers.ledgerComingSoonSubtitle, + glyph: .shield + ) + + Divider() + .overlay(CosignTheme.line) + .padding(.leading, 14) + + ComingSoonSignerRow( + title: CosignCopy.Signers.yubiKeyComingSoonTitle, + subtitle: CosignCopy.Signers.yubiKeyComingSoonSubtitle, + glyph: .wave + ) } } } .cosignScreenIdentifier("screen.add-signer-chooser") - .presentationDetents([.height(330), .medium]) + .presentationDetents([.height(390), .medium]) .presentationDragIndicator(.hidden) } diff --git a/Modules/UI/Sources/SignersListView.swift b/Modules/UI/Sources/SignersListView.swift index 4ee9063..0c23d76 100644 --- a/Modules/UI/Sources/SignersListView.swift +++ b/Modules/UI/Sources/SignersListView.swift @@ -145,10 +145,6 @@ public struct SignersListView: View { switch sheet { case .hotWallet: AddHotWalletView() - case .ledger: - AddLedgerView() - case .yubikey: - AddYubiKeyView() } } .sheet( diff --git a/Modules/UI/Sources/YubiKeyOnboardingComponents.swift b/Modules/UI/Sources/YubiKeyOnboardingComponents.swift deleted file mode 100644 index 233869e..0000000 --- a/Modules/UI/Sources/YubiKeyOnboardingComponents.swift +++ /dev/null @@ -1,196 +0,0 @@ -import SwiftUI - -struct YubiKeyMark: View { - var size: CGFloat = 120 - var color: Color = CosignTheme.accent - - var body: some View { - Canvas { context, canvasSize in - let scale = min(canvasSize.width, canvasSize.height) / 120 - func rect(_ originX: CGFloat, _ originY: CGFloat, _ width: CGFloat, _ height: CGFloat) -> CGRect { - CGRect(x: originX * scale, y: originY * scale, width: width * scale, height: height * scale) - } - - let bodyPath = Path(roundedRect: rect(40, 28, 40, 64), cornerRadius: 14 * scale) - context.stroke(bodyPath, with: .color(color), lineWidth: 3 * scale) - - let disc = Path(ellipseIn: rect(51, 41, 18, 18)) - context.stroke(disc, with: .color(color), lineWidth: 3 * scale) - - var stem = Path() - stem.move(to: CGPoint(x: 60 * scale, y: 59 * scale)) - stem.addLine(to: CGPoint(x: 60 * scale, y: 77 * scale)) - stem.move(to: CGPoint(x: 54 * scale, y: 70 * scale)) - stem.addLine(to: CGPoint(x: 66 * scale, y: 70 * scale)) - context.stroke(stem, with: .color(color), style: StrokeStyle(lineWidth: 3 * scale, lineCap: .round)) - } - .frame(width: size, height: size) - } -} - -/// Concentric rings around the key mark, used on the tap/insert and touch screens. -struct YubiKeyHaloView: View { - var pulses = false - @State private var pulsing = false - - var body: some View { - ZStack { - Circle() - .stroke(CosignTheme.accent.opacity(0.25), lineWidth: 1) - .frame(width: 150, height: 150) - Circle() - .stroke(CosignTheme.accent.opacity(0.16), lineWidth: 1) - .frame(width: 90, height: 90) - if pulses { - Circle() - .stroke(CosignTheme.accent.opacity(0.40), lineWidth: 2) - .frame(width: 132, height: 132) - .scaleEffect(pulsing ? 1.0 : 0.5) - .opacity(pulsing ? 0 : 0.8) - } - YubiKeyMark(size: 120) - } - .frame(width: 150, height: 150) - .onAppear { - guard pulses else { return } - withAnimation(.easeOut(duration: 1.8).repeatForever(autoreverses: false)) { - pulsing = true - } - } - } -} - -struct YubiKeyStatusPill: View { - let text: String - - var body: some View { - HStack(spacing: 8) { - Circle().fill(CosignTheme.accent).frame(width: 8, height: 8) - Text(text) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.accent) - } - } -} - -struct YubiKeyPINDots: View { - let count: Int - var capacity = 8 - - var body: some View { - HStack(spacing: 14) { - ForEach(0 ..< capacity, id: \.self) { index in - Circle() - .fill(index < count ? CosignTheme.accent : CosignTheme.surface3) - .frame(width: 14, height: 14) - .overlay { Circle().stroke(CosignTheme.line, lineWidth: 1) } - } - } - } -} - -struct YubiKeyPINPad: View { - @Binding var pin: String - var maxLength = 8 - var isDisabled = false - - private let keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] - - var body: some View { - VStack(spacing: 12) { - ForEach(0 ..< 3, id: \.self) { row in - HStack(spacing: 12) { - ForEach(0 ..< 3, id: \.self) { column in - digitKey(keys[row * 3 + column]) - } - } - } - HStack(spacing: 12) { - Color.clear.frame(maxWidth: .infinity, minHeight: 58) - digitKey("0") - deleteKey - } - } - .disabled(isDisabled) - } - - private func digitKey(_ value: String) -> some View { - Button { - guard pin.count < maxLength else { return } - pin.append(value) - } label: { - Text(value) - .font(CosignTheme.FontStyle.titleL) - .foregroundStyle(CosignTheme.ink) - .frame(maxWidth: .infinity, minHeight: 58) - .background(CosignTheme.surface2, in: .rect(cornerRadius: CosignTheme.Radius.medium)) - .overlay { - RoundedRectangle(cornerRadius: CosignTheme.Radius.medium) - .stroke(CosignTheme.line, lineWidth: 1) - } - } - .buttonStyle(.plain) - .accessibilityIdentifier("yubikey-pin-\(value)") - } - - private var deleteKey: some View { - Button { - guard !pin.isEmpty else { return } - pin.removeLast() - } label: { - Text(CosignCopy.YubiKey.pinDeleteLabel) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - .frame(maxWidth: .infinity, minHeight: 58) - .background(CosignTheme.surface2, in: .rect(cornerRadius: CosignTheme.Radius.medium)) - .overlay { - RoundedRectangle(cornerRadius: CosignTheme.Radius.medium) - .stroke(CosignTheme.line, lineWidth: 1) - } - } - .buttonStyle(.plain) - .accessibilityLabel(CosignCopy.YubiKey.pinDeleteAccessibility) - .accessibilityIdentifier("yubikey-pin-delete") - } -} - -struct YubiKeySignerSummaryCard: View { - let name: String - let address: String - - var body: some View { - CosignCard { - HStack(spacing: 14) { - RoundedRectangle(cornerRadius: 13) - .fill( - LinearGradient( - colors: [CosignTheme.accent, Color(hex: 0x241808)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 44, height: 44) - .overlay { YubiKeyMark(size: 26, color: CosignTheme.accentInk) } - - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text(name) - .font(CosignTheme.FontStyle.titleM) - .foregroundStyle(CosignTheme.ink) - Text(CosignCopy.YubiKey.hardwareTag) - .font(CosignTheme.FontStyle.eyebrow) - .foregroundStyle(CosignTheme.mint) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(CosignTheme.mintWash, in: .rect(cornerRadius: 4)) - } - Text(cosignShortAddress(address)) - .font(CosignTheme.FontStyle.mono) - .foregroundStyle(CosignTheme.inkDim) - } - - Spacer(minLength: 0) - } - } - } -} diff --git a/Modules/UI/Sources/YubiKeyOnboardingRecovery.swift b/Modules/UI/Sources/YubiKeyOnboardingRecovery.swift deleted file mode 100644 index 17be4ae..0000000 --- a/Modules/UI/Sources/YubiKeyOnboardingRecovery.swift +++ /dev/null @@ -1,213 +0,0 @@ -import Signers -import SwiftUI - -enum YubiKeyOnboardingFailure { - case wrongPIN(retriesRemaining: Int) - case pinLocked - case notProvisioned - case noKey - case nfcUnavailable - case touchTimedOut - case lostConnection - - static func classify(_ error: any Error) -> YubiKeyOnboardingFailure { - if let enrollment = error as? YubiKeyEnrollmentError { - switch enrollment { - case let .wrongPIN(retriesRemaining): return .wrongPIN(retriesRemaining: retriesRemaining) - case .pinLocked: return .pinLocked - } - } - - if let signer = error as? YubiKeySignerError { - switch signer { - case let .invalidPIN(retriesRemaining): return .wrongPIN(retriesRemaining: retriesRemaining) - case .pinBlocked: return .pinLocked - default: return .lostConnection - } - } - - if error is YubiKeyPIVRegistrationError { - return .notProvisioned - } - - return classifyByDescription(error) - } - - private static func classifyByDescription(_ error: any Error) -> YubiKeyOnboardingFailure { - let text = String(describing: error).lowercased() - if text.contains("nfc"), text.contains("not") || text.contains("unavailable") || text.contains("support") { - return .nfcUnavailable - } - if text.contains("timeout") || text.contains("timed out") || text.contains("expired") { - return .touchTimedOut - } - if text.contains("no tag") || text.contains("not found") || text.contains("no key") { - return .noKey - } - return .lostConnection - } -} - -enum YubiKeyRecoveryAction { - case reEnterPIN - case retryConnect - case useWired - case startOver - case dismiss -} - -struct YubiKeyRecovery: Equatable { - enum Kind { - case wrongPIN - case pinLocked - case notProvisioned - case noKey - case nfcUnavailable - case touchTimedOut - case lostConnection - case addressMismatch - case alreadyAdded - } - - let kind: Kind - let action: YubiKeyRecoveryAction - let retriesRemaining: Int? - - static let alreadyAdded = YubiKeyRecovery(kind: .alreadyAdded, action: .dismiss) - - init(kind: Kind, action: YubiKeyRecoveryAction, retriesRemaining: Int? = nil) { - self.kind = kind - self.action = action - self.retriesRemaining = retriesRemaining - } - - init(failure: YubiKeyOnboardingFailure) { - switch failure { - case let .wrongPIN(retriesRemaining): - self.init(kind: .wrongPIN, action: .reEnterPIN, retriesRemaining: retriesRemaining) - case .pinLocked: - self.init(kind: .pinLocked, action: .startOver) - case .notProvisioned: - self.init(kind: .notProvisioned, action: .startOver) - case .noKey: - self.init(kind: .noKey, action: .retryConnect) - case .nfcUnavailable: - self.init(kind: .nfcUnavailable, action: .useWired) - case .touchTimedOut: - self.init(kind: .touchTimedOut, action: .retryConnect) - case .lostConnection: - self.init(kind: .lostConnection, action: .retryConnect) - } - } -} - -extension YubiKeyRecovery { - typealias Copy = CosignCopy.YubiKey.Recovery - - var title: String { - switch kind { - case .wrongPIN: Copy.wrongPINTitle - case .pinLocked: Copy.pinLockedTitle - case .notProvisioned: Copy.notProvisionedTitle - case .noKey: Copy.noKeyTitle - case .nfcUnavailable: Copy.nfcUnavailableTitle - case .touchTimedOut: Copy.touchTimedOutTitle - case .lostConnection: Copy.lostConnectionTitle - case .addressMismatch: Copy.mismatchTitle - case .alreadyAdded: Copy.alreadyAddedTitle - } - } - - var message: String { - switch kind { - case .wrongPIN: Copy.wrongPINMessage(retriesRemaining: retriesRemaining ?? 0) - case .pinLocked: Copy.pinLockedMessage - case .notProvisioned: Copy.notProvisionedMessage - case .noKey: Copy.noKeyMessage - case .nfcUnavailable: Copy.nfcUnavailableMessage - case .touchTimedOut: Copy.touchTimedOutMessage - case .lostConnection: Copy.lostConnectionMessage - case .addressMismatch: Copy.mismatchMessage - case .alreadyAdded: Copy.alreadyAddedMessage - } - } - - var actionTitle: String { - switch kind { - case .wrongPIN: Copy.wrongPINAction - case .pinLocked: Copy.pinLockedAction - case .notProvisioned: Copy.notProvisionedAction - case .noKey: Copy.noKeyAction - case .nfcUnavailable: Copy.nfcUnavailableAction - case .touchTimedOut: Copy.touchTimedOutAction - case .lostConnection: Copy.lostConnectionAction - case .addressMismatch: Copy.mismatchAction - case .alreadyAdded: CosignCopy.YubiKey.doneButtonTitle - } - } - - var isDanger: Bool { - switch kind { - case .wrongPIN, .pinLocked, .notProvisioned, .addressMismatch: true - default: false - } - } - - var accent: Color { - isDanger ? CosignTheme.riskRed : CosignTheme.riskAmber - } - - var glyph: CosignGlyph { - switch kind { - case .wrongPIN, .pinLocked: .lock - case .notProvisioned, .addressMismatch: .warning - case .noKey: .search - case .nfcUnavailable, .lostConnection: .wave - case .touchTimedOut: .clock - case .alreadyAdded: .check - } - } -} - -struct YubiKeyRecoveryCard: View { - let recovery: YubiKeyRecovery - let action: () -> Void - - var body: some View { - CosignCard { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top, spacing: 12) { - CosignGlyphView(glyph: recovery.glyph, size: 18, color: recovery.accent) - .frame(width: 38, height: 38) - .background(recovery.accent.opacity(0.12), in: .rect(cornerRadius: CosignTheme.Radius.medium)) - - VStack(alignment: .leading, spacing: 4) { - Text(recovery.title) - .font(CosignTheme.FontStyle.titleM) - .foregroundStyle(CosignTheme.ink) - Text(recovery.message) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.inkDim) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - Button(action: action) { - Text(recovery.actionTitle) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(recovery.accent) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CosignTheme.surface2, in: .rect(cornerRadius: CosignTheme.Radius.control)) - .overlay { - RoundedRectangle(cornerRadius: CosignTheme.Radius.control) - .stroke(CosignTheme.line, lineWidth: 1) - } - } - .buttonStyle(.plain) - .accessibilityIdentifier("yubikey-recovery-action") - } - } - } -} diff --git a/Modules/UI/Sources/YubiKeySetupNotice.swift b/Modules/UI/Sources/YubiKeySetupNotice.swift deleted file mode 100644 index e8c9ab4..0000000 --- a/Modules/UI/Sources/YubiKeySetupNotice.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import Signers - -struct YubiKeySetupNotice: Identifiable { - let id = UUID() - let title: String - let message: String -} - -func yubiKeySetupNotice(for error: any Error) -> YubiKeySetupNotice { - if let registrationError = error as? YubiKeyPIVRegistrationError { - return yubiKeyRegistrationNotice(for: registrationError) - } - - if let signerError = error as? YubiKeySignerError { - return YubiKeySetupNotice( - title: CosignCopy.YubiKeySigning.checkFailedTitle, - message: signerError.errorDescription ?? String(describing: signerError) - ) - } - - if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription { - return YubiKeySetupNotice( - title: CosignCopy.YubiKeySigning.checkFailedTitle, - message: description - ) - } - - return YubiKeySetupNotice( - title: CosignCopy.YubiKeySigning.checkFailedTitle, - message: String(describing: error) - ) -} - -private func yubiKeyRegistrationNotice(for error: YubiKeyPIVRegistrationError) -> YubiKeySetupNotice { - switch error { - case let .unsupportedPublicKey(slot): - YubiKeySetupNotice( - title: CosignCopy.YubiKeySigning.unsupportedKeyTitle, - message: CosignCopy.YubiKeySigning.unsupportedKeyMessage(slotName: slot.displayName) - ) - case let .noEd25519PublicKey(slot): - YubiKeySetupNotice( - title: CosignCopy.YubiKeySigning.keyNotFoundTitle, - message: CosignCopy.YubiKeySigning.keyNotFoundMessage(slotName: slot.displayName) - ) - case let .noUsablePublicKey(slot, metadataError, certificateError): - YubiKeySetupNotice( - title: CosignCopy.YubiKeySigning.keyNotReadableTitle, - message: CosignCopy.YubiKeySigning.keyNotReadableMessage( - slotName: slot.displayName, - metadataError: String(describing: metadataError), - certificateError: String(describing: certificateError) - ) - ) - } -} diff --git a/Modules/UI/Sources/YubiKeySigningOptions.swift b/Modules/UI/Sources/YubiKeySigningOptions.swift deleted file mode 100644 index 0b7f6bf..0000000 --- a/Modules/UI/Sources/YubiKeySigningOptions.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Signers -import SwiftUI - -enum YubiKeyTransportChoice: String, CaseIterable, Identifiable { - case wired - case nfc - - var id: Self { - self - } - - var label: String { - switch self { - case .wired: - CosignCopy.YubiKeySigning.pluggedInTransportTitle - case .nfc: - CosignCopy.YubiKeySigning.tapTransportTitle - } - } - - var statusLabel: String { - switch self { - case .wired: - CosignCopy.YubiKeySigning.pluggedInTransportStatus - case .nfc: - CosignCopy.YubiKeySigning.nfcTransportStatus - } - } - - func connectionPreference(alertMessage: String) -> YubiKeyConnectionPreference { - switch self { - case .wired: - .wired - case .nfc: - .nfc(alertMessage: alertMessage) - } - } -} - -struct YubiKeySigningOptions: Equatable { - var pin = "" - var transport: YubiKeyTransportChoice = .wired - - var trimmedPIN: String { - pin.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var hasValidPINLength: Bool { - (6 ... 8).contains(trimmedPIN.utf8.count) - } -} - -struct YubiKeySigningControls: View { - @Binding var options: YubiKeySigningOptions - let isDisabled: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - CosignSectionTitle(title: CosignCopy.YubiKeySigning.sectionTitle) - CosignCard { - YubiKeyTransportSelector(selection: $options.transport, isDisabled: isDisabled) - - SecureField(CosignCopy.YubiKeySigning.pinPlaceholder, text: $options.pin) - .textContentType(.password) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .disabled(isDisabled) - - if !options.pin.isEmpty, !options.hasValidPINLength { - Text(CosignCopy.YubiKeySigning.pinLengthMessage) - .font(CosignTheme.FontStyle.caption) - .foregroundStyle(CosignTheme.riskRed) - } - } - } - } -} - -struct YubiKeyTransportSelector: View { - @Binding var selection: YubiKeyTransportChoice - var isDisabled = false - - var body: some View { - HStack(spacing: 8) { - ForEach(YubiKeyTransportChoice.allCases) { transport in - Button { - selection = transport - } label: { - transportOption(transport) - } - .buttonStyle(.plain) - .disabled(isDisabled) - } - } - } - - private func transportOption(_ transport: YubiKeyTransportChoice) -> some View { - let isSelected = selection == transport - - return HStack(spacing: 8) { - CosignGlyphView( - glyph: transport.glyph, - size: 15, - color: isSelected ? CosignTheme.accentDeep : CosignTheme.inkFaint - ) - Text(transport.label) - .font(CosignTheme.FontStyle.body) - .foregroundStyle(isSelected ? CosignTheme.ink : CosignTheme.inkDim) - .lineLimit(1) - Spacer(minLength: 0) - if isSelected { - CosignGlyphView(glyph: .check, size: 14, color: CosignTheme.accentDeep) - } - } - .padding(.vertical, 11) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity) - .background( - isSelected ? CosignTheme.accentWash : CosignTheme.surface2, - in: .rect(cornerRadius: CosignTheme.Radius.medium) - ) - .overlay { - RoundedRectangle(cornerRadius: CosignTheme.Radius.medium) - .stroke(isSelected ? CosignTheme.accent.opacity(0.65) : CosignTheme.line, lineWidth: 1) - } - .opacity(isDisabled ? 0.55 : 1) - } -} - -private extension YubiKeyTransportChoice { - var glyph: CosignGlyph { - switch self { - case .wired: - .key - case .nfc: - .wave - } - } -} diff --git a/Project.swift b/Project.swift index acaf181..ecce093 100644 --- a/Project.swift +++ b/Project.swift @@ -120,12 +120,7 @@ let project = Project( )), developmentRegion: "en" ), - packages: [ - .remote( - url: "https://github.com/Yubico/yubikit-swift.git", - requirement: .upToNextMajor(from: "1.3.0") - ) - ], + packages: [], targets: [ // CosignCore: thin Swift wrapper around the UniFFI-generated bindings, // which are emitted into Modules/CosignCore/Sources/Generated/ by @@ -187,14 +182,13 @@ let project = Project( dependencies: [.target(name: "Core")] ), - // Signers: hot wallets and external signing device integrations. + // Signers: hot wallet signing only. TargetFactory.framework( name: "Signers", sources: ["Modules/Signers/Sources/**"], dependencies: [ .target(name: "Core"), - .target(name: "CosignCore"), - .package(product: "YubiKit") + .target(name: "CosignCore") ] ), TargetFactory.unitTests( diff --git a/UITests/Sources/CosignDemoDesignWalkthroughUITests.swift b/UITests/Sources/CosignDemoDesignWalkthroughUITests.swift index 09c4ba3..6d66122 100644 --- a/UITests/Sources/CosignDemoDesignWalkthroughUITests.swift +++ b/UITests/Sources/CosignDemoDesignWalkthroughUITests.swift @@ -456,8 +456,7 @@ final class SignerFlowUITests: DemoWalkthroughUITestCase { chooserName: "32-add-signer-chooser", name: "33-add-hot-wallet" ) - captureAddSignerFresh(option: "ledger", screen: "screen.add-ledger", chooserName: nil, name: "34-add-ledger") - captureAddSignerFresh(option: "yubikey", screen: "screen.add-yubikey", chooserName: nil, name: "35-add-yubikey") + } func testDemoImportWalletFlow() { diff --git a/core/static/index.html b/core/static/index.html index 572b3c6..328f914 100644 --- a/core/static/index.html +++ b/core/static/index.html @@ -4,7 +4,7 @@ Cosign - + @@ -14,206 +14,319 @@ a { color:inherit; } ::selection { background:rgba(242,194,108,0.24); } .phone { box-shadow:0 50px 90px -28px rgba(0,0,0,0.8); } - @media (max-width: 880px){ .hide-narrow { display:none !important; } } + @media (max-width: 900px){ .hide-narrow { display:none !important; } } + + @keyframes cosignScan { + 0%, 16% { opacity:0; transform:translateY(6px); } + 22% { opacity:1; } + 46% { opacity:1; } + 52% { opacity:0; transform:translateY(268px); } + 100% { opacity:0; transform:translateY(268px); } + } + @keyframes cosignRaw { + 0%, 24% { opacity:1; filter:blur(0px); } + 44% { opacity:0; filter:blur(7px); } + 90% { opacity:0; filter:blur(7px); } + 99%, 100% { opacity:1; filter:blur(0px); } + } + @keyframes cosignDecoded { + 0%, 34% { opacity:0; transform:translateY(10px); } + 54% { opacity:1; transform:translateY(0); } + 88% { opacity:1; transform:translateY(0); } + 95%, 100% { opacity:0; transform:translateY(10px); } + } + @keyframes cosignPulse { + 0%, 100% { opacity:0.5; } + 50% { opacity:1; } + } + + /* ── Phones (≤600px) ── */ + @media (max-width: 600px) { + /* 1. Header nav: keep only wordmark + primary CTA; hide GitHub and other nav items */ + .hide-phone { display: none !important; } + + /* 2. Sticky how-it-works: phone pins at top, beats scroll beneath */ + .sticky-phone-col { + order: -1 !important; + top: 68px !important; + max-height: 55vh !important; + overflow: hidden; + } + .beats-col { order: 2 !important; } + + /* 4. Touch targets: ≥44px hit height for header CTA and toggle buttons */ + .header-cta { min-height: 44px !important; padding-top: 11px !important; padding-bottom: 11px !important; } + [data-toggle] { min-height: 44px !important; } + + /* Motion: neutralize parallax transforms on phones */ + [data-parallax] { transform: none !important; } + }
-
-
+ +
+
-
-
+ +
+
-

A native iOS signer for Solana Squads multisigs.

-

Review and co-sign Squads v4 proposals from your phone, with your keys held on device. Every proposal is decoded and inspectable, so you always know what you are about to approve.

- -
-
PlatformNative iOS, built in SwiftUI
-
CustodyKeys held on your device
-
ReleasePublic TestFlight beta on devnet
-
- -
- Join the TestFlight beta - See how it works +
Public TestFlight beta · Solana devnet
+

Know what
you sign.

+

Squads multisig proposals arrive encoded, and most signers approve what they cannot read. Cosign is a native iOS signer that decodes every instruction into plain language, so you review the real action before you add your signature.

+ +
Native iOS · SwiftUI · Squads v4 · keys held on device
-
-
- Cosign decoded proposal detail -
-
- Cosign sign sheet with hold to approve + +
+
+
+
9:41
+
Proposal 14 · Operations
+ +
+
+ +
+
Encoded instruction
+
program: Vote111111111111
data: 02 00 00 00 7c 9a f3 1b
a4 05 e2 00 3a d1 90 4f 8c
6b 21 ff 0e 55 c3 88 12 ad
accounts: 7cNd… · SqDs… · 11111…
+
Unreadable without decoding
+
+ +
+
SOL transfer
Review
+
+
Amount250 SOL
+
To7cNd…xK29
+
FromOperations · Vault 01
+
ConfidenceDecoded
+
+
+
+ +
Hold to approve
+
-
+ +
-
- 01 - Decoded and inspectable -
-

Know exactly what you are signing.

-

Cosign decodes every instruction into plain language, with a severity and a confidence read on the data. You can open the raw fields when you want them. Nothing is hidden behind a blind signature.

+
The problem
+

A signature on bytes you cannot read.

+

A Squads proposal is a serialized transaction. The Squads web app can decode it, but the moment you confirm on a hardware wallet you are back to approving a hash the device cannot explain. Cosign keeps the decoded action in front of you through to the signature, so approving stays a decision rather than a leap of faith.

+
Toggle the panel to see the difference.
-
-
- Cosign proposal with IDL confidence + +
+
+ +
-
- Cosign decoded proposal detail +
+
+
Raw proposal
+
program: Vote1111111111111111111111
data: 02 00 00 00 7c 9a f3 1b a4 05 e2 00
3a d1 90 4f 8c 6b 21 ff 0e 55 c3 88 12 ad
7f b2 44 91 0c 6e 33 a0 d5 1e 8b
accounts: 7cNd…xK29 · SqDs…op3X · 1111…
+
You are asked to approve this.
+
+
+
SOL transfer
Review
+
+
Amount250 SOL
+
Recipient7cNd…xK29
+
From vaultOperations · Vault 01
+
ConfidenceDecoded from the on-chain program
+
+
-
+ +
-
-
- Cosign sign sheet with threshold ring -
-
-
- 02 - Co-sign to the threshold +
On-device decode
+

The decode happens on your phone.

+

Cosign reads the instruction bytes and works out the action locally, on the device. The relay only transports data and can run an optional simulation. It never decides what you are signing, so the plain-language action you approve is computed where your keys live.

+
+
+
+
RELAY
+
Transports data · optional simulation
no keys
+
instruction bytes
+
+
YOUR DEVICE
+
02 00 7c 9a f3…Send 250 SOL
+
Decoded locally, then signed with Face ID
+
-

Add your signature, watch the threshold close.

-

See who has approved and how many signatures remain. The threshold ring fills as co-signers sign, and Face ID confirms before anything is broadcast. Hold to approve keeps an accidental tap from spending anything.

-
-
-
-
- 03 - Hardware signers + +
+
+

From encoded proposal to verifiable receipt.

+ how it works +
+ +
+
+
+
+ Cosign decoded proposal detail + Cosign sign sheet threshold ring + Cosign import wallet behind Face ID + Cosign verifiable receipt +
-

Bring your own hardware.

-

Connect a Ledger over Bluetooth or a YubiKey over NFC or USB. The guided flow checks the device, confirms the address on the hardware itself, and only then adds it as a signer. Recovery phrases never touch the relay.

-
-
- Cosign add YubiKey -
-
- Cosign add signer + +
+
+
01Decoded and inspectable
+

Read the action, not the bytes.

+

Every instruction is decoded into plain language with a severity and a confidence read on the data. Open the raw fields whenever you want them. Nothing is hidden behind a blind signature.

-
-
-
-
-
-
-
- Cosign import recovery phrase +
+
02Co-sign to the threshold
+

Add your signature, watch the threshold close.

+

See who has approved and how many signatures remain. The threshold ring fills as co-signers sign, and Face ID confirms before anything is broadcast. Hold to approve keeps an accidental tap from spending anything.

-
- Cosign signer detail + +
+
03Self-custody
+

Your keys stay on the device.

+

Keys are generated and held on your iPhone, in the iOS Keychain. Import an existing wallet from its recovery phrase or a raw Solana secret key, all behind Face ID. Cosign cannot move your funds.

+

Hardware signer support is on the roadmap.

-
-
-
- 04 - Self-custody + +
+
04Verifiable receipt
+

A receipt you can check on-chain.

+

After a proposal executes, Cosign shows the signature and the decoded result, with a link to inspect the transaction on-chain. What you approved and what landed are the same thing, and you can prove it.

-

Your keys never leave the phone.

-

Keys are generated and stored in the iOS Keychain or on your hardware signer. Import an existing wallet from its recovery phrase or a raw Solana secret key, behind Face ID, and manage each signer from one place. Cosign cannot move your funds.

-
-
-
-
- Cosign transaction inspection + +
+
+

Only your device can sign.

+ trust model +
+

Cosign splits the work so the parts that matter stay with you. The relay helps you read the chain. It never holds keys and it cannot move funds. Signing happens only on your iPhone, and settlement happens only on Solana.

+ +
+
+
Your iPhone
holds keys · decodes · signs
+
+
Keys generated and held in the Keychain
+
Signs transactions behind Face ID
+
Decodes the proposal locally, then signs
-
- Cosign proposal receipt +
+ +
+
The relay
transports · simulates
+
+
Transports data and offers optional simulation
+
Holds no keys, takes no custody
+
Cannot sign or move your funds
-
-
- 05 - Verifiable receipt + +
+
Solana
settles · public
+
+
Executes the signed transaction
+
The multisig program enforces the threshold
+
Public record anyone can verify
-

A receipt you can check on-chain.

-

After a proposal executes, Cosign shows the signature and the decoded result, with a link to inspect the transaction on-chain. What you approved and what landed are the same thing, and you can prove it.

+
signing path, on your device only·the relay sits outside it
-
+ +
-
- 06 - Verifiable builds -
-

See where your build came from.

-

Every release build is signed, and the app shows that identity, its version, commit, and fingerprint, so you can confirm on device that you're running a signed build. A signed provenance chain, not a black box you have to trust.

+
Verifiable builds
+

Match the app against the public record.

+

Every release is built in the open and signed, then published as a GitHub Release. The app shows its version, commit, and SHA-256 fingerprint, so you can match the build you are running against that published record, by eye or with a scan. Provenance you can check, not a black box you have to trust.

+ See the releases on GitHub
-
+
Verified build
v0.1.0
Releasev0.1.0+1751312345
Commitaca62f4c
-
Fingerprint · SHA-256
aca62f4c9d3b71e04f2a8c61bd09e710f5a23c4e8b90d172a6f3c5
+
Fingerprint · SHA-256
aca62f4c9d3b71e04f2a8c61bd09e710f5a23c4e8b90d172a6f3c5
+
+ + + + + + + + + +
+
-
-
-
-

The relay cannot move your funds.

-

Cosign is built so the parts that matter stay with you. The network helps you read; only your device can sign.

-
-
-
Keys
On device, in the Keychain or on hardware
Never uploaded. Approvals happen only on your iPhone.
-
Relay
A thin, verifiable client
Proxies Solana RPC and decodes proposals. No keys, no custody. Verify every result against the public chain.
-
-
-
- -
+ +
-

Know what you sign.

-

The beta runs on Solana devnet, so you can connect signers, build proposals, and co-sign without real funds at risk.

+

Know what you sign.

+

The beta runs on Solana devnet, so you can create squads, build proposals, and co-sign without real funds at risk.

+
cosign
-
TestFlight beta on devnet
- Join the TestFlight beta +
TestFlight beta · Solana devnet
@@ -227,6 +340,7 @@

Cosign
+ GitHub Privacy Solana Squads multisig signer
@@ -234,5 +348,89 @@

cosign / privacy

Privacy

-

Last updated June 2026

+

Last updated July 2026

Cosign is built so that we do not need your personal data to operate. There are no accounts, and your signing keys never leave your device. This page explains what that means in practice.

@@ -49,7 +49,7 @@

No 02

Keys stay on your device

-

Signing keys are generated and stored on your iPhone, in the iOS Keychain, or on a connected hardware signer. They never leave the device and we never receive them. Recovery phrases are shown only on your device, behind Face ID.

+

Signing keys are generated and stored on your iPhone, in the iOS Keychain. They never leave the device and we never receive them. Recovery phrases and secret keys are entered and shown only on your device, behind Face ID.

03