From fe1d0649b5824416383ea8176758eeed28c9f5c1 Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Sat, 18 Mar 2023 12:25:47 +0530 Subject: [PATCH] feat: add signal repository + tests --- .eslintignore | 3 +- jest.config.js | 3 + src/Defaults/index.ts | 10 +- src/Signal/libsignal.ts | 141 ++++++++++++++++++++++++++++ src/Socket/business.ts | 8 +- src/Socket/groups.ts | 4 +- src/Socket/messages-recv.ts | 9 +- src/Socket/messages-send.ts | 29 +++--- src/Socket/socket.ts | 27 +++--- src/Tests/test.libsignal.ts | 165 +++++++++++++++++++++++++++++++++ src/Types/Auth.ts | 11 ++- src/Types/GroupMetadata.ts | 4 +- src/Types/Message.ts | 5 +- src/Types/Product.ts | 3 +- src/Types/Signal.ts | 68 ++++++++++++++ src/Types/Socket.ts | 8 +- src/Types/index.ts | 1 + src/Utils/business.ts | 4 +- src/Utils/decode-wa-message.ts | 35 +++++-- src/Utils/generics.ts | 4 +- src/Utils/signal.ts | 164 ++++---------------------------- 21 files changed, 500 insertions(+), 206 deletions(-) create mode 100644 src/Signal/libsignal.ts create mode 100644 src/Tests/test.libsignal.ts create mode 100644 src/Types/Signal.ts diff --git a/.eslintignore b/.eslintignore index 8ad1369..f1d610b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,4 +5,5 @@ coverage .eslintrc.json src/WABinary/index.ts WAProto -WASignalGroup \ No newline at end of file +WASignalGroup +Example/test.ts \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 09eb9af..9d83927 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,4 +8,7 @@ module.exports = { 'transform': { '^.+\\.(ts|tsx)$': 'ts-jest' }, + moduleNameMapper: { + '^axios$': require.resolve('axios'), + }, } \ No newline at end of file diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index aea5621..6491a8c 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -1,5 +1,6 @@ import { proto } from '../../WAProto' -import type { MediaType, SocketConfig } from '../Types' +import { makeLibSignalRepository } from '../Signal/libsignal' +import type { AuthenticationState, MediaType, SocketConfig, WAVersion } from '../Types' import { Browsers } from '../Utils' import logger from '../Utils/logger' import { version } from './baileys-version.json' @@ -35,7 +36,7 @@ export const PROCESSABLE_HISTORY_TYPES = [ ] export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { - version: version as any, + version: version as WAVersion, browser: Browsers.baileys('Chrome'), waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', connectTimeoutMs: 20_000, @@ -47,7 +48,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { customUploadHosts: [], retryRequestDelayMs: 250, fireInitQueries: true, - auth: undefined as any, + auth: undefined as unknown as AuthenticationState, markOnlineOnConnect: true, syncFullHistory: false, patchMessageBeforeSending: msg => msg, @@ -61,7 +62,8 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { patch: false, snapshot: false, }, - getMessage: async() => undefined + getMessage: async() => undefined, + makeSignalRepository: makeLibSignalRepository } export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = { diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts new file mode 100644 index 0000000..1375050 --- /dev/null +++ b/src/Signal/libsignal.ts @@ -0,0 +1,141 @@ +import * as libsignal from 'libsignal' +import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage, SenderKeyName, SenderKeyRecord } from '../../WASignalGroup' +import { SignalAuthState } from '../Types' +import { SignalRepository } from '../Types/Signal' +import { generateSignalPubKey } from '../Utils' + +export function makeLibSignalRepository(auth: SignalAuthState): SignalRepository { + const storage = signalStorage(auth) + return { + decryptGroupMessage({ group, authorJid, msg }) { + const senderName = jidToSignalSenderKeyName(group, authorJid) + const cipher = new GroupCipher(storage, senderName) + + return cipher.decrypt(msg) + }, + async processSenderKeyDistributionMessage({ item, authorJid }) { + const builder = new GroupSessionBuilder(storage) + const senderName = jidToSignalSenderKeyName(item.groupId!, authorJid) + + const senderMsg = new SenderKeyDistributionMessage(null, null, null, null, item.axolotlSenderKeyDistributionMessage) + const { [senderName]: senderKey } = await auth.keys.get('sender-key', [senderName]) + if(!senderKey) { + await storage.storeSenderKey(senderName, new SenderKeyRecord()) + } + + await builder.process(senderName, senderMsg) + }, + async decryptMessage({ jid, type, ciphertext }) { + const addr = jidToSignalProtocolAddress(jid) + const session = new libsignal.SessionCipher(storage, addr) + let result: Buffer + switch (type) { + case 'pkmsg': + result = await session.decryptPreKeyWhisperMessage(ciphertext) + break + case 'msg': + result = await session.decryptWhisperMessage(ciphertext) + break + } + + return result + }, + async encryptMessage({ jid, data }) { + const addr = jidToSignalProtocolAddress(jid) + const cipher = new libsignal.SessionCipher(storage, addr) + + const { type: sigType, body } = await cipher.encrypt(data) + const type = sigType === 3 ? 'pkmsg' : 'msg' + return { type, ciphertext: Buffer.from(body, 'binary') } + }, + async encryptGroupMessage({ group, meId, data }) { + const senderName = jidToSignalSenderKeyName(group, meId) + const builder = new GroupSessionBuilder(storage) + + const { [senderName]: senderKey } = await auth.keys.get('sender-key', [senderName]) + if(!senderKey) { + await storage.storeSenderKey(senderName, new SenderKeyRecord()) + } + + const senderKeyDistributionMessage = await builder.create(senderName) + const session = new GroupCipher(storage, senderName) + const ciphertext = await session.encrypt(data) + + return { + ciphertext, + senderKeyDistributionMessage: senderKeyDistributionMessage.serialize(), + } + }, + async injectE2ESession({ jid, session }) { + const cipher = new libsignal.SessionBuilder(storage, jidToSignalProtocolAddress(jid)) + await cipher.initOutgoing(session) + }, + jidToSignalProtocolAddress(jid) { + return jidToSignalProtocolAddress(jid).toString() + }, + } +} + +const jidToSignalAddress = (jid: string) => jid.split('@')[0] + +const jidToSignalProtocolAddress = (jid: string) => { + return new libsignal.ProtocolAddress(jidToSignalAddress(jid), 0) +} + +const jidToSignalSenderKeyName = (group: string, user: string): string => { + return new SenderKeyName(group, jidToSignalProtocolAddress(user)).toString() +} + +function signalStorage({ creds, keys }: SignalAuthState) { + return { + loadSession: async(id: string) => { + const { [id]: sess } = await keys.get('session', [id]) + if(sess) { + return libsignal.SessionRecord.deserialize(sess) + } + }, + storeSession: async(id, session) => { + await keys.set({ 'session': { [id]: session.serialize() } }) + }, + isTrustedIdentity: () => { + return true + }, + loadPreKey: async(id: number | string) => { + const keyId = id.toString() + const { [keyId]: key } = await keys.get('pre-key', [keyId]) + if(key) { + return { + privKey: Buffer.from(key.private), + pubKey: Buffer.from(key.public) + } + } + }, + removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }), + loadSignedPreKey: () => { + const key = creds.signedPreKey + return { + privKey: Buffer.from(key.keyPair.private), + pubKey: Buffer.from(key.keyPair.public) + } + }, + loadSenderKey: async(keyId: string) => { + const { [keyId]: key } = await keys.get('sender-key', [keyId]) + if(key) { + return new SenderKeyRecord(key) + } + }, + storeSenderKey: async(keyId, key) => { + await keys.set({ 'sender-key': { [keyId]: key.serialize() } }) + }, + getOurRegistrationId: () => ( + creds.registrationId + ), + getOurIdentity: () => { + const { signedIdentityKey } = creds + return { + privKey: Buffer.from(signedIdentityKey.private), + pubKey: generateSignalPubKey(signedIdentityKey.public), + } + } + } +} \ No newline at end of file diff --git a/src/Socket/business.ts b/src/Socket/business.ts index 457701e..dddb8b0 100644 --- a/src/Socket/business.ts +++ b/src/Socket/business.ts @@ -54,7 +54,7 @@ export const makeBusinessSocket = (config: SocketConfig) => { tag: 'product_catalog', attrs: { jid, - allow_shop_source: 'true' + 'allow_shop_source': 'true' }, content: queryParamNodes } @@ -72,13 +72,13 @@ export const makeBusinessSocket = (config: SocketConfig) => { to: S_WHATSAPP_NET, type: 'get', xmlns: 'w:biz:catalog', - smax_id: '35' + 'smax_id': '35' }, content: [ { tag: 'collections', attrs: { - biz_jid: jid, + 'biz_jid': jid, }, content: [ { @@ -116,7 +116,7 @@ export const makeBusinessSocket = (config: SocketConfig) => { to: S_WHATSAPP_NET, type: 'get', xmlns: 'fb:thrift_iq', - smax_id: '5' + 'smax_id': '5' }, content: [ { diff --git a/src/Socket/groups.ts b/src/Socket/groups.ts index 1c8dbcd..6a0dcca 100644 --- a/src/Socket/groups.ts +++ b/src/Socket/groups.ts @@ -1,5 +1,5 @@ import { proto } from '../../WAProto' -import { GroupMetadata, ParticipantAction, SocketConfig, WAMessageKey, WAMessageStubType } from '../Types' +import { GroupMetadata, GroupParticipant, ParticipantAction, SocketConfig, WAMessageKey, WAMessageStubType } from '../Types' import { generateMessageID, unixTimestampSeconds } from '../Utils' import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, getBinaryNodeChildString, jidEncode, jidNormalizedUser } from '../WABinary' import { makeChatsSocket } from './chats' @@ -278,7 +278,7 @@ export const extractGroupMetadata = (result: BinaryNode) => { ({ attrs }) => { return { id: attrs.jid, - admin: attrs.type || null as any, + admin: (attrs.type || null) as GroupParticipant['admin'], } } ), diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 72a008e..c8dfa01 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -22,8 +22,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { ev, authState, ws, - query, processingMutex, + signalRepository, + query, upsertMessage, resyncAppState, onUnexpectedError, @@ -543,7 +544,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } const handleMessage = async(node: BinaryNode) => { - const { fullMessage: msg, category, author, decrypt } = decryptMessageNode(node, authState) + const { fullMessage: msg, category, author, decrypt } = decryptMessageNode( + node, + authState.creds.me!.id, + signalRepository + ) if(shouldIgnoreJid(msg.key.remoteJid!)) { logger.debug({ key: msg.key }, 'ignored message') await sendMessageAck(node) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 3aab253..10bd162 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -4,7 +4,7 @@ import NodeCache from 'node-cache' import { proto } from '../../WAProto' import { DEFAULT_CACHE_TTLS, WA_DEFAULT_EPHEMERAL } from '../Defaults' import { AnyMessageContent, MediaConnInfo, MessageReceiptType, MessageRelayOptions, MiscMessageGenerationOptions, SocketConfig, WAMessageKey } from '../Types' -import { aggregateMessageKeysNotFromMe, assertMediaContent, bindWaitForEvent, decryptMediaRetryData, encodeSignedDeviceIdentity, encodeWAMessage, encryptMediaRetryRequest, encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, generateMessageID, generateWAMessage, getStatusCodeForMediaRetry, getUrlFromDirectPath, getWAUploadToServer, jidToSignalProtocolAddress, parseAndInjectE2ESessions, unixTimestampSeconds } from '../Utils' +import { aggregateMessageKeysNotFromMe, assertMediaContent, bindWaitForEvent, decryptMediaRetryData, encodeSignedDeviceIdentity, encodeWAMessage, encryptMediaRetryRequest, extractDeviceJids, generateMessageID, generateWAMessage, getStatusCodeForMediaRetry, getUrlFromDirectPath, getWAUploadToServer, parseAndInjectE2ESessions, unixTimestampSeconds } from '../Utils' import { getUrlInfo } from '../Utils/link-preview' import { areJidsSameUser, BinaryNode, BinaryNodeAttributes, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, isJidUser, jidDecode, jidEncode, jidNormalizedUser, JidWithDevice, S_WHATSAPP_NET } from '../WABinary' import { makeGroupsSocket } from './groups' @@ -22,6 +22,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { ev, authState, processingMutex, + signalRepository, upsertMessage, query, fetchPrivacySettings, @@ -215,10 +216,14 @@ export const makeMessagesSocket = (config: SocketConfig) => { if(force) { jidsRequiringFetch = jids } else { - const addrs = jids.map(jid => jidToSignalProtocolAddress(jid).toString()) + const addrs = jids.map(jid => ( + signalRepository + .jidToSignalProtocolAddress(jid) + )) const sessions = await authState.keys.get('session', addrs) for(const jid of jids) { - const signalId = jidToSignalProtocolAddress(jid).toString() + const signalId = signalRepository + .jidToSignalProtocolAddress(jid) if(!sessions[signalId]) { jidsRequiringFetch.push(jid) } @@ -247,7 +252,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { } ] }) - await parseAndInjectE2ESessions(result, authState) + await parseAndInjectE2ESessions(result, signalRepository) didFetchNewSession = true } @@ -267,7 +272,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { const nodes = await Promise.all( jids.map( async jid => { - const { type, ciphertext } = await encryptSignalProto(jid, bytes, authState) + const { type, ciphertext } = await signalRepository + .encryptMessage({ jid, data: bytes }) if(type === 'pkmsg') { shouldIncludeDeviceIdentity = true } @@ -365,11 +371,12 @@ export const makeMessagesSocket = (config: SocketConfig) => { const patched = await patchMessageBeforeSending(message, devices.map(d => jidEncode(d.user, 's.whatsapp.net', d.device))) const bytes = encodeWAMessage(patched) - const { ciphertext, senderKeyDistributionMessageKey } = await encryptSenderKeyMsgSignalProto( - destinationJid, - bytes, - meId, - authState + const { ciphertext, senderKeyDistributionMessage } = await signalRepository.encryptGroupMessage( + { + group: destinationJid, + data: bytes, + meId, + } ) const senderKeyJids: string[] = [] @@ -390,7 +397,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const senderKeyMsg: proto.IMessage = { senderKeyDistributionMessage: { - axolotlSenderKeyDistributionMessage: senderKeyDistributionMessageKey, + axolotlSenderKeyDistributionMessage: senderKeyDistributionMessage, groupId: destinationJid } } diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index a164670..b91a3a3 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -29,6 +29,7 @@ export const makeSocket = ({ transactionOpts, qrTimeout, options, + makeSignalRepository }: SocketConfig) => { const ws = new WebSocket(waWebSocketUrl, undefined, { origin: DEFAULT_ORIGIN, @@ -48,6 +49,7 @@ export const makeSocket = ({ const { creds } = authState // add transaction capability const keys = addTransactionCapability(authState.keys, logger, transactionOpts) + const signalRepository = makeSignalRepository({ creds, keys }) let lastDateRecv: Date let epoch = 1 @@ -90,24 +92,26 @@ export const makeSocket = ({ } /** log & process any unexpected errors */ - const onUnexpectedError = (error: Error, msg: string) => { + const onUnexpectedError = (err: Error | Boom, msg: string) => { logger.error( - { trace: error.stack, output: (error as any).output }, + { err }, `unexpected error in '${msg}'` ) } /** await the next incoming message */ - const awaitNextMessage = async(sendMsg?: Uint8Array) => { + const awaitNextMessage = async(sendMsg?: Uint8Array) => { if(ws.readyState !== ws.OPEN) { - throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) + throw new Boom('Connection Closed', { + statusCode: DisconnectReason.connectionClosed + }) } - let onOpen: (data: any) => void + let onOpen: (data: T) => void let onClose: (err: Error) => void - const result = promiseTimeout(connectTimeoutMs, (resolve, reject) => { - onOpen = (data: any) => resolve(data) + const result = promiseTimeout(connectTimeoutMs, (resolve, reject) => { + onOpen = resolve onClose = mapWebSocketError(reject) ws.on('frame', onOpen) ws.on('close', onClose) @@ -132,11 +136,11 @@ export const makeSocket = ({ * @param json query that was sent * @param timeoutMs timeout after which the promise will reject */ - const waitForMessage = async(msgId: string, timeoutMs = defaultQueryTimeoutMs) => { + const waitForMessage = async(msgId: string, timeoutMs = defaultQueryTimeoutMs) => { let onRecv: (json) => void let onErr: (err) => void try { - const result = await promiseTimeout(timeoutMs, + const result = await promiseTimeout(timeoutMs, (resolve, reject) => { onRecv = resolve onErr = err => { @@ -148,7 +152,7 @@ export const makeSocket = ({ ws.off('error', onErr) }, ) - return result as any + return result } finally { ws.off(`TAG:${msgId}`, onRecv!) ws.off('close', onErr!) // if the socket closes, you'll never receive the message @@ -186,7 +190,7 @@ export const makeSocket = ({ const init = proto.HandshakeMessage.encode(helloMsg).finish() - const result = await awaitNextMessage(init) + const result = await awaitNextMessage(init) const handshake = proto.HandshakeMessage.decode(result) logger.trace({ handshake }, 'handshake recv from WA Web') @@ -591,6 +595,7 @@ export const makeSocket = ({ ws, ev, authState: { creds, keys }, + signalRepository, get user() { return authState.creds.me }, diff --git a/src/Tests/test.libsignal.ts b/src/Tests/test.libsignal.ts new file mode 100644 index 0000000..15a475f --- /dev/null +++ b/src/Tests/test.libsignal.ts @@ -0,0 +1,165 @@ +import { makeLibSignalRepository } from '../Signal/libsignal' +import { SignalAuthState, SignalDataTypeMap } from '../Types' +import { Curve, generateRegistrationId, generateSignalPubKey, signedKeyPair } from '../Utils' + +describe('Signal Tests', () => { + + it('should correctly encrypt/decrypt 1 message', async() => { + const user1 = makeUser() + const user2 = makeUser() + + const msg = Buffer.from('hello there!') + + await prepareForSendingMessage(user1, user2) + + const result = await user1.repository.encryptMessage( + { jid: user2.jid, data: msg } + ) + + const dec = await user2.repository.decryptMessage( + { jid: user1.jid, ...result } + ) + + expect(dec).toEqual(msg) + }) + + it('should correctly encrypt/decrypt multiple messages', async() => { + const user1 = makeUser() + const user2 = makeUser() + + const msg = Buffer.from('hello there!') + + await prepareForSendingMessage(user1, user2) + + for(let i = 0;i < 10;i++) { + const result = await user1.repository.encryptMessage( + { jid: user2.jid, data: msg } + ) + + const dec = await user2.repository.decryptMessage( + { jid: user1.jid, ...result } + ) + + expect(dec).toEqual(msg) + } + }) + + it('should encrypt/decrypt messages from group', async() => { + const groupId = '123456@g.us' + const participants = [...Array(5)].map(makeUser) + + const msg = Buffer.from('hello there!') + + const sender = participants[0] + const enc = await sender.repository.encryptGroupMessage( + { + group: groupId, + meId: sender.jid, + data: msg + } + ) + + for(const participant of participants) { + if(participant === sender) { + continue + } + + await participant.repository.processSenderKeyDistributionMessage( + { + item: { + groupId, + axolotlSenderKeyDistributionMessage: enc.senderKeyDistributionMessage + }, + authorJid: sender.jid + } + ) + + const dec = await participant.repository.decryptGroupMessage( + { + group: groupId, + authorJid: sender.jid, + msg: enc.ciphertext + } + ) + expect(dec).toEqual(msg) + } + }) +}) + +type User = ReturnType + +function makeUser() { + const store = makeTestAuthState() + const jid = `${Math.random().toString().replace('.', '')}@s.whatsapp.net` + const repository = makeLibSignalRepository(store) + return { store, jid, repository } +} + +async function prepareForSendingMessage( + sender: User, + receiver: User, +) { + const preKeyId = 2 + const preKey = Curve.generateKeyPair() + await sender.repository.injectE2ESession( + { + jid: receiver.jid, + session: { + registrationId: receiver.store.creds.registrationId, + identityKey: generateSignalPubKey(receiver.store.creds.signedIdentityKey.public), + signedPreKey: { + keyId: receiver.store.creds.signedPreKey.keyId, + publicKey: generateSignalPubKey(receiver.store.creds.signedPreKey.keyPair.public), + signature: receiver.store.creds.signedPreKey.signature, + }, + preKey: { + keyId: preKeyId, + publicKey: generateSignalPubKey(preKey.public), + } + } + } + ) + + await receiver.store.keys.set({ + 'pre-key': { + [preKeyId]: preKey + } + }) +} + +function makeTestAuthState(): SignalAuthState { + const identityKey = Curve.generateKeyPair() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store: { [_: string]: any } = {} + return { + creds: { + signedIdentityKey: identityKey, + registrationId: generateRegistrationId(), + signedPreKey: signedKeyPair(identityKey, 1), + }, + keys: { + get(type, ids) { + const data: { [_: string]: SignalDataTypeMap[typeof type] } = { } + for(const id of ids) { + const item = store[getUniqueId(type, id)] + if(typeof item !== 'undefined') { + data[id] = item + } + } + + return data + }, + set(data) { + for(const type in data) { + for(const id in data[type]) { + store[getUniqueId(type, id)] = data[type][id] + } + } + }, + } + } + + function getUniqueId(type: string, id: string) { + return `${type}.${id}` + } +} \ No newline at end of file diff --git a/src/Types/Auth.ts b/src/Types/Auth.ts index 1010da6..72bde7c 100644 --- a/src/Types/Auth.ts +++ b/src/Types/Auth.ts @@ -3,7 +3,12 @@ import type { Contact } from './Contact' import type { MinimalMessage } from './Message' export type KeyPair = { public: Uint8Array, private: Uint8Array } -export type SignedKeyPair = { keyPair: KeyPair, signature: Uint8Array, keyId: number } +export type SignedKeyPair = { + keyPair: KeyPair + signature: Uint8Array + keyId: number + timestampS?: number +} export type ProtocolAddress = { name: string // jid @@ -57,8 +62,8 @@ export type AuthenticationCreds = SignalCreds & { export type SignalDataTypeMap = { 'pre-key': KeyPair - 'session': any - 'sender-key': any + 'session': Uint8Array + 'sender-key': Uint8Array 'sender-key-memory': { [jid: string]: boolean } 'app-state-sync-key': proto.Message.IAppStateSyncKeyData 'app-state-sync-version': LTHashState diff --git a/src/Types/GroupMetadata.ts b/src/Types/GroupMetadata.ts index 15eb69b..853a49f 100644 --- a/src/Types/GroupMetadata.ts +++ b/src/Types/GroupMetadata.ts @@ -32,10 +32,10 @@ export interface GroupMetadata { export interface WAGroupCreateResponse { status: number gid?: string - participants?: [{ [key: string]: any }] + participants?: [{ [key: string]: {} }] } export interface GroupModificationResponse { status: number - participants?: { [key: string]: any } + participants?: { [key: string]: {} } } \ No newline at end of file diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 2337eef..34853ed 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -1,5 +1,4 @@ import { AxiosRequestConfig } from 'axios' -import type NodeCache from 'node-cache' import type { Logger } from 'pino' import type { Readable } from 'stream' import type { URL } from 'url' @@ -19,9 +18,9 @@ export type WATextMessage = proto.Message.IExtendedTextMessage export type WAContextInfo = proto.IContextInfo export type WALocationMessage = proto.Message.ILocationMessage export type WAGenericMediaMessage = proto.Message.IVideoMessage | proto.Message.IImageMessage | proto.Message.IAudioMessage | proto.Message.IDocumentMessage | proto.Message.IStickerMessage -// eslint-disable-next-line no-unused-vars +// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars export import WAMessageStubType = proto.WebMessageInfo.StubType -// eslint-disable-next-line no-unused-vars +// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars export import WAMessageStatus = proto.WebMessageInfo.Status export type WAMediaUpload = Buffer | { url: URL | string } | { stream: Readable } /** Set of message types that are supported by the library */ diff --git a/src/Types/Product.ts b/src/Types/Product.ts index 159416a..2f51b1c 100644 --- a/src/Types/Product.ts +++ b/src/Types/Product.ts @@ -3,12 +3,13 @@ import { WAMediaUpload } from './Message' export type CatalogResult = { data: { paging: { cursors: { before: string, after: string } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any[] } } export type ProductCreateResult = { - data: { product: any } + data: { product: {} } } export type CatalogStatus = { diff --git a/src/Types/Signal.ts b/src/Types/Signal.ts new file mode 100644 index 0000000..12b8e5c --- /dev/null +++ b/src/Types/Signal.ts @@ -0,0 +1,68 @@ +import { proto } from '../../WAProto' + +type DecryptGroupSignalOpts = { + group: string + authorJid: string + msg: Uint8Array +} + +type ProcessSenderKeyDistributionMessageOpts = { + item: proto.Message.ISenderKeyDistributionMessage + authorJid: string +} + +type DecryptSignalProtoOpts = { + jid: string + type: 'pkmsg' | 'msg' + ciphertext: Uint8Array +} + +type EncryptMessageOpts = { + jid: string + data: Uint8Array +} + +type EncryptGroupMessageOpts = { + group: string + data: Uint8Array + meId: string +} + +type PreKey = { + keyId: number + publicKey: Uint8Array +} + +type SignedPreKey = PreKey & { + signature: Uint8Array +} + +type E2ESession = { + registrationId: number + identityKey: Uint8Array + signedPreKey: SignedPreKey + preKey: PreKey +} + +type E2ESessionOpts = { + jid: string + session: E2ESession +} + +export type SignalRepository = { + decryptGroupMessage(opts: DecryptGroupSignalOpts): Promise + processSenderKeyDistributionMessage( + opts: ProcessSenderKeyDistributionMessageOpts + ): Promise + decryptMessage(opts: DecryptSignalProtoOpts): Promise + encryptMessage(opts: EncryptMessageOpts): Promise<{ + type: 'pkmsg' | 'msg' + ciphertext: Uint8Array + }> + encryptGroupMessage(opts: EncryptGroupMessageOpts): Promise<{ + senderKeyDistributionMessage: Uint8Array + ciphertext: Uint8Array + }> + injectE2ESession(opts: E2ESessionOpts): Promise + jidToSignalProtocolAddress(jid: string): string +} \ No newline at end of file diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index 0534457..42edaa7 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -4,8 +4,9 @@ import type { Agent } from 'https' import type { Logger } from 'pino' import type { URL } from 'url' import { proto } from '../../WAProto' -import { AuthenticationState, TransactionCapabilityOptions } from './Auth' +import { AuthenticationState, SignalAuthState, TransactionCapabilityOptions } from './Auth' import { MediaConnInfo } from './Message' +import { SignalRepository } from './Signal' export type WAVersion = [number, number, number] export type WABrowserDescription = [string, string, string] @@ -106,7 +107,10 @@ export type SocketConfig = { options: AxiosRequestConfig<{}> /** * fetch a message from your store - * implement this so that messages failed to send (solves the "this message can take a while" issue) can be retried + * implement this so that messages failed to send + * (solves the "this message can take a while" issue) can be retried * */ getMessage: (key: proto.IMessageKey) => Promise + + makeSignalRepository: (auth: SignalAuthState) => SignalRepository } diff --git a/src/Types/index.ts b/src/Types/index.ts index 57ca635..213fdf9 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -8,6 +8,7 @@ export * from './Socket' export * from './Events' export * from './Product' export * from './Call' +export * from './Signal' import { AuthenticationState } from './Auth' import { SocketConfig } from './Socket' diff --git a/src/Utils/business.ts b/src/Utils/business.ts index b2fd3f4..57459a5 100644 --- a/src/Utils/business.ts +++ b/src/Utils/business.ts @@ -148,7 +148,7 @@ export const toProductNode = (productId: string | undefined, product: ProductCre if('originCountryCode' in product) { if(typeof product.originCountryCode === 'undefined') { - attrs.compliance_category = 'COUNTRY_ORIGIN_EXEMPT' + attrs['compliance_category'] = 'COUNTRY_ORIGIN_EXEMPT' } else { content.push({ tag: 'compliance_info', @@ -166,7 +166,7 @@ export const toProductNode = (productId: string | undefined, product: ProductCre if(typeof product.isHidden !== 'undefined') { - attrs.is_hidden = product.isHidden.toString() + attrs['is_hidden'] = product.isHidden.toString() } const node: BinaryNode = { diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index c526186..1677051 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -1,9 +1,8 @@ import { Boom } from '@hapi/boom' import { proto } from '../../WAProto' -import { AuthenticationState, WAMessageKey } from '../Types' +import { SignalRepository, WAMessageKey } from '../Types' import { areJidsSameUser, BinaryNode, isJidBroadcast, isJidGroup, isJidStatusBroadcast, isJidUser } from '../WABinary' import { unpadRandomMax16 } from './generics' -import { decryptGroupSignalProto, decryptSignalProto, processSenderKeyMessage } from './signal' const NO_MESSAGE_FOUND_ERROR_TEXT = 'Message absent from node' @@ -13,7 +12,10 @@ type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'di * Decode the received node as a message. * @note this will only parse the message, not decrypt it */ -export function decodeMessageNode(stanza: BinaryNode, meId: string) { +export function decodeMessageNode( + stanza: BinaryNode, + meId: string +) { let msgType: MessageType let chatId: string let author: string @@ -92,8 +94,12 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string) { } } -export const decryptMessageNode = (stanza: BinaryNode, auth: AuthenticationState) => { - const { fullMessage, author, sender } = decodeMessageNode(stanza, auth.creds.me!.id) +export const decryptMessageNode = ( + stanza: BinaryNode, + meId: string, + repository: SignalRepository +) => { + const { fullMessage, author, sender } = decodeMessageNode(stanza, meId) return { fullMessage, category: stanza.attrs.category, @@ -118,18 +124,26 @@ export const decryptMessageNode = (stanza: BinaryNode, auth: AuthenticationState decryptables += 1 - let msgBuffer: Buffer + let msgBuffer: Uint8Array try { const e2eType = attrs.type switch (e2eType) { case 'skmsg': - msgBuffer = await decryptGroupSignalProto(sender, author, content, auth) + msgBuffer = await repository.decryptGroupMessage({ + group: sender, + authorJid: author, + msg: content + }) break case 'pkmsg': case 'msg': const user = isJidUser(sender) ? sender : author - msgBuffer = await decryptSignalProto(user, e2eType, content as Buffer, auth) + msgBuffer = await repository.decryptMessage({ + jid: user, + type: e2eType, + ciphertext: content + }) break default: throw new Error(`Unknown e2e type: ${e2eType}`) @@ -138,7 +152,10 @@ export const decryptMessageNode = (stanza: BinaryNode, auth: AuthenticationState let msg: proto.IMessage = proto.Message.decode(unpadRandomMax16(msgBuffer)) msg = msg.deviceSentMessage?.message || msg if(msg.senderKeyDistributionMessage) { - await processSenderKeyMessage(author, msg.senderKeyDistributionMessage, auth) + await repository.processSenderKeyDistributionMessage({ + authorJid: author, + item: msg.senderKeyDistributionMessage + }) } if(fullMessage.message) { diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index d772f46..ab392e2 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -143,7 +143,7 @@ export const delayCancellable = (ms: number) => { return { delay, cancel } } -export async function promiseTimeout(ms: number | undefined, promise: (resolve: (v?: T) => void, reject: (error) => void) => void) { +export async function promiseTimeout(ms: number | undefined, promise: (resolve: (v: T) => void, reject: (error) => void) => void) { if(!ms) { return new Promise(promise) } @@ -177,7 +177,7 @@ export function bindWaitForEvent(ev: BaileysEve let listener: (item: BaileysEventMap[T]) => void let closeListener: any await ( - promiseTimeout( + promiseTimeout( timeoutMs, (resolve, reject) => { closeListener = ({ connection, lastDisconnect }) => { diff --git a/src/Utils/signal.ts b/src/Utils/signal.ts index acf787e..e1c44b9 100644 --- a/src/Utils/signal.ts +++ b/src/Utils/signal.ts @@ -1,22 +1,10 @@ -import * as libsignal from 'libsignal' -import { proto } from '../../WAProto' -import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage, SenderKeyName, SenderKeyRecord } from '../../WASignalGroup' import { KEY_BUNDLE_TYPE } from '../Defaults' -import { AuthenticationCreds, AuthenticationState, KeyPair, SignalAuthState, SignalIdentity, SignalKeyStore, SignedKeyPair } from '../Types/Auth' +import { SignalRepository } from '../Types' +import { AuthenticationCreds, AuthenticationState, KeyPair, SignalIdentity, SignalKeyStore, SignedKeyPair } from '../Types/Auth' import { assertNodeErrorFree, BinaryNode, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildUInt, jidDecode, JidWithDevice, S_WHATSAPP_NET } from '../WABinary' import { Curve, generateSignalPubKey } from './crypto' import { encodeBigEndian } from './generics' -const jidToSignalAddress = (jid: string) => jid.split('@')[0] - -export const jidToSignalProtocolAddress = (jid: string) => { - return new libsignal.ProtocolAddress(jidToSignalAddress(jid), 0) -} - -export const jidToSignalSenderKeyName = (group: string, user: string): string => { - return new SenderKeyName(group, jidToSignalProtocolAddress(user)).toString() -} - export const createSignalIdentity = ( wid: string, accountSignatureKey: Uint8Array @@ -77,134 +65,15 @@ export const xmppPreKey = (pair: KeyPair, id: number): BinaryNode => ( } ) -export const signalStorage = ({ creds, keys }: SignalAuthState) => ({ - loadSession: async(id: string) => { - const { [id]: sess } = await keys.get('session', [id]) - if(sess) { - return libsignal.SessionRecord.deserialize(sess) - } - }, - storeSession: async(id, session) => { - await keys.set({ 'session': { [id]: session.serialize() } }) - }, - isTrustedIdentity: () => { - return true - }, - loadPreKey: async(id: number | string) => { - const keyId = id.toString() - const { [keyId]: key } = await keys.get('pre-key', [keyId]) - if(key) { - return { - privKey: Buffer.from(key.private), - pubKey: Buffer.from(key.public) - } - } - }, - removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }), - loadSignedPreKey: () => { - const key = creds.signedPreKey - return { - privKey: Buffer.from(key.keyPair.private), - pubKey: Buffer.from(key.keyPair.public) - } - }, - loadSenderKey: async(keyId: string) => { - const { [keyId]: key } = await keys.get('sender-key', [keyId]) - if(key) { - return new SenderKeyRecord(key) - } - }, - storeSenderKey: async(keyId, key) => { - await keys.set({ 'sender-key': { [keyId]: key.serialize() } }) - }, - getOurRegistrationId: () => ( - creds.registrationId - ), - getOurIdentity: () => { - const { signedIdentityKey } = creds - return { - privKey: Buffer.from(signedIdentityKey.private), - pubKey: generateSignalPubKey(signedIdentityKey.public), - } - } -}) - -export const decryptGroupSignalProto = (group: string, user: string, msg: Buffer | Uint8Array, auth: SignalAuthState) => { - const senderName = jidToSignalSenderKeyName(group, user) - const cipher = new GroupCipher(signalStorage(auth), senderName) - - return cipher.decrypt(Buffer.from(msg)) -} - -export const processSenderKeyMessage = async( - authorJid: string, - item: proto.Message.ISenderKeyDistributionMessage, - auth: SignalAuthState +export const parseAndInjectE2ESessions = async( + node: BinaryNode, + repository: SignalRepository ) => { - const builder = new GroupSessionBuilder(signalStorage(auth)) - const senderName = jidToSignalSenderKeyName(item.groupId!, authorJid) - - const senderMsg = new SenderKeyDistributionMessage(null, null, null, null, item.axolotlSenderKeyDistributionMessage) - const { [senderName]: senderKey } = await auth.keys.get('sender-key', [senderName]) - if(!senderKey) { - const record = new SenderKeyRecord() - await auth.keys.set({ 'sender-key': { [senderName]: record } }) - } - - await builder.process(senderName, senderMsg) -} - -export const decryptSignalProto = async(user: string, type: 'pkmsg' | 'msg', msg: Buffer | Uint8Array, auth: SignalAuthState) => { - const addr = jidToSignalProtocolAddress(user) - const session = new libsignal.SessionCipher(signalStorage(auth), addr) - let result: Buffer - switch (type) { - case 'pkmsg': - result = await session.decryptPreKeyWhisperMessage(msg) - break - case 'msg': - result = await session.decryptWhisperMessage(msg) - break - } - - return result -} - - -export const encryptSignalProto = async(user: string, buffer: Buffer, auth: SignalAuthState) => { - const addr = jidToSignalProtocolAddress(user) - const cipher = new libsignal.SessionCipher(signalStorage(auth), addr) - - const { type: sigType, body } = await cipher.encrypt(buffer) - const type = sigType === 3 ? 'pkmsg' : 'msg' - return { type, ciphertext: Buffer.from(body, 'binary') } -} - -export const encryptSenderKeyMsgSignalProto = async(group: string, data: Uint8Array | Buffer, meId: string, auth: SignalAuthState) => { - const storage = signalStorage(auth) - const senderName = jidToSignalSenderKeyName(group, meId) - const builder = new GroupSessionBuilder(storage) - - const { [senderName]: senderKey } = await auth.keys.get('sender-key', [senderName]) - if(!senderKey) { - const record = new SenderKeyRecord() - await auth.keys.set({ 'sender-key': { [senderName]: record } }) - } - - const senderKeyDistributionMessage = await builder.create(senderName) - const session = new GroupCipher(storage, senderName) - return { - ciphertext: await session.encrypt(data) as Uint8Array, - senderKeyDistributionMessageKey: senderKeyDistributionMessage.serialize() as Buffer, - } -} - -export const parseAndInjectE2ESessions = async(node: BinaryNode, auth: SignalAuthState) => { const extractKey = (key: BinaryNode) => ( key ? ({ - keyId: getBinaryNodeChildUInt(key, 'id', 3), - publicKey: generateSignalPubKey(getBinaryNodeChildBuffer(key, 'value')!), - signature: getBinaryNodeChildBuffer(key, 'signature'), + keyId: getBinaryNodeChildUInt(key, 'id', 3)!, + publicKey: generateSignalPubKey(getBinaryNodeChildBuffer(key, 'value')!)!, + signature: getBinaryNodeChildBuffer(key, 'signature')!, }) : undefined ) const nodes = getBinaryNodeChildren(getBinaryNodeChild(node, 'list'), 'user') @@ -221,14 +90,15 @@ export const parseAndInjectE2ESessions = async(node: BinaryNode, auth: SignalAut const jid = node.attrs.jid const registrationId = getBinaryNodeChildUInt(node, 'registration', 4) - const device = { - registrationId, - identityKey: generateSignalPubKey(identity), - signedPreKey: extractKey(signedKey), - preKey: extractKey(key) - } - const cipher = new libsignal.SessionBuilder(signalStorage(auth), jidToSignalProtocolAddress(jid)) - await cipher.initOutgoing(device) + await repository.injectE2ESession({ + jid, + session: { + registrationId: registrationId!, + identityKey: generateSignalPubKey(identity), + signedPreKey: extractKey(signedKey)!, + preKey: extractKey(key)! + } + }) } ) )