Skip to content

Commit

Permalink
refactor: turn hkdf functions to async and remove extra deps (#1272)
Browse files Browse the repository at this point in the history
* refactor: remove futoin-hkdf dependency and update hkdf implementation

* refactor: use crypto subtle and update functions to async

---------

Co-authored-by: Rajeh Taher <rajeh@reforward.dev>
  • Loading branch information
jlucaso1 and purpshell authored Mar 1, 2025
1 parent e6f98c3 commit 8083754
Show file tree
Hide file tree
Showing 11 changed files with 78 additions and 48 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
"audio-decode": "^2.1.3",
"axios": "^1.6.0",
"cache-manager": "^5.7.6",
"futoin-hkdf": "^1.5.1",
"libphonenumber-js": "^1.10.20",
"libsignal": "github:WhiskeySockets/libsignal-node",
"lodash": "^4.17.21",
Expand Down
4 changes: 2 additions & 2 deletions src/Socket/messages-recv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const companionSharedKey = Curve.sharedKey(authState.creds.pairingEphemeralKeyPair.private, codePairingPublicKey)
const random = randomBytes(32)
const linkCodeSalt = randomBytes(32)
const linkCodePairingExpanded = hkdf(companionSharedKey, 32, {
const linkCodePairingExpanded = await hkdf(companionSharedKey, 32, {
salt: linkCodeSalt,
info: 'link_code_pairing_key_bundle_encryption_key'
})
Expand All @@ -486,7 +486,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const encryptedPayload = Buffer.concat([linkCodeSalt, encryptIv, encrypted])
const identitySharedKey = Curve.sharedKey(authState.creds.signedIdentityKey.private, primaryIdentityPublicKey)
const identityPayload = Buffer.concat([companionSharedKey, identitySharedKey, random])
authState.creds.advSecretKey = hkdf(identityPayload, 32, { info: 'adv_secret' }).toString('base64')
authState.creds.advSecretKey = (await hkdf(identityPayload, 32, { info: 'adv_secret' })).toString('base64')
await query({
tag: 'iq',
attrs: {
Expand Down
6 changes: 3 additions & 3 deletions src/Socket/messages-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,20 +654,20 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const content = assertMediaContent(message.message)
const mediaKey = content.mediaKey!
const meId = authState.creds.me!.id
const node = encryptMediaRetryRequest(message.key, mediaKey, meId)
const node = await encryptMediaRetryRequest(message.key, mediaKey, meId)

let error: Error | undefined = undefined
await Promise.all(
[
sendNode(node),
waitForMsgMediaUpdate(update => {
waitForMsgMediaUpdate(async(update) => {
const result = update.find(c => c.key.id === message.key.id)
if(result) {
if(result.error) {
error = result.error
} else {
try {
const media = decryptMediaRetryData(result.media!, mediaKey, result.key.id!)
const media = await decryptMediaRetryData(result.media!, mediaKey, result.key.id!)
if(media.result !== proto.MediaRetryNotification.ResultType.SUCCESS) {
const resultStr = proto.MediaRetryNotification.ResultType[media.result]
throw new Boom(
Expand Down
2 changes: 1 addition & 1 deletion src/Socket/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export const makeSocket = (config: SocketConfig) => {

logger.trace({ handshake }, 'handshake recv from WA')

const keyEnc = noise.processHandshake(handshake, creds.noiseKey)
const keyEnc = await noise.processHandshake(handshake, creds.noiseKey)

let node: proto.IClientPayload
if(!creds.me) {
Expand Down
12 changes: 6 additions & 6 deletions src/Utils/chat-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ type FetchAppStateSyncKey = (keyId: string) => Promise<proto.Message.IAppStateSy

export type ChatMutationMap = { [index: string]: ChatMutation }

const mutationKeys = (keydata: Uint8Array) => {
const expanded = hkdf(keydata, 160, { info: 'WhatsApp Mutation Keys' })
const mutationKeys = async(keydata: Uint8Array) => {
const expanded = await hkdf(keydata, 160, { info: 'WhatsApp Mutation Keys' })
return {
indexKey: expanded.slice(0, 32),
valueEncryptionKey: expanded.slice(32, 64),
Expand Down Expand Up @@ -144,7 +144,7 @@ export const encodeSyncdPatch = async(
})
const encoded = proto.SyncActionData.encode(dataProto).finish()

const keyValue = mutationKeys(key.keyData!)
const keyValue = await mutationKeys(key.keyData!)

const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey)
const valueMac = generateMac(operation, encValue, encKeyId, keyValue.valueMacKey)
Expand Down Expand Up @@ -261,7 +261,7 @@ export const decodeSyncdPatch = async(
throw new Boom(`failed to find key "${base64Key}" to decode patch`, { statusCode: 404, data: { msg } })
}

const mainKey = mutationKeys(mainKeyObj.keyData!)
const mainKey = await mutationKeys(mainKeyObj.keyData!)
const mutationmacs = msg.mutations!.map(mutation => mutation.record!.value!.blob!.slice(-32))

const patchMac = generatePatchMac(msg.snapshotMac!, mutationmacs, toNumber(msg.version!.version), name, mainKey.patchMacKey)
Expand Down Expand Up @@ -390,7 +390,7 @@ export const decodeSyncdSnapshot = async(
throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
}

const result = mutationKeys(keyEnc.keyData!)
const result = await mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
if(Buffer.compare(snapshot.mac!, computedSnapshotMac) !== 0) {
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name} from snapshot`)
Expand Down Expand Up @@ -458,7 +458,7 @@ export const decodePatches = async(
throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
}

const result = mutationKeys(keyEnc.keyData!)
const result = await mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
if(Buffer.compare(snapshotMac!, computedSnapshotMac) !== 0) {
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name}`)
Expand Down
42 changes: 39 additions & 3 deletions src/Utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto'
import HKDF from 'futoin-hkdf'
import * as libsignal from 'libsignal'
import { KEY_BUNDLE_TYPE } from '../Defaults'
import { KeyPair } from '../Types'
Expand Down Expand Up @@ -122,10 +121,47 @@ export function md5(buffer: Buffer) {
}

// HKDF key expansion
export function hkdf(buffer: Uint8Array | Buffer, expandedLength: number, info: { salt?: Buffer, info?: string }) {
return HKDF(!Buffer.isBuffer(buffer) ? Buffer.from(buffer) : buffer, expandedLength, info)
export async function hkdf(
buffer: Uint8Array | Buffer,
expandedLength: number,
info: { salt?: Buffer, info?: string }
): Promise<Buffer> {
// Ensure we have a Uint8Array for the key material
const inputKeyMaterial = buffer instanceof Uint8Array
? buffer
: new Uint8Array(buffer)

// Set default values if not provided
const salt = info.salt ? new Uint8Array(info.salt) : new Uint8Array(0)
const infoBytes = info.info
? new TextEncoder().encode(info.info)
: new Uint8Array(0)

// Import the input key material
const importedKey = await crypto.subtle.importKey(
'raw',
inputKeyMaterial,
{ name: 'HKDF' },
false,
['deriveBits']
)

// Derive bits using HKDF
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: salt,
info: infoBytes
},
importedKey,
expandedLength * 8 // Convert bytes to bits
)

return Buffer.from(derivedBits)
}


export async function derivePairingCodeKey(pairingCode: string, salt: Buffer): Promise<Buffer> {
// Convert inputs to formats Web Crypto API can work with
const encoder = new TextEncoder()
Expand Down
6 changes: 3 additions & 3 deletions src/Utils/generics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export const generateMessageIDV2 = (userId?: string): string => {
export const generateMessageID = () => '3EB0' + randomBytes(18).toString('hex').toUpperCase()

export function bindWaitForEvent<T extends keyof BaileysEventMap>(ev: BaileysEventEmitter, event: T) {
return async(check: (u: BaileysEventMap[T]) => boolean | undefined, timeoutMs?: number) => {
return async(check: (u: BaileysEventMap[T]) => Promise<boolean | undefined>, timeoutMs?: number) => {
let listener: (item: BaileysEventMap[T]) => void
let closeListener: (state: Partial<ConnectionState>) => void
await (
Expand All @@ -223,8 +223,8 @@ export function bindWaitForEvent<T extends keyof BaileysEventMap>(ev: BaileysEve
}

ev.on('connection.update', closeListener)
listener = (update) => {
if(check(update)) {
listener = async(update) => {
if(await check(update)) {
resolve()
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/Utils/lt-hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ class d {
var n = this
return n.add(n.subtract(e, r), t)
}
_addSingle(e, t) {
async _addSingle(e, t) {
var r = this
const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer
const n = new Uint8Array(await hkdf(Buffer.from(t), o, { info: r.salt })).buffer
return r.performPointwiseWithOverflow(e, n, ((e, t) => e + t))
}
_subtractSingle(e, t) {
async _subtractSingle(e, t) {
var r = this

const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer
const n = new Uint8Array(await hkdf(Buffer.from(t), o, { info: r.salt })).buffer
return r.performPointwiseWithOverflow(e, n, ((e, t) => e - t))
}
performPointwiseWithOverflow(e, t, r) {
Expand Down
18 changes: 9 additions & 9 deletions src/Utils/messages-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const hkdfInfoKey = (type: MediaType) => {
}

/** generates all the keys required to encrypt/decrypt & sign a media message */
export function getMediaKeys(buffer: Uint8Array | string | null | undefined, mediaType: MediaType): MediaDecryptionKeyInfo {
export async function getMediaKeys(buffer: Uint8Array | string | null | undefined, mediaType: MediaType): Promise<MediaDecryptionKeyInfo> {
if(!buffer) {
throw new Boom('Cannot derive from empty media key')
}
Expand All @@ -65,7 +65,7 @@ export function getMediaKeys(buffer: Uint8Array | string | null | undefined, med
}

// expand using HKDF to 112 bytes, also pass in the relevant app info
const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
const expandedMediaKey = await hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
return {
iv: expandedMediaKey.slice(0, 16),
cipherKey: expandedMediaKey.slice(16, 48),
Expand Down Expand Up @@ -344,7 +344,7 @@ export const encryptedStream = async(
logger?.debug('fetched media stream')

const mediaKey = Crypto.randomBytes(32)
const { cipherKey, iv, macKey } = getMediaKeys(mediaKey, mediaType)
const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType)
const encWriteStream = new Readable({ read: () => {} })

let bodyPath: string | undefined
Expand Down Expand Up @@ -458,13 +458,13 @@ export type MediaDownloadOptions = {

export const getUrlFromDirectPath = (directPath: string) => `https://${DEF_HOST}${directPath}`

export const downloadContentFromMessage = (
export const downloadContentFromMessage = async(
{ mediaKey, directPath, url }: DownloadableMessage,
type: MediaType,
opts: MediaDownloadOptions = { }
) => {
const downloadUrl = url || getUrlFromDirectPath(directPath!)
const keys = getMediaKeys(mediaKey, type)
const keys = await getMediaKeys(mediaKey, type)

return downloadEncryptedContent(downloadUrl, keys, opts)
}
Expand Down Expand Up @@ -673,7 +673,7 @@ const getMediaRetryKey = (mediaKey: Buffer | Uint8Array) => {
/**
* Generate a binary node that will request the phone to re-upload the media & return the newly uploaded URL
*/
export const encryptMediaRetryRequest = (
export const encryptMediaRetryRequest = async(
key: proto.IMessageKey,
mediaKey: Buffer | Uint8Array,
meId: string
Expand All @@ -682,7 +682,7 @@ export const encryptMediaRetryRequest = (
const recpBuffer = proto.ServerErrorReceipt.encode(recp).finish()

const iv = Crypto.randomBytes(12)
const retryKey = getMediaRetryKey(mediaKey)
const retryKey = await getMediaRetryKey(mediaKey)
const ciphertext = aesEncryptGCM(recpBuffer, retryKey, iv, Buffer.from(key.id!))

const req: BinaryNode = {
Expand Down Expand Up @@ -752,12 +752,12 @@ export const decodeMediaRetryNode = (node: BinaryNode) => {
return event
}

export const decryptMediaRetryData = (
export const decryptMediaRetryData = async(
{ ciphertext, iv }: { ciphertext: Uint8Array, iv: Uint8Array },
mediaKey: Uint8Array,
msgId: string
) => {
const retryKey = getMediaRetryKey(mediaKey)
const retryKey = await getMediaRetryKey(mediaKey)
const plaintext = aesDecryptGCM(ciphertext, retryKey, iv, Buffer.from(msgId))
return proto.MediaRetryNotification.decode(plaintext)
}
Expand Down
22 changes: 11 additions & 11 deletions src/Utils/noise-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,22 @@ export const makeNoiseHandler = ({
return result
}

const localHKDF = (data: Uint8Array) => {
const key = hkdf(Buffer.from(data), 64, { salt, info: '' })
const localHKDF = async(data: Uint8Array) => {
const key = await hkdf(Buffer.from(data), 64, { salt, info: '' })
return [key.slice(0, 32), key.slice(32)]
}

const mixIntoKey = (data: Uint8Array) => {
const [write, read] = localHKDF(data)
const mixIntoKey = async(data: Uint8Array) => {
const [write, read] = await localHKDF(data)
salt = write
encKey = read
decKey = read
readCounter = 0
writeCounter = 0
}

const finishInit = () => {
const [write, read] = localHKDF(new Uint8Array(0))
const finishInit = async() => {
const [write, read] = await localHKDF(new Uint8Array(0))
encKey = write
decKey = read
hash = Buffer.from([])
Expand All @@ -82,7 +82,7 @@ export const makeNoiseHandler = ({
}

const data = Buffer.from(NOISE_MODE)
let hash = Buffer.from(data.byteLength === 32 ? data : sha256(data))
let hash = data.byteLength === 32 ? data : sha256(data)
let salt = hash
let encKey = hash
let decKey = hash
Expand All @@ -102,12 +102,12 @@ export const makeNoiseHandler = ({
authenticate,
mixIntoKey,
finishInit,
processHandshake: ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => {
processHandshake: async({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => {
authenticate(serverHello!.ephemeral!)
mixIntoKey(Curve.sharedKey(privateKey, serverHello!.ephemeral!))
await mixIntoKey(Curve.sharedKey(privateKey, serverHello!.ephemeral!))

const decStaticContent = decrypt(serverHello!.static!)
mixIntoKey(Curve.sharedKey(privateKey, decStaticContent))
await mixIntoKey(Curve.sharedKey(privateKey, decStaticContent))

const certDecoded = decrypt(serverHello!.payload!)

Expand All @@ -120,7 +120,7 @@ export const makeNoiseHandler = ({
}

const keyEnc = encrypt(noiseKey.public)
mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello!.ephemeral!))
await mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello!.ephemeral!))

return keyEnc
},
Expand Down
5 changes: 0 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3461,11 +3461,6 @@ functions-have-names@^1.2.3:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==

futoin-hkdf@^1.5.1:
version "1.5.3"
resolved "https://registry.yarnpkg.com/futoin-hkdf/-/futoin-hkdf-1.5.3.tgz#6c8024f2e1429da086d4e18289ef2239ad33ee35"
integrity sha512-SewY5KdMpaoCeh7jachEWFsh1nNlaDjNHZXWqL5IGwtpEYHTgkr2+AMCgNwKWkcc0wpSYrZfR7he4WdmHFtDxQ==

gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
Expand Down

0 comments on commit 8083754

Please sign in to comment.