diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift index 4850c08a80..b5eac3f6e4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift @@ -180,6 +180,13 @@ public final class KeychainSigner: Signer, @unchecked Sendable { /// swift-sdk/CLAUDE.md "no mnemonic round-tripping". private let mnemonicResolver: MnemonicResolver + /// The same `WalletStorage` handed to `mnemonicResolver`. The + /// `canSign` preflights consult it for `hasMnemonic` so preflight + /// and sign always answer from the same store — constructing a + /// fresh `WalletStorage()` there would desync the two when an + /// alternative storage is injected. + private let mnemonicStorage: WalletStorage + /// Raw pointer to the FFI signer handle. Boxed by Rust and freed /// in `deinit`. private var handlePtr: OpaquePointer! @@ -194,10 +201,14 @@ public final class KeychainSigner: Signer, @unchecked Sendable { /// - network: forwarded to `dash_sdk_signer_create_from_private_key` /// for WIF address derivation; does not affect signature output. /// - keychain: defaults to `KeychainManager.shared`. + /// - storage: mnemonic source for the resolver-based signing + /// paths. Defaults to a fresh `WalletStorage()` — overridable + /// for tests. public init( modelContainer: ModelContainer, network: Network = .testnet, - keychain: KeychainManager = .shared + keychain: KeychainManager = .shared, + storage: WalletStorage = WalletStorage() ) { self.modelContainer = modelContainer self.network = network @@ -206,7 +217,8 @@ public final class KeychainSigner: Signer, @unchecked Sendable { // One resolver per signer instance. Cheap to keep around — // it's just an opaque handle + a Swift-side `WalletStorage` // reference. Used by the platform-address signing branch. - self.mnemonicResolver = MnemonicResolver() + self.mnemonicStorage = storage + self.mnemonicResolver = MnemonicResolver(storage: storage) // Hand Rust an opaque NON-owning pointer to self. The // Swift owner is responsible for keeping `self` alive @@ -376,7 +388,7 @@ public final class KeychainSigner: Signer, @unchecked Sendable { } // Existence check only — do NOT materialize the mnemonic // bytes on the preflight path. - return WalletStorage().hasMnemonic(for: resolved.walletId) + return mnemonicStorage.hasMnemonic(for: resolved.walletId) } var found = false @@ -407,7 +419,7 @@ public final class KeychainSigner: Signer, @unchecked Sendable { wid.count == 32, let path = row.identityDerivationPath, !path.isEmpty, - WalletStorage().hasMnemonic(for: wid) + self.mnemonicStorage.hasMnemonic(for: wid) { found = true return diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityResolverSignIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityResolverSignIntegrationTests.swift index 40a5b882a7..09d51fb071 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityResolverSignIntegrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityResolverSignIntegrationTests.swift @@ -17,6 +17,45 @@ final class IdentityResolverSignIntegrationTests: XCTestCase { private let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + /// In-memory `WalletStorage` so the resolver path under test never + /// touches the real macOS Keychain — CI runners often have a locked + /// or permission-denied keychain session (`keychainError(-60008)` / + /// `(-61)`), which is an environment failure, not what these tests + /// cover. The lock mirrors the real storage's thread-safety: the + /// resolver trampoline reads from a Rust worker thread. + private final class InMemoryWalletStorage: WalletStorage { + private var mnemonics: [Data: Data] = [:] + private let lock = NSLock() + + override func storeMnemonic(_ mnemonic: String, for walletId: Data) throws { + lock.lock() + defer { lock.unlock() } + mnemonics[walletId] = Data(mnemonic.utf8) + } + + override func retrieveMnemonicUTF8Bytes(for walletId: Data) throws -> Data { + lock.lock() + defer { lock.unlock() } + guard let data = mnemonics[walletId], !data.isEmpty else { + throw WalletStorageError.mnemonicNotFound + } + return data + } + + override func deleteMnemonic(for walletId: Data) throws { + lock.lock() + defer { lock.unlock() } + mnemonics[walletId] = nil + } + + // `hasMnemonic` (the `canSign` preflight) delegates here. + override func mnemonicAvailability(for walletId: Data) -> MnemonicAvailability { + lock.lock() + defer { lock.unlock() } + return mnemonics[walletId] != nil ? .present : .absent + } + } + /// A consistent breadcrumb (the path derives to the row's pubkey) signs via /// the resolver: the seed is read from the Keychain, the key is derived on /// demand, the binding accepts it, and a 65-byte ECDSA signature comes back — @@ -34,7 +73,7 @@ final class IdentityResolverSignIntegrationTests: XCTestCase { XCTAssertEqual(pubkey.count, 33, "compressed secp256k1 pubkey") // Seed the resolver's source: the mnemonic in WalletStorage, keyed by walletId. - let storage = WalletStorage() + let storage = InMemoryWalletStorage() try storage.storeMnemonic(mnemonic, for: walletId) defer { try? storage.deleteMnemonic(for: walletId) } @@ -48,7 +87,12 @@ final class IdentityResolverSignIntegrationTests: XCTestCase { ctx.insert(row) try ctx.save() - let signer = KeychainSigner(modelContainer: container, network: .testnet) + let signer = KeychainSigner(modelContainer: container, network: .testnet, storage: storage) + + // The preflight consults the same injected storage as the sign + // path, so a resolver-signable key must report signable. + XCTAssertTrue(signer.canSign(publicKey: pubkey, keyType: 0)) + let message = Data("identity state transition".utf8) let result = signer.signIdentityKeyOnDemand(publicKey: pubkey, keyType: 0, data: message) @@ -75,7 +119,7 @@ final class IdentityResolverSignIntegrationTests: XCTestCase { let wrongPath = try KeyDerivation.getIdentityAuthenticationPath( network: .testnet, identityIndex: 9, keyIndex: 9) - let storage = WalletStorage() + let storage = InMemoryWalletStorage() try storage.storeMnemonic(mnemonic, for: walletId) defer { try? storage.deleteMnemonic(for: walletId) } @@ -88,7 +132,7 @@ final class IdentityResolverSignIntegrationTests: XCTestCase { ctx.insert(row) try ctx.save() - let signer = KeychainSigner(modelContainer: container, network: .testnet) + let signer = KeychainSigner(modelContainer: container, network: .testnet, storage: storage) let result = signer.signIdentityKeyOnDemand( publicKey: pubkey, keyType: 0, data: Data("x".utf8))