Skip to content

Commit d6a719d

Browse files
author
Ezequiel Leanes
authored
feat: get HMAC keys from conversations (#265)
get HMAC keys from conversation
1 parent 5eb30c9 commit d6a719d

File tree

9 files changed

+195
-24
lines changed

9 files changed

+195
-24
lines changed

Sources/XMTPiOS/ConversationV2.swift

+1-2
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,7 @@ public struct ConversationV2 {
8585
content: encodedContent,
8686
topic: topic,
8787
keyMaterial: keyMaterial,
88-
codec: codec,
89-
shouldPush: options?.shouldPush
88+
codec: codec
9089
)
9190

9291
let topic = options?.ephemeral == true ? ephemeralTopic : topic

Sources/XMTPiOS/Conversations.swift

+33
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,39 @@ public actor Conversations {
581581
a.createdAt < b.createdAt
582582
}
583583
}
584+
585+
public func getHmacKeys(request: Xmtp_KeystoreApi_V1_GetConversationHmacKeysRequest? = nil) -> Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse {
586+
let thirtyDayPeriodsSinceEpoch = Int(Date().timeIntervalSince1970) / (60 * 60 * 24 * 30)
587+
var hmacKeysResponse = Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse()
588+
589+
var topics = conversationsByTopic
590+
591+
if let requestTopics = request?.topics, !requestTopics.isEmpty {
592+
topics = topics.filter { requestTopics.contains($0.key) }
593+
}
594+
595+
for (topic, conversation) in topics {
596+
guard let keyMaterial = conversation.keyMaterial else { continue }
597+
598+
var hmacKeys = Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse.HmacKeys()
599+
600+
for period in (thirtyDayPeriodsSinceEpoch - 1)...(thirtyDayPeriodsSinceEpoch + 1) {
601+
let info = "\(period)-\(client.address)"
602+
do {
603+
let hmacKey = try Crypto.deriveKey(secret: keyMaterial, nonce: Data(), info: Data(info.utf8))
604+
var hmacKeyData = Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse.HmacKeyData()
605+
hmacKeyData.hmacKey = hmacKey
606+
hmacKeyData.thirtyDayPeriodsSinceEpoch = Int32(period)
607+
hmacKeys.values.append(hmacKeyData)
608+
} catch {
609+
print("Error calculating HMAC key for topic \(topic): \(error)")
610+
}
611+
}
612+
hmacKeysResponse.hmacKeys[topic] = hmacKeys
613+
}
614+
615+
return hmacKeysResponse
616+
}
584617

585618
private func listIntroductionPeers(pagination: Pagination?) async throws -> [String: Date] {
586619
let envelopes = try await client.apiClient.query(

Sources/XMTPiOS/Crypto.swift

+16-8
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,13 @@ enum Crypto {
7676

7777
static func deriveKey(secret: Data, nonce: Data, info: Data) throws -> Data {
7878
let key = HKDF<SHA256>.deriveKey(
79-
inputKeyMaterial: SymmetricKey(data: secret),
80-
salt: nonce,
81-
info: info,
82-
outputByteCount: 32
83-
)
84-
return key.withUnsafeBytes { body in
85-
Data(body)
86-
}
79+
inputKeyMaterial: SymmetricKey(data: secret),
80+
salt: nonce,
81+
info: info,
82+
outputByteCount: 32)
83+
return key.withUnsafeBytes { body in
84+
Data(body)
85+
}
8786
}
8887

8988
static func secureRandomBytes(count: Int) throws -> Data {
@@ -136,4 +135,13 @@ enum Crypto {
136135
static func importHmacKey(keyData: Data) -> SymmetricKey {
137136
return SymmetricKey(data: keyData)
138137
}
138+
139+
static func verifyHmacSignature(key: SymmetricKey, signature: Data, message: Data) -> Bool {
140+
let isValid = HMAC<SHA256>.isValidAuthenticationCode(
141+
signature,
142+
authenticating: message,
143+
using: key
144+
)
145+
return isValid
146+
}
139147
}

Sources/XMTPiOS/Messages/DecryptedMessage.swift

-1
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,4 @@ public struct DecryptedMessage {
1313
public var senderAddress: String
1414
public var sentAt: Date
1515
public var topic: String = ""
16-
public var shouldPush: Bool?
1716
}

Sources/XMTPiOS/Messages/MessageV2.swift

+5-5
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,7 @@ extension MessageV2 {
5858
encodedContent: encodedMessage,
5959
senderAddress: try signed.sender.walletAddress,
6060
sentAt: Date(timeIntervalSince1970: Double(header.createdNs / 1_000_000) / 1000),
61-
topic: topic,
62-
shouldPush: message.shouldPush
61+
topic: topic
6362
)
6463
}
6564

@@ -81,7 +80,7 @@ extension MessageV2 {
8180
}
8281
}
8382

84-
static func encode<Codec: ContentCodec>(client: Client, content encodedContent: EncodedContent, topic: String, keyMaterial: Data, codec: Codec, shouldPush: Bool? = nil) async throws -> MessageV2 {
83+
static func encode<Codec: ContentCodec>(client: Client, content encodedContent: EncodedContent, topic: String, keyMaterial: Data, codec: Codec) async throws -> MessageV2 {
8584
let payload = try encodedContent.serializedData()
8685

8786
let date = Date()
@@ -108,13 +107,14 @@ extension MessageV2 {
108107
let senderHmac = try Crypto.generateHmacSignature(secret: keyMaterial, info: infoEncoded, message: headerBytes)
109108

110109
let decoded = try codec.decode(content: encodedContent, client: client)
111-
let calculatedShouldPush = try codec.shouldPush(content: decoded)
110+
let shouldPush = try codec.shouldPush(content: decoded)
111+
112112

113113
return MessageV2(
114114
headerBytes: headerBytes,
115115
ciphertext: ciphertext,
116116
senderHmac: senderHmac,
117-
shouldPush: shouldPush ?? calculatedShouldPush
117+
shouldPush: shouldPush
118118
)
119119
}
120120
}

Sources/XMTPiOS/SendOptions.swift

+1-3
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,10 @@ public struct SendOptions {
1111
public var compression: EncodedContentCompression?
1212
public var contentType: ContentTypeID?
1313
public var ephemeral: Bool = false
14-
public var shouldPush: Bool?
1514

16-
public init(compression: EncodedContentCompression? = nil, contentType: ContentTypeID? = nil, ephemeral: Bool = false, __shouldPush: Bool? = nil) {
15+
public init(compression: EncodedContentCompression? = nil, contentType: ContentTypeID? = nil, ephemeral: Bool = false) {
1716
self.compression = compression
1817
self.contentType = contentType
1918
self.ephemeral = ephemeral
20-
self.shouldPush = __shouldPush
2119
}
2220
}

Tests/XMTPTests/ConversationsTest.swift

+71
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import Foundation
99
import XCTest
1010
@testable import XMTPiOS
11+
import XMTPTestHelpers
12+
import CryptoKit
1113

1214
@available(macOS 13.0, *)
1315
@available(iOS 15, *)
@@ -122,4 +124,73 @@ class ConversationsTests: XCTestCase {
122124
XCTAssertFalse(Topic.isValidTopic(topic: directMessageV2))
123125
XCTAssertFalse(Topic.isValidTopic(topic: preferenceList))
124126
}
127+
128+
func testReturnsAllHMACKeys() async throws {
129+
try TestConfig.skipIfNotRunningLocalNodeTests()
130+
131+
let alix = try PrivateKey.generate()
132+
let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false))
133+
let alixClient = try await Client.create(
134+
account: alix,
135+
options: opts
136+
)
137+
var conversations: [Conversation] = []
138+
for _ in 0..<5 {
139+
let account = try PrivateKey.generate()
140+
let client = try await Client.create(account: account, options: opts)
141+
do {
142+
let newConversation = try await alixClient.conversations.newConversation(
143+
with: client.address,
144+
context: InvitationV1.Context(conversationID: "hi")
145+
)
146+
conversations.append(newConversation)
147+
} catch {
148+
print("Error creating conversation: \(error)")
149+
}
150+
}
151+
152+
let thirtyDayPeriodsSinceEpoch = Int(Date().timeIntervalSince1970) / (60 * 60 * 24 * 30)
153+
154+
let hmacKeys = await alixClient.conversations.getHmacKeys()
155+
156+
let topics = hmacKeys.hmacKeys.keys
157+
conversations.forEach { conversation in
158+
XCTAssertTrue(topics.contains(conversation.topic))
159+
}
160+
161+
var topicHmacs: [String: Data] = [:]
162+
let headerBytes = try Crypto.secureRandomBytes(count: 10)
163+
164+
for conversation in conversations {
165+
let topic = conversation.topic
166+
let payload = try? TextCodec().encode(content: "Hello, world!", client: alixClient)
167+
168+
_ = try await MessageV2.encode(
169+
client: alixClient,
170+
content: payload!,
171+
topic: topic,
172+
keyMaterial: headerBytes,
173+
codec: TextCodec()
174+
)
175+
176+
let keyMaterial = conversation.keyMaterial
177+
let info = "\(thirtyDayPeriodsSinceEpoch)-\(alixClient.address)"
178+
let key = try Crypto.deriveKey(secret: keyMaterial!, nonce: Data(), info: Data(info.utf8))
179+
let hmac = try Crypto.calculateMac(headerBytes, key)
180+
181+
topicHmacs[topic] = hmac
182+
}
183+
184+
for (topic, hmacData) in hmacKeys.hmacKeys {
185+
for (idx, hmacKeyThirtyDayPeriod) in hmacData.values.enumerated() {
186+
let valid = Crypto.verifyHmacSignature(
187+
key: SymmetricKey(data: hmacKeyThirtyDayPeriod.hmacKey),
188+
signature: topicHmacs[topic]!,
189+
message: headerBytes
190+
)
191+
192+
XCTAssertTrue(valid == (idx == 1))
193+
}
194+
}
195+
}
125196
}

Tests/XMTPTests/CryptoTests.swift

+67
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,71 @@ final class CryptoTests: XCTestCase {
6060

6161
XCTAssertEqual(decryptedText, msg)
6262
}
63+
64+
func testGenerateAndValidateHmac() async throws {
65+
let secret = try Crypto.secureRandomBytes(count: 32)
66+
let info = try Crypto.secureRandomBytes(count: 32)
67+
let message = try Crypto.secureRandomBytes(count: 32)
68+
let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message)
69+
let key = try Crypto.hkdfHmacKey(secret: secret, info: info)
70+
let valid = Crypto.verifyHmacSignature(key: key, signature: hmac, message: message)
71+
72+
XCTAssertTrue(valid)
73+
}
74+
75+
func testGenerateAndValidateHmacWithExportedKey() async throws {
76+
let secret = try Crypto.secureRandomBytes(count: 32)
77+
let info = try Crypto.secureRandomBytes(count: 32)
78+
let message = try Crypto.secureRandomBytes(count: 32)
79+
let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message)
80+
let key = try Crypto.hkdfHmacKey(secret: secret, info: info)
81+
let exportedKey = Crypto.exportHmacKey(key: key)
82+
let importedKey = Crypto.importHmacKey(keyData: exportedKey)
83+
let valid = Crypto.verifyHmacSignature(key: importedKey, signature: hmac, message: message)
84+
85+
XCTAssertTrue(valid)
86+
}
87+
88+
func testGenerateDifferentHmacKeysWithDifferentInfos() async throws {
89+
let secret = try Crypto.secureRandomBytes(count: 32)
90+
let info1 = try Crypto.secureRandomBytes(count: 32)
91+
let info2 = try Crypto.secureRandomBytes(count: 32)
92+
let key1 = try Crypto.hkdfHmacKey(secret: secret, info: info1)
93+
let key2 = try Crypto.hkdfHmacKey(secret: secret, info: info2)
94+
let exportedKey1 = Crypto.exportHmacKey(key: key1)
95+
let exportedKey2 = Crypto.exportHmacKey(key: key2)
96+
97+
XCTAssertNotEqual(exportedKey1, exportedKey2)
98+
}
99+
100+
func testValidateHmacWithWrongMessage() async throws {
101+
let secret = try Crypto.secureRandomBytes(count: 32)
102+
let info = try Crypto.secureRandomBytes(count: 32)
103+
let message = try Crypto.secureRandomBytes(count: 32)
104+
let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message)
105+
let key = try Crypto.hkdfHmacKey(secret: secret, info: info)
106+
let valid = Crypto.verifyHmacSignature(
107+
key: key,
108+
signature: hmac,
109+
message: try Crypto.secureRandomBytes(count: 32)
110+
)
111+
112+
XCTAssertFalse(valid)
113+
}
114+
115+
func testValidateHmacWithWrongKey() async throws {
116+
let secret = try Crypto.secureRandomBytes(count: 32)
117+
let info = try Crypto.secureRandomBytes(count: 32)
118+
let message = try Crypto.secureRandomBytes(count: 32)
119+
let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message)
120+
let valid = Crypto.verifyHmacSignature(
121+
key: try Crypto.hkdfHmacKey(
122+
secret: try Crypto.secureRandomBytes(count: 32),
123+
info: try Crypto.secureRandomBytes(count: 32)),
124+
signature: hmac,
125+
message: message
126+
)
127+
128+
XCTAssertFalse(valid)
129+
}
63130
}

XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift

+1-5
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,7 @@ struct LoginView: View {
127127
name: "XMTP Chat",
128128
description: "It's a chat app.",
129129
url: "https://localhost:4567",
130-
icons: [],
131-
redirect: AppMetadata.Redirect(
132-
native: "",
133-
universal: nil
134-
)
130+
icons: []
135131
)
136132
)
137133

0 commit comments

Comments
 (0)