Skip to content

Commit

Permalink
Merge pull request #519 from xmtp/rygine/sender-hmac
Browse files Browse the repository at this point in the history
Add `senderHmac` and `shouldPush` to `MessageV2`
  • Loading branch information
rygine authored Jan 25, 2024
2 parents fb4e15f + 12895cb commit 06993ee
Show file tree
Hide file tree
Showing 23 changed files with 669 additions and 60 deletions.
7 changes: 4 additions & 3 deletions bench/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 = {
Expand Down
6 changes: 3 additions & 3 deletions bench/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
})
)
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
},
"dependencies": {
"@noble/secp256k1": "^1.5.2",
"@xmtp/proto": "^3.34.0",
"@xmtp/proto": "^3.39.0-beta.3",
"@xmtp/user-preferences-bindings-wasm": "^0.3.4",
"async-mutex": "^0.4.0",
"elliptic": "^6.5.4",
Expand Down
10 changes: 8 additions & 2 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,10 @@ export default class Client<ContentTypes = any> {
async encodeContent(
content: ContentTypes,
options?: SendOptions
): Promise<Uint8Array> {
): Promise<{
payload: Uint8Array
shouldPush: boolean
}> {
const contentType = options?.contentType || ContentTypeText
const codec = this.codecFor(contentType)
if (!codec) {
Expand All @@ -651,7 +654,10 @@ export default class Client<ContentTypes = any> {
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<{
Expand Down
16 changes: 12 additions & 4 deletions src/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,26 +190,34 @@ 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
shouldPush: boolean

constructor(
id: string,
bytes: Uint8Array,
obj: proto.Message,
header: proto.MessageHeaderV2
header: proto.MessageHeaderV2,
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
bytes: Uint8Array,
senderHmac: Uint8Array,
shouldPush: boolean
): Promise<MessageV2> {
const id = bytesToHex(await sha256(bytes))

return new MessageV2(id, bytes, obj, header)
return new MessageV2(id, bytes, obj, header, senderHmac, shouldPush)
}

get sent(): Date {
Expand Down
1 change: 1 addition & 0 deletions src/MessageContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface ContentCodec<T> {
encode(content: T, registry: CodecRegistry): EncodedContent
decode(content: EncodedContent, registry: CodecRegistry): T
fallback(content: T): string | undefined
shouldPush: (content: T) => boolean
}

// xmtp.org/fallback
Expand Down
4 changes: 4 additions & 0 deletions src/codecs/Composite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,8 @@ export class CompositeCodec implements ContentCodec<Composite> {
fallback(content: Composite): string | undefined {
return undefined
}

shouldPush() {
return false
}
}
4 changes: 4 additions & 0 deletions src/codecs/Text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,8 @@ export class TextCodec implements ContentCodec<string> {
fallback(content: string): string | undefined {
return undefined
}

shouldPush() {
return true
}
}
62 changes: 39 additions & 23 deletions src/conversations/Conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -290,7 +284,7 @@ export class ConversationV1<ContentTypes>
} 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()

Expand Down Expand Up @@ -399,7 +393,7 @@ export class ConversationV1<ContentTypes>
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(
Expand Down Expand Up @@ -608,8 +602,15 @@ export class ConversationV2<ContentTypes>
content: Exclude<ContentTypes, undefined>,
options?: SendOptions
): Promise<DecodedMessage<ContentTypes>> {
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

Expand Down Expand Up @@ -643,6 +644,7 @@ export class ConversationV2<ContentTypes>
async createMessage(
// Payload is expected to have already gone through `client.encodeContent`
payload: Uint8Array,
shouldPush: boolean,
timestamp?: Date
): Promise<MessageV2> {
const header: message.MessageHeaderV2 = {
Expand All @@ -662,14 +664,18 @@ export class ConversationV2<ContentTypes>
}
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, shouldPush },
}
const bytes = message.Message.encode(protoMsg).finish()

return MessageV2.create(protoMsg, header, bytes)
return MessageV2.create(protoMsg, header, bytes, senderHmac, shouldPush)
}

private async decryptBatch(
Expand Down Expand Up @@ -713,10 +719,7 @@ export class ConversationV2<ContentTypes>
}
}

private async encryptMessage(
payload: Uint8Array,
headerBytes: Uint8Array
): Promise<ciphertext.Ciphertext> {
private async encryptMessage(payload: Uint8Array, headerBytes: Uint8Array) {
const { responses } = await this.client.keystore.encryptV2({
requests: [
{
Expand All @@ -729,8 +732,8 @@ export class ConversationV2<ContentTypes>
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(
Expand Down Expand Up @@ -785,8 +788,15 @@ export class ConversationV2<ContentTypes>
content: any, // eslint-disable-line @typescript-eslint/no-explicit-any
options?: SendOptions
): Promise<PreparedMessage> {
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
Expand Down Expand Up @@ -833,7 +843,13 @@ export class ConversationV2<ContentTypes>
throw new Error('topic mismatch')
}

return MessageV2.create(msg, header, env.message)
return MessageV2.create(
msg,
header,
env.message,
msg.v2.senderHmac,
msg.v2.shouldPush
)
}

async decodeMessage(
Expand Down
50 changes: 50 additions & 0 deletions src/crypto/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down Expand Up @@ -83,3 +84,52 @@ async function hkdf(secret: Uint8Array, salt: Uint8Array): Promise<CryptoKey> {
['encrypt', 'decrypt']
)
}

export async function hkdfHmacKey(
secret: Uint8Array,
info: Uint8Array
): Promise<CryptoKey> {
const key = await crypto.subtle.importKey('raw', secret, 'HKDF', false, [
'deriveKey',
])
return crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: hkdfNoSalt, info },
key,
{ name: 'HMAC', hash: 'SHA-256', length: 256 },
true,
['sign', 'verify']
)
}

export async function generateHmacSignature(
secret: Uint8Array,
info: Uint8Array,
message: Uint8Array
): Promise<Uint8Array> {
const key = await hkdfHmacKey(secret, info)
const signed = await crypto.subtle.sign('HMAC', key, message)
return new Uint8Array(signed)
}

export async function verifyHmacSignature(
key: CryptoKey,
signature: Uint8Array,
message: Uint8Array
): Promise<boolean> {
return await crypto.subtle.verify('HMAC', key, signature, message)
}

export async function exportHmacKey(key: CryptoKey): Promise<Uint8Array> {
const exported = await crypto.subtle.exportKey('raw', key)
return new Uint8Array(exported)
}

export async function importHmacKey(key: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: 'SHA-256', length: 256 },
true,
['sign', 'verify']
)
}
15 changes: 14 additions & 1 deletion src/crypto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,27 @@ 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'

export {
utils,
encrypt,
decrypt,
exportHmacKey,
generateHmacSignature,
hkdfHmacKey,
importHmacKey,
verifyHmacSignature,
Ciphertext,
UnsignedPublicKey,
SignedPublicKey,
Expand Down
Loading

0 comments on commit 06993ee

Please sign in to comment.