6e394bd540
Fixes: - chats.ts: revert encodeResult/initial to ! (guaranteed by callback assignment) - groups.ts: fix operator precedence with ?? (wrap +attrs in parens), fix groupId type - messages-recv.ts: extract messageKey.id to local const with fallback - socket.ts: revert onClose! (guaranteed by synchronous callback assignment) - event-buffer.ts: add 'notify' fallback for MessageUpsertType - messages.ts: add filePath const after null guard, fix contextInfo type assertion - noise-handler.ts: restore array index ! (guaranteed by length check) Build now compiles with zero new errors. https://claude.ai/code/session_01E2cfX1N3sJgCJBTvzGazSG
285 lines
7.6 KiB
TypeScript
285 lines
7.6 KiB
TypeScript
import { Boom } from '@hapi/boom'
|
|
import { proto } from '../../WAProto/index.js'
|
|
import { NOISE_MODE, WA_CERT_DETAILS } from '../Defaults'
|
|
import type { KeyPair } from '../Types'
|
|
import type { BinaryNode } from '../WABinary'
|
|
import { decodeBinaryNode } from '../WABinary'
|
|
import { aesDecryptGCM, aesEncryptGCM, Curve, hkdf, sha256 } from './crypto'
|
|
import type { ILogger } from './logger'
|
|
|
|
const IV_LENGTH = 12
|
|
|
|
const EMPTY_BUFFER = Buffer.alloc(0)
|
|
|
|
const generateIV = (counter: number): Uint8Array => {
|
|
const iv = new ArrayBuffer(IV_LENGTH)
|
|
new DataView(iv).setUint32(8, counter)
|
|
return new Uint8Array(iv)
|
|
}
|
|
|
|
class TransportState {
|
|
private readCounter = 0
|
|
private writeCounter = 0
|
|
|
|
private readonly iv = new Uint8Array(IV_LENGTH)
|
|
|
|
constructor(
|
|
private readonly encKey: Uint8Array,
|
|
private readonly decKey: Uint8Array
|
|
) {}
|
|
|
|
encrypt(plaintext: Uint8Array): Uint8Array {
|
|
const c = this.writeCounter++
|
|
this.iv[8] = (c >>> 24) & 0xff
|
|
this.iv[9] = (c >>> 16) & 0xff
|
|
this.iv[10] = (c >>> 8) & 0xff
|
|
this.iv[11] = c & 0xff
|
|
|
|
return aesEncryptGCM(plaintext, this.encKey, this.iv, EMPTY_BUFFER)
|
|
}
|
|
|
|
decrypt(ciphertext: Uint8Array): Buffer {
|
|
const c = this.readCounter++
|
|
this.iv[8] = (c >>> 24) & 0xff
|
|
this.iv[9] = (c >>> 16) & 0xff
|
|
this.iv[10] = (c >>> 8) & 0xff
|
|
this.iv[11] = c & 0xff
|
|
|
|
return aesDecryptGCM(ciphertext, this.decKey, this.iv, EMPTY_BUFFER) as Buffer
|
|
}
|
|
}
|
|
|
|
export const makeNoiseHandler = ({
|
|
keyPair: { private: privateKey, public: publicKey },
|
|
NOISE_HEADER,
|
|
logger,
|
|
routingInfo
|
|
}: {
|
|
keyPair: KeyPair
|
|
NOISE_HEADER: Uint8Array
|
|
logger: ILogger
|
|
routingInfo?: Buffer | undefined
|
|
}) => {
|
|
logger = logger.child({ class: 'ns' })
|
|
|
|
const data = Buffer.from(NOISE_MODE)
|
|
let hash = data.byteLength === 32 ? data : sha256(data)
|
|
let salt: Buffer = hash
|
|
let encKey: Buffer = hash
|
|
let decKey: Buffer = hash
|
|
let counter = 0
|
|
let sentIntro = false
|
|
|
|
let inBytes: Buffer = Buffer.alloc(0)
|
|
|
|
let transport: TransportState | null = null
|
|
let isWaitingForTransport = false
|
|
let pendingOnFrame: ((buff: Uint8Array | BinaryNode) => void) | null = null
|
|
|
|
let introHeader: Buffer
|
|
if (routingInfo) {
|
|
introHeader = Buffer.alloc(7 + routingInfo.byteLength + NOISE_HEADER.length)
|
|
introHeader.write('ED', 0, 'utf8')
|
|
introHeader.writeUint8(0, 2)
|
|
introHeader.writeUint8(1, 3)
|
|
introHeader.writeUint8(routingInfo.byteLength >> 16, 4)
|
|
introHeader.writeUint16BE(routingInfo.byteLength & 65535, 5)
|
|
introHeader.set(routingInfo, 7)
|
|
introHeader.set(NOISE_HEADER, 7 + routingInfo.byteLength)
|
|
} else {
|
|
introHeader = Buffer.from(NOISE_HEADER)
|
|
}
|
|
|
|
const authenticate = (data: Uint8Array) => {
|
|
if (!transport) {
|
|
hash = sha256(Buffer.concat([hash, data]))
|
|
}
|
|
}
|
|
|
|
const encrypt = (plaintext: Uint8Array): Uint8Array => {
|
|
if (transport) {
|
|
return transport.encrypt(plaintext)
|
|
}
|
|
|
|
const result = aesEncryptGCM(plaintext, encKey, generateIV(counter++), hash)
|
|
authenticate(result)
|
|
return result
|
|
}
|
|
|
|
const decrypt = (ciphertext: Uint8Array): Uint8Array => {
|
|
if (transport) {
|
|
return transport.decrypt(ciphertext)
|
|
}
|
|
|
|
const result = aesDecryptGCM(ciphertext, decKey, generateIV(counter++), hash)
|
|
authenticate(ciphertext)
|
|
return result
|
|
}
|
|
|
|
const localHKDF = (data: Uint8Array): [Uint8Array, Uint8Array] => {
|
|
const key = hkdf(Buffer.from(data), 64, { salt, info: '' })
|
|
return [key.subarray(0, 32), key.subarray(32)]
|
|
}
|
|
|
|
const mixIntoKey = (data: Uint8Array) => {
|
|
const [write, read] = localHKDF(data)
|
|
salt = Buffer.from(write)
|
|
encKey = Buffer.from(read)
|
|
decKey = Buffer.from(read)
|
|
counter = 0
|
|
}
|
|
|
|
const finishInit = async () => {
|
|
isWaitingForTransport = true
|
|
const [write, read] = localHKDF(new Uint8Array(0))
|
|
transport = new TransportState(write, read)
|
|
isWaitingForTransport = false
|
|
|
|
logger.trace('Noise handler transitioned to Transport state')
|
|
|
|
if (pendingOnFrame) {
|
|
logger.trace({ length: inBytes.length }, 'Flushing buffered frames after transport ready')
|
|
await processData(pendingOnFrame)
|
|
pendingOnFrame = null
|
|
}
|
|
}
|
|
|
|
const processData = async (onFrame: (buff: Uint8Array | BinaryNode) => void) => {
|
|
let size: number | undefined
|
|
|
|
while (true) {
|
|
if (inBytes.length < 3) return
|
|
|
|
size = (inBytes[0]! << 16) | (inBytes[1]! << 8) | inBytes[2]!
|
|
|
|
if (inBytes.length < size + 3) return
|
|
|
|
let frame: Uint8Array | BinaryNode = inBytes.subarray(3, size + 3)
|
|
inBytes = inBytes.subarray(size + 3)
|
|
|
|
if (transport) {
|
|
const result = transport.decrypt(frame)
|
|
frame = await decodeBinaryNode(result)
|
|
}
|
|
|
|
if (logger.level === 'trace') {
|
|
logger.trace({ msg: (frame as BinaryNode)?.attrs?.id }, 'recv frame')
|
|
}
|
|
|
|
onFrame(frame)
|
|
}
|
|
}
|
|
|
|
authenticate(NOISE_HEADER)
|
|
authenticate(publicKey)
|
|
|
|
return {
|
|
encrypt,
|
|
decrypt,
|
|
authenticate,
|
|
mixIntoKey,
|
|
finishInit,
|
|
processHandshake: ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => {
|
|
if (!serverHello?.ephemeral) {
|
|
throw new Boom('Missing server hello ephemeral', { statusCode: 500 })
|
|
}
|
|
|
|
if (!serverHello?.static) {
|
|
throw new Boom('Missing server hello static', { statusCode: 500 })
|
|
}
|
|
|
|
if (!serverHello?.payload) {
|
|
throw new Boom('Missing server hello payload', { statusCode: 500 })
|
|
}
|
|
|
|
authenticate(serverHello.ephemeral)
|
|
mixIntoKey(Curve.sharedKey(privateKey, serverHello.ephemeral))
|
|
|
|
const decStaticContent = decrypt(serverHello.static)
|
|
mixIntoKey(Curve.sharedKey(privateKey, decStaticContent))
|
|
|
|
const certDecoded = decrypt(serverHello.payload)
|
|
|
|
const { intermediate: certIntermediate, leaf } = proto.CertChain.decode(certDecoded)
|
|
// leaf
|
|
if (!leaf?.details || !leaf?.signature) {
|
|
throw new Boom('invalid noise leaf certificate', { statusCode: 400 })
|
|
}
|
|
|
|
if (!certIntermediate?.details || !certIntermediate?.signature) {
|
|
throw new Boom('invalid noise intermediate certificate', { statusCode: 400 })
|
|
}
|
|
|
|
const details = proto.CertChain.NoiseCertificate.Details.decode(certIntermediate.details)
|
|
|
|
const { issuerSerial } = details
|
|
|
|
if (!details.key) {
|
|
throw new Boom('Missing certificate key', { statusCode: 500 })
|
|
}
|
|
|
|
const verify = Curve.verify(details.key, leaf.details, leaf.signature)
|
|
|
|
const verifyIntermediate = Curve.verify(
|
|
WA_CERT_DETAILS.PUBLIC_KEY,
|
|
certIntermediate.details,
|
|
certIntermediate.signature
|
|
)
|
|
|
|
if (!verify) {
|
|
throw new Boom('noise certificate signature invalid', { statusCode: 400 })
|
|
}
|
|
|
|
if (!verifyIntermediate) {
|
|
throw new Boom('noise intermediate certificate signature invalid', { statusCode: 400 })
|
|
}
|
|
|
|
if (issuerSerial !== WA_CERT_DETAILS.SERIAL) {
|
|
throw new Boom('certification match failed', { statusCode: 400 })
|
|
}
|
|
|
|
const keyEnc = encrypt(noiseKey.public)
|
|
mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello.ephemeral))
|
|
|
|
return keyEnc
|
|
},
|
|
encodeFrame: (data: Buffer | Uint8Array) => {
|
|
if (transport) {
|
|
data = transport.encrypt(data)
|
|
}
|
|
|
|
const dataLen = data.byteLength
|
|
const introSize = sentIntro ? 0 : introHeader.length
|
|
const frame = Buffer.allocUnsafe(introSize + 3 + dataLen)
|
|
|
|
if (!sentIntro) {
|
|
frame.set(introHeader)
|
|
sentIntro = true
|
|
}
|
|
|
|
frame[introSize] = (dataLen >>> 16) & 0xff
|
|
frame[introSize + 1] = (dataLen >>> 8) & 0xff
|
|
frame[introSize + 2] = dataLen & 0xff
|
|
|
|
frame.set(data, introSize + 3)
|
|
|
|
return frame
|
|
},
|
|
decodeFrame: async (newData: Buffer | Uint8Array, onFrame: (buff: Uint8Array | BinaryNode) => void) => {
|
|
if (isWaitingForTransport) {
|
|
inBytes = Buffer.concat([inBytes, newData])
|
|
pendingOnFrame = onFrame
|
|
return
|
|
}
|
|
|
|
if (inBytes.length === 0) {
|
|
inBytes = Buffer.from(newData)
|
|
} else {
|
|
inBytes = Buffer.concat([inBytes, newData])
|
|
}
|
|
|
|
await processData(onFrame)
|
|
}
|
|
}
|
|
}
|