Skip to content

Commit

Permalink
feat: get HMAC keys from conversations (#265)
Browse files Browse the repository at this point in the history
get HMAC keys from conversation
  • Loading branch information
Ezequiel Leanes authored Feb 28, 2024
1 parent 5eb30c9 commit d6a719d
Show file tree
Hide file tree
Showing 9 changed files with 195 additions and 24 deletions.
3 changes: 1 addition & 2 deletions Sources/XMTPiOS/ConversationV2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions Sources/XMTPiOS/Conversations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 16 additions & 8 deletions Sources/XMTPiOS/Crypto.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,13 @@ enum Crypto {

static func deriveKey(secret: Data, nonce: Data, info: Data) throws -> Data {
let key = HKDF<SHA256>.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 {
Expand Down Expand Up @@ -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<SHA256>.isValidAuthenticationCode(
signature,
authenticating: message,
using: key
)
return isValid
}
}
1 change: 0 additions & 1 deletion Sources/XMTPiOS/Messages/DecryptedMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,4 @@ public struct DecryptedMessage {
public var senderAddress: String
public var sentAt: Date
public var topic: String = ""
public var shouldPush: Bool?
}
10 changes: 5 additions & 5 deletions Sources/XMTPiOS/Messages/MessageV2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand All @@ -81,7 +80,7 @@ extension MessageV2 {
}
}

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

let date = Date()
Expand All @@ -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
)
}
}
4 changes: 1 addition & 3 deletions Sources/XMTPiOS/SendOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
71 changes: 71 additions & 0 deletions Tests/XMTPTests/ConversationsTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import Foundation
import XCTest
@testable import XMTPiOS
import XMTPTestHelpers
import CryptoKit

@available(macOS 13.0, *)
@available(iOS 15, *)
Expand Down Expand Up @@ -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))
}
}
}
}
67 changes: 67 additions & 0 deletions Tests/XMTPTests/CryptoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
6 changes: 1 addition & 5 deletions XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
)
)

Expand Down

0 comments on commit d6a719d

Please sign in to comment.