diff --git a/Package.swift b/Package.swift index 22f432b6..dcd476fa 100644 --- a/Package.swift +++ b/Package.swift @@ -26,7 +26,7 @@ let package = Package( .package(url: "https://github.com/1024jp/GzipSwift", from: "5.2.0"), .package(url: "https://github.com/bufbuild/connect-swift", exact: "0.3.0"), .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"), - .package(url: "https://github.com/xmtp/libxmtp-swift", revision: "92274fe"), + .package(url: "https://github.com/xmtp/libxmtp-swift", revision: "503086d"), // .package(path: "../libxmtp-swift") ], targets: [ diff --git a/Sources/XMTPTestHelpers/TestHelpers.swift b/Sources/XMTPTestHelpers/TestHelpers.swift index 9ef4a82e..5182b70b 100644 --- a/Sources/XMTPTestHelpers/TestHelpers.swift +++ b/Sources/XMTPTestHelpers/TestHelpers.swift @@ -23,7 +23,7 @@ public struct TestConfig { } static public func skipIfNotRunningLocalNodeTests() throws { - try XCTSkipIf(!TEST_SERVER_ENABLED, "requires local node") +// try XCTSkipIf(!TEST_SERVER_ENABLED, "requires local node") } static public func skip(because: String) throws { diff --git a/Sources/XMTPiOS/Client.swift b/Sources/XMTPiOS/Client.swift index b50bfd46..acd6faf1 100644 --- a/Sources/XMTPiOS/Client.swift +++ b/Sources/XMTPiOS/Client.swift @@ -22,10 +22,10 @@ public struct ClientOptions { /// Specify which XMTP network to connect to. Defaults to ``.dev`` public var env: XMTPEnvironment = .dev - /// Optional: Specify self-reported version e.g. XMTPInbox/v1.0.0. + /// Specify whether the API client should use TLS security. In general this should only be false when using the `.local` environment. public var isSecure: Bool = true - /// Specify whether the API client should use TLS security. In general this should only be false when using the `.local` environment. + /// /// Optional: Specify self-reported version e.g. XMTPInbox/v1.0.0. public var appVersion: String? public init(env: XMTPEnvironment = .dev, isSecure: Bool = true, appVersion: String? = nil) { @@ -44,11 +44,23 @@ public struct ClientOptions { /// `preCreateIdentityCallback` will be called immediately before a Create Identity wallet signature is requested from the user. public var preCreateIdentityCallback: PreEventCallback? - public init(api: Api = Api(), codecs: [any ContentCodec] = [], preEnableIdentityCallback: PreEventCallback? = nil, preCreateIdentityCallback: PreEventCallback? = nil) { + public var enableAlphaMLS: Bool = false + public var mlsEncryptionKey: Data? + + public init( + api: Api = Api(), + codecs: [any ContentCodec] = [], + preEnableIdentityCallback: PreEventCallback? = nil, + preCreateIdentityCallback: PreEventCallback? = nil, + enableAlphaMLS: Bool = false, + mlsEncryptionKey: Data? = nil + ) { self.api = api self.codecs = codecs self.preEnableIdentityCallback = preEnableIdentityCallback self.preCreateIdentityCallback = preCreateIdentityCallback + self.enableAlphaMLS = enableAlphaMLS + self.mlsEncryptionKey = mlsEncryptionKey } } @@ -60,11 +72,12 @@ public struct ClientOptions { /// 2. To sign a random salt used to encrypt the key bundle in storage. This happens every time the client is started, including the very first time). /// /// > Important: The client connects to the XMTP `dev` environment by default. Use ``ClientOptions`` to change this and other parameters of the network connection. -public final class Client: Sendable { +public final class Client { /// The wallet address of the ``SigningKey`` used to create this Client. public let address: String let privateKeyBundleV1: PrivateKeyBundleV1 let apiClient: ApiClient + let v3Client: LibXMTP.FfiXmtpClient? /// Access ``Conversations`` for this Client. public lazy var conversations: Conversations = .init(client: self) @@ -95,28 +108,64 @@ public final class Client: Sendable { ) return try await create(account: account, apiClient: apiClient, options: options) } catch { - throw ClientError.creationError(error.localizedDescription) + throw ClientError.creationError("\(error)") + } + } + + static func initV3Client(address: String, options: ClientOptions?, source: LegacyIdentitySource, privateKeyBundleV1: PrivateKeyBundleV1) async throws -> FfiXmtpClient? { + let v3Client: FfiXmtpClient? + + if options?.enableAlphaMLS == true && options?.api.env == .local { + let dbURL = URL.documentsDirectory.appendingPathComponent("xmtp-\(options?.api.env.rawValue ?? "")-\(address).db3") + v3Client = try await LibXMTP.createClient( + logger: XMTPLogger(), + host: GRPCApiClient.envToUrl(env: options?.api.env ?? .local), + isSecure: (options?.api.env ?? .local) != .local, + db: dbURL.path, + encryptionKey: options?.mlsEncryptionKey, + accountAddress: address, + legacyIdentitySource: source, + legacySignedPrivateKeyProto: try privateKeyBundleV1.toV2().identityKey.serializedData() + ) + } else { + v3Client = nil } + + return v3Client } static func create(account: SigningKey, apiClient: ApiClient, options: ClientOptions? = nil) async throws -> Client { - let privateKeyBundleV1 = try await loadOrCreateKeys(for: account, apiClient: apiClient, options: options) + let (privateKeyBundleV1, source) = try await loadOrCreateKeys(for: account, apiClient: apiClient, options: options) + + let v3Client = try await initV3Client( + address: account.address, + options: options, + source: source, + privateKeyBundleV1: privateKeyBundleV1 + ) - let client = try Client(address: account.address, privateKeyBundleV1: privateKeyBundleV1, apiClient: apiClient) + if let textToSign = v3Client?.textToSign() { + let signature = try await account.sign(message: textToSign).rawData + try await v3Client?.registerIdentity(recoverableWalletSignature: signature) + } + + let client = try Client(address: account.address, privateKeyBundleV1: privateKeyBundleV1, apiClient: apiClient, v3Client: v3Client) try await client.ensureUserContactPublished() + for codec in (options?.codecs ?? []) { + client.register(codec: codec) + } + return client } - static func loadOrCreateKeys(for account: SigningKey, apiClient: ApiClient, options: ClientOptions? = nil) async throws -> PrivateKeyBundleV1 { - // swiftlint:disable no_optional_try + static func loadOrCreateKeys(for account: SigningKey, apiClient: ApiClient, options: ClientOptions? = nil) async throws -> (PrivateKeyBundleV1, LegacyIdentitySource) { if let keys = try await loadPrivateKeys(for: account, apiClient: apiClient, options: options) { - // swiftlint:enable no_optional_try print("loading existing private keys.") #if DEBUG print("Loaded existing private keys.") #endif - return keys + return (keys, .network) } else { #if DEBUG print("No existing keys found, creating new bundle.") @@ -133,7 +182,7 @@ public final class Client: Sendable { Envelope(topic: .userPrivateStoreKeyBundle(account.address), timestamp: Date(), message: encryptedKeys.serializedData()), ]) - return keys + return (keys, .keyGenerator) } } @@ -155,12 +204,24 @@ public final class Client: Sendable { return nil } + public func canMessageV3(address: String) async throws -> Bool { + guard let v3Client else { + return false + } + + return try await v3Client.canMessage(accountAddresses: [address]) == [true] + } + public static func from(bundle: PrivateKeyBundle, options: ClientOptions? = nil) async throws -> Client { return try await from(v1Bundle: bundle.v1, options: options) } /// Create a Client from saved v1 key bundle. - public static func from(v1Bundle: PrivateKeyBundleV1, options: ClientOptions? = nil) async throws -> Client { + public static func from( + v1Bundle: PrivateKeyBundleV1, + options: ClientOptions? = nil, + signingKey: SigningKey? = nil + ) async throws -> Client { let address = try v1Bundle.identityKey.publicKey.recoverWalletSignerPublicKey().walletAddress let options = options ?? ClientOptions() @@ -172,13 +233,36 @@ public final class Client: Sendable { rustClient: client ) - return try Client(address: address, privateKeyBundleV1: v1Bundle, apiClient: apiClient) + let v3Client = try await initV3Client( + address: address, + options: options, + source: .static, + privateKeyBundleV1: v1Bundle + ) + + if let v3Client, let textToSign = v3Client.textToSign() { + guard let signingKey else { + throw ClientError.creationError("No v3 keys found, you must pass a SigningKey in order to enable alpha MLS features") + } + + let signature = try await signingKey.sign(message: textToSign) + try await v3Client.registerIdentity(recoverableWalletSignature: signature.rawData) + } + + let result = try Client(address: address, privateKeyBundleV1: v1Bundle, apiClient: apiClient, v3Client: v3Client) + + for codec in options.codecs { + result.register(codec: codec) + } + + return result } - init(address: String, privateKeyBundleV1: PrivateKeyBundleV1, apiClient: ApiClient) throws { + init(address: String, privateKeyBundleV1: PrivateKeyBundleV1, apiClient: ApiClient, v3Client: LibXMTP.FfiXmtpClient?) throws { self.address = address self.privateKeyBundleV1 = privateKeyBundleV1 self.apiClient = apiClient + self.v3Client = v3Client } public var privateKeyBundle: PrivateKeyBundle { diff --git a/Sources/XMTPiOS/Extensions/URL.swift b/Sources/XMTPiOS/Extensions/URL.swift new file mode 100644 index 00000000..243f8a36 --- /dev/null +++ b/Sources/XMTPiOS/Extensions/URL.swift @@ -0,0 +1,24 @@ +// +// File.swift +// +// +// Created by Pat Nakajima on 2/1/24. +// + +import Foundation + +extension URL { + static var documentsDirectory: URL { + // swiftlint:disable no_optional_try + guard let documentsDirectory = try? FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) else { + fatalError("No documents directory") + } + + return documentsDirectory + } +} diff --git a/Sources/XMTPiOS/XMTPLogger.swift b/Sources/XMTPiOS/XMTPLogger.swift new file mode 100644 index 00000000..1281576f --- /dev/null +++ b/Sources/XMTPiOS/XMTPLogger.swift @@ -0,0 +1,29 @@ +// +// Logger.swift +// +// +// Created by Pat Nakajima on 8/28/23. +// + +import Foundation +import LibXMTP +import os + +class XMTPLogger: FfiLogger { + let logger = Logger() + + func log(level: UInt32, levelLabel: String, message: String) { + switch level { + case 1: + logger.error("libxmtp[\(levelLabel)] - \(message)") + case 2, 3: + logger.info("libxmtp[\(levelLabel)] - \(message)") + case 4: + logger.debug("libxmtp[\(levelLabel)] - \(message)") + case 5: + logger.trace("libxmtp[\(levelLabel)] - \(message)") + default: + print("libxmtp[\(levelLabel)] - \(message)") + } + } +} diff --git a/Tests/XMTPTests/ClientTests.swift b/Tests/XMTPTests/ClientTests.swift index 4850094f..f09dbcf9 100644 --- a/Tests/XMTPTests/ClientTests.swift +++ b/Tests/XMTPTests/ClientTests.swift @@ -20,6 +20,38 @@ class ClientTests: XCTestCase { _ = try await Client.create(account: fakeWallet) } + func testPassingMLSEncryptionKey() async throws { + try TestConfig.skipIfNotRunningLocalNodeTests() + + let bo = try PrivateKey.generate() + let key = try Crypto.secureRandomBytes(count: 32) + + _ = try await Client.create( + account: bo, + options: .init( + api: .init(env: .local, isSecure: false), + enableAlphaMLS: true, + mlsEncryptionKey: key + ) + ) + + do { + _ = try await Client.create( + account: bo, + options: .init( + api: .init(env: .local, isSecure: false), + enableAlphaMLS: true, + mlsEncryptionKey: nil // No key should error + ) + ) + + XCTFail("did not throw") + } catch { + XCTAssert(true) + } + } + + func testCanMessage() async throws { let fixtures = await fixtures() let notOnNetwork = try PrivateKey.generate() diff --git a/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 230d80f3..30e11be1 100644 --- a/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/xmtp/libxmtp-swift", "state" : { - "branch" : "92274fe", - "revision" : "92274fe0dde1fc7f8f716ebcffa3d252813be56d" + "branch" : "503086d", + "revision" : "503086d91ba6c3420aca118e4812d62a004d17ee" } }, {