From d6a719d3e84816098eeb8c5787f0c78a511860f4 Mon Sep 17 00:00:00 2001 From: Ezequiel Leanes Date: Tue, 27 Feb 2024 23:42:53 -0300 Subject: [PATCH] feat: get HMAC keys from conversations (#265) get HMAC keys from conversation --- Sources/XMTPiOS/ConversationV2.swift | 3 +- Sources/XMTPiOS/Conversations.swift | 33 +++++++++ Sources/XMTPiOS/Crypto.swift | 24 ++++--- .../XMTPiOS/Messages/DecryptedMessage.swift | 1 - Sources/XMTPiOS/Messages/MessageV2.swift | 10 +-- Sources/XMTPiOS/SendOptions.swift | 4 +- Tests/XMTPTests/ConversationsTest.swift | 71 +++++++++++++++++++ Tests/XMTPTests/CryptoTests.swift | 67 +++++++++++++++++ .../XMTPiOSExample/Views/LoginView.swift | 6 +- 9 files changed, 195 insertions(+), 24 deletions(-) diff --git a/Sources/XMTPiOS/ConversationV2.swift b/Sources/XMTPiOS/ConversationV2.swift index e0379f56..91b83dc5 100644 --- a/Sources/XMTPiOS/ConversationV2.swift +++ b/Sources/XMTPiOS/ConversationV2.swift @@ -85,8 +85,7 @@ public struct ConversationV2 { content: encodedContent, topic: topic, keyMaterial: keyMaterial, - codec: codec, - shouldPush: options?.shouldPush + codec: codec ) let topic = options?.ephemeral == true ? ephemeralTopic : topic diff --git a/Sources/XMTPiOS/Conversations.swift b/Sources/XMTPiOS/Conversations.swift index db751a24..2771ecdb 100644 --- a/Sources/XMTPiOS/Conversations.swift +++ b/Sources/XMTPiOS/Conversations.swift @@ -581,6 +581,39 @@ public actor Conversations { a.createdAt < b.createdAt } } + + public func getHmacKeys(request: Xmtp_KeystoreApi_V1_GetConversationHmacKeysRequest? = nil) -> Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse { + let thirtyDayPeriodsSinceEpoch = Int(Date().timeIntervalSince1970) / (60 * 60 * 24 * 30) + var hmacKeysResponse = Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse() + + var topics = conversationsByTopic + + if let requestTopics = request?.topics, !requestTopics.isEmpty { + topics = topics.filter { requestTopics.contains($0.key) } + } + + for (topic, conversation) in topics { + guard let keyMaterial = conversation.keyMaterial else { continue } + + var hmacKeys = Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse.HmacKeys() + + for period in (thirtyDayPeriodsSinceEpoch - 1)...(thirtyDayPeriodsSinceEpoch + 1) { + let info = "\(period)-\(client.address)" + do { + let hmacKey = try Crypto.deriveKey(secret: keyMaterial, nonce: Data(), info: Data(info.utf8)) + var hmacKeyData = Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse.HmacKeyData() + hmacKeyData.hmacKey = hmacKey + hmacKeyData.thirtyDayPeriodsSinceEpoch = Int32(period) + hmacKeys.values.append(hmacKeyData) + } catch { + print("Error calculating HMAC key for topic \(topic): \(error)") + } + } + hmacKeysResponse.hmacKeys[topic] = hmacKeys + } + + return hmacKeysResponse + } private func listIntroductionPeers(pagination: Pagination?) async throws -> [String: Date] { let envelopes = try await client.apiClient.query( diff --git a/Sources/XMTPiOS/Crypto.swift b/Sources/XMTPiOS/Crypto.swift index 72c397a7..04fe4f82 100644 --- a/Sources/XMTPiOS/Crypto.swift +++ b/Sources/XMTPiOS/Crypto.swift @@ -76,14 +76,13 @@ enum Crypto { static func deriveKey(secret: Data, nonce: Data, info: Data) throws -> Data { let key = HKDF.deriveKey( - inputKeyMaterial: SymmetricKey(data: secret), - salt: nonce, - info: info, - outputByteCount: 32 - ) - return key.withUnsafeBytes { body in - Data(body) - } + inputKeyMaterial: SymmetricKey(data: secret), + salt: nonce, + info: info, + outputByteCount: 32) + return key.withUnsafeBytes { body in + Data(body) + } } static func secureRandomBytes(count: Int) throws -> Data { @@ -136,4 +135,13 @@ enum Crypto { static func importHmacKey(keyData: Data) -> SymmetricKey { return SymmetricKey(data: keyData) } + + static func verifyHmacSignature(key: SymmetricKey, signature: Data, message: Data) -> Bool { + let isValid = HMAC.isValidAuthenticationCode( + signature, + authenticating: message, + using: key + ) + return isValid + } } diff --git a/Sources/XMTPiOS/Messages/DecryptedMessage.swift b/Sources/XMTPiOS/Messages/DecryptedMessage.swift index 481cf849..2109b5b0 100644 --- a/Sources/XMTPiOS/Messages/DecryptedMessage.swift +++ b/Sources/XMTPiOS/Messages/DecryptedMessage.swift @@ -13,5 +13,4 @@ public struct DecryptedMessage { public var senderAddress: String public var sentAt: Date public var topic: String = "" - public var shouldPush: Bool? } diff --git a/Sources/XMTPiOS/Messages/MessageV2.swift b/Sources/XMTPiOS/Messages/MessageV2.swift index 0dc64403..66e1816f 100644 --- a/Sources/XMTPiOS/Messages/MessageV2.swift +++ b/Sources/XMTPiOS/Messages/MessageV2.swift @@ -58,8 +58,7 @@ extension MessageV2 { encodedContent: encodedMessage, senderAddress: try signed.sender.walletAddress, sentAt: Date(timeIntervalSince1970: Double(header.createdNs / 1_000_000) / 1000), - topic: topic, - shouldPush: message.shouldPush + topic: topic ) } @@ -81,7 +80,7 @@ extension MessageV2 { } } - static func encode(client: Client, content encodedContent: EncodedContent, topic: String, keyMaterial: Data, codec: Codec, shouldPush: Bool? = nil) async throws -> MessageV2 { + static func encode(client: Client, content encodedContent: EncodedContent, topic: String, keyMaterial: Data, codec: Codec) async throws -> MessageV2 { let payload = try encodedContent.serializedData() let date = Date() @@ -108,13 +107,14 @@ extension MessageV2 { let senderHmac = try Crypto.generateHmacSignature(secret: keyMaterial, info: infoEncoded, message: headerBytes) let decoded = try codec.decode(content: encodedContent, client: client) - let calculatedShouldPush = try codec.shouldPush(content: decoded) + let shouldPush = try codec.shouldPush(content: decoded) + return MessageV2( headerBytes: headerBytes, ciphertext: ciphertext, senderHmac: senderHmac, - shouldPush: shouldPush ?? calculatedShouldPush + shouldPush: shouldPush ) } } diff --git a/Sources/XMTPiOS/SendOptions.swift b/Sources/XMTPiOS/SendOptions.swift index 50c6ece6..15b09c0d 100644 --- a/Sources/XMTPiOS/SendOptions.swift +++ b/Sources/XMTPiOS/SendOptions.swift @@ -11,12 +11,10 @@ public struct SendOptions { public var compression: EncodedContentCompression? public var contentType: ContentTypeID? public var ephemeral: Bool = false - public var shouldPush: Bool? - public init(compression: EncodedContentCompression? = nil, contentType: ContentTypeID? = nil, ephemeral: Bool = false, __shouldPush: Bool? = nil) { + public init(compression: EncodedContentCompression? = nil, contentType: ContentTypeID? = nil, ephemeral: Bool = false) { self.compression = compression self.contentType = contentType self.ephemeral = ephemeral - self.shouldPush = __shouldPush } } diff --git a/Tests/XMTPTests/ConversationsTest.swift b/Tests/XMTPTests/ConversationsTest.swift index 84a6f21f..5d9ccb91 100644 --- a/Tests/XMTPTests/ConversationsTest.swift +++ b/Tests/XMTPTests/ConversationsTest.swift @@ -8,6 +8,8 @@ import Foundation import XCTest @testable import XMTPiOS +import XMTPTestHelpers +import CryptoKit @available(macOS 13.0, *) @available(iOS 15, *) @@ -122,4 +124,73 @@ class ConversationsTests: XCTestCase { XCTAssertFalse(Topic.isValidTopic(topic: directMessageV2)) XCTAssertFalse(Topic.isValidTopic(topic: preferenceList)) } + + func testReturnsAllHMACKeys() async throws { + try TestConfig.skipIfNotRunningLocalNodeTests() + + let alix = try PrivateKey.generate() + let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) + let alixClient = try await Client.create( + account: alix, + options: opts + ) + var conversations: [Conversation] = [] + for _ in 0..<5 { + let account = try PrivateKey.generate() + let client = try await Client.create(account: account, options: opts) + do { + let newConversation = try await alixClient.conversations.newConversation( + with: client.address, + context: InvitationV1.Context(conversationID: "hi") + ) + conversations.append(newConversation) + } catch { + print("Error creating conversation: \(error)") + } + } + + let thirtyDayPeriodsSinceEpoch = Int(Date().timeIntervalSince1970) / (60 * 60 * 24 * 30) + + let hmacKeys = await alixClient.conversations.getHmacKeys() + + let topics = hmacKeys.hmacKeys.keys + conversations.forEach { conversation in + XCTAssertTrue(topics.contains(conversation.topic)) + } + + var topicHmacs: [String: Data] = [:] + let headerBytes = try Crypto.secureRandomBytes(count: 10) + + for conversation in conversations { + let topic = conversation.topic + let payload = try? TextCodec().encode(content: "Hello, world!", client: alixClient) + + _ = try await MessageV2.encode( + client: alixClient, + content: payload!, + topic: topic, + keyMaterial: headerBytes, + codec: TextCodec() + ) + + let keyMaterial = conversation.keyMaterial + let info = "\(thirtyDayPeriodsSinceEpoch)-\(alixClient.address)" + let key = try Crypto.deriveKey(secret: keyMaterial!, nonce: Data(), info: Data(info.utf8)) + let hmac = try Crypto.calculateMac(headerBytes, key) + + topicHmacs[topic] = hmac + } + + for (topic, hmacData) in hmacKeys.hmacKeys { + for (idx, hmacKeyThirtyDayPeriod) in hmacData.values.enumerated() { + let valid = Crypto.verifyHmacSignature( + key: SymmetricKey(data: hmacKeyThirtyDayPeriod.hmacKey), + signature: topicHmacs[topic]!, + message: headerBytes + ) + + XCTAssertTrue(valid == (idx == 1)) + } + } + } } diff --git a/Tests/XMTPTests/CryptoTests.swift b/Tests/XMTPTests/CryptoTests.swift index 9d9ee074..7f4e03cd 100644 --- a/Tests/XMTPTests/CryptoTests.swift +++ b/Tests/XMTPTests/CryptoTests.swift @@ -60,4 +60,71 @@ final class CryptoTests: XCTestCase { XCTAssertEqual(decryptedText, msg) } + + func testGenerateAndValidateHmac() async throws { + let secret = try Crypto.secureRandomBytes(count: 32) + let info = try Crypto.secureRandomBytes(count: 32) + let message = try Crypto.secureRandomBytes(count: 32) + let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message) + let key = try Crypto.hkdfHmacKey(secret: secret, info: info) + let valid = Crypto.verifyHmacSignature(key: key, signature: hmac, message: message) + + XCTAssertTrue(valid) + } + + func testGenerateAndValidateHmacWithExportedKey() async throws { + let secret = try Crypto.secureRandomBytes(count: 32) + let info = try Crypto.secureRandomBytes(count: 32) + let message = try Crypto.secureRandomBytes(count: 32) + let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message) + let key = try Crypto.hkdfHmacKey(secret: secret, info: info) + let exportedKey = Crypto.exportHmacKey(key: key) + let importedKey = Crypto.importHmacKey(keyData: exportedKey) + let valid = Crypto.verifyHmacSignature(key: importedKey, signature: hmac, message: message) + + XCTAssertTrue(valid) + } + + func testGenerateDifferentHmacKeysWithDifferentInfos() async throws { + let secret = try Crypto.secureRandomBytes(count: 32) + let info1 = try Crypto.secureRandomBytes(count: 32) + let info2 = try Crypto.secureRandomBytes(count: 32) + let key1 = try Crypto.hkdfHmacKey(secret: secret, info: info1) + let key2 = try Crypto.hkdfHmacKey(secret: secret, info: info2) + let exportedKey1 = Crypto.exportHmacKey(key: key1) + let exportedKey2 = Crypto.exportHmacKey(key: key2) + + XCTAssertNotEqual(exportedKey1, exportedKey2) + } + + func testValidateHmacWithWrongMessage() async throws { + let secret = try Crypto.secureRandomBytes(count: 32) + let info = try Crypto.secureRandomBytes(count: 32) + let message = try Crypto.secureRandomBytes(count: 32) + let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message) + let key = try Crypto.hkdfHmacKey(secret: secret, info: info) + let valid = Crypto.verifyHmacSignature( + key: key, + signature: hmac, + message: try Crypto.secureRandomBytes(count: 32) + ) + + XCTAssertFalse(valid) + } + + func testValidateHmacWithWrongKey() async throws { + let secret = try Crypto.secureRandomBytes(count: 32) + let info = try Crypto.secureRandomBytes(count: 32) + let message = try Crypto.secureRandomBytes(count: 32) + let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message) + let valid = Crypto.verifyHmacSignature( + key: try Crypto.hkdfHmacKey( + secret: try Crypto.secureRandomBytes(count: 32), + info: try Crypto.secureRandomBytes(count: 32)), + signature: hmac, + message: message + ) + + XCTAssertFalse(valid) + } } diff --git a/XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift b/XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift index aa1bf911..81b41fd8 100644 --- a/XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift +++ b/XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift @@ -127,11 +127,7 @@ struct LoginView: View { name: "XMTP Chat", description: "It's a chat app.", url: "https://localhost:4567", - icons: [], - redirect: AppMetadata.Redirect( - native: "", - universal: nil - ) + icons: [] ) )