From b2437a345cd618e747f04c9ffaa34a8c30468a87 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 12 Jan 2024 12:47:03 -0600 Subject: [PATCH 01/21] feat: add `shouldPush` to ContentCodec, TextCodec --- src/MessageContent.ts | 1 + src/codecs/Text.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/MessageContent.ts b/src/MessageContent.ts index 216016d78..de203131b 100644 --- a/src/MessageContent.ts +++ b/src/MessageContent.ts @@ -58,6 +58,7 @@ export interface ContentCodec { encode(content: T, registry: CodecRegistry): EncodedContent decode(content: EncodedContent, registry: CodecRegistry): T fallback(content: T): string | undefined + shouldPush: (content: T) => boolean } // xmtp.org/fallback diff --git a/src/codecs/Text.ts b/src/codecs/Text.ts index b93c5358e..3da35eaf7 100644 --- a/src/codecs/Text.ts +++ b/src/codecs/Text.ts @@ -39,4 +39,8 @@ export class TextCodec implements ContentCodec { fallback(content: string): string | undefined { return undefined } + + shouldPush() { + return true + } } From 8399807283bd221b6177bb8e8b3457a818d56207 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 12 Jan 2024 13:53:40 -0600 Subject: [PATCH 02/21] fix: add `shouldPush` to `ContentTypeComposite` --- src/codecs/Composite.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/codecs/Composite.ts b/src/codecs/Composite.ts index 1a8c13178..13895ec0f 100644 --- a/src/codecs/Composite.ts +++ b/src/codecs/Composite.ts @@ -112,4 +112,8 @@ export class CompositeCodec implements ContentCodec { fallback(content: Composite): string | undefined { return undefined } + + shouldPush() { + return false + } } From 04254219f0e0579366a25a9a205221d0e5c65e6e Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 12 Jan 2024 13:54:29 -0600 Subject: [PATCH 03/21] chore: upgrade `@xmtp/proto` --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 171adcc30..df9fdba31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.34.0", + "@xmtp/proto": "^3.37.0-beta.1", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", @@ -5511,9 +5511,9 @@ } }, "node_modules/@xmtp/proto": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.34.0.tgz", - "integrity": "sha512-UJ0doz01peGEi5+fJ6th6JsUXFLMHaVk9L9avrv7L7FhfmAzM/V5iqHao8YpIKyMrJ7BelsGrbDcTfe28SsxFg==", + "version": "3.37.0-beta.1", + "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.37.0-beta.1.tgz", + "integrity": "sha512-060+4wSnrN1yvUxEDdWyJt0v8f2gr0lt6Bt5wK8/9w6hH0OWyzO09PHccbHe2wkBqg36gANMPw9rQh2hmtlAkg==", "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", diff --git a/package.json b/package.json index f9037b94f..cb27a2bbc 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ }, "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.34.0", + "@xmtp/proto": "^3.37.0-beta.1", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", From 202601c6baba1c0b4c82c34a958214425f8434b3 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 12 Jan 2024 14:00:44 -0600 Subject: [PATCH 04/21] feat: add `senderHmac` to `MessageV2` --- src/Message.ts | 12 ++++++++---- src/conversations/Conversation.ts | 28 +++++++++++----------------- src/keystore/InMemoryKeystore.ts | 11 ++++++++--- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/src/Message.ts b/src/Message.ts index d32df3dc8..e2b068ded 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -190,26 +190,30 @@ export class MessageV1 extends MessageBase implements proto.MessageV1 { export class MessageV2 extends MessageBase implements proto.MessageV2 { senderAddress: string | undefined - private header: proto.MessageHeaderV2 // eslint-disable-line camelcase + private header: proto.MessageHeaderV2 + senderHmac: Uint8Array constructor( id: string, bytes: Uint8Array, obj: proto.Message, - header: proto.MessageHeaderV2 + header: proto.MessageHeaderV2, + senderHmac: Uint8Array ) { super(id, bytes, obj) this.header = header + this.senderHmac = senderHmac } static async create( obj: proto.Message, header: proto.MessageHeaderV2, - bytes: Uint8Array + bytes: Uint8Array, + senderHmac: Uint8Array ): Promise { const id = bytesToHex(await sha256(bytes)) - return new MessageV2(id, bytes, obj, header) + return new MessageV2(id, bytes, obj, header, senderHmac) } get sent(): Date { diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index a12b4a47c..f36c5a517 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -15,13 +15,7 @@ import Client, { } from '../Client' import { InvitationContext } from '../Invitation' import { DecodedMessage, MessageV1, MessageV2 } from '../Message' -import { - messageApi, - message, - content as proto, - keystore, - ciphertext, -} from '@xmtp/proto' +import { messageApi, message, content as proto, keystore } from '@xmtp/proto' import { SignedPublicKey, Signature, @@ -662,14 +656,17 @@ export class ConversationV2 } const signedBytes = proto.SignedContent.encode(signed).finish() - const ciphertext = await this.encryptMessage(signedBytes, headerBytes) + const { encrypted: ciphertext, senderHmac } = await this.encryptMessage( + signedBytes, + headerBytes + ) const protoMsg = { v1: undefined, - v2: { headerBytes, ciphertext }, + v2: { headerBytes, ciphertext, senderHmac }, } const bytes = message.Message.encode(protoMsg).finish() - return MessageV2.create(protoMsg, header, bytes) + return MessageV2.create(protoMsg, header, bytes, senderHmac) } private async decryptBatch( @@ -713,10 +710,7 @@ export class ConversationV2 } } - private async encryptMessage( - payload: Uint8Array, - headerBytes: Uint8Array - ): Promise { + private async encryptMessage(payload: Uint8Array, headerBytes: Uint8Array) { const { responses } = await this.client.keystore.encryptV2({ requests: [ { @@ -729,8 +723,8 @@ export class ConversationV2 if (responses.length !== 1) { throw new Error('Invalid response length') } - const { encrypted } = getResultOrThrow(responses[0]) - return encrypted + const { encrypted, senderHmac } = getResultOrThrow(responses[0]) + return { encrypted, senderHmac } } private async buildDecodedMessage( @@ -833,7 +827,7 @@ export class ConversationV2 throw new Error('topic mismatch') } - return MessageV2.create(msg, header, env.message) + return MessageV2.create(msg, header, env.message, msg.v2.senderHmac) } async decodeMessage( diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 71b8531c9..24b8c5a9a 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -35,6 +35,7 @@ import { generateUserPreferencesTopic, } from '../crypto/selfEncryption' import { KeystoreInterface } from '..' +import { generateHmacSignature } from '../crypto/encryption' const { ErrorCode } = keystore @@ -295,10 +296,14 @@ export default class InMemoryKeystore implements KeystoreInterface { ) } + const keyMaterial = getKeyMaterial(topicData.invitation) + const ciphertext = await encryptV2(payload, keyMaterial, headerBytes) + return { - encrypted: await encryptV2( - payload, - getKeyMaterial(topicData.invitation), + encrypted: ciphertext, + senderHmac: await generateHmacSignature( + keyMaterial, + new TextEncoder().encode(contentTopic), headerBytes ), } From f0ed97cf108262a1fb3f63badc09d6cc752d647f Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 12 Jan 2024 14:02:04 -0600 Subject: [PATCH 05/21] fix: update `getResultOrThrow` return type --- src/utils/keystore.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/utils/keystore.ts b/src/utils/keystore.ts index d3ce25cfc..3ef018a08 100644 --- a/src/utils/keystore.ts +++ b/src/utils/keystore.ts @@ -4,6 +4,12 @@ import { KeystoreError } from '../keystore/errors' import { MessageV1 } from '../Message' import { WithoutUndefined } from './typedefs' +type EncryptionResponseResult< + T extends + | keystore.DecryptResponse_Response + | keystore.EncryptResponse_Response, +> = WithoutUndefined['result'] + // Validates the Keystore response. Throws on errors or missing fields. // Returns a type with all possibly undefined fields required to be defined export const getResultOrThrow = < @@ -12,10 +18,11 @@ export const getResultOrThrow = < | keystore.EncryptResponse_Response, >( response: T -): WithoutUndefined> => { +) => { if (response.error) { throw new KeystoreError(response.error.code, response.error.message) } + if (!response.result) { throw new KeystoreError( keystore.ErrorCode.ERROR_CODE_UNSPECIFIED, @@ -31,9 +38,7 @@ export const getResultOrThrow = < throw new Error('Missing decrypted result') } - return response.result as unknown as WithoutUndefined< - NonNullable - > + return response.result as EncryptionResponseResult } export const buildDecryptV1Request = ( From 19bc071f5861637382d20b4a72b183a72e61a44e Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 12 Jan 2024 14:04:05 -0600 Subject: [PATCH 06/21] feat: add hmac encryption --- src/crypto/encryption.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/crypto/encryption.ts b/src/crypto/encryption.ts index 0e9460acb..f570bc2b1 100644 --- a/src/crypto/encryption.ts +++ b/src/crypto/encryption.ts @@ -83,3 +83,36 @@ async function hkdf(secret: Uint8Array, salt: Uint8Array): Promise { ['encrypt', 'decrypt'] ) } + +async function hkdfHmacKey( + secret: Uint8Array, + salt: Uint8Array +): Promise { + const key = await crypto.subtle.importKey('raw', secret, 'HKDF', false, [ + 'deriveKey', + ]) + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt, info: hkdfNoInfo }, + key, + { name: 'HMAC', hash: 'SHA-256', length: 256 }, + false, + ['sign', 'verify'] + ) +} + +async function generateHmac( + key: CryptoKey, + message: Uint8Array +): Promise { + const signed = await crypto.subtle.sign('HMAC', key, message) + return new Uint8Array(signed) +} + +export async function generateHmacSignature( + secret: Uint8Array, + salt: Uint8Array, + message: Uint8Array +): Promise { + const key = await hkdfHmacKey(secret, salt) + return generateHmac(key, message) +} From 033a776c792cb1d6de5da6e4d2f7d657e05a6c6d Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 17 Jan 2024 15:43:35 -0600 Subject: [PATCH 07/21] feat: add method to get conversation HMAC keys --- package-lock.json | 8 ++--- package.json | 2 +- src/crypto/encryption.ts | 2 +- src/keystore/InMemoryKeystore.ts | 57 ++++++++++++++++++++++++++++---- src/keystore/interfaces.ts | 5 +++ src/keystore/rpcDefinitions.ts | 4 +++ 6 files changed, 66 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index df9fdba31..b4344004d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.37.0-beta.1", + "@xmtp/proto": "^3.37.0-beta.2", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", @@ -5511,9 +5511,9 @@ } }, "node_modules/@xmtp/proto": { - "version": "3.37.0-beta.1", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.37.0-beta.1.tgz", - "integrity": "sha512-060+4wSnrN1yvUxEDdWyJt0v8f2gr0lt6Bt5wK8/9w6hH0OWyzO09PHccbHe2wkBqg36gANMPw9rQh2hmtlAkg==", + "version": "3.37.0-beta.2", + "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.37.0-beta.2.tgz", + "integrity": "sha512-D8vqMe9MCpbKx6ECw01VxLaiMRK4jtoeYuCTCwunsc/Z44SgxEKi+bO7q6T7EsIcaBBdz06rLvyjSdj/s1CCQA==", "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", diff --git a/package.json b/package.json index cb27a2bbc..30a968e11 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ }, "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.37.0-beta.1", + "@xmtp/proto": "^3.37.0-beta.2", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", diff --git a/src/crypto/encryption.ts b/src/crypto/encryption.ts index f570bc2b1..f6552695b 100644 --- a/src/crypto/encryption.ts +++ b/src/crypto/encryption.ts @@ -84,7 +84,7 @@ async function hkdf(secret: Uint8Array, salt: Uint8Array): Promise { ) } -async function hkdfHmacKey( +export async function hkdfHmacKey( secret: Uint8Array, salt: Uint8Array ): Promise { diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 24b8c5a9a..6837f427c 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -35,7 +35,7 @@ import { generateUserPreferencesTopic, } from '../crypto/selfEncryption' import { KeystoreInterface } from '..' -import { generateHmacSignature } from '../crypto/encryption' +import { generateHmacSignature, hkdfHmacKey } from '../crypto/encryption' const { ErrorCode } = keystore @@ -298,14 +298,19 @@ export default class InMemoryKeystore implements KeystoreInterface { const keyMaterial = getKeyMaterial(topicData.invitation) const ciphertext = await encryptV2(payload, keyMaterial, headerBytes) + const thirtyDayPeriodsSinceEpoch = Math.floor( + Date.now() / 1000 / 60 / 60 / 24 / 30 + ) + const salt = `${thirtyDayPeriodsSinceEpoch}-${this.accountAddress}` + const hmac = await generateHmacSignature( + keyMaterial, + new TextEncoder().encode(salt), + headerBytes + ) return { encrypted: ciphertext, - senderHmac: await generateHmacSignature( - keyMaterial, - new TextEncoder().encode(contentTopic), - headerBytes - ), + senderHmac: hmac, } }, ErrorCode.ERROR_CODE_INVALID_INPUT @@ -585,4 +590,44 @@ export default class InMemoryKeystore implements KeystoreInterface { lookupTopic(topic: string) { return this.v2Store.lookup(topic) } + + async getV2ConversationHmacKeys(): Promise { + const thirtyDayPeriodsSinceEpoch = Math.floor( + Date.now() / 1000 / 60 / 60 / 24 / 30 + ) + + const hmacKeys: keystore.GetConversationHmacKeysResponse['hmacKeys'] = {} + + this.v2Store.topics.forEach(async (topicData) => { + if (topicData.invitation?.topic) { + const keyMaterial = getKeyMaterial(topicData.invitation) + const values = await Promise.all( + [ + thirtyDayPeriodsSinceEpoch - 1, + thirtyDayPeriodsSinceEpoch, + thirtyDayPeriodsSinceEpoch + 1, + ].map(async (value) => { + const salt = `${value}-${this.accountAddress}` + const hmacKey = await hkdfHmacKey( + keyMaterial, + new TextEncoder().encode(salt) + ) + return { + thirtyDayPeriodsSinceEpoch: value, + // convert CryptoKey to Uint8Array to match the proto + hmacKey: new Uint8Array( + await crypto.subtle.exportKey('raw', hmacKey) + ), + } + }) + ) + + hmacKeys[topicData.invitation.topic] = { + values, + } + } + }) + + return { hmacKeys } + } } diff --git a/src/keystore/interfaces.ts b/src/keystore/interfaces.ts index 7ea373309..e68ad9ffe 100644 --- a/src/keystore/interfaces.ts +++ b/src/keystore/interfaces.ts @@ -98,6 +98,11 @@ export interface Keystore { * Get the private preferences topic identifier */ getPrivatePreferencesTopicIdentifier(): Promise + /** + * Returns the conversation HMAC keys for the current, previous, and next + * 30 day periods since the epoch + */ + getV2ConversationHmacKeys(): Promise } export type TopicData = WithoutUndefined diff --git a/src/keystore/rpcDefinitions.ts b/src/keystore/rpcDefinitions.ts index 6c823d890..5eda32a17 100644 --- a/src/keystore/rpcDefinitions.ts +++ b/src/keystore/rpcDefinitions.ts @@ -199,6 +199,10 @@ export const apiDefs = { req: null, res: keystore.GetPrivatePreferencesTopicIdentifierResponse, }, + getV2ConversationHmacKeys: { + req: null, + res: keystore.GetConversationHmacKeysResponse, + }, } export type KeystoreApiDefs = typeof apiDefs From 5b655a0e1cf0324354dff2cd9e27e0b68def4328 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 17 Jan 2024 21:51:26 -0600 Subject: [PATCH 08/21] test: update `TestKeyCodec` --- test/ContentTypeTestKey.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/ContentTypeTestKey.ts b/test/ContentTypeTestKey.ts index 0b844f510..18ee221d8 100644 --- a/test/ContentTypeTestKey.ts +++ b/test/ContentTypeTestKey.ts @@ -29,4 +29,8 @@ export class TestKeyCodec implements ContentCodec { fallback(content: PublicKey): string | undefined { return 'publickey bundle' } + + shouldPush() { + return false + } } From eddf4d93ac1048495ff1a207ca94edab9051b66d Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 18 Jan 2024 10:00:59 -0600 Subject: [PATCH 09/21] refactor: adjust function naming --- src/crypto/encryption.ts | 6 +++--- src/keystore/InMemoryKeystore.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/crypto/encryption.ts b/src/crypto/encryption.ts index f6552695b..8e315cabf 100644 --- a/src/crypto/encryption.ts +++ b/src/crypto/encryption.ts @@ -100,7 +100,7 @@ export async function hkdfHmacKey( ) } -async function generateHmac( +async function generateHmacWithKey( key: CryptoKey, message: Uint8Array ): Promise { @@ -108,11 +108,11 @@ async function generateHmac( return new Uint8Array(signed) } -export async function generateHmacSignature( +export async function generateHmac( secret: Uint8Array, salt: Uint8Array, message: Uint8Array ): Promise { const key = await hkdfHmacKey(secret, salt) - return generateHmac(key, message) + return generateHmacWithKey(key, message) } diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 6837f427c..8e0460324 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -35,7 +35,7 @@ import { generateUserPreferencesTopic, } from '../crypto/selfEncryption' import { KeystoreInterface } from '..' -import { generateHmacSignature, hkdfHmacKey } from '../crypto/encryption' +import { generateHmac, hkdfHmacKey } from '../crypto/encryption' const { ErrorCode } = keystore @@ -302,7 +302,7 @@ export default class InMemoryKeystore implements KeystoreInterface { Date.now() / 1000 / 60 / 60 / 24 / 30 ) const salt = `${thirtyDayPeriodsSinceEpoch}-${this.accountAddress}` - const hmac = await generateHmacSignature( + const hmac = await generateHmac( keyMaterial, new TextEncoder().encode(salt), headerBytes From 70fa02fb44999a52e705480d0c8edcd3b4ac4e1e Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 18 Jan 2024 17:16:30 -0600 Subject: [PATCH 10/21] fix: allow for HMAC key to be extractable --- src/crypto/encryption.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crypto/encryption.ts b/src/crypto/encryption.ts index 8e315cabf..9019143c4 100644 --- a/src/crypto/encryption.ts +++ b/src/crypto/encryption.ts @@ -95,7 +95,7 @@ export async function hkdfHmacKey( { name: 'HKDF', hash: 'SHA-256', salt, info: hkdfNoInfo }, key, { name: 'HMAC', hash: 'SHA-256', length: 256 }, - false, + true, ['sign', 'verify'] ) } From eb5dd758793abf36b3a8f98ab80d10e3fd47104a Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 19 Jan 2024 16:27:34 -0600 Subject: [PATCH 11/21] refactor: add encryption helpers --- src/crypto/encryption.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/crypto/encryption.ts b/src/crypto/encryption.ts index 9019143c4..795170717 100644 --- a/src/crypto/encryption.ts +++ b/src/crypto/encryption.ts @@ -116,3 +116,26 @@ export async function generateHmac( const key = await hkdfHmacKey(secret, salt) return generateHmacWithKey(key, message) } + +export async function validateHmac( + key: CryptoKey, + signature: Uint8Array, + message: Uint8Array +): Promise { + return await crypto.subtle.verify('HMAC', key, signature, message) +} + +export async function exportHmacKey(key: CryptoKey): Promise { + const exported = await crypto.subtle.exportKey('raw', key) + return new Uint8Array(exported) +} + +export async function importHmacKey(key: Uint8Array): Promise { + return crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: 'SHA-256', length: 256 }, + true, + ['sign', 'verify'] + ) +} From fbd438c595819d64afbdba843dad268286cc9c55 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 19 Jan 2024 16:28:17 -0600 Subject: [PATCH 12/21] fix: await key gen before returning --- src/keystore/InMemoryKeystore.ts | 56 ++++++++++++++++---------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 8e0460324..390f6fecf 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -35,7 +35,7 @@ import { generateUserPreferencesTopic, } from '../crypto/selfEncryption' import { KeystoreInterface } from '..' -import { generateHmac, hkdfHmacKey } from '../crypto/encryption' +import { exportHmacKey, generateHmac, hkdfHmacKey } from '../crypto/encryption' const { ErrorCode } = keystore @@ -598,35 +598,35 @@ export default class InMemoryKeystore implements KeystoreInterface { const hmacKeys: keystore.GetConversationHmacKeysResponse['hmacKeys'] = {} - this.v2Store.topics.forEach(async (topicData) => { - if (topicData.invitation?.topic) { - const keyMaterial = getKeyMaterial(topicData.invitation) - const values = await Promise.all( - [ - thirtyDayPeriodsSinceEpoch - 1, - thirtyDayPeriodsSinceEpoch, - thirtyDayPeriodsSinceEpoch + 1, - ].map(async (value) => { - const salt = `${value}-${this.accountAddress}` - const hmacKey = await hkdfHmacKey( - keyMaterial, - new TextEncoder().encode(salt) - ) - return { - thirtyDayPeriodsSinceEpoch: value, - // convert CryptoKey to Uint8Array to match the proto - hmacKey: new Uint8Array( - await crypto.subtle.exportKey('raw', hmacKey) - ), - } - }) - ) + await Promise.all( + this.v2Store.topics.map(async (topicData) => { + if (topicData.invitation?.topic) { + const keyMaterial = getKeyMaterial(topicData.invitation) + const values = await Promise.all( + [ + thirtyDayPeriodsSinceEpoch - 1, + thirtyDayPeriodsSinceEpoch, + thirtyDayPeriodsSinceEpoch + 1, + ].map(async (value) => { + const salt = `${value}-${this.accountAddress}` + const hmacKey = await hkdfHmacKey( + keyMaterial, + new TextEncoder().encode(salt) + ) + return { + thirtyDayPeriodsSinceEpoch: value, + // convert CryptoKey to Uint8Array to match the proto + hmacKey: await exportHmacKey(hmacKey), + } + }) + ) - hmacKeys[topicData.invitation.topic] = { - values, + hmacKeys[topicData.invitation.topic] = { + values, + } } - } - }) + }) + ) return { hmacKeys } } From 0b9b9519c68096ccbf7e05501aa3f904a5d975e0 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 19 Jan 2024 16:29:17 -0600 Subject: [PATCH 13/21] test: add HMAC encryption tests --- test/crypto/encryption.test.ts | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 test/crypto/encryption.test.ts diff --git a/test/crypto/encryption.test.ts b/test/crypto/encryption.test.ts new file mode 100644 index 000000000..4e95f925f --- /dev/null +++ b/test/crypto/encryption.test.ts @@ -0,0 +1,62 @@ +import { + importHmacKey, + exportHmacKey, + hkdfHmacKey, + validateHmac, + generateHmac, +} from '../../src/crypto/encryption' +import crypto from '../../src/crypto/crypto' + +describe('HMAC encryption', () => { + it('generates and validates HMAC', async () => { + const secret = crypto.getRandomValues(new Uint8Array(32)) + const salt = crypto.getRandomValues(new Uint8Array(32)) + const message = crypto.getRandomValues(new Uint8Array(32)) + const hmac = await generateHmac(secret, salt, message) + const key = await hkdfHmacKey(secret, salt) + const valid = await validateHmac(key, hmac, message) + expect(valid).toBe(true) + }) + + it('generates and validates HMAC with imported key', async () => { + const secret = crypto.getRandomValues(new Uint8Array(32)) + const salt = crypto.getRandomValues(new Uint8Array(32)) + const message = crypto.getRandomValues(new Uint8Array(32)) + const hmac = await generateHmac(secret, salt, message) + const key = await hkdfHmacKey(secret, salt) + const exportedKey = await exportHmacKey(key) + const importedKey = await importHmacKey(exportedKey) + const valid = await validateHmac(importedKey, hmac, message) + expect(valid).toBe(true) + }) + + it('fails to validate HMAC with wrong message', async () => { + const secret = crypto.getRandomValues(new Uint8Array(32)) + const salt = crypto.getRandomValues(new Uint8Array(32)) + const message = crypto.getRandomValues(new Uint8Array(32)) + const hmac = await generateHmac(secret, salt, message) + const key = await hkdfHmacKey(secret, salt) + const valid = await validateHmac( + key, + hmac, + crypto.getRandomValues(new Uint8Array(32)) + ) + expect(valid).toBe(false) + }) + + it('fails to validate HMAC with wrong key', async () => { + const secret = crypto.getRandomValues(new Uint8Array(32)) + const salt = crypto.getRandomValues(new Uint8Array(32)) + const message = crypto.getRandomValues(new Uint8Array(32)) + const hmac = await generateHmac(secret, salt, message) + const valid = await validateHmac( + await hkdfHmacKey( + crypto.getRandomValues(new Uint8Array(32)), + crypto.getRandomValues(new Uint8Array(32)) + ), + hmac, + message + ) + expect(valid).toBe(false) + }) +}) From 0e23746b475da07ce2258c817a35acd8cdc7bcdf Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 19 Jan 2024 16:29:58 -0600 Subject: [PATCH 14/21] test: add InMemoryKeystore tests --- test/keystore/InMemoryKeystore.test.ts | 157 +++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/test/keystore/InMemoryKeystore.test.ts b/test/keystore/InMemoryKeystore.test.ts index fdbe92d67..b85fd2ebe 100644 --- a/test/keystore/InMemoryKeystore.test.ts +++ b/test/keystore/InMemoryKeystore.test.ts @@ -19,6 +19,13 @@ import Token from '../../src/authn/Token' import Long from 'long' import { CreateInviteResponse } from '@xmtp/proto/ts/dist/types/keystore_api/v1/keystore.pb' import { ethers } from 'ethers' +import { getKeyMaterial } from '../../src/keystore/utils' +import { + generateHmac, + hkdfHmacKey, + importHmacKey, + validateHmac, +} from '../../src/crypto/encryption' describe('InMemoryKeystore', () => { let aliceKeys: PrivateKeyBundleV1 @@ -395,6 +402,54 @@ describe('InMemoryKeystore', () => { expect(equalBytes(payload, decrypted.result!.decrypted)).toBeTruthy() }) + + it('generates a valid sender HMAC', async () => { + const recipient = SignedPublicKeyBundle.fromLegacyBundle( + bobKeys.getPublicKeyBundle() + ) + const createdNs = dateToNs(new Date()) + const response = await aliceKeystore.createInvite({ + recipient, + createdNs, + context: undefined, + }) + + const payload = new TextEncoder().encode('Hello, world!') + const headerBytes = new Uint8Array(10) + + const { + responses: [encrypted], + } = await aliceKeystore.encryptV2({ + requests: [ + { + contentTopic: response.conversation!.topic, + payload, + headerBytes, + }, + ], + }) + + if (encrypted.error) { + throw encrypted.error + } + + const thirtyDayPeriodsSinceEpoch = Math.floor( + Date.now() / 1000 / 60 / 60 / 24 / 30 + ) + const topicData = aliceKeystore.lookupTopic(response.conversation!.topic) + const keyMaterial = getKeyMaterial(topicData!.invitation) + const hmacKey = await hkdfHmacKey( + keyMaterial, + new TextEncoder().encode( + `${thirtyDayPeriodsSinceEpoch}-${aliceKeystore.walletAddress}` + ) + ) + + expect(encrypted.result?.senderHmac).toBeTruthy() + expect( + await validateHmac(hmacKey, encrypted.result!.senderHmac, headerBytes) + ).toBeTruthy() + }) }) describe('SignDigest', () => { @@ -806,4 +861,106 @@ describe('InMemoryKeystore', () => { ).toBeTruthy() }) }) + + describe('getV2ConversationHmacKeys', () => { + it('returns conversation HMAC keys', async () => { + const baseTime = new Date() + const timestamps = Array.from( + { length: 5 }, + (_, i) => new Date(baseTime.getTime() + i) + ) + + const invites = await Promise.all( + [...timestamps].map(async (createdAt) => { + let keys = await PrivateKeyBundleV1.generate(newWallet()) + + const recipient = SignedPublicKeyBundle.fromLegacyBundle( + keys.getPublicKeyBundle() + ) + + return aliceKeystore.createInvite({ + recipient, + createdNs: dateToNs(createdAt), + context: undefined, + }) + }) + ) + + const thirtyDayPeriodsSinceEpoch = Math.floor( + Date.now() / 1000 / 60 / 60 / 24 / 30 + ) + + const periods = [ + thirtyDayPeriodsSinceEpoch - 1, + thirtyDayPeriodsSinceEpoch, + thirtyDayPeriodsSinceEpoch + 1, + ] + + const { hmacKeys } = await aliceKeystore.getV2ConversationHmacKeys() + + const topics = Object.keys(hmacKeys) + invites.forEach((invite) => { + expect(topics.includes(invite.conversation!.topic)).toBeTruthy() + }) + + const topicHmacs: { + [topic: string]: Uint8Array + } = {} + const headerBytes = new Uint8Array(10) + + await Promise.all( + invites.map(async (invite) => { + const topic = invite.conversation!.topic + const payload = new TextEncoder().encode('Hello, world!') + + const { + responses: [encrypted], + } = await aliceKeystore.encryptV2({ + requests: [ + { + contentTopic: topic, + payload, + headerBytes, + }, + ], + }) + + if (encrypted.error) { + throw encrypted.error + } + + const topicData = aliceKeystore.lookupTopic(topic) + const keyMaterial = getKeyMaterial(topicData!.invitation) + const salt = `${thirtyDayPeriodsSinceEpoch}-${aliceKeystore.walletAddress}` + const hmac = await generateHmac( + keyMaterial, + new TextEncoder().encode(salt), + headerBytes + ) + + topicHmacs[topic] = hmac + }) + ) + + await Promise.all( + Object.keys(hmacKeys).map(async (topic) => { + const hmacData = hmacKeys[topic] + + await Promise.all( + hmacData.values.map( + async ({ hmacKey, thirtyDayPeriodsSinceEpoch }, idx) => { + expect(thirtyDayPeriodsSinceEpoch).toBe(periods[idx]) + const valid = await validateHmac( + await importHmacKey(hmacKey), + topicHmacs[topic], + headerBytes + ) + expect(valid).toBe(idx === 1 ? true : false) + } + ) + ) + }) + ) + }) + }) }) From ceef65a7dc3a20a61b5e325d84d35ad51192d393 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 19 Jan 2024 17:20:03 -0600 Subject: [PATCH 15/21] refactor: rename HMAC functions --- src/crypto/encryption.ts | 15 ++++----------- src/keystore/InMemoryKeystore.ts | 8 ++++++-- test/crypto/encryption.test.ts | 20 ++++++++++---------- test/keystore/InMemoryKeystore.test.ts | 14 +++++++++----- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/src/crypto/encryption.ts b/src/crypto/encryption.ts index 795170717..a88da2214 100644 --- a/src/crypto/encryption.ts +++ b/src/crypto/encryption.ts @@ -100,24 +100,17 @@ export async function hkdfHmacKey( ) } -async function generateHmacWithKey( - key: CryptoKey, - message: Uint8Array -): Promise { - const signed = await crypto.subtle.sign('HMAC', key, message) - return new Uint8Array(signed) -} - -export async function generateHmac( +export async function generateHmacSignature( secret: Uint8Array, salt: Uint8Array, message: Uint8Array ): Promise { const key = await hkdfHmacKey(secret, salt) - return generateHmacWithKey(key, message) + const signed = await crypto.subtle.sign('HMAC', key, message) + return new Uint8Array(signed) } -export async function validateHmac( +export async function verifyHmacSignature( key: CryptoKey, signature: Uint8Array, message: Uint8Array diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 390f6fecf..5349a3e31 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -35,7 +35,11 @@ import { generateUserPreferencesTopic, } from '../crypto/selfEncryption' import { KeystoreInterface } from '..' -import { exportHmacKey, generateHmac, hkdfHmacKey } from '../crypto/encryption' +import { + exportHmacKey, + generateHmacSignature, + hkdfHmacKey, +} from '../crypto/encryption' const { ErrorCode } = keystore @@ -302,7 +306,7 @@ export default class InMemoryKeystore implements KeystoreInterface { Date.now() / 1000 / 60 / 60 / 24 / 30 ) const salt = `${thirtyDayPeriodsSinceEpoch}-${this.accountAddress}` - const hmac = await generateHmac( + const hmac = await generateHmacSignature( keyMaterial, new TextEncoder().encode(salt), headerBytes diff --git a/test/crypto/encryption.test.ts b/test/crypto/encryption.test.ts index 4e95f925f..556b21ee0 100644 --- a/test/crypto/encryption.test.ts +++ b/test/crypto/encryption.test.ts @@ -2,8 +2,8 @@ import { importHmacKey, exportHmacKey, hkdfHmacKey, - validateHmac, - generateHmac, + verifyHmacSignature, + generateHmacSignature, } from '../../src/crypto/encryption' import crypto from '../../src/crypto/crypto' @@ -12,9 +12,9 @@ describe('HMAC encryption', () => { const secret = crypto.getRandomValues(new Uint8Array(32)) const salt = crypto.getRandomValues(new Uint8Array(32)) const message = crypto.getRandomValues(new Uint8Array(32)) - const hmac = await generateHmac(secret, salt, message) + const hmac = await generateHmacSignature(secret, salt, message) const key = await hkdfHmacKey(secret, salt) - const valid = await validateHmac(key, hmac, message) + const valid = await verifyHmacSignature(key, hmac, message) expect(valid).toBe(true) }) @@ -22,11 +22,11 @@ describe('HMAC encryption', () => { const secret = crypto.getRandomValues(new Uint8Array(32)) const salt = crypto.getRandomValues(new Uint8Array(32)) const message = crypto.getRandomValues(new Uint8Array(32)) - const hmac = await generateHmac(secret, salt, message) + const hmac = await generateHmacSignature(secret, salt, message) const key = await hkdfHmacKey(secret, salt) const exportedKey = await exportHmacKey(key) const importedKey = await importHmacKey(exportedKey) - const valid = await validateHmac(importedKey, hmac, message) + const valid = await verifyHmacSignature(importedKey, hmac, message) expect(valid).toBe(true) }) @@ -34,9 +34,9 @@ describe('HMAC encryption', () => { const secret = crypto.getRandomValues(new Uint8Array(32)) const salt = crypto.getRandomValues(new Uint8Array(32)) const message = crypto.getRandomValues(new Uint8Array(32)) - const hmac = await generateHmac(secret, salt, message) + const hmac = await generateHmacSignature(secret, salt, message) const key = await hkdfHmacKey(secret, salt) - const valid = await validateHmac( + const valid = await verifyHmacSignature( key, hmac, crypto.getRandomValues(new Uint8Array(32)) @@ -48,8 +48,8 @@ describe('HMAC encryption', () => { const secret = crypto.getRandomValues(new Uint8Array(32)) const salt = crypto.getRandomValues(new Uint8Array(32)) const message = crypto.getRandomValues(new Uint8Array(32)) - const hmac = await generateHmac(secret, salt, message) - const valid = await validateHmac( + const hmac = await generateHmacSignature(secret, salt, message) + const valid = await verifyHmacSignature( await hkdfHmacKey( crypto.getRandomValues(new Uint8Array(32)), crypto.getRandomValues(new Uint8Array(32)) diff --git a/test/keystore/InMemoryKeystore.test.ts b/test/keystore/InMemoryKeystore.test.ts index b85fd2ebe..01fae2c0e 100644 --- a/test/keystore/InMemoryKeystore.test.ts +++ b/test/keystore/InMemoryKeystore.test.ts @@ -21,10 +21,10 @@ import { CreateInviteResponse } from '@xmtp/proto/ts/dist/types/keystore_api/v1/ import { ethers } from 'ethers' import { getKeyMaterial } from '../../src/keystore/utils' import { - generateHmac, + generateHmacSignature, hkdfHmacKey, importHmacKey, - validateHmac, + verifyHmacSignature, } from '../../src/crypto/encryption' describe('InMemoryKeystore', () => { @@ -447,7 +447,11 @@ describe('InMemoryKeystore', () => { expect(encrypted.result?.senderHmac).toBeTruthy() expect( - await validateHmac(hmacKey, encrypted.result!.senderHmac, headerBytes) + await verifyHmacSignature( + hmacKey, + encrypted.result!.senderHmac, + headerBytes + ) ).toBeTruthy() }) }) @@ -932,7 +936,7 @@ describe('InMemoryKeystore', () => { const topicData = aliceKeystore.lookupTopic(topic) const keyMaterial = getKeyMaterial(topicData!.invitation) const salt = `${thirtyDayPeriodsSinceEpoch}-${aliceKeystore.walletAddress}` - const hmac = await generateHmac( + const hmac = await generateHmacSignature( keyMaterial, new TextEncoder().encode(salt), headerBytes @@ -950,7 +954,7 @@ describe('InMemoryKeystore', () => { hmacData.values.map( async ({ hmacKey, thirtyDayPeriodsSinceEpoch }, idx) => { expect(thirtyDayPeriodsSinceEpoch).toBe(periods[idx]) - const valid = await validateHmac( + const valid = await verifyHmacSignature( await importHmacKey(hmacKey), topicHmacs[topic], headerBytes From 462906993afc5cb33e6b4474bde7f5e1b61b2608 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 19 Jan 2024 17:22:54 -0600 Subject: [PATCH 16/21] build: export HMAC functions --- src/crypto/index.ts | 15 ++++++++++++++- src/index.ts | 5 +++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/crypto/index.ts b/src/crypto/index.ts index bbdc493ca..a5615ada2 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -9,7 +9,15 @@ import { import { UnsignedPublicKey, SignedPublicKey, PublicKey } from './PublicKey' import Signature, { WalletSigner } from './Signature' import * as utils from './utils' -import { encrypt, decrypt } from './encryption' +import { + decrypt, + encrypt, + exportHmacKey, + generateHmacSignature, + hkdfHmacKey, + importHmacKey, + verifyHmacSignature, +} from './encryption' import Ciphertext from './Ciphertext' import SignedEciesCiphertext from './SignedEciesCiphertext' @@ -17,6 +25,11 @@ export { utils, encrypt, decrypt, + exportHmacKey, + generateHmacSignature, + hkdfHmacKey, + importHmacKey, + verifyHmacSignature, Ciphertext, UnsignedPublicKey, SignedPublicKey, diff --git a/src/index.ts b/src/index.ts index c0764daaf..7da647c36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,11 @@ export { Signature, encrypt, decrypt, + exportHmacKey, + generateHmacSignature, + hkdfHmacKey, + importHmacKey, + verifyHmacSignature, } from './crypto' export { default as Stream } from './Stream' export { Signer } from './types/Signer' From 482f4ff3e826a9c50841cbfd9ae1368a797700b1 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:02:28 -0800 Subject: [PATCH 17/21] fix: replace salt with info --- src/crypto/encryption.ts | 9 +++++---- src/keystore/InMemoryKeystore.ts | 4 ++-- test/crypto/encryption.test.ts | 32 +++++++++++++++++++++----------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/crypto/encryption.ts b/src/crypto/encryption.ts index a88da2214..e8f27c56b 100644 --- a/src/crypto/encryption.ts +++ b/src/crypto/encryption.ts @@ -3,6 +3,7 @@ import Ciphertext, { AESGCMNonceSize, KDFSaltSize } from './Ciphertext' import crypto from './crypto' const hkdfNoInfo = new ArrayBuffer(0) +const hkdfNoSalt = new ArrayBuffer(0) // This is a variation of https://github.com/paulmillr/noble-secp256k1/blob/main/index.ts#L1378-L1388 // that uses `digest('SHA-256', bytes)` instead of `digest('SHA-256', bytes.buffer)` @@ -86,13 +87,13 @@ async function hkdf(secret: Uint8Array, salt: Uint8Array): Promise { export async function hkdfHmacKey( secret: Uint8Array, - salt: Uint8Array + info: Uint8Array ): Promise { const key = await crypto.subtle.importKey('raw', secret, 'HKDF', false, [ 'deriveKey', ]) return crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt, info: hkdfNoInfo }, + { name: 'HKDF', hash: 'SHA-256', salt: hkdfNoSalt, info }, key, { name: 'HMAC', hash: 'SHA-256', length: 256 }, true, @@ -102,10 +103,10 @@ export async function hkdfHmacKey( export async function generateHmacSignature( secret: Uint8Array, - salt: Uint8Array, + info: Uint8Array, message: Uint8Array ): Promise { - const key = await hkdfHmacKey(secret, salt) + const key = await hkdfHmacKey(secret, info) const signed = await crypto.subtle.sign('HMAC', key, message) return new Uint8Array(signed) } diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 5349a3e31..01c5bdb01 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -305,10 +305,10 @@ export default class InMemoryKeystore implements KeystoreInterface { const thirtyDayPeriodsSinceEpoch = Math.floor( Date.now() / 1000 / 60 / 60 / 24 / 30 ) - const salt = `${thirtyDayPeriodsSinceEpoch}-${this.accountAddress}` + const info = `${thirtyDayPeriodsSinceEpoch}-${this.accountAddress}` const hmac = await generateHmacSignature( keyMaterial, - new TextEncoder().encode(salt), + new TextEncoder().encode(info), headerBytes ) diff --git a/test/crypto/encryption.test.ts b/test/crypto/encryption.test.ts index 556b21ee0..8c114deff 100644 --- a/test/crypto/encryption.test.ts +++ b/test/crypto/encryption.test.ts @@ -10,32 +10,42 @@ import crypto from '../../src/crypto/crypto' describe('HMAC encryption', () => { it('generates and validates HMAC', async () => { const secret = crypto.getRandomValues(new Uint8Array(32)) - const salt = crypto.getRandomValues(new Uint8Array(32)) + const info = crypto.getRandomValues(new Uint8Array(32)) const message = crypto.getRandomValues(new Uint8Array(32)) - const hmac = await generateHmacSignature(secret, salt, message) - const key = await hkdfHmacKey(secret, salt) + const hmac = await generateHmacSignature(secret, info, message) + const key = await hkdfHmacKey(secret, info) const valid = await verifyHmacSignature(key, hmac, message) expect(valid).toBe(true) }) it('generates and validates HMAC with imported key', async () => { const secret = crypto.getRandomValues(new Uint8Array(32)) - const salt = crypto.getRandomValues(new Uint8Array(32)) + const info = crypto.getRandomValues(new Uint8Array(32)) const message = crypto.getRandomValues(new Uint8Array(32)) - const hmac = await generateHmacSignature(secret, salt, message) - const key = await hkdfHmacKey(secret, salt) + const hmac = await generateHmacSignature(secret, info, message) + const key = await hkdfHmacKey(secret, info) const exportedKey = await exportHmacKey(key) const importedKey = await importHmacKey(exportedKey) const valid = await verifyHmacSignature(importedKey, hmac, message) expect(valid).toBe(true) }) + it('generates different HMAC keys with different infos', async () => { + const secret = crypto.getRandomValues(new Uint8Array(32)) + const info1 = crypto.getRandomValues(new Uint8Array(32)) + const info2 = crypto.getRandomValues(new Uint8Array(32)) + const key1 = await hkdfHmacKey(secret, info1) + const key2 = await hkdfHmacKey(secret, info2) + + expect(await exportHmacKey(key1)).not.toEqual(await exportHmacKey(key2)) + }) + it('fails to validate HMAC with wrong message', async () => { const secret = crypto.getRandomValues(new Uint8Array(32)) - const salt = crypto.getRandomValues(new Uint8Array(32)) + const info = crypto.getRandomValues(new Uint8Array(32)) const message = crypto.getRandomValues(new Uint8Array(32)) - const hmac = await generateHmacSignature(secret, salt, message) - const key = await hkdfHmacKey(secret, salt) + const hmac = await generateHmacSignature(secret, info, message) + const key = await hkdfHmacKey(secret, info) const valid = await verifyHmacSignature( key, hmac, @@ -46,9 +56,9 @@ describe('HMAC encryption', () => { it('fails to validate HMAC with wrong key', async () => { const secret = crypto.getRandomValues(new Uint8Array(32)) - const salt = crypto.getRandomValues(new Uint8Array(32)) + const info = crypto.getRandomValues(new Uint8Array(32)) const message = crypto.getRandomValues(new Uint8Array(32)) - const hmac = await generateHmacSignature(secret, salt, message) + const hmac = await generateHmacSignature(secret, info, message) const valid = await verifyHmacSignature( await hkdfHmacKey( crypto.getRandomValues(new Uint8Array(32)), From b71c92001d90f27d7c2f72b3b977e5e5920aeb81 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:04:26 -0800 Subject: [PATCH 18/21] fix: replace one more salt with info --- src/keystore/InMemoryKeystore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 01c5bdb01..e5f2f49e7 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -612,10 +612,10 @@ export default class InMemoryKeystore implements KeystoreInterface { thirtyDayPeriodsSinceEpoch, thirtyDayPeriodsSinceEpoch + 1, ].map(async (value) => { - const salt = `${value}-${this.accountAddress}` + const info = `${value}-${this.accountAddress}` const hmacKey = await hkdfHmacKey( keyMaterial, - new TextEncoder().encode(salt) + new TextEncoder().encode(info) ) return { thirtyDayPeriodsSinceEpoch: value, From 15e830592571423cb1e960aab38d789a4cea8581 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:05:36 -0800 Subject: [PATCH 19/21] fix: replace last salt with info --- test/keystore/InMemoryKeystore.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/keystore/InMemoryKeystore.test.ts b/test/keystore/InMemoryKeystore.test.ts index 01fae2c0e..b33c85881 100644 --- a/test/keystore/InMemoryKeystore.test.ts +++ b/test/keystore/InMemoryKeystore.test.ts @@ -935,10 +935,10 @@ describe('InMemoryKeystore', () => { const topicData = aliceKeystore.lookupTopic(topic) const keyMaterial = getKeyMaterial(topicData!.invitation) - const salt = `${thirtyDayPeriodsSinceEpoch}-${aliceKeystore.walletAddress}` + const info = `${thirtyDayPeriodsSinceEpoch}-${aliceKeystore.walletAddress}` const hmac = await generateHmacSignature( keyMaterial, - new TextEncoder().encode(salt), + new TextEncoder().encode(info), headerBytes ) From faea2a63dc30c0877c5589053c0e9f765264502e Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Sat, 20 Jan 2024 00:13:42 -0600 Subject: [PATCH 20/21] feat: add shouldPush to messages --- bench/decode.ts | 7 +-- bench/encode.ts | 6 +-- package-lock.json | 8 ++-- package.json | 2 +- src/Client.ts | 10 ++++- src/Message.ts | 10 +++-- src/conversations/Conversation.ts | 40 +++++++++++++---- test/Client.test.ts | 59 +++++++++++++++++++++++-- test/Message.test.ts | 2 +- test/conversations/Conversation.test.ts | 31 ++++++++++--- 10 files changed, 141 insertions(+), 34 deletions(-) diff --git a/bench/decode.ts b/bench/decode.ts index e87b131bd..7e99e2769 100644 --- a/bench/decode.ts +++ b/bench/decode.ts @@ -22,9 +22,10 @@ const decodeV1 = () => { const bob = await newPrivateKeyBundle() const message = randomBytes(size) + const { payload } = await alice.encodeContent(message) const encodedMessage = await MessageV1.encode( alice.keystore, - await alice.encodeContent(message), + payload, alice.publicKeyBundle, bob.getPublicKeyBundle(), new Date() @@ -75,8 +76,8 @@ const decodeV2 = () => { new Date(), undefined ) - const payload = await alice.encodeContent(message) - const encodedMessage = await convo.createMessage(payload) + const { payload, shouldPush } = await alice.encodeContent(message) + const encodedMessage = await convo.createMessage(payload, shouldPush) const messageBytes = encodedMessage.toBytes() const envelope = { diff --git a/bench/encode.ts b/bench/encode.ts index 7c52b9959..fa712bcb3 100644 --- a/bench/encode.ts +++ b/bench/encode.ts @@ -22,7 +22,7 @@ const encodeV1 = () => { // The returned function is the actual benchmark. Everything above is setup return async () => { - const encodedMessage = await alice.encodeContent(message) + const { payload: encodedMessage } = await alice.encodeContent(message) await MessageV1.encode( alice.keystore, encodedMessage, @@ -57,11 +57,11 @@ const encodeV2 = () => { undefined ) const message = randomBytes(size) - const payload = await alice.encodeContent(message) + const { payload, shouldPush } = await alice.encodeContent(message) // The returned function is the actual benchmark. Everything above is setup return async () => { - await convo.createMessage(payload) + await convo.createMessage(payload, shouldPush) } }) ) diff --git a/package-lock.json b/package-lock.json index b4344004d..4645e3745 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.37.0-beta.2", + "@xmtp/proto": "^3.39.0-beta.2", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", @@ -5511,9 +5511,9 @@ } }, "node_modules/@xmtp/proto": { - "version": "3.37.0-beta.2", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.37.0-beta.2.tgz", - "integrity": "sha512-D8vqMe9MCpbKx6ECw01VxLaiMRK4jtoeYuCTCwunsc/Z44SgxEKi+bO7q6T7EsIcaBBdz06rLvyjSdj/s1CCQA==", + "version": "3.39.0-beta.2", + "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.39.0-beta.2.tgz", + "integrity": "sha512-pHqbZUrd62qcWZj6MryOKfdeo/lirJrxt1Pv0c1hdpCEyz2J19j+TkGLkoiq7f5wrU3z9Yci+HooAj9BfHZ8og==", "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", diff --git a/package.json b/package.json index 30a968e11..0ed6a2ba3 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ }, "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.37.0-beta.2", + "@xmtp/proto": "^3.39.0-beta.2", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", diff --git a/src/Client.ts b/src/Client.ts index 98f3e54fe..227309502 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -635,7 +635,10 @@ export default class Client { async encodeContent( content: ContentTypes, options?: SendOptions - ): Promise { + ): Promise<{ + payload: Uint8Array + shouldPush: boolean + }> { const contentType = options?.contentType || ContentTypeText const codec = this.codecFor(contentType) if (!codec) { @@ -651,7 +654,10 @@ export default class Client { encoded.compression = options.compression } await compress(encoded) - return proto.EncodedContent.encode(encoded).finish() + return { + payload: proto.EncodedContent.encode(encoded).finish(), + shouldPush: codec.shouldPush(content), + } } async decodeContent(contentBytes: Uint8Array): Promise<{ diff --git a/src/Message.ts b/src/Message.ts index e2b068ded..6e20b6478 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -192,28 +192,32 @@ export class MessageV2 extends MessageBase implements proto.MessageV2 { senderAddress: string | undefined private header: proto.MessageHeaderV2 senderHmac: Uint8Array + shouldPush: boolean constructor( id: string, bytes: Uint8Array, obj: proto.Message, header: proto.MessageHeaderV2, - senderHmac: Uint8Array + senderHmac: Uint8Array, + shouldPush: boolean ) { super(id, bytes, obj) this.header = header this.senderHmac = senderHmac + this.shouldPush = shouldPush } static async create( obj: proto.Message, header: proto.MessageHeaderV2, bytes: Uint8Array, - senderHmac: Uint8Array + senderHmac: Uint8Array, + shouldPush: boolean ): Promise { const id = bytesToHex(await sha256(bytes)) - return new MessageV2(id, bytes, obj, header, senderHmac) + return new MessageV2(id, bytes, obj, header, senderHmac, shouldPush) } get sent(): Date { diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index f36c5a517..74826e8fd 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -284,7 +284,7 @@ export class ConversationV1 } else { topics = [topic] } - const payload = await this.client.encodeContent(content, options) + const { payload } = await this.client.encodeContent(content, options) const msg = await this.createMessage(payload, recipient, options?.timestamp) const msgBytes = msg.toBytes() @@ -393,7 +393,7 @@ export class ConversationV1 topics = [topic] } const contentType = options?.contentType || ContentTypeText - const payload = await this.client.encodeContent(content, options) + const { payload } = await this.client.encodeContent(content, options) const msg = await this.createMessage(payload, recipient, options?.timestamp) await this.client.publishEnvelopes( @@ -602,8 +602,15 @@ export class ConversationV2 content: Exclude, options?: SendOptions ): Promise> { - const payload = await this.client.encodeContent(content, options) - const msg = await this.createMessage(payload, options?.timestamp) + const { payload, shouldPush } = await this.client.encodeContent( + content, + options + ) + const msg = await this.createMessage( + payload, + shouldPush, + options?.timestamp + ) const topic = options?.ephemeral ? this.ephemeralTopic : this.topic @@ -637,6 +644,7 @@ export class ConversationV2 async createMessage( // Payload is expected to have already gone through `client.encodeContent` payload: Uint8Array, + shouldPush: boolean, timestamp?: Date ): Promise { const header: message.MessageHeaderV2 = { @@ -660,13 +668,14 @@ export class ConversationV2 signedBytes, headerBytes ) + const protoMsg = { v1: undefined, - v2: { headerBytes, ciphertext, senderHmac }, + v2: { headerBytes, ciphertext, senderHmac, shouldPush }, } const bytes = message.Message.encode(protoMsg).finish() - return MessageV2.create(protoMsg, header, bytes, senderHmac) + return MessageV2.create(protoMsg, header, bytes, senderHmac, shouldPush) } private async decryptBatch( @@ -779,8 +788,15 @@ export class ConversationV2 content: any, // eslint-disable-line @typescript-eslint/no-explicit-any options?: SendOptions ): Promise { - const payload = await this.client.encodeContent(content, options) - const msg = await this.createMessage(payload, options?.timestamp) + const { payload, shouldPush } = await this.client.encodeContent( + content, + options + ) + const msg = await this.createMessage( + payload, + shouldPush, + options?.timestamp + ) const msgBytes = msg.toBytes() const topic = options?.ephemeral ? this.ephemeralTopic : this.topic @@ -827,7 +843,13 @@ export class ConversationV2 throw new Error('topic mismatch') } - return MessageV2.create(msg, header, env.message, msg.v2.senderHmac) + return MessageV2.create( + msg, + header, + env.message, + msg.v2.senderHmac, + msg.v2.shouldPush + ) } async decodeMessage( diff --git a/test/Client.test.ts b/test/Client.test.ts index ae4eab112..0e926d3fa 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -6,7 +6,7 @@ import { waitForUserContact, newLocalHostClientWithCustomWallet, } from './helpers' -import { buildUserContactTopic } from '../src/utils' +import { EnvelopeWithMessage, buildUserContactTopic } from '../src/utils' import Client, { ClientOptions } from '../src/Client' import { ApiUrls, @@ -20,15 +20,17 @@ import { } from '../src' import NetworkKeyManager from '../src/keystore/providers/NetworkKeyManager' import TopicPersistence from '../src/keystore/persistence/TopicPersistence' -import { PrivateKeyBundleV1 } from '../src/crypto' +import { PrivateKey, PrivateKeyBundleV1 } from '../src/crypto' import { Wallet } from 'ethers' import { NetworkKeystoreProvider } from '../src/keystore/providers' import { PublishResponse } from '@xmtp/proto/ts/dist/types/message_api/v1/message_api.pb' import LocalStoragePonyfill from '../src/keystore/persistence/LocalStoragePonyfill' +import { message } from '@xmtp/proto' import { createWalletClient, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from 'viem/chains' import { generatePrivateKey } from 'viem/accounts' +import { ContentTypeTestKey, TestKeyCodec } from './ContentTypeTestKey' type TestCase = { name: string @@ -196,11 +198,25 @@ describe('encodeContent', () => { 173, 229, ]) - const payload = await c.encodeContent(uncompressed, { + const { payload } = await c.encodeContent(uncompressed, { compression: Compression.COMPRESSION_DEFLATE, }) assert.deepEqual(Uint8Array.from(payload), compressed) }) + + it('returns shouldPush based on content codec', async () => { + const alice = await newLocalHostClient() + alice.registerCodec(new TestKeyCodec()) + + const { shouldPush: result1 } = await alice.encodeContent('gm') + expect(result1).toBe(true) + + const key = PrivateKey.generate().publicKey + const { shouldPush: result2 } = await alice.encodeContent(key, { + contentType: ContentTypeTestKey, + }) + expect(result2).toBe(false) + }) }) describe('canMessage', () => { @@ -296,6 +312,43 @@ describe('canMessageMultipleBatches', () => { }) }) +describe('listEnvelopes', () => { + it('has envelopes with senderHmac and shouldPush', async () => { + const alice = await newLocalHostClient() + const bob = await newLocalHostClient() + alice.registerCodec(new TestKeyCodec()) + const convo = await alice.conversations.newConversation(bob.address) + await convo.send('hi') + const key = PrivateKey.generate().publicKey + await convo.send(key, { + contentType: ContentTypeTestKey, + }) + + const envelopes = await alice.listEnvelopes( + convo.topic, + (env: EnvelopeWithMessage) => Promise.resolve(env) + ) + + const msg1 = message.Message.decode(envelopes[0].message) + if (!msg1.v2) { + throw new Error('unknown message version') + } + const header1 = message.MessageHeaderV2.decode(msg1.v2.headerBytes) + expect(header1.topic).toEqual(convo.topic) + expect(msg1.v2.senderHmac).toBeDefined() + expect(msg1.v2.shouldPush).toBe(true) + + const msg2 = message.Message.decode(envelopes[1].message) + if (!msg2.v2) { + throw new Error('unknown message version') + } + const header2 = message.MessageHeaderV2.decode(msg2.v2.headerBytes) + expect(header2.topic).toEqual(convo.topic) + expect(msg2.v2.senderHmac).toBeDefined() + expect(msg2.v2.shouldPush).toBe(false) + }) +}) + describe('publishEnvelopes', () => { it('can send a valid envelope', async () => { const c = await newLocalHostClient() diff --git a/test/Message.test.ts b/test/Message.test.ts index 8b91ca638..ddafd437f 100644 --- a/test/Message.test.ts +++ b/test/Message.test.ts @@ -154,7 +154,7 @@ describe('Message', function () { env: 'local', privateKeyOverride: alice.encode(), }) - const payload = await aliceClient.encodeContent(text) + const { payload } = await aliceClient.encodeContent(text) const timestamp = new Date() const sender = alice.getPublicKeyBundle() const recipient = bob.getPublicKeyBundle() diff --git a/test/conversations/Conversation.test.ts b/test/conversations/Conversation.test.ts index e7e354768..e6cf1e202 100644 --- a/test/conversations/Conversation.test.ts +++ b/test/conversations/Conversation.test.ts @@ -1,4 +1,4 @@ -import { DecodedMessage, MessageV1 } from './../../src/Message' +import { DecodedMessage, MessageV1, MessageV2 } from './../../src/Message' import { buildDirectMessageTopic } from './../../src/utils' import { Client, Compression, ContentTypeId, ContentTypeText } from '../../src' import { SortDirection } from '../../src/ApiClient' @@ -531,6 +531,17 @@ describe('conversation', () => { await bs.return() await as.return() + + const messages = await alice.listEnvelopes( + ac.topic, + ac.processEnvelope.bind(ac) + ) + + expect(messages).toHaveLength(2) + expect(messages[0].shouldPush).toBe(true) + expect(messages[0].senderHmac).toBeDefined() + expect(messages[1].shouldPush).toBe(true) + expect(messages[1].senderHmac).toBeDefined() }) // it('rejects spoofed contact bundles', async () => { @@ -663,6 +674,9 @@ describe('conversation', () => { metadata: {}, } ) + if (!(aliceConvo instanceof ConversationV2)) { + fail() + } await sleep(100) const bobConvo = await bob.conversations.newConversation(alice.address, { conversationId: 'xmtp.org/key', @@ -674,7 +688,6 @@ describe('conversation', () => { // alice doesn't recognize the type expect( - // @ts-expect-error aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -682,7 +695,6 @@ describe('conversation', () => { // bob doesn't recognize the type alice.registerCodec(new TestKeyCodec()) - // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -704,7 +716,6 @@ describe('conversation', () => { // both recognize the type bob.registerCodec(new TestKeyCodec()) - // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -719,13 +730,23 @@ describe('conversation', () => { ...ContentTypeTestKey, versionMajor: 2, }) - // @ts-expect-error expect(aliceConvo.send(key, { contentType: type2 })).rejects.toThrow( 'unknown content type xmtp.test/public-key:2.0' ) await bobStream.return() await aliceStream.return() + + const messages = await alice.listEnvelopes( + aliceConvo.topic, + aliceConvo.processEnvelope.bind(aliceConvo) + ) + + expect(messages).toHaveLength(2) + expect(messages[0].shouldPush).toBe(false) + expect(messages[0].senderHmac).toBeDefined() + expect(messages[1].shouldPush).toBe(false) + expect(messages[1].senderHmac).toBeDefined() }) }) }) From 12895cb7edeec8d7b8f646dd4154ce2772a9b5b1 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 24 Jan 2024 15:25:20 -0600 Subject: [PATCH 21/21] fix: allow specific topics when getting HMAC keys --- package-lock.json | 8 +- package.json | 2 +- src/keystore/InMemoryKeystore.ts | 19 ++++- src/keystore/rpcDefinitions.ts | 6 +- test/keystore/InMemoryKeystore.test.ts | 107 ++++++++++++++++++++++++- 5 files changed, 132 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4645e3745..100723a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.39.0-beta.2", + "@xmtp/proto": "^3.39.0-beta.3", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", @@ -5511,9 +5511,9 @@ } }, "node_modules/@xmtp/proto": { - "version": "3.39.0-beta.2", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.39.0-beta.2.tgz", - "integrity": "sha512-pHqbZUrd62qcWZj6MryOKfdeo/lirJrxt1Pv0c1hdpCEyz2J19j+TkGLkoiq7f5wrU3z9Yci+HooAj9BfHZ8og==", + "version": "3.39.0-beta.3", + "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.39.0-beta.3.tgz", + "integrity": "sha512-vsAthHovb8c/18/VloSrG7GGcW1OCYRK0M9kRK5Sq+09fX5cWNt3Urt4Bw0TTtmPQqM/2ghK9slgC1qQF80jkA==", "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", diff --git a/package.json b/package.json index 0ed6a2ba3..7b5135f8e 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ }, "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.39.0-beta.2", + "@xmtp/proto": "^3.39.0-beta.3", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index e5f2f49e7..1998fb9f9 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -34,12 +34,12 @@ import { userPreferencesEncrypt, generateUserPreferencesTopic, } from '../crypto/selfEncryption' -import { KeystoreInterface } from '..' import { exportHmacKey, generateHmacSignature, hkdfHmacKey, } from '../crypto/encryption' +import { KeystoreInterface } from './rpcDefinitions' const { ErrorCode } = keystore @@ -595,15 +595,28 @@ export default class InMemoryKeystore implements KeystoreInterface { return this.v2Store.lookup(topic) } - async getV2ConversationHmacKeys(): Promise { + async getV2ConversationHmacKeys( + req?: keystore.GetConversationHmacKeysRequest + ): Promise { const thirtyDayPeriodsSinceEpoch = Math.floor( Date.now() / 1000 / 60 / 60 / 24 / 30 ) const hmacKeys: keystore.GetConversationHmacKeysResponse['hmacKeys'] = {} + let topics = this.v2Store.topics + + // if specific topics are requested, only include those topics + if (req?.topics) { + topics = topics.filter( + (topicData) => + topicData.invitation !== undefined && + req.topics.includes(topicData.invitation.topic) + ) + } + await Promise.all( - this.v2Store.topics.map(async (topicData) => { + topics.map(async (topicData) => { if (topicData.invitation?.topic) { const keyMaterial = getKeyMaterial(topicData.invitation) const values = await Promise.all( diff --git a/src/keystore/rpcDefinitions.ts b/src/keystore/rpcDefinitions.ts index 5eda32a17..f24cd5e27 100644 --- a/src/keystore/rpcDefinitions.ts +++ b/src/keystore/rpcDefinitions.ts @@ -199,8 +199,12 @@ export const apiDefs = { req: null, res: keystore.GetPrivatePreferencesTopicIdentifierResponse, }, + /** + * Returns the conversation HMAC keys for the current, previous, and next + * 30 day periods since the epoch + */ getV2ConversationHmacKeys: { - req: null, + req: keystore.GetConversationHmacKeysRequest, res: keystore.GetConversationHmacKeysResponse, }, } diff --git a/test/keystore/InMemoryKeystore.test.ts b/test/keystore/InMemoryKeystore.test.ts index b33c85881..d020dc47f 100644 --- a/test/keystore/InMemoryKeystore.test.ts +++ b/test/keystore/InMemoryKeystore.test.ts @@ -867,7 +867,7 @@ describe('InMemoryKeystore', () => { }) describe('getV2ConversationHmacKeys', () => { - it('returns conversation HMAC keys', async () => { + it('returns all conversation HMAC keys', async () => { const baseTime = new Date() const timestamps = Array.from( { length: 5 }, @@ -966,5 +966,110 @@ describe('InMemoryKeystore', () => { }) ) }) + + it('returns specific conversation HMAC keys', async () => { + const baseTime = new Date() + const timestamps = Array.from( + { length: 10 }, + (_, i) => new Date(baseTime.getTime() + i) + ) + + const invites = await Promise.all( + [...timestamps].map(async (createdAt) => { + let keys = await PrivateKeyBundleV1.generate(newWallet()) + + const recipient = SignedPublicKeyBundle.fromLegacyBundle( + keys.getPublicKeyBundle() + ) + + return aliceKeystore.createInvite({ + recipient, + createdNs: dateToNs(createdAt), + context: undefined, + }) + }) + ) + + const thirtyDayPeriodsSinceEpoch = Math.floor( + Date.now() / 1000 / 60 / 60 / 24 / 30 + ) + + const periods = [ + thirtyDayPeriodsSinceEpoch - 1, + thirtyDayPeriodsSinceEpoch, + thirtyDayPeriodsSinceEpoch + 1, + ] + + const randomInvites = invites.slice(3, 8) + + const { hmacKeys } = await aliceKeystore.getV2ConversationHmacKeys({ + topics: randomInvites.map((invite) => invite.conversation!.topic), + }) + + const topics = Object.keys(hmacKeys) + expect(topics.length).toBe(randomInvites.length) + randomInvites.forEach((invite) => { + expect(topics.includes(invite.conversation!.topic)).toBeTruthy() + }) + + const topicHmacs: { + [topic: string]: Uint8Array + } = {} + const headerBytes = new Uint8Array(10) + + await Promise.all( + randomInvites.map(async (invite) => { + const topic = invite.conversation!.topic + const payload = new TextEncoder().encode('Hello, world!') + + const { + responses: [encrypted], + } = await aliceKeystore.encryptV2({ + requests: [ + { + contentTopic: topic, + payload, + headerBytes, + }, + ], + }) + + if (encrypted.error) { + throw encrypted.error + } + + const topicData = aliceKeystore.lookupTopic(topic) + const keyMaterial = getKeyMaterial(topicData!.invitation) + const info = `${thirtyDayPeriodsSinceEpoch}-${aliceKeystore.walletAddress}` + const hmac = await generateHmacSignature( + keyMaterial, + new TextEncoder().encode(info), + headerBytes + ) + + topicHmacs[topic] = hmac + }) + ) + + await Promise.all( + Object.keys(hmacKeys).map(async (topic) => { + const hmacData = hmacKeys[topic] + + await Promise.all( + hmacData.values.map( + async ({ hmacKey, thirtyDayPeriodsSinceEpoch }, idx) => { + expect(thirtyDayPeriodsSinceEpoch).toBe(periods[idx]) + const valid = await verifyHmacSignature( + await importHmacKey(hmacKey), + topicHmacs[topic], + headerBytes + ) + expect(valid).toBe(idx === 1 ? true : false) + } + ) + ) + }) + ) + }) }) })