diff --git a/.cspell.json b/.cspell.json index e32f92c4da..0fff8aa310 100644 --- a/.cspell.json +++ b/.cspell.json @@ -55,9 +55,11 @@ "protobuf", "protoc", "reactjs", + "recid", "rlnrelay", "sandboxed", "secio", + "secp", "staticnode", "statusim", "submodule", diff --git a/package-lock.json b/package-lock.json index 23ba3ada1c..6cabfd7689 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "debug": "^4.3.1", "it-concat": "^2.0.0", "it-length-prefixed": "^5.0.2", + "js-sha3": "^0.8.0", "libp2p": "^0.31.7", "libp2p-gossipsub": "^0.10.0", "libp2p-mplex": "^0.10.3", @@ -19,6 +20,7 @@ "libp2p-tcp": "^0.15.4", "libp2p-websockets": "^0.15.6", "multiaddr": "^9.0.1", + "secp256k1": "^4.0.2", "ts-proto": "^1.79.7", "uuid": "^8.3.2" }, @@ -30,6 +32,7 @@ "@types/google-protobuf": "^3.7.4", "@types/mocha": "^8.2.2", "@types/node": "^14.14.31", + "@types/secp256k1": "^4.0.2", "@types/tail": "^2.0.0", "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^4.0.1", @@ -3586,6 +3589,15 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, + "node_modules/@types/secp256k1": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/secp256k1/-/secp256k1-4.0.2.tgz", + "integrity": "sha512-QMg+9v0bbNJ2peLuHRWxzmy0HRJIG6gFZNhaRSp7S3ggSbCCxiqQB2/ybvhXyhHOCequpNkrx7OavNhrWOsW0A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/tail": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/tail/-/tail-2.2.0.tgz", @@ -22021,6 +22033,15 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, + "@types/secp256k1": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/secp256k1/-/secp256k1-4.0.2.tgz", + "integrity": "sha512-QMg+9v0bbNJ2peLuHRWxzmy0HRJIG6gFZNhaRSp7S3ggSbCCxiqQB2/ybvhXyhHOCequpNkrx7OavNhrWOsW0A==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/tail": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/tail/-/tail-2.2.0.tgz", diff --git a/package.json b/package.json index 471d3c8b5c..a179bf6c10 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "debug": "^4.3.1", "it-concat": "^2.0.0", "it-length-prefixed": "^5.0.2", + "js-sha3": "^0.8.0", "libp2p": "^0.31.7", "libp2p-gossipsub": "^0.10.0", "libp2p-mplex": "^0.10.3", @@ -66,6 +67,7 @@ "libp2p-tcp": "^0.15.4", "libp2p-websockets": "^0.15.6", "multiaddr": "^9.0.1", + "secp256k1": "^4.0.2", "ts-proto": "^1.79.7", "uuid": "^8.3.2" }, @@ -77,6 +79,7 @@ "@types/google-protobuf": "^3.7.4", "@types/mocha": "^8.2.2", "@types/node": "^14.14.31", + "@types/secp256k1": "^4.0.2", "@types/tail": "^2.0.0", "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^4.0.1", diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000000..0de74f04e3 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,3 @@ +export function hexToBuf(str: string): Buffer { + return Buffer.from(str.replace(/0x/i, ''), 'hex'); +} diff --git a/src/lib/waku_message/version_1.spec.ts b/src/lib/waku_message/version_1.spec.ts new file mode 100644 index 0000000000..84aa769a28 --- /dev/null +++ b/src/lib/waku_message/version_1.spec.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; +import fc from 'fast-check'; +import * as secp256k1 from 'secp256k1'; + +import { decode, encode } from './version_1'; + +describe('Waku Message Version 1', function () { + it('Sign & Recover', function () { + fc.assert( + fc.property( + fc.uint8Array(), + fc.uint8Array({ minLength: 32, maxLength: 32 }), + (message, privKey) => { + const enc = encode(message, privKey); + const res = decode(enc); + + const pubKey = secp256k1.publicKeyCreate(privKey, false); + + expect(res?.payload).deep.equal(message); + expect(res?.sig?.publicKey).deep.equal(pubKey); + } + ) + ); + }); +}); diff --git a/src/lib/waku_message/version_1.ts b/src/lib/waku_message/version_1.ts new file mode 100644 index 0000000000..aef1a82db1 --- /dev/null +++ b/src/lib/waku_message/version_1.ts @@ -0,0 +1,148 @@ +import { Buffer } from 'buffer'; +import { randomBytes } from 'crypto'; + +import { keccak256 } from 'js-sha3'; +import * as secp256k1 from 'secp256k1'; + +import { hexToBuf } from '../utils'; + +const FlagsLength = 1; +const FlagMask = 3; // 0011 +const IsSignedMask = 4; // 0100 +const PaddingTarget = 256; +const SignatureLength = 65; + +/** + * Encode a Waku Message Payload using version 1. Payload get encrypted and + * a signature may be included + * @param messagePayload: The payload to include in the message + * @param sigPrivKey: If set, a signature using this private key is added. + */ + +export function encode( + messagePayload: Uint8Array, + sigPrivKey?: Uint8Array +): Uint8Array { + let envelope = Buffer.from([0]); // No flags + envelope = addPayloadSizeField(envelope, messagePayload); + envelope = Buffer.concat([envelope, messagePayload]); + + // Calculate padding: + let rawSize = + FlagsLength + + getSizeOfPayloadSizeField(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 = Buffer.concat([envelope, pad]); + + if (sigPrivKey) { + envelope[0] |= IsSignedMask; + const hash = keccak256(envelope); + const s = secp256k1.ecdsaSign(hexToBuf(hash), sigPrivKey); + envelope = Buffer.concat([envelope, s.signature, Buffer.from([s.recid])]); + } + + return envelope; +} + +export type DecodeResult = { + payload: Uint8Array; + sig?: { + signature: Uint8Array; + publicKey: Uint8Array; + }; +}; + +export function decode(message: Uint8Array | Buffer): DecodeResult | undefined { + const buf = Buffer.from(message); + + let start = 1; + let sig; + + const sizeOfPayloadSizeField = buf.readUIntLE(0, 1) & FlagMask; + + if (sizeOfPayloadSizeField === 0) return; + + const payloadSize = buf.readUIntLE(start, sizeOfPayloadSizeField); + start += sizeOfPayloadSizeField; + const payload = buf.slice(start, start + payloadSize); + + const isSigned = (buf.readUIntLE(0, 1) & IsSignedMask) == IsSignedMask; + if (isSigned) { + const signature = getSignature(buf); + const hash = getHash(buf, isSigned); + const publicKey = ecRecoverPubKey(hash, signature); + sig = { signature, publicKey }; + } + + return { payload, sig }; +} + +/** + * Computes the flags & auxiliary-field as per [26/WAKU-PAYLOAD](rfc.vac.dev/spec/26/). + */ +function addPayloadSizeField(msg: Buffer, payload: Uint8Array): Buffer { + const fieldSize = getSizeOfPayloadSizeField(payload); + let field = Buffer.alloc(4); + field.writeUInt32LE(payload.length, 0); + field = field.slice(0, fieldSize); + msg = Buffer.concat([msg, field]); + msg[0] |= fieldSize; + return msg; +} + +/** + * Returns the size of the auxiliary-field which in turns contains the payload size + */ +function getSizeOfPayloadSizeField(payload: Uint8Array): number { + let s = 1; + for (let i = payload.length; i >= 256; i /= 256) { + s++; + } + return s; +} + +function validateDataIntegrity(value: Buffer, expectedSize: number): boolean { + if (value.length !== expectedSize) { + return false; + } + + if (expectedSize > 3 && value.equals(Buffer.alloc(value.length))) { + return false; + } + + return true; +} + +function getSignature(message: Buffer): Buffer { + return message.slice(message.length - SignatureLength, message.length); +} + +function getHash(message: Buffer, isSigned: boolean): string { + if (isSigned) { + return keccak256(message.slice(0, message.length - SignatureLength)); + } + return keccak256(message); +} + +function ecRecoverPubKey(messageHash: string, signature: Buffer): Uint8Array { + const recovery = signature.slice(64).readIntBE(0, 1); + return secp256k1.ecdsaRecover( + signature.slice(0, 64), + recovery, + hexToBuf(messageHash), + false + ); +}