From e6efd0438c4c1b34672af1a9e3cd8f7aa08c0755 Mon Sep 17 00:00:00 2001 From: "fryorcraken.eth" Date: Fri, 4 Nov 2022 11:38:32 +1100 Subject: [PATCH] chore: make message-encryption compile --- package-lock.json | 83 +++- .../core/src/lib/waku_message/version_0.ts | 3 +- packages/message-encryption/package.json | 11 +- packages/message-encryption/src/constants.ts | 10 + packages/message-encryption/src/crypto.ts | 76 +++ packages/message-encryption/src/ecies.ts | 194 ++++++++ packages/message-encryption/src/index.spec.ts | 208 ++++++++ packages/message-encryption/src/index.ts | 459 ++++++++++++++++++ packages/message-encryption/src/symmetric.ts | 32 ++ 9 files changed, 1063 insertions(+), 13 deletions(-) create mode 100644 packages/message-encryption/src/constants.ts create mode 100644 packages/message-encryption/src/crypto.ts create mode 100644 packages/message-encryption/src/ecies.ts create mode 100644 packages/message-encryption/src/index.spec.ts create mode 100644 packages/message-encryption/src/index.ts create mode 100644 packages/message-encryption/src/symmetric.ts diff --git a/package-lock.json b/package-lock.json index 775da4fac7..a34cc7ea3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5471,6 +5471,10 @@ "resolved": "packages/interfaces", "link": true }, + "node_modules/@waku/message-encryption": { + "resolved": "packages/message-encryption", + "link": true + }, "node_modules/@waku/tests": { "resolved": "packages/tests", "link": true @@ -22506,12 +22510,11 @@ }, "packages/core": { "name": "@waku/core", - "version": "0.0.2", + "version": "0.0.1", "license": "MIT OR Apache-2.0", "dependencies": { "@chainsafe/libp2p-gossipsub": "^4.1.1", "@chainsafe/libp2p-noise": "^8.0.1", - "@libp2p/crypto": "^1.0.4", "@libp2p/interface-connection": "3.0.1", "@libp2p/interface-peer-discovery": "^1.0.0", "@libp2p/interface-peer-id": "^1.0.2", @@ -22523,14 +22526,12 @@ "@libp2p/peer-id": "^1.1.10", "@libp2p/websockets": "^3.0.3", "@multiformats/multiaddr": "^11.0.6", - "@noble/secp256k1": "^1.3.4", - "@waku/byte-utils": "0.0.1", - "@waku/interfaces": "0.0.1", + "@waku/byte-utils": "*", + "@waku/interfaces": "*", "debug": "^4.3.4", "it-all": "^1.0.6", "it-length-prefixed": "^8.0.2", "it-pipe": "^2.0.4", - "js-sha3": "^0.8.0", "libp2p": "0.38.0", "p-event": "^5.0.1", "protons-runtime": "^3.1.0", @@ -22904,6 +22905,41 @@ "npm": ">=7.0.0" } }, + "packages/message-encryption": { + "name": "@waku/message-encryption", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@noble/secp256k1": "^1.3.4", + "@waku/byte-utils": "*", + "@waku/interfaces": "*", + "js-sha3": "^0.8.0" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^22.0.0", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.3.0", + "@typescript-eslint/eslint-plugin": "^5.8.1", + "@typescript-eslint/parser": "^5.8.1", + "chai": "^4.3.6", + "cspell": "^5.14.0", + "eslint": "^8.6.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-eslint-comments": "^3.2.0", + "eslint-plugin-functional": "^4.0.2", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-prettier": "^4.0.0", + "fast-check": "^2.14.0", + "npm-run-all": "^4.1.5", + "prettier": "^2.1.1", + "rollup": "^2.75.0", + "ts-loader": "^9.4.1", + "typescript": "^4.6.3" + }, + "engines": { + "node": ">=16" + } + }, "packages/tests": { "name": "@waku/tests", "version": "0.0.1", @@ -27315,7 +27351,6 @@ "requires": { "@chainsafe/libp2p-gossipsub": "^4.1.1", "@chainsafe/libp2p-noise": "^8.0.1", - "@libp2p/crypto": "^1.0.4", "@libp2p/interface-connection": "3.0.1", "@libp2p/interface-peer-discovery": "^1.0.0", "@libp2p/interface-peer-id": "^1.0.2", @@ -27327,7 +27362,6 @@ "@libp2p/peer-id": "^1.1.10", "@libp2p/websockets": "^3.0.3", "@multiformats/multiaddr": "^11.0.6", - "@noble/secp256k1": "^1.3.4", "@rollup/plugin-commonjs": "^22.0.0", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^13.3.0", @@ -27340,8 +27374,8 @@ "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^5.8.1", "@typescript-eslint/parser": "^5.8.1", - "@waku/byte-utils": "0.0.1", - "@waku/interfaces": "0.0.1", + "@waku/byte-utils": "*", + "@waku/interfaces": "*", "app-root-path": "^3.0.0", "chai": "^4.3.4", "cspell": "^5.14.0", @@ -27359,7 +27393,6 @@ "it-all": "^1.0.6", "it-length-prefixed": "^8.0.2", "it-pipe": "^2.0.4", - "js-sha3": "^0.8.0", "jsdom": "^19.0.0", "jsdom-global": "^3.0.2", "karma": "^6.3.12", @@ -27631,6 +27664,34 @@ } } }, + "@waku/message-encryption": { + "version": "file:packages/message-encryption", + "requires": { + "@noble/secp256k1": "^1.3.4", + "@rollup/plugin-commonjs": "^22.0.0", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.3.0", + "@typescript-eslint/eslint-plugin": "^5.8.1", + "@typescript-eslint/parser": "^5.8.1", + "@waku/byte-utils": "*", + "@waku/interfaces": "*", + "chai": "^4.3.6", + "cspell": "^5.14.0", + "eslint": "^8.6.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-eslint-comments": "^3.2.0", + "eslint-plugin-functional": "^4.0.2", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-prettier": "^4.0.0", + "fast-check": "^2.14.0", + "js-sha3": "^0.8.0", + "npm-run-all": "^4.1.5", + "prettier": "^2.1.1", + "rollup": "^2.75.0", + "ts-loader": "^9.4.1", + "typescript": "^4.6.3" + } + }, "@waku/tests": { "version": "file:packages/tests", "requires": { diff --git a/packages/core/src/lib/waku_message/version_0.ts b/packages/core/src/lib/waku_message/version_0.ts index 4220a20c9c..ac3dfd379a 100644 --- a/packages/core/src/lib/waku_message/version_0.ts +++ b/packages/core/src/lib/waku_message/version_0.ts @@ -10,9 +10,10 @@ import debug from "debug"; import * as proto from "../../proto/message"; const log = debug("waku:message:version-0"); - const OneMillion = BigInt(1_000_000); + export const Version = 0; +export { proto }; export class MessageV0 implements Message { constructor(protected proto: proto.WakuMessage) {} diff --git a/packages/message-encryption/package.json b/packages/message-encryption/package.json index 0f1c288318..3ae008d454 100644 --- a/packages/message-encryption/package.json +++ b/packages/message-encryption/package.json @@ -53,8 +53,17 @@ "engines": { "node": ">=16" }, - "dependencies": {}, + "browser": { + "crypto": false + }, + "dependencies": { + "@noble/secp256k1": "^1.3.4", + "@waku/byte-utils": "*", + "@waku/interfaces": "*", + "js-sha3": "^0.8.0" + }, "devDependencies": { + "fast-check": "^2.14.0", "@rollup/plugin-commonjs": "^22.0.0", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^13.3.0", diff --git a/packages/message-encryption/src/constants.ts b/packages/message-encryption/src/constants.ts new file mode 100644 index 0000000000..9d2036458b --- /dev/null +++ b/packages/message-encryption/src/constants.ts @@ -0,0 +1,10 @@ +export const Symmetric = { + keySize: 32, + ivSize: 12, + tagSize: 16, + algorithm: { name: "AES-GCM", length: 128 }, +}; + +export const Asymmetric = { + keySize: 32, +}; diff --git a/packages/message-encryption/src/crypto.ts b/packages/message-encryption/src/crypto.ts new file mode 100644 index 0000000000..81344b02c1 --- /dev/null +++ b/packages/message-encryption/src/crypto.ts @@ -0,0 +1,76 @@ +import nodeCrypto from "crypto"; + +import * as secp from "@noble/secp256k1"; +import { concat } from "@waku/byte-utils"; +import sha3 from "js-sha3"; + +import { Asymmetric, Symmetric } from "./constants.js"; + +declare const self: Record | undefined; +const crypto: { node?: any; web?: any } = { + node: nodeCrypto, + web: typeof self === "object" && "crypto" in self ? self.crypto : undefined, +}; + +export function getSubtle(): SubtleCrypto { + if (crypto.web) { + return crypto.web.subtle; + } else if (crypto.node) { + return crypto.node.webcrypto.subtle; + } else { + throw new Error( + "The environment doesn't have Crypto Subtle API (if in the browser, be sure to use to be in a secure context, ie, https)" + ); + } +} + +export const randomBytes = secp.utils.randomBytes; +export const sha256 = secp.utils.sha256; + +/** + * Generate a new private key to be used for asymmetric encryption. + * + * Use {@link getPublicKey} to get the corresponding Public Key. + */ +export function generatePrivateKey(): Uint8Array { + return randomBytes(Asymmetric.keySize); +} + +/** + * Generate a new symmetric key to be used for symmetric encryption. + */ +export function generateSymmetricKey(): Uint8Array { + return randomBytes(Symmetric.keySize); +} + +/** + * Return the public key for the given private key, to be used for asymmetric + * encryption. + */ +export const getPublicKey = secp.getPublicKey; + +/** + * ECDSA Sign a message with the given private key. + * + * @param message The message to sign, usually a hash. + * @param privateKey The ECDSA private key to use to sign the message. + * + * @returns The signature and the recovery id concatenated. + */ +export async function sign( + message: Uint8Array, + privateKey: Uint8Array +): Promise { + const [signature, recoveryId] = await secp.sign(message, privateKey, { + recovered: true, + der: false, + }); + return concat( + [signature, new Uint8Array([recoveryId])], + signature.length + 1 + ); +} + +export function keccak256(input: Uint8Array): Uint8Array { + return new Uint8Array(sha3.keccak256.arrayBuffer(input)); +} diff --git a/packages/message-encryption/src/ecies.ts b/packages/message-encryption/src/ecies.ts new file mode 100644 index 0000000000..b83d2a1d72 --- /dev/null +++ b/packages/message-encryption/src/ecies.ts @@ -0,0 +1,194 @@ +import * as secp from "@noble/secp256k1"; +import { concat, hexToBytes } from "@waku/byte-utils"; + +import { getSubtle, randomBytes, sha256 } from "./crypto.js"; +/** + * HKDF as implemented in go-ethereum. + */ +function kdf(secret: Uint8Array, outputLength: number): Promise { + let ctr = 1; + let written = 0; + let willBeResult = Promise.resolve(new Uint8Array()); + while (written < outputLength) { + const counters = new Uint8Array([ctr >> 24, ctr >> 16, ctr >> 8, ctr]); + const countersSecret = concat( + [counters, secret], + counters.length + secret.length + ); + const willBeHashResult = sha256(countersSecret); + willBeResult = willBeResult.then((result) => + willBeHashResult.then((hashResult) => { + const _hashResult = new Uint8Array(hashResult); + return concat( + [result, _hashResult], + result.length + _hashResult.length + ); + }) + ); + written += 32; + ctr += 1; + } + return willBeResult; +} + +function aesCtrEncrypt( + counter: Uint8Array, + key: ArrayBufferLike, + data: ArrayBufferLike +): Promise { + return getSubtle() + .importKey("raw", key, "AES-CTR", false, ["encrypt"]) + .then((cryptoKey) => + getSubtle().encrypt( + { name: "AES-CTR", counter: counter, length: 128 }, + cryptoKey, + data + ) + ) + .then((bytes) => new Uint8Array(bytes)); +} + +function aesCtrDecrypt( + counter: Uint8Array, + key: ArrayBufferLike, + data: ArrayBufferLike +): Promise { + return getSubtle() + .importKey("raw", key, "AES-CTR", false, ["decrypt"]) + .then((cryptoKey) => + getSubtle().decrypt( + { name: "AES-CTR", counter: counter, length: 128 }, + cryptoKey, + data + ) + ) + .then((bytes) => new Uint8Array(bytes)); +} + +function hmacSha256Sign( + key: ArrayBufferLike, + msg: ArrayBufferLike +): PromiseLike { + const algorithm = { name: "HMAC", hash: { name: "SHA-256" } }; + return getSubtle() + .importKey("raw", key, algorithm, false, ["sign"]) + .then((cryptoKey) => getSubtle().sign(algorithm, cryptoKey, msg)) + .then((bytes) => new Uint8Array(bytes)); +} + +function hmacSha256Verify( + key: ArrayBufferLike, + msg: ArrayBufferLike, + sig: ArrayBufferLike +): Promise { + const algorithm = { name: "HMAC", hash: { name: "SHA-256" } }; + const _key = getSubtle().importKey("raw", key, algorithm, false, ["verify"]); + return _key.then((cryptoKey) => + getSubtle().verify(algorithm, cryptoKey, sig, msg) + ); +} + +/** + * Derive shared secret for given private and public keys. + * + * @param privateKeyA Sender's private key (32 bytes) + * @param publicKeyB Recipient's public key (65 bytes) + * @returns A promise that resolves with the derived shared secret (Px, 32 bytes) + * @throws Error If arguments are invalid + */ +function derive(privateKeyA: Uint8Array, publicKeyB: Uint8Array): Uint8Array { + if (privateKeyA.length !== 32) { + throw new Error( + `Bad private key, it should be 32 bytes but it's actually ${privateKeyA.length} bytes long` + ); + } else if (publicKeyB.length !== 65) { + throw new Error( + `Bad public key, it should be 65 bytes but it's actually ${publicKeyB.length} bytes long` + ); + } else if (publicKeyB[0] !== 4) { + throw new Error("Bad public key, a valid public key would begin with 4"); + } else { + const px = secp.getSharedSecret(privateKeyA, publicKeyB, true); + // Remove the compression prefix + return new Uint8Array(hexToBytes(px).slice(1)); + } +} + +/** + * Encrypt message for given recipient's public key. + * + * @param publicKeyTo Recipient's public key (65 bytes) + * @param msg The message being encrypted + * @return A promise that resolves with the ECIES structure serialized + */ +export async function encrypt( + publicKeyTo: Uint8Array, + msg: Uint8Array +): Promise { + const ephemPrivateKey = randomBytes(32); + + const sharedPx = await derive(ephemPrivateKey, publicKeyTo); + + const hash = await kdf(sharedPx, 32); + + const iv = randomBytes(16); + const encryptionKey = hash.slice(0, 16); + const cipherText = await aesCtrEncrypt(iv, encryptionKey, msg); + + const ivCipherText = concat([iv, cipherText], iv.length + cipherText.length); + + const macKey = await sha256(hash.slice(16)); + const hmac = await hmacSha256Sign(macKey, ivCipherText); + const ephemPublicKey = secp.getPublicKey(ephemPrivateKey, false); + + return concat( + [ephemPublicKey, ivCipherText, hmac], + ephemPublicKey.length + ivCipherText.length + hmac.length + ); +} + +const metaLength = 1 + 64 + 16 + 32; + +/** + * Decrypt message using given private key. + * + * @param privateKey A 32-byte private key of recipient of the message + * @param encrypted ECIES serialized structure (result of ECIES encryption) + * @returns The clear text + * @throws Error If decryption fails + */ +export async function decrypt( + privateKey: Uint8Array, + encrypted: Uint8Array +): Promise { + if (encrypted.length <= metaLength) { + throw new Error( + `Invalid Ciphertext. Data is too small. It should ba at least ${metaLength} bytes` + ); + } else if (encrypted[0] !== 4) { + throw new Error( + `Not a valid ciphertext. It should begin with 4 but actually begin with ${encrypted[0]}` + ); + } else { + // deserialize + const ephemPublicKey = encrypted.slice(0, 65); + const cipherTextLength = encrypted.length - metaLength; + const iv = encrypted.slice(65, 65 + 16); + const cipherAndIv = encrypted.slice(65, 65 + 16 + cipherTextLength); + const ciphertext = cipherAndIv.slice(16); + const msgMac = encrypted.slice(65 + 16 + cipherTextLength); + + // check HMAC + const px = derive(privateKey, ephemPublicKey); + const hash = await kdf(px, 32); + const [encryptionKey, macKey] = await sha256(hash.slice(16)).then( + (macKey) => [hash.slice(0, 16), macKey] + ); + + if (!(await hmacSha256Verify(macKey, cipherAndIv, msgMac))) { + throw new Error("Incorrect MAC"); + } + + return aesCtrDecrypt(iv, encryptionKey, ciphertext); + } +} diff --git a/packages/message-encryption/src/index.spec.ts b/packages/message-encryption/src/index.spec.ts new file mode 100644 index 0000000000..2ecea93ddf --- /dev/null +++ b/packages/message-encryption/src/index.spec.ts @@ -0,0 +1,208 @@ +import { expect } from "chai"; +import fc from "fast-check"; + +import { getPublicKey } from "./crypto.js"; + +import { + AsymDecoder, + AsymEncoder, + decryptAsymmetric, + decryptSymmetric, + encryptAsymmetric, + encryptSymmetric, + postCipher, + preCipher, + SymDecoder, + SymEncoder, +} from "./index.js"; + +const TestContentTopic = "/test/1/waku-message/utf8"; + +describe("Waku Message version 1", function () { + it("Round trip binary encryption [asymmetric, no signature]", async function () { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 1 }), + fc.uint8Array({ min: 1, minLength: 32, maxLength: 32 }), + async (payload, privateKey) => { + const publicKey = getPublicKey(privateKey); + + const encoder = new AsymEncoder(TestContentTopic, publicKey); + const bytes = await encoder.toWire({ payload }); + + const decoder = new AsymDecoder(TestContentTopic, privateKey); + const protoResult = await decoder.fromWireToProtoObj(bytes!); + if (!protoResult) throw "Failed to proto decode"; + const result = await decoder.fromProtoObj(protoResult); + if (!result) throw "Failed to decode"; + + expect(result.contentTopic).to.equal(TestContentTopic); + expect(result.version).to.equal(1); + expect(result?.payload).to.deep.equal(payload); + expect(result.signature).to.be.undefined; + expect(result.signaturePublicKey).to.be.undefined; + } + ) + ); + }); + + it("R trip binary encryption [asymmetric, signature]", async function () { + this.timeout(4000); + + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 1 }), + fc.uint8Array({ min: 1, minLength: 32, maxLength: 32 }), + fc.uint8Array({ min: 1, minLength: 32, maxLength: 32 }), + async (payload, alicePrivateKey, bobPrivateKey) => { + const alicePublicKey = getPublicKey(alicePrivateKey); + const bobPublicKey = getPublicKey(bobPrivateKey); + + const encoder = new AsymEncoder( + TestContentTopic, + bobPublicKey, + alicePrivateKey + ); + const bytes = await encoder.toWire({ payload }); + + const decoder = new AsymDecoder(TestContentTopic, bobPrivateKey); + const protoResult = await decoder.fromWireToProtoObj(bytes!); + if (!protoResult) throw "Failed to proto decode"; + const result = await decoder.fromProtoObj(protoResult); + if (!result) throw "Failed to decode"; + + expect(result.contentTopic).to.equal(TestContentTopic); + expect(result.version).to.equal(1); + expect(result?.payload).to.deep.equal(payload); + expect(result.signature).to.not.be.undefined; + expect(result.signaturePublicKey).to.deep.eq(alicePublicKey); + } + ) + ); + }); + + it("Round trip binary encryption [symmetric, no signature]", async function () { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 1 }), + fc.uint8Array({ min: 1, minLength: 32, maxLength: 32 }), + async (payload, symKey) => { + const encoder = new SymEncoder(TestContentTopic, symKey); + const bytes = await encoder.toWire({ payload }); + + const decoder = new SymDecoder(TestContentTopic, symKey); + const protoResult = await decoder.fromWireToProtoObj(bytes!); + if (!protoResult) throw "Failed to proto decode"; + const result = await decoder.fromProtoObj(protoResult); + if (!result) throw "Failed to decode"; + + expect(result.contentTopic).to.equal(TestContentTopic); + expect(result.version).to.equal(1); + expect(result?.payload).to.deep.equal(payload); + expect(result.signature).to.be.undefined; + expect(result.signaturePublicKey).to.be.undefined; + } + ) + ); + }); + + it("Round trip binary encryption [symmetric, signature]", async function () { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 1 }), + fc.uint8Array({ min: 1, minLength: 32, maxLength: 32 }), + fc.uint8Array({ min: 1, minLength: 32, maxLength: 32 }), + async (payload, sigPrivKey, symKey) => { + const sigPubKey = getPublicKey(sigPrivKey); + + const encoder = new SymEncoder(TestContentTopic, symKey, sigPrivKey); + const bytes = await encoder.toWire({ payload }); + + const decoder = new SymDecoder(TestContentTopic, symKey); + const protoResult = await decoder.fromWireToProtoObj(bytes!); + if (!protoResult) throw "Failed to proto decode"; + const result = await decoder.fromProtoObj(protoResult); + if (!result) throw "Failed to decode"; + + expect(result.contentTopic).to.equal(TestContentTopic); + expect(result.version).to.equal(1); + expect(result?.payload).to.deep.equal(payload); + expect(result.signature).to.not.be.undefined; + expect(result.signaturePublicKey).to.deep.eq(sigPubKey); + } + ) + ); + }); +}); + +describe("Encryption helpers", () => { + it("Asymmetric encrypt & decrypt", async function () { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 1 }), + fc.uint8Array({ min: 1, minLength: 32, maxLength: 32 }), + async (message, privKey) => { + const publicKey = getPublicKey(privKey); + + const enc = await encryptAsymmetric(message, publicKey); + const res = await decryptAsymmetric(enc, privKey); + + expect(res).deep.equal(message); + } + ) + ); + }); + + it("Symmetric encrypt & Decrypt", async function () { + await fc.assert( + fc.asyncProperty( + fc.uint8Array(), + fc.uint8Array({ minLength: 32, maxLength: 32 }), + async (message, key) => { + const enc = await encryptSymmetric(message, key); + const res = await decryptSymmetric(enc, key); + + expect(res).deep.equal(message); + } + ) + ); + }); + + it("pre and post cipher", async function () { + await fc.assert( + fc.asyncProperty(fc.uint8Array(), async (message) => { + const enc = await preCipher(message); + const res = postCipher(enc); + + expect(res?.payload).deep.equal( + message, + "Payload was not encrypted then decrypted correctly" + ); + }) + ); + }); + + it("Sign & Recover", async function () { + await fc.assert( + fc.asyncProperty( + fc.uint8Array(), + fc.uint8Array({ minLength: 32, maxLength: 32 }), + async (message, sigPrivKey) => { + const sigPubKey = getPublicKey(sigPrivKey); + + const enc = await preCipher(message, sigPrivKey); + const res = postCipher(enc); + + expect(res?.payload).deep.equal( + message, + "Payload was not encrypted then decrypted correctly" + ); + expect(res?.sig?.publicKey).deep.equal( + sigPubKey, + "signature Public key was not recovered from encrypted then decrypted signature" + ); + } + ) + ); + }); +}); diff --git a/packages/message-encryption/src/index.ts b/packages/message-encryption/src/index.ts new file mode 100644 index 0000000000..baccaef194 --- /dev/null +++ b/packages/message-encryption/src/index.ts @@ -0,0 +1,459 @@ +import * as secp from "@noble/secp256k1"; +import { concat, hexToBytes } from "@waku/byte-utils"; +import { + DecoderV0, + MessageV0, + proto, +} from "@waku/core/lib/waku_message/version_0"; +import type { Decoder, Encoder, Message, ProtoMessage } from "@waku/interfaces"; +import debug from "debug"; + +import { Symmetric } from "./constants.js"; +import { 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 const Version = 1; + +export type Signature = { + signature: Uint8Array; + publicKey: Uint8Array | undefined; +}; + +export class MessageV1 extends MessageV0 implements Message { + 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; + } +} + +export class AsymEncoder implements Encoder { + constructor( + public contentTopic: string, + private publicKey: Uint8Array, + private sigPrivKey?: Uint8Array + ) {} + + 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, + }; + } +} + +export class SymEncoder implements Encoder { + constructor( + public contentTopic: string, + private symKey: Uint8Array, + private sigPrivKey?: Uint8Array + ) {} + + 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, + }; + } +} + +export class AsymDecoder extends DecoderV0 implements Decoder { + 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 MessageV1( + protoMessage, + res.payload, + res.sig?.signature, + res.sig?.publicKey + ); + } +} + +export class SymDecoder extends DecoderV0 implements Decoder { + 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 MessageV1( + protoMessage, + res.payload, + res.sig?.signature, + res.sig?.publicKey + ); + } +} + +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 }; +} diff --git a/packages/message-encryption/src/symmetric.ts b/packages/message-encryption/src/symmetric.ts new file mode 100644 index 0000000000..3e531edeb3 --- /dev/null +++ b/packages/message-encryption/src/symmetric.ts @@ -0,0 +1,32 @@ +import { Symmetric } from "./constants"; +import { getSubtle, randomBytes } from "./crypto.js"; + +export async function encrypt( + iv: Uint8Array, + key: Uint8Array, + clearText: Uint8Array +): Promise { + return getSubtle() + .importKey("raw", key, Symmetric.algorithm, false, ["encrypt"]) + .then((cryptoKey) => + getSubtle().encrypt({ iv, ...Symmetric.algorithm }, cryptoKey, clearText) + ) + .then((cipher) => new Uint8Array(cipher)); +} + +export async function decrypt( + iv: Uint8Array, + key: Uint8Array, + cipherText: Uint8Array +): Promise { + return getSubtle() + .importKey("raw", key, Symmetric.algorithm, false, ["decrypt"]) + .then((cryptoKey) => + getSubtle().decrypt({ iv, ...Symmetric.algorithm }, cryptoKey, cipherText) + ) + .then((clear) => new Uint8Array(clear)); +} + +export function generateIv(): Uint8Array { + return randomBytes(Symmetric.ivSize); +}