diff --git a/.cspell.json b/.cspell.json index 9bcb16b2b7..5913dbc1ea 100644 --- a/.cspell.json +++ b/.cspell.json @@ -171,7 +171,8 @@ "proto", "*.spec.ts", "*.log", - "CHANGELOG.md" + "CHANGELOG.md", + "test_keystore.ts" ], "patterns": [ { diff --git a/package-lock.json b/package-lock.json index 5af6364b73..57bd6f48d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6872,9 +6872,9 @@ "license": "MIT" }, "node_modules/@wagmi/cli": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@wagmi/cli/-/cli-2.7.0.tgz", - "integrity": "sha512-M0FDVK2/mQSOJne3nG7GiZrecw069GYFY6YGQZbG9IyxPgfOHRgVBvGkeXzGXmb3ezFlzn5jCCIQ2q/9lYh07g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@wagmi/cli/-/cli-2.8.0.tgz", + "integrity": "sha512-2VhDj8u8vwLZwMZ8CX4pTuO0Qm28Z9uH9qOEWgF/xXUCeVV+4e4YsknEyGcoxYwEmTkdlCmuCvMj4up2XK6vxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7143,6 +7143,11 @@ "integrity": "sha512-2Xp7e92y4qZpsiTPGBSVr4gVJ9mJTLaudlo0DQxNpxJUBtoJKpxdH5xDCQDiorbkWZC2j9EId+ohhxHO/xC1QQ==", "license": "MIT or Apache2" }, + "node_modules/@waku/zerokit-rln-wasm-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@waku/zerokit-rln-wasm-utils/-/zerokit-rln-wasm-utils-0.1.0.tgz", + "integrity": "sha512-3ccyg9+CtRXFJfWaxI/kx8Aec5B2S9YUmZAVhPRdN1EG6iQYG2hgvAurx8ZF9/zOppdrhzzyvCgDPg5kRUlOfQ==" + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -17851,12 +17856,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-sha3": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz", - "integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -32504,9 +32503,9 @@ } }, "node_modules/viem": { - "version": "2.38.4", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.38.4.tgz", - "integrity": "sha512-qnyPNg6Lz1EEC86si/1dq7GlOyZVFHSgAW+p8Q31R5idnAYCOdTM2q5KLE4/ykMeMXzY0bnp5MWTtR/wjCtWmQ==", + "version": "2.39.0", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.39.0.tgz", + "integrity": "sha512-rCN+IfnMESlrg/iPyyVL+M9NS/BHzyyNy72470tFmbTuscY3iPaZGMtJDcHKKV8TC6HV9DjWk0zWX6cpu0juyA==", "funding": [ { "type": "github", @@ -32569,27 +32568,6 @@ } } }, - "node_modules/viem/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/void-elements": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", @@ -34031,6 +34009,12 @@ "@esbuild/win32-x64": "0.21.5" } }, + "packages/browser-tests/node_modules/js-sha3": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz", + "integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==", + "license": "MIT" + }, "packages/browser-tests/node_modules/undici-types": { "version": "6.19.8", "dev": true, @@ -34201,6 +34185,10 @@ } } }, + "packages/enr/node_modules/js-sha3": { + "version": "0.9.3", + "license": "MIT" + }, "packages/headless-tests": { "name": "@waku/headless-tests", "version": "0.1.0", @@ -34269,6 +34257,10 @@ "node": ">=22" } }, + "packages/message-encryption/node_modules/js-sha3": { + "version": "0.9.3", + "license": "MIT" + }, "packages/proto": { "name": "@waku/proto", "version": "0.0.15", @@ -34736,6 +34728,7 @@ "@waku/core": "^0.0.40", "@waku/utils": "^0.0.27", "@waku/zerokit-rln-wasm": "^0.2.1", + "@waku/zerokit-rln-wasm-utils": "^0.1.0", "chai": "^5.1.2", "chai-as-promised": "^8.0.1", "chai-spies": "^1.1.0", @@ -34757,7 +34750,6 @@ "@types/sinon": "^17.0.3", "@wagmi/cli": "^2.7.0", "@waku/build-utils": "^1.0.0", - "@waku/interfaces": "0.0.34", "@waku/message-encryption": "^0.0.37", "deep-equal-in-any-order": "^2.0.6", "fast-check": "^3.23.2", @@ -34917,6 +34909,13 @@ "node": ">=0.3.1" } }, + "packages/rln/node_modules/js-sha3": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz", + "integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==", + "dev": true, + "license": "MIT" + }, "packages/rln/node_modules/loupe": { "version": "3.1.3", "license": "MIT" diff --git a/packages/rln/.mocharc.cjs b/packages/rln/.mocharc.cjs index 268cf0c611..b61a6aaee5 100644 --- a/packages/rln/.mocharc.cjs +++ b/packages/rln/.mocharc.cjs @@ -24,4 +24,4 @@ if (process.env.CI) { console.log("Running tests serially. To enable parallel execution update mocha config"); } -module.exports = config; \ No newline at end of file +module.exports = config; diff --git a/packages/rln/karma.conf.cjs b/packages/rln/karma.conf.cjs index f922aa1d9c..0f0d91677b 100644 --- a/packages/rln/karma.conf.cjs +++ b/packages/rln/karma.conf.cjs @@ -38,6 +38,14 @@ module.exports = function (config) { watched: false, type: "wasm", nocache: true + }, + { + pattern: "../../node_modules/@waku/zerokit-rln-wasm-utils/*.wasm", + included: false, + served: true, + watched: false, + type: "wasm", + nocache: true } ], @@ -82,6 +90,12 @@ module.exports = function (config) { __dirname, "../../node_modules/@waku/zerokit-rln-wasm/rln_wasm_bg.wasm" ), + "/base/rln_wasm_utils_bg.wasm": + "/absolute" + + path.resolve( + __dirname, + "../../node_modules/@waku/zerokit-rln-wasm-utils/rln_wasm_utils_bg.wasm" + ), "/base/rln.wasm": "/absolute" + path.resolve(__dirname, "src/resources/rln.wasm"), "/base/rln_final.zkey": diff --git a/packages/rln/package.json b/packages/rln/package.json index bff065a5c9..a360ec15ef 100644 --- a/packages/rln/package.json +++ b/packages/rln/package.json @@ -61,7 +61,6 @@ "@types/sinon": "^17.0.3", "@wagmi/cli": "^2.7.0", "@waku/build-utils": "^1.0.0", - "@waku/interfaces": "0.0.34", "@waku/message-encryption": "^0.0.37", "deep-equal-in-any-order": "^2.0.6", "fast-check": "^3.23.2", @@ -84,6 +83,7 @@ "@waku/core": "^0.0.40", "@waku/utils": "^0.0.27", "@waku/zerokit-rln-wasm": "^0.2.1", + "@waku/zerokit-rln-wasm-utils": "^0.1.0", "chai": "^5.1.2", "chai-as-promised": "^8.0.1", "chai-spies": "^1.1.0", diff --git a/packages/rln/src/keystore/keystore.ts b/packages/rln/src/keystore/keystore.ts index f001f2938d..5dc4010ca4 100644 --- a/packages/rln/src/keystore/keystore.ts +++ b/packages/rln/src/keystore/keystore.ts @@ -29,6 +29,16 @@ import type { const log = new Logger("rln:keystore"); +/** + * Custom replacer function to handle BigInt serialization in JSON.stringify + */ +const bigIntReplacer = (_key: string, value: unknown): unknown => { + if (typeof value === "bigint") { + return value.toString(); + } + return value; +}; + type NwakuCredential = { crypto: { cipher: ICipherModule["function"]; @@ -160,7 +170,7 @@ export class Keystore { } public toString(): string { - return JSON.stringify(this.data); + return JSON.stringify(this.data, bigIntReplacer); } public toObject(): NwakuKeystore { @@ -328,20 +338,23 @@ export class Keystore { options.identity; return utf8ToBytes( - JSON.stringify({ - treeIndex: options.membership.treeIndex, - identityCredential: { - idCommitment: Array.from(IDCommitment), - idNullifier: Array.from(IDNullifier), - idSecretHash: Array.from(IDSecretHash), - idTrapdoor: Array.from(IDTrapdoor) + JSON.stringify( + { + treeIndex: options.membership.treeIndex, + identityCredential: { + idCommitment: Array.from(IDCommitment), + idNullifier: Array.from(IDNullifier), + idSecretHash: Array.from(IDSecretHash), + idTrapdoor: Array.from(IDTrapdoor) + }, + membershipContract: { + chainId: options.membership.chainId, + address: options.membership.address + }, + userMessageLimit: options.membership.rateLimit }, - membershipContract: { - chainId: options.membership.chainId, - address: options.membership.address - }, - userMessageLimit: options.membership.rateLimit - }) + bigIntReplacer + ) ); } } diff --git a/packages/rln/src/proof.spec.ts b/packages/rln/src/proof.spec.ts new file mode 100644 index 0000000000..eba9a8b685 --- /dev/null +++ b/packages/rln/src/proof.spec.ts @@ -0,0 +1,104 @@ +import { expect } from "chai"; + +import { Keystore } from "./keystore/index.js"; +import { RLNInstance } from "./rln.js"; +import { BytesUtils } from "./utils/index.js"; +import { + calculateRateCommitment, + getPathDirectionsFromIndex, + MERKLE_TREE_DEPTH, + reconstructMerkleRoot +} from "./utils/merkle.js"; +import { + TEST_CREDENTIALS, + TEST_KEYSTORE_PASSWORD, + TEST_MERKLE_ROOT +} from "./utils/test_keystore.js"; + +describe("RLN Proof Integration Tests", function () { + this.timeout(30000); + + TEST_CREDENTIALS.forEach((credential, index) => { + describe(`Credential ${index + 1}`, function () { + it("validate stored merkle proof data", function () { + const merkleProof = credential.merkleProof.map((p) => BigInt(p)); + + expect(merkleProof).to.be.an("array"); + expect(merkleProof).to.have.lengthOf(MERKLE_TREE_DEPTH); + + for (let i = 0; i < merkleProof.length; i++) { + const element = merkleProof[i]; + expect(element).to.be.a( + "bigint", + `Proof element ${i} should be a bigint` + ); + // Note: First element can be 0 for some tree positions (e.g., credential 3) + } + }); + + it("should generate a valid RLN proof", async function () { + const rlnInstance = await RLNInstance.create(); + const keystore = Keystore.fromString(credential.keystoreJson); + if (!keystore) { + throw new Error("Failed to load test keystore"); + } + const credentialHash = credential.credentialHash; + const decrypted = await keystore.readCredential( + credentialHash, + TEST_KEYSTORE_PASSWORD + ); + if (!decrypted) { + throw new Error("Failed to unlock credential with provided password"); + } + + const idCommitment = decrypted.identity.IDCommitmentBigInt; + + const merkleProof = credential.merkleProof.map((p) => BigInt(p)); + const merkleRoot = BigInt(TEST_MERKLE_ROOT); + const membershipIndex = BigInt(credential.membershipIndex); + const rateLimit = BigInt(credential.rateLimit); + + const rateCommitment = calculateRateCommitment(idCommitment, rateLimit); + + const proofElementIndexes = getPathDirectionsFromIndex(membershipIndex); + + expect(proofElementIndexes).to.have.lengthOf(MERKLE_TREE_DEPTH); + + const reconstructedRoot = reconstructMerkleRoot( + merkleProof, + membershipIndex, + rateCommitment + ); + + expect(reconstructedRoot).to.equal( + merkleRoot, + "Reconstructed root should match stored root" + ); + + const testMessage = new TextEncoder().encode("test"); + + const proof = await rlnInstance.zerokit.generateRLNProof( + testMessage, + new Date(), + decrypted.identity.IDSecretHash, + merkleProof.map((element) => + BytesUtils.bytes32FromBigInt(element, "little") + ), + proofElementIndexes.map((idx) => + BytesUtils.writeUintLE(new Uint8Array(1), idx, 0, 1) + ), + Number(rateLimit), + 0 + ); + + const isValid = rlnInstance.zerokit.verifyRLNProof( + BytesUtils.writeUintLE(new Uint8Array(8), testMessage.length, 0, 8), + testMessage, + proof, + [BytesUtils.bytes32FromBigInt(merkleRoot, "little")] + ); + expect(isValid).to.be.true; + }); + }); + }); +}); diff --git a/packages/rln/src/rln.ts b/packages/rln/src/rln.ts index 491fd14d44..739dcefd3a 100644 --- a/packages/rln/src/rln.ts +++ b/packages/rln/src/rln.ts @@ -1,5 +1,6 @@ import { Logger } from "@waku/utils"; import init, * as zerokitRLN from "@waku/zerokit-rln-wasm"; +import initUtils from "@waku/zerokit-rln-wasm-utils"; import { DEFAULT_RATE_LIMIT } from "./contract/constants.js"; import { RLNCredentialsManager } from "./credentials_manager.js"; @@ -16,6 +17,7 @@ export class RLNInstance extends RLNCredentialsManager { */ public static async create(): Promise { try { + await initUtils(); await init(); zerokitRLN.initPanicHook(); diff --git a/packages/rln/src/utils/bytes.ts b/packages/rln/src/utils/bytes.ts index 4df17bd380..fbf7fc47be 100644 --- a/packages/rln/src/utils/bytes.ts +++ b/packages/rln/src/utils/bytes.ts @@ -1,3 +1,4 @@ +import { bytesToBigInt, numberToBytes } from "viem"; export class BytesUtils { /** * Concatenate Uint8Arrays @@ -32,18 +33,40 @@ export class BytesUtils { return 0n; } - // Create a copy to avoid modifying the original array const workingBytes = new Uint8Array(bytes); - // Reverse bytes if input is little-endian to work with big-endian internally if (inputEndianness === "little") { workingBytes.reverse(); } - // Convert to BigInt - let result = 0n; - for (let i = 0; i < workingBytes.length; i++) { - result = (result << 8n) | BigInt(workingBytes[i]); + return bytesToBigInt(workingBytes, { size: 32 }); + } + + /** + * Convert a BigInt to a bytes32 (32-byte Uint8Array) + * @param value - The BigInt to convert (must fit in 32 bytes) + * @param outputEndianness - Endianness of the output bytes ('big' or 'little') + * @returns 32-byte Uint8Array representation of the BigInt + */ + public static bytes32FromBigInt( + value: bigint, + outputEndianness: "big" | "little" = "little" + ): Uint8Array { + if (value < 0n) { + throw new Error("Cannot convert negative BigInt to bytes"); + } + + if (value >> 256n !== 0n) { + throw new Error( + `BigInt value is too large to fit in 32 bytes (max bit length: 256)` + ); + } + + const result = numberToBytes(value, { size: 32 }); + + // If we need little-endian output, reverse the array + if (outputEndianness === "little") { + result.reverse(); } return result; @@ -52,7 +75,7 @@ export class BytesUtils { /** * Writes an unsigned integer to a buffer in little-endian format */ - public static writeUIntLE( + public static writeUintLE( buf: Uint8Array, value: number, offset: number, diff --git a/packages/rln/src/utils/epoch.ts b/packages/rln/src/utils/epoch.ts index 19b2f81108..458ec46246 100644 --- a/packages/rln/src/utils/epoch.ts +++ b/packages/rln/src/utils/epoch.ts @@ -1,5 +1,7 @@ import { Logger } from "@waku/utils"; +import { BytesUtils } from "./bytes.js"; + const DefaultEpochUnitSeconds = 10; // the rln-relay epoch length in seconds const log = new Logger("rln:epoch"); @@ -15,11 +17,7 @@ export function dateToEpoch( } export function epochIntToBytes(epoch: number): Uint8Array { - const bytes = new Uint8Array(32); - const db = new DataView(bytes.buffer); - db.setUint32(0, epoch, true); - log.info("encoded epoch", epoch, bytes); - return bytes; + return BytesUtils.writeUintLE(new Uint8Array(32), epoch, 0, 32); } export function epochBytesToInt(bytes: Uint8Array): number { diff --git a/packages/rln/src/utils/hash.ts b/packages/rln/src/utils/hash.ts index 6aa8e29277..c7a39152a7 100644 --- a/packages/rln/src/utils/hash.ts +++ b/packages/rln/src/utils/hash.ts @@ -1,25 +1,18 @@ -import * as zerokitRLN from "@waku/zerokit-rln-wasm"; +import { hash, poseidonHash as poseidon } from "@waku/zerokit-rln-wasm-utils"; import { BytesUtils } from "./bytes.js"; export function poseidonHash(...input: Array): Uint8Array { - const inputLen = BytesUtils.writeUIntLE( + const inputLen = BytesUtils.writeUintLE( new Uint8Array(8), input.length, 0, 8 ); const lenPrefixedData = BytesUtils.concatenate(inputLen, ...input); - return zerokitRLN.poseidonHash(lenPrefixedData); + return poseidon(lenPrefixedData, true); } export function sha256(input: Uint8Array): Uint8Array { - const inputLen = BytesUtils.writeUIntLE( - new Uint8Array(8), - input.length, - 0, - 8 - ); - const lenPrefixedData = BytesUtils.concatenate(inputLen, input); - return zerokitRLN.hash(lenPrefixedData); + return hash(input, true); } diff --git a/packages/rln/src/utils/merkle.spec.ts b/packages/rln/src/utils/merkle.spec.ts new file mode 100644 index 0000000000..952219db1b --- /dev/null +++ b/packages/rln/src/utils/merkle.spec.ts @@ -0,0 +1,87 @@ +import { expect } from "chai"; +import fc from "fast-check"; + +import { getPathDirectionsFromIndex, MERKLE_TREE_DEPTH } from "./merkle.js"; + +describe("getPathDirectionsFromIndex", () => { + it("should return an array of length MERKLE_TREE_DEPTH", () => { + const result = getPathDirectionsFromIndex(0n); + expect(result).to.have.lengthOf(MERKLE_TREE_DEPTH); + }); + + it("should return all zeros for index 0", () => { + const result = getPathDirectionsFromIndex(0n); + expect(result.every((bit) => bit === 0)).to.be.true; + }); + + it("should return [1, 0, 0, ...] for index 1 (only LSB set)", () => { + const result = getPathDirectionsFromIndex(1n); + expect(result[0]).to.equal(1); + expect(result.slice(1).every((bit) => bit === 0)).to.be.true; + }); + + it("should return [0, 1, 0, ...] for index 2 (only bit 1 set)", () => { + const result = getPathDirectionsFromIndex(2n); + expect(result[0]).to.equal(0); + expect(result[1]).to.equal(1); + expect(result.slice(2).every((bit) => bit === 0)).to.be.true; + }); + + it("should return [1, 1, 0, ...] for index 3 (bits 0 and 1 set)", () => { + const result = getPathDirectionsFromIndex(3n); + expect(result[0]).to.equal(1); + expect(result[1]).to.equal(1); + expect(result.slice(2).every((bit) => bit === 0)).to.be.true; + }); + + it("should correctly extract bits for a known value", () => { + // Index 42 = 0b101010: bits 1, 3, 5 are set + const result = getPathDirectionsFromIndex(42n); + expect(result[0]).to.equal(0); // bit 0 + expect(result[1]).to.equal(1); // bit 1 + expect(result[2]).to.equal(0); // bit 2 + expect(result[3]).to.equal(1); // bit 3 + expect(result[4]).to.equal(0); // bit 4 + expect(result[5]).to.equal(1); // bit 5 + }); + + it("should only contain 0 or 1 values", () => { + fc.assert( + fc.property(fc.bigInt({ min: 0n, max: 2n ** 20n - 1n }), (index) => { + const result = getPathDirectionsFromIndex(index); + return result.every((bit) => bit === 0 || bit === 1); + }) + ); + }); + + it("should produce consistent results for the same input", () => { + fc.assert( + fc.property(fc.bigInt({ min: 0n, max: 2n ** 20n - 1n }), (index) => { + const result1 = getPathDirectionsFromIndex(index); + const result2 = getPathDirectionsFromIndex(index); + return ( + result1.length === result2.length && + result1.every((bit, i) => bit === result2[i]) + ); + }) + ); + }); + + it("should reconstruct the original index from path directions", () => { + fc.assert( + fc.property(fc.bigInt({ min: 0n, max: 2n ** 20n - 1n }), (index) => { + const pathDirections = getPathDirectionsFromIndex(index); + + // Reconstruct the index from path directions + let reconstructed = 0n; + for (let level = 0; level < MERKLE_TREE_DEPTH; level++) { + if (pathDirections[level] === 1) { + reconstructed |= 1n << BigInt(level); + } + } + + return reconstructed === index; + }) + ); + }); +}); diff --git a/packages/rln/src/utils/merkle.ts b/packages/rln/src/utils/merkle.ts new file mode 100644 index 0000000000..fda9af680b --- /dev/null +++ b/packages/rln/src/utils/merkle.ts @@ -0,0 +1,86 @@ +import { BytesUtils } from "./bytes.js"; +import { poseidonHash } from "./hash.js"; + +/** + * The fixed depth of the Merkle tree used in the RLN contract + * This is a constant that will never change for the on-chain implementation + */ +export const MERKLE_TREE_DEPTH = 20; + +/** + * Reconstructs a Merkle tree root from a proof and leaf information + * + * @param proof - Array of MERKLE_TREE_DEPTH bigint elements representing the Merkle proof + * @param leafIndex - The index of the leaf in the tree (used to determine left/right positioning) + * @param leafValue - The value of the leaf (typically the rate commitment) + * @returns The reconstructed root as a bigint + */ +export function reconstructMerkleRoot( + proof: readonly bigint[], + leafIndex: bigint, + leafValue: bigint +): bigint { + if (proof.length !== MERKLE_TREE_DEPTH) { + throw new Error( + `Expected proof of length ${MERKLE_TREE_DEPTH}, got ${proof.length}` + ); + } + + let currentValue = BytesUtils.bytes32FromBigInt(leafValue); + + for (let level = 0; level < MERKLE_TREE_DEPTH; level++) { + const bit = (leafIndex >> BigInt(level)) & 1n; + + const proofBytes = BytesUtils.bytes32FromBigInt(proof[level]); + + if (bit === 0n) { + // Current node is a left child: hash(current, proof[level]) + currentValue = poseidonHash(currentValue, proofBytes); + } else { + // Current node is a right child: hash(proof[level], current) + currentValue = poseidonHash(proofBytes, currentValue); + } + } + + return BytesUtils.toBigInt(currentValue, "little"); +} + +/** + * Calculates the rate commitment from an ID commitment and rate limit + * This matches the contract's calculation: PoseidonT3.hash([idCommitment, rateLimit]) + * + * @param idCommitment - The identity commitment as a bigint + * @param rateLimit - The rate limit as a bigint + * @returns The rate commitment as a bigint + */ +export function calculateRateCommitment( + idCommitment: bigint, + rateLimit: bigint +): bigint { + const idBytes = BytesUtils.bytes32FromBigInt(idCommitment); + const rateLimitBytes = BytesUtils.bytes32FromBigInt(rateLimit); + + const hashResult = poseidonHash(idBytes, rateLimitBytes); + return BytesUtils.toBigInt(hashResult, "little"); +} + +/** + * Converts a leaf index to an array of path direction bits + * + * @param leafIndex - The index of the leaf in the tree + * @returns Array of MERKLE_TREE_DEPTH numbers (0 or 1) representing path directions + * - 0 means the node is a left child (hash order: current, sibling) + * - 1 means the node is a right child (hash order: sibling, current) + */ +export function getPathDirectionsFromIndex(leafIndex: bigint): number[] { + const pathDirections: number[] = []; + + // For each level (0 to MERKLE_TREE_DEPTH-1), extract the bit that determines left/right + for (let level = 0; level < MERKLE_TREE_DEPTH; level++) { + // Check if bit `level` is set in the leaf index + const bit = (leafIndex >> BigInt(level)) & 1n; + pathDirections.push(Number(bit)); + } + + return pathDirections; +} diff --git a/packages/rln/src/utils/test_keystore.ts b/packages/rln/src/utils/test_keystore.ts new file mode 100644 index 0000000000..a4a50e2249 --- /dev/null +++ b/packages/rln/src/utils/test_keystore.ts @@ -0,0 +1,133 @@ +export const TEST_KEYSTORE_PASSWORD = "12345678"; + +/** + * Common merkle root for all test credentials. + * This is the current merkle root from the RLN contract on Linea Sepolia. + */ +export const TEST_MERKLE_ROOT = + "14161516722319025955693784143663226902768300298867580360846832421996857757959"; + +/** + * Test credential 1 - Original test credential + */ +export const TEST_CREDENTIAL_1 = { + keystoreJson: + '{"application":"waku-rln-relay","appIdentifier":"01234567890abcdef","version":"0.2","credentials":{"E0A8AC077B95F64C1B2C4B116468B22EFA3B1CFF250069AE07422F645BAA555E":{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"96aff104d7bb23cefb57a4c5e816a3b9"},"ciphertext":"1ae2c7a47274d12d6a4b439da48abfa89be29e4ba3308d153e2e808d3e120cc85da472ab1e0278c945231092162d31d753ecb48484ac0c3a7efe6380d08f5dedecc9cda26bd156a30d232b9da4313c5ec92b21cd3dc3ca03cff68afde94a063799b658cc3e4a5c648e620d584a8a184d2d473e3e94c897e21e0de7580639dcf40c0133f36896ac5bee2dd5fe8810a5441e31e1938ecc4b195db57c1b6d320a374508406dfb7a4879081b70100140515b4c6c551f25f9b4c9a7214ac2dc222410bf74666407343dfd4af477c85cf2f316bb7a512a88948d88f5474374563d51d02c13eede6b6cf64fab7991e529157d7de39033099d26f323d9710159b47d2511695b4fb428e3b02c760e1470a3ece712c6a03692d067e0e17930bc25ce7dc4ad2634e07ef51fa7369de6b4d495c7ae1d8ad8dccdd2fa12802db4203c527887adf5eb42e2551e120b8a455892d0ac9369faf708465a983c03c7c8f77c268f85cacc7b718a1e9e2800b160ca1f7a78f2c160cbc97396f5dfe0e0f3b35addb4f8d667021c79eec5248122d8c983075b9e8ca20679e90a12bdbeefb33df21523b4e1ea7ab57ddc706b43bf4827fbc3530d20cb906468af5c5c31ac08815f3ed1d00341be7e287a3fb7ef67aecf2e56f694c51ba6db8641ac873e26659c92a8527c42df2d5ac15ff6201bdfa8a5ee34b6a90ff864fba89370a8c51efcb4ed1b69f3ed0e37ee97c66eb84763f107e1214e088e3149b2433a8da595293343b2290b0a84b7f796b70005d1672446d98d45da7c89c3eb8d91ece94ee41099f9f43c6810ce71d9f75ac3dffe1de0c79e40baad486ecaefbd0cc0e89aed7e0a16ea271a371d3f5927a1c7b813608de5715692e58322260a4bcd4ccba4b2376df01f58645c16a7b37c8473b94c7577ae774e5c72132ed15507ab2027ddabf137aa417b134b653eda247314","kdf":"pbkdf2","kdfparams":{"dklen":32,"c":262144,"prf":"hmac-sha256","salt":"5f2081f089e9e277873bf1f538c60d714749a2bb910d8f1ed119d8d403235a8c"},"mac":"8d0667893b7d3b5f0b37c43edef616a8d295dc58292c98655eec8b5fe2ad69c3"}}}}', + credentialHash: + "E0A8AC077B95F64C1B2C4B116468B22EFA3B1CFF250069AE07422F645BAA555E", + merkleProof: [ + "21837427992620339064281119305700224965155897361776876451171527491637273262703", + "2849928341676773476316761863425436901389023422598778907382563142042850204484", + "21699429914184421678079077958020273488709892845081201722564329942861605328226", + "8522396354694062508299995669286882048091268903835874022564768254605186873188", + "4967828252976847302563643214799688359334626491919847999565033460501719790119", + "985039452502497454598906195897243897432778848314526706136284672198477696437", + "7251144181820773951456156321290290981868224061213863204327156870116224356459", + "1241870589869015758600129850815671823696180350556207862318506998039540071293", + "21551820661461729022865262380882070649935529853313286572328683688269863701601", + "16870197621778677478951480138572599814910741341994641594346262317677658226992", + "12413880268183407374852357075976609371175688755676981206018884971008854919922", + "14271763308400718165336499097156975241954733520325982997864342600795471836726", + "20066985985293572387227381049700832219069292839614107140851619262827735677018", + "9394776414966240069580838672673694685292165040808226440647796406499139370960", + "11331146992410411304059858900317123658895005918277453009197229807340014528524", + "15819538789928229930262697811477882737253464456578333862691129291651619515538", + "19217088683336594659449020493828377907203207941212636669271704950158751593251", + "21035245323335827719745544373081896983162834604456827698288649288827293579666", + "6939770416153240137322503476966641397417391950902474480970945462551409848591", + "10941962436777715901943463195175331263348098796018438960955633645115732864202" + ], + membershipIndex: "703", + rateLimit: "300" +}; + +/** + * Test credential 2 - Second registered credential + */ +export const TEST_CREDENTIAL_2 = { + keystoreJson: + '{"application":"waku-rln-relay","appIdentifier":"01234567890abcdef","version":"0.2","credentials":{"8E0DAE6CDD9E53A853C8A0E43B7F638D9B3A51FD42615053B9211814F4B27B27":{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"f2dc039318ad9eb7e667a8b6087d9d9f"},"ciphertext":"0387452ed348b9a232b164702cdf196626867487866a84ccc79bf49f6172e65d67c1725511e3f941dcbce93b3d11fc84f57caa562d9e87ba900654448b074bad7c4a0428e65022355e66e8fb2fbd7a4929b78bd3c42b95afa56ee6710b4d6d88937900dfb0bedfca404111fc785344f21369ea4cd489b09af49989c7bfe81790984c32a175eb9f0ed8113a1d9a04723e019439f73047c428327bcc9efb5cca0cd1058a6dae98482e9691f24b5c790ba6ed2785508430b8b87a1053dedc3333684b8dc0a80121313fa7a1cc578ed05c7c32895afcad6657e92f90f2b8b45504beffa380befeec84e15c19daef1883d27c8b78851e3988ffde418d71ea8401ba81baa6f55c007f1d6f525e42e29bec521c55227c039f76de1a58ab9e96eff766912236d357196259bff8915b5f0fcd70830e66aa6184e59ed6e445d68c6c63bdb3710b1314b77414d6bbabb28e9bd86fb0b2d3b55cdc932d9d784518dd583b8f8a138deecedcf8e68430a098ddcff28c9c68c7e9af8f3add733f57b1ec72ec6d814fb82ab0f4895ba2a8021051d565a85764a7fd9cbd684e263699b822c2557e8e632ce206a3828d7272c64c41bed2c09604df31346128fc9db707dc9b292fa99bb83f2594a16902bf6ee821146985c2d27abe0374d078b59bdb78071f2ee22e8aee2c149a0a9318a5b87c7d89dbdae71b6cc88997a8b9b25940b3d0c4825519659ccaba4b9b3c013b124824405b77ff61ee280c9667fc9a54706ec57288bab26056d74902388b615a00bcf3f4b4f5b94ef095e21423418cf871c85ee538b27282ac6fad5e674365e191d4e4bf52898dfca47500c655d18d086f5e353d04b9d67cdbafc1230a981345c214a4c3e1e91272eb71536f088012d978d192084e4dd1e91e27a28ed1d76cd032686afe9bd6affb1904bca74359db9e2792","kdf":"pbkdf2","kdfparams":{"dklen":32,"c":262144,"prf":"hmac-sha256","salt":"3aca6d6a340113ee99f03061521a7ee0c04a96c5e775439f4118ff25548b5109"},"mac":"9d82905f6cbe9fe54f8b01e48e0d4f9380d53cb7a01a0625319c798138de3a13"}}}}', + credentialHash: + "8E0DAE6CDD9E53A853C8A0E43B7F638D9B3A51FD42615053B9211814F4B27B27", + merkleProof: [ + "20014406844564576052490341969353219944173965183955536177202758958282682615759", + "10748678928687460399649891835823436287448179277915720784752998995356022701106", + "18890115756516565941918724645428866674652169782497696213331466077914044216209", + "13735924407428554882792772515344488675895942927305185961288084894379223575584", + "4983563937544786238998621737339846087729923245413437244922923133609600747396", + "19712377064642672829441595136074946683621277828620209496774504837737984048981", + "22692684738489870725053514375871342421368934538955345436357027565293868088", + "1241870589869015758600129850815671823696180350556207862318506998039540071293", + "21551820661461729022865262380882070649935529853313286572328683688269863701601", + "16870197621778677478951480138572599814910741341994641594346262317677658226992", + "12413880268183407374852357075976609371175688755676981206018884971008854919922", + "14271763308400718165336499097156975241954733520325982997864342600795471836726", + "20066985985293572387227381049700832219069292839614107140851619262827735677018", + "9394776414966240069580838672673694685292165040808226440647796406499139370960", + "11331146992410411304059858900317123658895005918277453009197229807340014528524", + "15819538789928229930262697811477882737253464456578333862691129291651619515538", + "19217088683336594659449020493828377907203207941212636669271704950158751593251", + "21035245323335827719745544373081896983162834604456827698288649288827293579666", + "6939770416153240137322503476966641397417391950902474480970945462551409848591", + "10941962436777715901943463195175331263348098796018438960955633645115732864202" + ], + membershipIndex: "719", + rateLimit: "300" +}; + +/** + * Test credential 3 - Third registered credential + */ +export const TEST_CREDENTIAL_3 = { + keystoreJson: + '{"application":"waku-rln-relay","appIdentifier":"01234567890abcdef","version":"0.2","credentials":{"E9D666502CA119FF1DB4DFFB2102C4E8EF88F6F947EC07FF949FCAB1CC00B43D":{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"6c00ff9f1a0e9e857ecc9d91418e1037"},"ciphertext":"e32e6d799c2fe36b4a8adcd77bc1e349c394407f5a1bd88947e774743b8bb2a4adc5b72b7de47c86fe78a8ecf5bec6c8a2923ada89f4cdc292f96bee22640974ea3ffe565e3054e2b623c7581dff200f0837d7c50641f71dce0e7c91445c98be2474a5618a4be8b8e09a83874e2e29f9e127e285710e9dea5e0c852079ab72f6d71da7e594f15baebca34f6c28f2be89d5d0602f23dea055096485079948360d0a709ecad5d980a0acc11fdde9eca7610250808ec100d398afe9b7a1d5fbb85fe4a0911f00ad359f785da92da83b5498b8f46d6a1120b37d17a5a0bbeb3e5a0d644cdd0200e086564b7bdb443ebaff9c6adcbe31800aecd710c83f8da80dc32eb5ff0c4e8b47ef641cc53608044c759e259256252dd20d8f41acc45839f8098ed315e8c67b7c1f487a88e8cc80c8a7515a1d52f1c13f03dac5962ba5277598d869500cb8349fd167bdf9068db8f0ccf988a4ea9847b9563fb606c57220956c7844de323ee73e26cb9c41662e232f48dd12e8c6a839e5a9f73c1a01434a7b0eac9e8854742abf3dac8ab669b10374bc75994104d7543892991d20ef1b5b71a9237f73d0a5fbd6c3d901c14128e61156d5d7f731b7f23aa85ed1ce46a01684167ad661683729ebe7f1b22eedc18fa86184436579f9e0d22b1312f897cf052d4827e6a28614f7c64176e791eaa255adfe29a1b509fe00dc330e17f866af5a5f051c4932af776b715bf3c2544b973552194c10ef6e2f1b2efb48c3f1b7890d3a40ce047a8f0aeb629f54645ac42f619ec407e2ad1881f61f1debd2dbf7a84f8e38b44d5c24a91a7959953617fc40e2f81b0c0e66c7e47b028fb0df5f29b5c8cfb604d83163032b5fd4e20bc42783e94428a9fbc3cdd8290d4631ee60951d28075e4170c1dcd6c737fa7b6c6fe91e28b411c6246ea99ab451e385a6753125c5200796a696c8b2cfbbf0627a4eeec2","kdf":"pbkdf2","kdfparams":{"dklen":32,"c":262144,"prf":"hmac-sha256","salt":"d1b9ca82d30d8ae9f38646a71719b2b308c394def4e22caf680bcba4ce63ef5d"},"mac":"dcf8dfd7e6b73094bb7d532abd6335e85636a84f88eec651154cd96ff1901965"}}}}', + credentialHash: + "E9D666502CA119FF1DB4DFFB2102C4E8EF88F6F947EC07FF949FCAB1CC00B43D", + merkleProof: [ + "0", + "14744269619966411208579211824598458697587494354926760081771325075741142829156", + "7423237065226347324353380772367382631490014989348495481811164164159255474657", + "11286972368698509976183087595462810875513684078608517520839298933882497716792", + "13858066062358147395240647533838086138583260449298457453046337422370457366761", + "19712377064642672829441595136074946683621277828620209496774504837737984048981", + "22692684738489870725053514375871342421368934538955345436357027565293868088", + "1241870589869015758600129850815671823696180350556207862318506998039540071293", + "21551820661461729022865262380882070649935529853313286572328683688269863701601", + "16870197621778677478951480138572599814910741341994641594346262317677658226992", + "12413880268183407374852357075976609371175688755676981206018884971008854919922", + "14271763308400718165336499097156975241954733520325982997864342600795471836726", + "20066985985293572387227381049700832219069292839614107140851619262827735677018", + "9394776414966240069580838672673694685292165040808226440647796406499139370960", + "11331146992410411304059858900317123658895005918277453009197229807340014528524", + "15819538789928229930262697811477882737253464456578333862691129291651619515538", + "19217088683336594659449020493828377907203207941212636669271704950158751593251", + "21035245323335827719745544373081896983162834604456827698288649288827293579666", + "6939770416153240137322503476966641397417391950902474480970945462551409848591", + "10941962436777715901943463195175331263348098796018438960955633645115732864202" + ], + membershipIndex: "720", + rateLimit: "300" +}; + +/** + * Array of all test credentials for iteration + */ +export const TEST_CREDENTIALS = [ + TEST_CREDENTIAL_1, + TEST_CREDENTIAL_2, + TEST_CREDENTIAL_3 +]; + +/** + * @deprecated Use TEST_CREDENTIAL_1, TEST_MERKLE_ROOT, and TEST_KEYSTORE_PASSWORD instead + * Kept for backwards compatibility + */ +export const TEST_KEYSTORE_DATA = { + keystoreJson: TEST_CREDENTIAL_1.keystoreJson, + credentialHash: TEST_CREDENTIAL_1.credentialHash, + password: TEST_KEYSTORE_PASSWORD, + merkleProof: TEST_CREDENTIAL_1.merkleProof, + merkleRoot: TEST_MERKLE_ROOT, + membershipIndex: TEST_CREDENTIAL_1.membershipIndex, + rateLimit: TEST_CREDENTIAL_1.rateLimit +}; diff --git a/packages/rln/src/zerokit.spec.ts b/packages/rln/src/zerokit.browser.spec.ts similarity index 100% rename from packages/rln/src/zerokit.spec.ts rename to packages/rln/src/zerokit.browser.spec.ts diff --git a/packages/rln/src/zerokit.ts b/packages/rln/src/zerokit.ts index 47df182f00..19bf46adc4 100644 --- a/packages/rln/src/zerokit.ts +++ b/packages/rln/src/zerokit.ts @@ -1,14 +1,22 @@ import * as zerokitRLN from "@waku/zerokit-rln-wasm"; +import { generateSeededExtendedMembershipKey } from "@waku/zerokit-rln-wasm-utils"; -import { DEFAULT_RATE_LIMIT } from "./contract/constants.js"; +import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./contract/constants.js"; import { IdentityCredential } from "./identity.js"; import { WitnessCalculator } from "./resources/witness_calculator"; +import { BytesUtils } from "./utils/bytes.js"; +import { dateToEpoch, epochIntToBytes } from "./utils/epoch.js"; +import { poseidonHash, sha256 } from "./utils/hash.js"; +import { MERKLE_TREE_DEPTH } from "./utils/merkle.js"; export class Zerokit { public constructor( private readonly zkRLN: number, private readonly witnessCalculator: WitnessCalculator, - private readonly _rateLimit: number = DEFAULT_RATE_LIMIT + private readonly _rateLimit: number = DEFAULT_RATE_LIMIT, + private readonly rlnIdentifier: Uint8Array = new TextEncoder().encode( + "rln/waku-rln-relay/v2.0.0" + ) ) {} public get getZkRLN(): number { @@ -26,10 +34,134 @@ export class Zerokit { public generateSeededIdentityCredential(seed: string): IdentityCredential { const stringEncoder = new TextEncoder(); const seedBytes = stringEncoder.encode(seed); - const memKeys = zerokitRLN.generateSeededExtendedMembershipKey( - this.zkRLN, - seedBytes - ); + const memKeys = generateSeededExtendedMembershipKey(seedBytes, true); return IdentityCredential.fromBytes(memKeys); } + + private async serializeWitness( + idSecretHash: Uint8Array, + pathElements: Uint8Array[], + identityPathIndex: Uint8Array[], + msg: Uint8Array, + epoch: Uint8Array, + rateLimit: number, + messageNumberId: number + ): Promise { + const externalNullifier = poseidonHash( + sha256(epoch), + sha256(this.rlnIdentifier) + ); + const pathElementsBytes = new Uint8Array(8 + pathElements.length * 32); + BytesUtils.writeUintLE(pathElementsBytes, pathElements.length, 0, 8); + for (let i = 0; i < pathElements.length; i++) { + // We assume that the path elements are already in little-endian format + pathElementsBytes.set(pathElements[i], 8 + i * 32); + } + const identityPathIndexBytes = new Uint8Array( + 8 + identityPathIndex.length * 1 + ); + BytesUtils.writeUintLE( + identityPathIndexBytes, + identityPathIndex.length, + 0, + 8 + ); + for (let i = 0; i < identityPathIndex.length; i++) { + // We assume that each identity path index is already in little-endian format + identityPathIndexBytes.set(identityPathIndex[i], 8 + i * 1); + } + const x = sha256(msg); + return BytesUtils.concatenate( + idSecretHash, + BytesUtils.writeUintLE(new Uint8Array(32), rateLimit, 0, 32), + BytesUtils.writeUintLE(new Uint8Array(32), messageNumberId, 0, 32), + pathElementsBytes, + identityPathIndexBytes, + x, + externalNullifier + ); + } + + public async generateRLNProof( + msg: Uint8Array, + epoch: Uint8Array | Date | undefined, + idSecretHash: Uint8Array, + pathElements: Uint8Array[], + identityPathIndex: Uint8Array[], + rateLimit: number, + messageNumberId: number + ): Promise { + if (epoch === undefined) { + epoch = epochIntToBytes(dateToEpoch(new Date())); + } else if (epoch instanceof Date) { + epoch = epochIntToBytes(dateToEpoch(epoch)); + } + + if (epoch.length !== 32) + throw new Error(`Epoch must be 32 bytes, got ${epoch.length}`); + if (idSecretHash.length !== 32) + throw new Error( + `ID secret hash must be 32 bytes, got ${idSecretHash.length}` + ); + if (pathElements.length !== MERKLE_TREE_DEPTH) + throw new Error(`Path elements must be ${MERKLE_TREE_DEPTH} bytes`); + if (identityPathIndex.length !== MERKLE_TREE_DEPTH) + throw new Error(`Identity path index must be ${MERKLE_TREE_DEPTH} bytes`); + if ( + rateLimit < RATE_LIMIT_PARAMS.MIN_RATE || + rateLimit > RATE_LIMIT_PARAMS.MAX_RATE + ) { + throw new Error( + `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}` + ); + } + + if (messageNumberId < 0 || messageNumberId >= rateLimit) { + throw new Error( + `messageNumberId must be an integer between 0 and ${rateLimit - 1}, got ${messageNumberId}` + ); + } + + const serializedWitness = await this.serializeWitness( + idSecretHash, + pathElements, + identityPathIndex, + msg, + epoch, + rateLimit, + messageNumberId + ); + const witnessJson: Record = zerokitRLN.rlnWitnessToJson( + this.zkRLN, + serializedWitness + ) as Record; + const calculatedWitness: bigint[] = + await this.witnessCalculator.calculateWitness(witnessJson); + return zerokitRLN.generateRLNProofWithWitness( + this.zkRLN, + calculatedWitness, + serializedWitness + ); + } + + public verifyRLNProof( + signalLength: Uint8Array, + signal: Uint8Array, + proof: Uint8Array, + roots: Uint8Array[] + ): boolean { + if (signalLength.length !== 8) + throw new Error("signalLength must be 8 bytes"); + if (proof.length !== 288) throw new Error("proof must be 288 bytes"); + if (roots.length == 0) throw new Error("roots array is empty"); + if (roots.find((root) => root.length !== 32)) { + throw new Error("All roots must be 32 bytes"); + } + + return zerokitRLN.verifyWithRoots( + this.zkRLN, + BytesUtils.concatenate(proof, signalLength, signal), + BytesUtils.concatenate(...roots) + ); + } }