import * as secp from "@noble/secp256k1"; import { concat, hexToBytes } from "@waku/byte-utils"; import { DecodedMessage as DecodedMessageV0, Decoder as DecoderV0, proto, } from "@waku/core/lib/waku_message/version_0"; import type { DecodedMessage as IDecodedMessage, Decoder as IDecoder, Encoder as IEncoder, Message, ProtoMessage, } from "@waku/interfaces"; import debug from "debug"; import { Symmetric } from "./constants.js"; import { generatePrivateKey, generateSymmetricKey, getPublicKey, keccak256, randomBytes, sign, } from "./crypto.js"; import * as ecies from "./ecies.js"; import * as symmetric from "./symmetric.js"; const log = debug("waku:message:version-1"); const FlagsLength = 1; const FlagMask = 3; // 0011 const IsSignedMask = 4; // 0100 const PaddingTarget = 256; const SignatureLength = 65; const OneMillion = BigInt(1_000_000); export { generatePrivateKey, generateSymmetricKey, getPublicKey }; export const Version = 1; export type Signature = { signature: Uint8Array; publicKey: Uint8Array | undefined; }; export class DecodedMessage extends DecodedMessageV0 implements IDecodedMessage { private readonly _decodedPayload: Uint8Array; constructor( proto: proto.WakuMessage, decodedPayload: Uint8Array, public signature?: Uint8Array, public signaturePublicKey?: Uint8Array ) { super(proto); this._decodedPayload = decodedPayload; } get payload(): Uint8Array { return this._decodedPayload; } } class AsymEncoder implements IEncoder { constructor( public contentTopic: string, private publicKey: Uint8Array, private sigPrivKey?: Uint8Array, public ephemeral: boolean = false ) {} async toWire(message: Partial): Promise { const protoMessage = await this.toProtoObj(message); if (!protoMessage) return; return proto.WakuMessage.encode(protoMessage); } async toProtoObj( message: Partial ): Promise { const timestamp = message.timestamp ?? new Date(); if (!message.payload) { log("No payload to encrypt, skipping: ", message); return; } const preparedPayload = await preCipher(message.payload, this.sigPrivKey); const payload = await encryptAsymmetric(preparedPayload, this.publicKey); return { payload, version: Version, contentTopic: this.contentTopic, timestamp: BigInt(timestamp.valueOf()) * OneMillion, rateLimitProof: message.rateLimitProof, ephemeral: this.ephemeral, }; } } export function createAsymEncoder( contentTopic: string, publicKey: Uint8Array, sigPrivKey?: Uint8Array, ephemeral = false ): AsymEncoder { return new AsymEncoder(contentTopic, publicKey, sigPrivKey, ephemeral); } class SymEncoder implements IEncoder { constructor( public contentTopic: string, private symKey: Uint8Array, private sigPrivKey?: Uint8Array, public ephemeral: boolean = false ) {} async toWire(message: Partial): Promise { const protoMessage = await this.toProtoObj(message); if (!protoMessage) return; return proto.WakuMessage.encode(protoMessage); } async toProtoObj( message: Partial ): Promise { const timestamp = message.timestamp ?? new Date(); if (!message.payload) { log("No payload to encrypt, skipping: ", message); return; } const preparedPayload = await preCipher(message.payload, this.sigPrivKey); const payload = await encryptSymmetric(preparedPayload, this.symKey); return { payload, version: Version, contentTopic: this.contentTopic, timestamp: BigInt(timestamp.valueOf()) * OneMillion, rateLimitProof: message.rateLimitProof, ephemeral: this.ephemeral, }; } } export function createSymEncoder( contentTopic: string, symKey: Uint8Array, sigPrivKey?: Uint8Array, ephemeral = false ): SymEncoder { return new SymEncoder(contentTopic, symKey, sigPrivKey, ephemeral); } class AsymDecoder extends DecoderV0 implements IDecoder { constructor(contentTopic: string, private privateKey: Uint8Array) { super(contentTopic); } async fromProtoObj( protoMessage: ProtoMessage ): Promise { const cipherPayload = protoMessage.payload; if (protoMessage.version !== Version) { log( "Failed to decrypt due to incorrect version, expected:", Version, ", actual:", protoMessage.version ); return; } let payload; if (!cipherPayload) { log(`No payload to decrypt for contentTopic ${this.contentTopic}`); return; } try { payload = await decryptAsymmetric(cipherPayload, this.privateKey); } catch (e) { log( `Failed to decrypt message using asymmetric decryption for contentTopic: ${this.contentTopic}`, e ); return; } if (!payload) { log(`Failed to decrypt payload for contentTopic ${this.contentTopic}`); return; } const res = await postCipher(payload); if (!res) { log(`Failed to decode payload for contentTopic ${this.contentTopic}`); return; } log("Message decrypted", protoMessage); return new DecodedMessage( protoMessage, res.payload, res.sig?.signature, res.sig?.publicKey ); } } export function createAsymDecoder( contentTopic: string, privateKey: Uint8Array ): AsymDecoder { return new AsymDecoder(contentTopic, privateKey); } class SymDecoder extends DecoderV0 implements IDecoder { constructor(contentTopic: string, private symKey: Uint8Array) { super(contentTopic); } async fromProtoObj( protoMessage: ProtoMessage ): Promise { const cipherPayload = protoMessage.payload; if (protoMessage.version !== Version) { log( "Failed to decrypt due to incorrect version, expected:", Version, ", actual:", protoMessage.version ); return; } let payload; if (!cipherPayload) { log(`No payload to decrypt for contentTopic ${this.contentTopic}`); return; } try { payload = await decryptSymmetric(cipherPayload, this.symKey); } catch (e) { log( `Failed to decrypt message using asymmetric decryption for contentTopic: ${this.contentTopic}`, e ); return; } if (!payload) { log(`Failed to decrypt payload for contentTopic ${this.contentTopic}`); return; } const res = await postCipher(payload); if (!res) { log(`Failed to decode payload for contentTopic ${this.contentTopic}`); return; } log("Message decrypted", protoMessage); return new DecodedMessage( protoMessage, res.payload, res.sig?.signature, res.sig?.publicKey ); } } export function createSymDecoder( contentTopic: string, symKey: Uint8Array ): SymDecoder { return new SymDecoder(contentTopic, symKey); } function getSizeOfPayloadSizeField(message: Uint8Array): number { const messageDataView = new DataView(message.buffer); return messageDataView.getUint8(0) & FlagMask; } function getPayloadSize( message: Uint8Array, sizeOfPayloadSizeField: number ): number { let payloadSizeBytes = message.slice(1, 1 + sizeOfPayloadSizeField); // int 32 == 4 bytes if (sizeOfPayloadSizeField < 4) { // If less than 4 bytes pad right (Little Endian). payloadSizeBytes = concat( [payloadSizeBytes, new Uint8Array(4 - sizeOfPayloadSizeField)], 4 ); } const payloadSizeDataView = new DataView(payloadSizeBytes.buffer); return payloadSizeDataView.getInt32(0, true); } function isMessageSigned(message: Uint8Array): boolean { const messageDataView = new DataView(message.buffer); return (messageDataView.getUint8(0) & IsSignedMask) == IsSignedMask; } /** * Proceed with Asymmetric encryption of the data as per [26/WAKU-PAYLOAD](https://rfc.vac.dev/spec/26/). * The data MUST be flags | payload-length | payload | [signature]. * The returned result can be set to `WakuMessage.payload`. * * @internal */ export async function encryptAsymmetric( data: Uint8Array, publicKey: Uint8Array | string ): Promise { return ecies.encrypt(hexToBytes(publicKey), data); } /** * Proceed with Asymmetric decryption of the data as per [26/WAKU-PAYLOAD](https://rfc.vac.dev/spec/26/). * The returned data is expected to be `flags | payload-length | payload | [signature]`. * * @internal */ export async function decryptAsymmetric( payload: Uint8Array, privKey: Uint8Array ): Promise { return ecies.decrypt(privKey, payload); } /** * Proceed with Symmetric encryption of the data as per [26/WAKU-PAYLOAD](https://rfc.vac.dev/spec/26/). * * @param data The data to encrypt, expected to be `flags | payload-length | payload | [signature]`. * @param key The key to use for encryption. * @returns The decrypted data, `cipherText | tag | iv` and can be set to `WakuMessage.payload`. * * @internal */ export async function encryptSymmetric( data: Uint8Array, key: Uint8Array | string ): Promise { const iv = symmetric.generateIv(); // Returns `cipher | tag` const cipher = await symmetric.encrypt(iv, hexToBytes(key), data); return concat([cipher, iv]); } /** * Proceed with Symmetric decryption of the data as per [26/WAKU-PAYLOAD](https://rfc.vac.dev/spec/26/). * * @param payload The cipher data, it is expected to be `cipherText | tag | iv`. * @param key The key to use for decryption. * @returns The decrypted data, expected to be `flags | payload-length | payload | [signature]`. * * @internal */ export async function decryptSymmetric( payload: Uint8Array, key: Uint8Array | string ): Promise { const ivStart = payload.length - Symmetric.ivSize; const cipher = payload.slice(0, ivStart); const iv = payload.slice(ivStart); return symmetric.decrypt(iv, hexToBytes(key), cipher); } /** * Computes the flags & auxiliary-field as per [26/WAKU-PAYLOAD](https://rfc.vac.dev/spec/26/). */ function addPayloadSizeField(msg: Uint8Array, payload: Uint8Array): Uint8Array { const fieldSize = computeSizeOfPayloadSizeField(payload); let field = new Uint8Array(4); const fieldDataView = new DataView(field.buffer); fieldDataView.setUint32(0, payload.length, true); field = field.slice(0, fieldSize); msg = concat([msg, field]); msg[0] |= fieldSize; return msg; } /** * Returns the size of the auxiliary-field which in turns contains the payload size */ function computeSizeOfPayloadSizeField(payload: Uint8Array): number { let s = 1; for (let i = payload.length; i >= 256; i /= 256) { s++; } return s; } function validateDataIntegrity( value: Uint8Array, expectedSize: number ): boolean { if (value.length !== expectedSize) { return false; } return expectedSize <= 3 || value.findIndex((i) => i !== 0) !== -1; } function getSignature(message: Uint8Array): Uint8Array { return message.slice(message.length - SignatureLength, message.length); } function getHash(message: Uint8Array, isSigned: boolean): Uint8Array { if (isSigned) { return keccak256(message.slice(0, message.length - SignatureLength)); } return keccak256(message); } function ecRecoverPubKey( messageHash: Uint8Array, signature: Uint8Array ): Uint8Array | undefined { const recoveryDataView = new DataView(signature.slice(64).buffer); const recovery = recoveryDataView.getUint8(0); const _signature = secp.Signature.fromCompact(signature.slice(0, 64)); return secp.recoverPublicKey(messageHash, _signature, recovery, false); } /** * Prepare the payload pre-encryption. * * @internal * @returns The encoded payload, ready for encryption using {@link encryptAsymmetric} * or {@link encryptSymmetric}. */ export async function preCipher( messagePayload: Uint8Array, sigPrivKey?: Uint8Array ): Promise { let envelope = new Uint8Array([0]); // No flags envelope = addPayloadSizeField(envelope, messagePayload); envelope = concat([envelope, messagePayload]); // Calculate padding: let rawSize = FlagsLength + computeSizeOfPayloadSizeField(messagePayload) + messagePayload.length; if (sigPrivKey) { rawSize += SignatureLength; } const remainder = rawSize % PaddingTarget; const paddingSize = PaddingTarget - remainder; const pad = randomBytes(paddingSize); if (!validateDataIntegrity(pad, paddingSize)) { throw new Error("failed to generate random padding of size " + paddingSize); } envelope = concat([envelope, pad]); if (sigPrivKey) { envelope[0] |= IsSignedMask; const hash = keccak256(envelope); const bytesSignature = await sign(hash, sigPrivKey); envelope = concat([envelope, bytesSignature]); } return envelope; } /** * Decode a decrypted payload. * * @internal */ export function postCipher( message: Uint8Array ): { payload: Uint8Array; sig?: Signature } | undefined { const sizeOfPayloadSizeField = getSizeOfPayloadSizeField(message); if (sizeOfPayloadSizeField === 0) return; const payloadSize = getPayloadSize(message, sizeOfPayloadSizeField); const payloadStart = 1 + sizeOfPayloadSizeField; const payload = message.slice(payloadStart, payloadStart + payloadSize); const isSigned = isMessageSigned(message); let sig; if (isSigned) { const signature = getSignature(message); const hash = getHash(message, isSigned); const publicKey = ecRecoverPubKey(hash, signature); sig = { signature, publicKey }; } return { payload, sig }; }