From 051263b00a042eb7330114d957a9b2c9cc8f0457 Mon Sep 17 00:00:00 2001 From: "fryorcraken.eth" Date: Fri, 4 Nov 2022 11:05:06 +1100 Subject: [PATCH 1/6] chore: use `multi-semantic-release` in release script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a1867dca06..09e8dcaa52 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "doc": "run-s doc:*", "doc:html": "typedoc # --treatWarningsAsErrors", "doc:cname": "echo 'js.waku.org' > docs/CNAME", - "release": "lerna run --concurrency 1 release -- --" + "release": "multi-semantic-release" }, "devDependencies": { "@semantic-release/changelog": "^6.0.1", From a20b7809d61ff9a9732aba82b99bbe99f229b935 Mon Sep 17 00:00:00 2001 From: "fryorcraken.eth" Date: Fri, 4 Nov 2022 11:13:12 +1100 Subject: [PATCH 2/6] chore: init message-encryption package --- packages/message-encryption/.eslintrc.cjs | 6 ++ packages/message-encryption/.mocharc.json | 11 +++ packages/message-encryption/.prettierignore | 4 + packages/message-encryption/karma.conf.cjs | 45 ++++++++++ packages/message-encryption/package.json | 90 +++++++++++++++++++ packages/message-encryption/rollup.config.js | 21 +++++ packages/message-encryption/tsconfig.dev.json | 8 ++ packages/message-encryption/tsconfig.json | 54 +++++++++++ typedoc.json | 3 +- 9 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packages/message-encryption/.eslintrc.cjs create mode 100644 packages/message-encryption/.mocharc.json create mode 100644 packages/message-encryption/.prettierignore create mode 100644 packages/message-encryption/karma.conf.cjs create mode 100644 packages/message-encryption/package.json create mode 100644 packages/message-encryption/rollup.config.js create mode 100644 packages/message-encryption/tsconfig.dev.json create mode 100644 packages/message-encryption/tsconfig.json diff --git a/packages/message-encryption/.eslintrc.cjs b/packages/message-encryption/.eslintrc.cjs new file mode 100644 index 0000000000..324f1f526d --- /dev/null +++ b/packages/message-encryption/.eslintrc.cjs @@ -0,0 +1,6 @@ +module.exports = { + parserOptions: { + tsconfigRootDir: __dirname, + project: "./tsconfig.dev.json", + }, +}; diff --git a/packages/message-encryption/.mocharc.json b/packages/message-encryption/.mocharc.json new file mode 100644 index 0000000000..db9dd73531 --- /dev/null +++ b/packages/message-encryption/.mocharc.json @@ -0,0 +1,11 @@ +{ + "extension": ["ts"], + "spec": "src/**/*.spec.ts", + "require": ["ts-node/register", "isomorphic-fetch", "jsdom-global/register"], + "loader": "ts-node/esm", + "node-option": [ + "experimental-specifier-resolution=node", + "loader=ts-node/esm" + ], + "exit": true +} diff --git a/packages/message-encryption/.prettierignore b/packages/message-encryption/.prettierignore new file mode 100644 index 0000000000..fecb37a393 --- /dev/null +++ b/packages/message-encryption/.prettierignore @@ -0,0 +1,4 @@ +build +bundle +dist +node_modules diff --git a/packages/message-encryption/karma.conf.cjs b/packages/message-encryption/karma.conf.cjs new file mode 100644 index 0000000000..8ee5033706 --- /dev/null +++ b/packages/message-encryption/karma.conf.cjs @@ -0,0 +1,45 @@ +process.env.CHROME_BIN = require("puppeteer").executablePath(); +const webpack = require("webpack"); + +module.exports = function (config) { + config.set({ + frameworks: ["webpack", "mocha"], + files: ["src/**/*.ts"], + preprocessors: { + "src/**/*.ts": ["webpack"], + }, + envPreprocessor: ["CI"], + reporters: ["progress"], + browsers: ["ChromeHeadless"], + singleRun: true, + client: { + mocha: { + timeout: 6000, // Default is 2s + }, + }, + webpack: { + mode: "development", + module: { + rules: [{ test: /\.([cm]?ts|tsx)$/, loader: "ts-loader" }], + }, + plugins: [ + new webpack.DefinePlugin({ + "process.env.CI": process.env.CI || false, + }), + new webpack.ProvidePlugin({ + process: "process/browser.js", + }), + ], + resolve: { + extensions: [".ts", ".tsx", ".js"], + extensionAlias: { + ".js": [".js", ".ts"], + ".cjs": [".cjs", ".cts"], + ".mjs": [".mjs", ".mts"], + }, + }, + stats: { warnings: false }, + devtool: "inline-source-map", + }, + }); +}; diff --git a/packages/message-encryption/package.json b/packages/message-encryption/package.json new file mode 100644 index 0000000000..0f1c288318 --- /dev/null +++ b/packages/message-encryption/package.json @@ -0,0 +1,90 @@ +{ + "name": "@waku/message-encryption", + "version": "0.0.1", + "description": "Waku Message Payload Encryption", + "types": "./dist/index.d.ts", + "module": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "type": "module", + "author": "Waku Team", + "homepage": "https://github.com/waku-org/js-waku/tree/master/packages/message-encryption#readme", + "repository": { + "type": "git", + "url": "https://github.com/waku-org/js-waku.git" + }, + "bugs": { + "url": "https://github.com/waku-org/js-waku/issues" + }, + "license": "MIT OR Apache-2.0", + "keywords": [ + "waku", + "decentralized", + "secure", + "communication", + "web3", + "ethereum", + "dapps", + "privacy" + ], + "scripts": { + "build": "run-s build:**", + "build:esm": "tsc", + "build:bundle": "rollup --config rollup.config.js", + "fix": "run-s fix:*", + "fix:prettier": "prettier . --write", + "fix:lint": "eslint src --ext .ts --ext .cjs --fix", + "check": "run-s check:*", + "check:lint": "eslint src --ext .ts", + "check:prettier": "prettier . --list-different", + "check:spelling": "cspell \"{README.md,src/**/*.ts}\"", + "check:tsc": "tsc -p tsconfig.dev.json", + "test": "run-s test:*", + "test:node": "TS_NODE_PROJECT=./tsconfig.dev.json mocha", + "test:browser": "karma start karma.conf.cjs", + "prepublish": "npm run build", + "reset-hard": "git clean -dfx -e .idea && git reset --hard && npm i && npm run build", + "release": "semantic-release" + }, + "engines": { + "node": ">=16" + }, + "dependencies": {}, + "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", + "npm-run-all": "^4.1.5", + "prettier": "^2.1.1", + "rollup": "^2.75.0", + "ts-loader": "^9.4.1", + "typescript": "^4.6.3" + }, + "typedoc": { + "entryPoint": "./src/index.ts" + }, + "files": [ + "dist", + "bundle", + "src/**/*.ts", + "!**/*.spec.*", + "!**/*.json", + "CHANGELOG.md", + "LICENSE", + "README.md" + ] +} diff --git a/packages/message-encryption/rollup.config.js b/packages/message-encryption/rollup.config.js new file mode 100644 index 0000000000..d22d3d231e --- /dev/null +++ b/packages/message-encryption/rollup.config.js @@ -0,0 +1,21 @@ +import commonjs from "@rollup/plugin-commonjs"; +import json from "@rollup/plugin-json"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; + +export default { + input: { + index: "dist/index.js", + }, + output: { + dir: "bundle", + format: "esm", + }, + plugins: [ + commonjs(), + json(), + nodeResolve({ + browser: true, + preferBuiltins: false, + }), + ], +}; diff --git a/packages/message-encryption/tsconfig.dev.json b/packages/message-encryption/tsconfig.dev.json new file mode 100644 index 0000000000..ffc27ef6ba --- /dev/null +++ b/packages/message-encryption/tsconfig.dev.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "module": "esnext", + "noEmit": true + }, + "exclude": [] +} diff --git a/packages/message-encryption/tsconfig.json b/packages/message-encryption/tsconfig.json new file mode 100644 index 0000000000..7b01b30897 --- /dev/null +++ b/packages/message-encryption/tsconfig.json @@ -0,0 +1,54 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "es2020", + "outDir": "dist/", + "rootDir": "src", + "moduleResolution": "node", + "module": "es2020", + "declaration": true, + "sourceMap": true, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + "resolveJsonModule": true /* Include modules imported with .json extension. */, + "tsBuildInfoFile": "dist/.tsbuildinfo", + "strict": true /* Enable all strict type-checking options. */, + + /* Strict Type-Checking Options */ + "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, + "strictNullChecks": true /* Enable strict null checks. */, + "strictFunctionTypes": true /* Enable strict checking of function types. */, + "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, + "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, + "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, + + /* Additional Checks */ + "noUnusedLocals": true /* Report errors on unused locals. */, + "noUnusedParameters": true /* Report errors on unused parameters. */, + "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, + "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, + "forceConsistentCasingInFileNames": true, + + /* Debugging Options */ + "traceResolution": false /* Report module resolution log messages. */, + "listEmittedFiles": false /* Print names of generated files part of the compilation. */, + "listFiles": false /* Print names of files part of the compilation. */, + "pretty": true /* Stylize errors and messages using color and context. */, + + // Due to broken types in indirect dependencies + "skipLibCheck": true, + + /* Experimental Options */ + // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, + // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, + + "lib": ["es2020", "dom"], + "types": ["node", "mocha"], + "typeRoots": ["node_modules/@types", "src/types"] + }, + "include": ["src"], + "exclude": ["src/**/*.spec.ts", "src/test_utils"], + "compileOnSave": false, + "ts-node": { + "files": true + } +} diff --git a/typedoc.json b/typedoc.json index 55192a4c00..4c614c573f 100644 --- a/typedoc.json +++ b/typedoc.json @@ -6,7 +6,8 @@ "packages/create", "packages/dns-discovery", "packages/enr", - "packages/interfaces" + "packages/interfaces", + "packages/message-encryption" ], "out": "docs", "exclude": ["**/*.spec.ts"], From 256b7223f33c031aa8e83c44079c4df4ea5dd7c8 Mon Sep 17 00:00:00 2001 From: "fryorcraken.eth" Date: Fri, 4 Nov 2022 11:28:14 +1100 Subject: [PATCH 3/6] chore!: extract version-1 from chore --- packages/core/package.json | 10 - packages/core/rollup.config.js | 1 - packages/core/src/index.ts | 6 - packages/core/src/lib/crypto.ts | 76 --- .../core/src/lib/waku_message/constants.ts | 10 - packages/core/src/lib/waku_message/ecies.ts | 194 -------- .../core/src/lib/waku_message/symmetric.ts | 33 -- .../src/lib/waku_message/version_1.spec.ts | 208 -------- .../core/src/lib/waku_message/version_1.ts | 457 ------------------ 9 files changed, 995 deletions(-) delete mode 100644 packages/core/src/lib/crypto.ts delete mode 100644 packages/core/src/lib/waku_message/constants.ts delete mode 100644 packages/core/src/lib/waku_message/ecies.ts delete mode 100644 packages/core/src/lib/waku_message/symmetric.ts delete mode 100644 packages/core/src/lib/waku_message/version_1.spec.ts delete mode 100644 packages/core/src/lib/waku_message/version_1.ts diff --git a/packages/core/package.json b/packages/core/package.json index 58d6da2bfa..7b41608030 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,10 +25,6 @@ "types": "./dist/lib/waku_message/version_0.d.ts", "import": "./dist/lib/waku_message/version_0.js" }, - "./lib/waku_message/version_1": { - "types": "./dist/lib/waku_message/version_1.d.ts", - "import": "./dist/lib/waku_message/version_1.js" - }, "./lib/waku_message/topic_only_message": { "types": "./dist/lib/waku_message/topic_only_message.d.ts", "import": "./dist/lib/waku_message/topic_only_message.js" @@ -87,9 +83,6 @@ "reset-hard": "git clean -dfx -e .idea && git reset --hard && npm i && npm run build", "release": "semantic-release" }, - "browser": { - "crypto": false - }, "engines": { "node": ">=16" }, @@ -97,7 +90,6 @@ "@waku/byte-utils": "*", "@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", @@ -109,13 +101,11 @@ "@libp2p/peer-id": "^1.1.10", "@libp2p/websockets": "^3.0.3", "@multiformats/multiaddr": "^11.0.6", - "@noble/secp256k1": "^1.3.4", "@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", diff --git a/packages/core/rollup.config.js b/packages/core/rollup.config.js index ce780d1a01..c8d31b555a 100644 --- a/packages/core/rollup.config.js +++ b/packages/core/rollup.config.js @@ -9,7 +9,6 @@ export default { "lib/predefined_bootstrap_nodes": "dist/lib/predefined_bootstrap_nodes.js", "lib/wait_for_remote_peer": "dist/lib/wait_for_remote_peer.js", "lib/waku_message/version_0": "dist/lib/waku_message/version_0.js", - "lib/waku_message/version_1": "dist/lib/waku_message/version_1.js", "lib/waku_message/topic_only_message": "dist/lib/waku_message/topic_only_message.js", }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d115a25ce1..e90b55e3f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,11 +1,5 @@ export { DefaultPubSubTopic } from "./lib/constants"; -export { - generatePrivateKey, - generateSymmetricKey, - getPublicKey, -} from "./lib/crypto"; - export * as proto_message from "./proto/message"; export * as proto_topic_only_message from "./proto/topic_only_message"; diff --git a/packages/core/src/lib/crypto.ts b/packages/core/src/lib/crypto.ts deleted file mode 100644 index 70507bc777..0000000000 --- a/packages/core/src/lib/crypto.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 "./waku_message/constants"; - -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/core/src/lib/waku_message/constants.ts b/packages/core/src/lib/waku_message/constants.ts deleted file mode 100644 index 9d2036458b..0000000000 --- a/packages/core/src/lib/waku_message/constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const Symmetric = { - keySize: 32, - ivSize: 12, - tagSize: 16, - algorithm: { name: "AES-GCM", length: 128 }, -}; - -export const Asymmetric = { - keySize: 32, -}; diff --git a/packages/core/src/lib/waku_message/ecies.ts b/packages/core/src/lib/waku_message/ecies.ts deleted file mode 100644 index 8b783f66b2..0000000000 --- a/packages/core/src/lib/waku_message/ecies.ts +++ /dev/null @@ -1,194 +0,0 @@ -import * as secp from "@noble/secp256k1"; -import { concat, hexToBytes } from "@waku/byte-utils"; - -import { getSubtle, randomBytes, sha256 } from "../crypto"; -/** - * 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/core/src/lib/waku_message/symmetric.ts b/packages/core/src/lib/waku_message/symmetric.ts deleted file mode 100644 index c44192e406..0000000000 --- a/packages/core/src/lib/waku_message/symmetric.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getSubtle, randomBytes } from "../crypto"; - -import { Symmetric } from "./constants"; - -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); -} diff --git a/packages/core/src/lib/waku_message/version_1.spec.ts b/packages/core/src/lib/waku_message/version_1.spec.ts deleted file mode 100644 index 91b75c2e0c..0000000000 --- a/packages/core/src/lib/waku_message/version_1.spec.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { expect } from "chai"; -import fc from "fast-check"; - -import { getPublicKey } from "../crypto"; - -import { - AsymDecoder, - AsymEncoder, - decryptAsymmetric, - decryptSymmetric, - encryptAsymmetric, - encryptSymmetric, - postCipher, - preCipher, - SymDecoder, - SymEncoder, -} from "./version_1"; - -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/core/src/lib/waku_message/version_1.ts b/packages/core/src/lib/waku_message/version_1.ts deleted file mode 100644 index 4dc426d157..0000000000 --- a/packages/core/src/lib/waku_message/version_1.ts +++ /dev/null @@ -1,457 +0,0 @@ -import * as secp from "@noble/secp256k1"; -import { concat, hexToBytes } from "@waku/byte-utils"; -import type { Decoder, Encoder, Message, ProtoMessage } from "@waku/interfaces"; -import debug from "debug"; - -import * as proto from "../../proto/message"; -import { keccak256, randomBytes, sign } from "../crypto"; - -import { Symmetric } from "./constants"; -import * as ecies from "./ecies"; -import * as symmetric from "./symmetric"; -import { DecoderV0, MessageV0 } from "./version_0"; - -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 }; -} From e6efd0438c4c1b34672af1a9e3cd8f7aa08c0755 Mon Sep 17 00:00:00 2001 From: "fryorcraken.eth" Date: Fri, 4 Nov 2022 11:38:32 +1100 Subject: [PATCH 4/6] 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); +} From 1a09aa18d5e78cb5ff1ae698713008e1f8deb57a Mon Sep 17 00:00:00 2001 From: "fryorcraken.eth" Date: Fri, 4 Nov 2022 11:45:15 +1100 Subject: [PATCH 5/6] chore: fix tests --- package-lock.json | 4 +++- packages/message-encryption/src/index.ts | 11 ++++++++++- packages/tests/package.json | 3 ++- packages/tests/tests/relay.node.spec.ts | 22 ++++++++++------------ packages/tests/tests/store.node.spec.ts | 20 +++++++++----------- packages/tests/tests/waku.node.spec.ts | 7 +++++-- 6 files changed, 39 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index a34cc7ea3b..ab40d739d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22949,7 +22949,8 @@ "@waku/core": "*", "@waku/create": "*", "@waku/enr": "*", - "@waku/interfaces": "*" + "@waku/interfaces": "*", + "@waku/message-encryption": "*" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.8.1", @@ -27702,6 +27703,7 @@ "@waku/create": "*", "@waku/enr": "*", "@waku/interfaces": "*", + "@waku/message-encryption": "*", "cspell": "^5.14.0", "eslint": "^8.6.0", "eslint-config-prettier": "^8.3.0", diff --git a/packages/message-encryption/src/index.ts b/packages/message-encryption/src/index.ts index baccaef194..feb42d6115 100644 --- a/packages/message-encryption/src/index.ts +++ b/packages/message-encryption/src/index.ts @@ -9,7 +9,14 @@ 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 { + generatePrivateKey, + generateSymmetricKey, + getPublicKey, + keccak256, + randomBytes, + sign, +} from "./crypto.js"; import * as ecies from "./ecies.js"; import * as symmetric from "./symmetric.js"; @@ -22,6 +29,8 @@ const PaddingTarget = 256; const SignatureLength = 65; const OneMillion = BigInt(1_000_000); +export { generatePrivateKey, generateSymmetricKey, getPublicKey }; + export const Version = 1; export type Signature = { diff --git a/packages/tests/package.json b/packages/tests/package.json index 1adbe1806a..7bc92a924d 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -59,7 +59,8 @@ "@waku/enr": "*", "@waku/create": "*", "@waku/interfaces": "*", - "@waku/byte-utils": "*" + "@waku/byte-utils": "*", + "@waku/message-encryption": "*" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.8.1", diff --git a/packages/tests/tests/relay.node.spec.ts b/packages/tests/tests/relay.node.spec.ts index 604439a503..2ed42b7e13 100644 --- a/packages/tests/tests/relay.node.spec.ts +++ b/packages/tests/tests/relay.node.spec.ts @@ -1,26 +1,24 @@ import { PeerId } from "@libp2p/interface-peer-id"; import { bytesToUtf8, utf8ToBytes } from "@waku/byte-utils"; -import { - DefaultPubSubTopic, - generatePrivateKey, - generateSymmetricKey, - getPublicKey, -} from "@waku/core"; +import { DefaultPubSubTopic } from "@waku/core"; import { waitForRemotePeer } from "@waku/core/lib/wait_for_remote_peer"; import { DecoderV0, EncoderV0, MessageV0, } from "@waku/core/lib/waku_message/version_0"; -import { - AsymDecoder, - AsymEncoder, - SymDecoder, - SymEncoder, -} from "@waku/core/lib/waku_message/version_1"; import { createPrivacyNode } from "@waku/create"; import type { Message, WakuPrivacy } from "@waku/interfaces"; import { Protocols } from "@waku/interfaces"; +import { + AsymDecoder, + AsymEncoder, + generatePrivateKey, + generateSymmetricKey, + getPublicKey, + SymDecoder, + SymEncoder, +} from "@waku/message-encryption"; import { expect } from "chai"; import debug from "debug"; diff --git a/packages/tests/tests/store.node.spec.ts b/packages/tests/tests/store.node.spec.ts index b63392b911..38b076bb67 100644 --- a/packages/tests/tests/store.node.spec.ts +++ b/packages/tests/tests/store.node.spec.ts @@ -1,21 +1,19 @@ import { bytesToUtf8, utf8ToBytes } from "@waku/byte-utils"; -import { - generatePrivateKey, - generateSymmetricKey, - getPublicKey, -} from "@waku/core"; import { PageDirection } from "@waku/core"; import { waitForRemotePeer } from "@waku/core/lib/wait_for_remote_peer"; import { DecoderV0, EncoderV0 } from "@waku/core/lib/waku_message/version_0"; -import { - AsymDecoder, - AsymEncoder, - SymDecoder, - SymEncoder, -} from "@waku/core/lib/waku_message/version_1"; import { createFullNode } from "@waku/create"; import type { Message, WakuFull } from "@waku/interfaces"; import { Protocols } from "@waku/interfaces"; +import { + AsymDecoder, + AsymEncoder, + generatePrivateKey, + generateSymmetricKey, + getPublicKey, + SymDecoder, + SymEncoder, +} from "@waku/message-encryption"; import { expect } from "chai"; import debug from "debug"; diff --git a/packages/tests/tests/waku.node.spec.ts b/packages/tests/tests/waku.node.spec.ts index 07c4e69010..9c3965c6fb 100644 --- a/packages/tests/tests/waku.node.spec.ts +++ b/packages/tests/tests/waku.node.spec.ts @@ -1,12 +1,15 @@ import type { PeerId } from "@libp2p/interface-peer-id"; import { bytesToUtf8, utf8ToBytes } from "@waku/byte-utils"; -import { generateSymmetricKey } from "@waku/core"; import { PeerDiscoveryStaticPeers } from "@waku/core/lib/peer_discovery_static_list"; import { waitForRemotePeer } from "@waku/core/lib/wait_for_remote_peer"; -import { SymDecoder, SymEncoder } from "@waku/core/lib/waku_message/version_1"; import { createLightNode, createPrivacyNode } from "@waku/create"; import type { Message, Waku, WakuLight, WakuPrivacy } from "@waku/interfaces"; import { Protocols } from "@waku/interfaces"; +import { + generateSymmetricKey, + SymDecoder, + SymEncoder, +} from "@waku/message-encryption"; import { expect } from "chai"; import { makeLogFileName, NOISE_KEY_1, NOISE_KEY_2, Nwaku } from "../src/"; From 3346dbbe573f42c42a088049523a53f27576fa37 Mon Sep 17 00:00:00 2001 From: "fryorcraken.eth" Date: Fri, 4 Nov 2022 13:33:41 +1100 Subject: [PATCH 6/6] chore: fix size config --- .size-limit.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.cjs b/.size-limit.cjs index 080fd8b46e..199a422e9d 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -20,7 +20,7 @@ module.exports = [ }, { name: "Asymmetric, symmetric encryption and signature", - path: "packages/core/bundle/lib/waku_message/version_1.js", + path: "packages/message-encryption/bundle/index.js", import: "{ MessageV1, AsymEncoder, AsymDecoder, SymEncoder, SymDecoder }", }, {