Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add senderHmac, shouldPush for entitlements #550

Merged
merged 21 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
10 changes: 8 additions & 2 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,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 @@ -654,7 +657,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 @@ -188,26 +188,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 @@ -111,4 +111,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 @@ -40,4 +40,8 @@ export class TextCodec implements ContentCodec<string> {
fallback(content: string): string | undefined {
return undefined
}

shouldPush() {
return true
}
}
56 changes: 39 additions & 17 deletions src/conversations/Conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type {
import type Client from '../Client'
import type { InvitationContext } from '../Invitation'
import { DecodedMessage, MessageV1, MessageV2 } from '../Message'
import type { messageApi, keystore, ciphertext } from '@xmtp/proto'
import type { messageApi, keystore } from '@xmtp/proto'
import { message, content as proto } from '@xmtp/proto'
import {
SignedPublicKey,
Expand Down Expand Up @@ -286,7 +286,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 @@ -395,7 +395,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 @@ -604,8 +604,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 @@ -639,6 +646,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 @@ -658,14 +666,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 @@ -709,10 +721,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 @@ -725,8 +734,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 @@ -781,8 +790,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 @@ -829,7 +845,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 Uint8Array().buffer
const hkdfNoSalt = new Uint8Array().buffer

// 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,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'

Expand All @@ -18,6 +26,11 @@ export {
utils,
encrypt,
decrypt,
exportHmacKey,
generateHmacSignature,
hkdfHmacKey,
importHmacKey,
verifyHmacSignature,
Ciphertext,
UnsignedPublicKey,
SignedPublicKey,
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export {
Signature,
encrypt,
decrypt,
exportHmacKey,
generateHmacSignature,
hkdfHmacKey,
importHmacKey,
verifyHmacSignature,
} from './crypto'
export { default as Stream } from './Stream'
export type { Signer } from './types/Signer'
Expand Down
Loading
Loading