From 429544bb2a126cc1a4b9f3a04c900426c952cb3c Mon Sep 17 00:00:00 2001 From: omikheev Date: Thu, 3 Sep 2026 16:49:11 -0400 Subject: [PATCH 1/2] performance: bounded replay store (option, self-pruning, WP 7.1 compatible), synchronous JS SHA-256 miner, accurate docs --- README.md | 2 +- assets/js/pow-worker.js | 290 +++++++++++++++++++++++++------- includes/class-cardea-admin.php | 2 +- includes/class-cardea-core.php | 88 +++++++++- readme.txt | 12 +- tests/js/pow-worker.test.js | 258 ++++++++++++---------------- uninstall.php | 8 +- 7 files changed, 432 insertions(+), 228 deletions(-) diff --git a/README.md b/README.md index 579ef41..ca1136b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Unlike traditional anti-spam solutions that rely on heavy database lookups, CAPT * **Zero DB Writes on Page Load:** Challenges are generated dynamically using HMAC signatures derived from WordPress salts. * **Client-Side Mining:** Heavy SHA-256 computation is offloaded to a background Web Worker, ensuring the main UI thread remains fluid for the user. -* **Stateless Validation:** The server verifies solutions mathematically. It only records state (via transients) upon a successful submission to prevent replay attacks. +* **Stateless Validation:** The server verifies solutions mathematically. It only records state in a capped, self-pruning replay store upon a successful submission to prevent replay attacks. ## Why Choose Cardea? diff --git a/assets/js/pow-worker.js b/assets/js/pow-worker.js index 5da7df1..dea7a42 100644 --- a/assets/js/pow-worker.js +++ b/assets/js/pow-worker.js @@ -10,43 +10,221 @@ * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * Performs the heavy SHA-256 hashing to find a valid Proof-of-Work solution. + * + * Uses a compact synchronous SHA-256 (FIPS 180-4) so mining works in any + * secure OR insecure context (no crypto.subtle dependency) and avoids the + * per-digest Promise overhead of the WebCrypto batched approach. The server + * remains the sole verifier of the hash, so this client implementation only + * affects mining speed, never verification. */ (function() { 'use strict'; + /* SHA-256 round constants (FIPS 180-4, 4.2.2). + * First 32 bits of the fractional parts of the cube roots of the first 64 primes. */ + const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]); + + + /* SHA-256 initial hash values (FIPS 180-4, 5.3.3). + * First 32 bits of the fractional parts of the square roots of the first 8 primes. */ + const INIT = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 + ]); + + + const HEX_CHARS = '0123456789abcdef'; + /** - * Convert string to Uint8Array. + * Right rotate (32-bit). * - * @param {string} str String to convert. - * @returns {Uint8Array} + * @param {number} v Value. + * @param {number} n Bits. + * @returns {number} */ - function stringToUint8Array(str) { - return new TextEncoder().encode(str); + function rotr(v, n) { + return (v >>> n) | (v << (32 - n)); } /** - * Convert ArrayBuffer to hex string. + * Compact incremental SHA-256 hasher. + */ + class Sha256 { + constructor() { + this.state = new Uint32Array(INIT); + this.block = new Uint8Array(64); + this.blockLen = 0; + this.byteLen = 0; + } + + /** + * Feed bytes into the hasher. + * + * @param {Uint8Array} data Input bytes. + * @returns {Sha256} This hasher (for chaining). + */ + update(data) { + const off0 = 0; + let off = off0; + const n = data.length; + + /* Complete any partially-filled block first. */ + if (this.blockLen > 0) { + const take = Math.min(n, 64 - this.blockLen); + this.block.set(data.subarray(off, off + take), this.blockLen); + off += take; + this.blockLen += take; + if (this.blockLen === 64) { + this.compress(this.block); + this.blockLen = 0; + } + } + + /* Consume full blocks in place. */ + while (off + 64 <= n) { + this.compress(data.subarray(off, off + 64)); + off += 64; + } + + /* Buffer the remainder. */ + if (off < n) { + this.block.set(data.subarray(off), 0); + this.blockLen = n - off; + } + + this.byteLen += n; + return this; + } + + /** + * Compress one 64-byte block into the state. + * + * @param {Uint8Array} block 64-byte block. + */ + compress(block) { + const w = new Uint32Array(64); + for (let i = 0; i < 16; i++) { + w[i] = (block[i * 4] << 24) | (block[i * 4 + 1] << 16) | (block[i * 4 + 2] << 8) | block[i * 4 + 3]; + } + for (let i = 16; i < 64; i++) { + const w15 = w[i - 15]; + const w2 = w[i - 2]; + const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3); + const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + + const h = this.state; + let a = h[0], b = h[1], c = h[2], d = h[3]; + let e = h[4], f = h[5], g = h[6], hh = h[7]; + + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[i] + w[i]) | 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) | 0; + hh = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + + h[0] = (h[0] + a) | 0; + h[1] = (h[1] + b) | 0; + h[2] = (h[2] + c) | 0; + h[3] = (h[3] + d) | 0; + h[4] = (h[4] + e) | 0; + h[5] = (h[5] + f) | 0; + h[6] = (h[6] + g) | 0; + h[7] = (h[7] + hh) | 0; + } + + /** + * Finalize and return the 32-byte digest. + * + * @returns {Uint8Array} Digest. + */ + digest() { + const bitLen = this.byteLen * 8; + const rem = this.blockLen; + const zeros = (rem > 55 ? 128 : 64) - rem - 1 - 8; + + /* 0x80 + zero padding + 64-bit big-endian bit length. */ + const pad = new Uint8Array(zeros + 9); + pad[0] = 0x80; + const hi = Math.floor(bitLen / 4294967296); + const lo = bitLen >>> 0; + pad[zeros + 1] = hi >>> 24; + pad[zeros + 2] = hi >>> 16; + pad[zeros + 3] = hi >>> 8; + pad[zeros + 4] = hi; + pad[zeros + 5] = lo >>> 24; + pad[zeros + 6] = lo >>> 16; + pad[zeros + 7] = lo >>> 8; + pad[zeros + 8] = lo; + + this.update(pad); + + const out = new Uint8Array(32); + for (let i = 0; i < 8; i++) { + out[i * 4] = this.state[i] >>> 24; + out[i * 4 + 1] = this.state[i] >>> 16; + out[i * 4 + 2] = this.state[i] >>> 8; + out[i * 4 + 3] = this.state[i]; + } + return out; + } + } + + /** + * Convert bytes to a hex string. * - * @param {ArrayBuffer} buffer Buffer to convert. + * @param {Uint8Array} bytes Bytes. * @returns {string} */ - function bufferToHex(buffer) { - const byteArray = new Uint8Array(buffer); - return Array.from(byteArray) - .map(byte => byte.toString(16).padStart(2, '0')) - .join(''); + function toHex(bytes) { + let out = ''; + for (let i = 0; i < bytes.length; i++) { + out += HEX_CHARS[bytes[i] >> 4] + HEX_CHARS[bytes[i] & 15]; + } + return out; + } + + /** + * SHA-256 of a UTF-8 string. + * + * @param {string} str Input string. + * @returns {string} Hex digest. + */ + function sha256Hex(str) { + return toHex(new Sha256().update(new TextEncoder().encode(str)).digest()); } /** - * Check if hash meets difficulty requirement. + * Check if a hash meets the difficulty requirement. * - * @param {string} hash Hash to check. - * @param {number} difficulty Number of leading zeros required. + * @param {string} hash The hash. + * @param {number} difficulty Required leading zeros. * @returns {boolean} */ function meetsDifficulty(hash, difficulty) { @@ -55,62 +233,46 @@ } /** - * Find a valid PoW solution. + * Find a valid PoW solution (unbounded counter). * - * @param {string} challenge Challenge string. - * @param {number} difficulty Required difficulty. - * @returns {Promise} The solution (counter value). + * @param {string} challenge Challenge string (nonce|timestamp|salt). + * @param {number} difficulty Required leading zeros. + * @returns {string} The solution (counter value). */ - async function findSolution(challenge, difficulty) { - let counter = 0; - const batchSize = 1000; - - // eslint-disable-next-line no-constant-condition - while (true) { - const batch = []; - - for (let i = 0; i < batchSize; i++) { - batch.push(counter + i); - } - - const results = await Promise.all( - batch.map(c => { - const input = challenge + c; - return crypto.subtle.digest('SHA-256', stringToUint8Array(input)) - .then(buffer => ({ - counter: c, - hash: bufferToHex(buffer) - })); - }) - ); - - for (const result of results) { - if (meetsDifficulty(result.hash, difficulty)) { - return result.counter.toString(); - } - } - - counter += batchSize; + function findSolution(challenge, difficulty) { + const prefixBytes = new TextEncoder().encode(challenge); + const prefix = '0'.repeat(difficulty); + const encoder = new TextEncoder(); - if (counter > 100000000) { - counter = 0; + let counter = 0; + for (;;) { + /* + * Message layout (unchanged from the WebCrypto era): + * SHA-256(challenge + counter), counter as a bare decimal string. + */ + const digest = new Sha256().update(prefixBytes).update(encoder.encode(String(counter))).digest(); + if (meetsDifficulty(toHex(digest), difficulty)) { + return counter.toString(); } + counter++; } } - self.onmessage = async function(e) { - const { challenge, difficulty } = e.data; +if (typeof self !== 'undefined') { + self.onmessage = function(e) { + const { challenge, difficulty } = e.data; - if (!challenge || !difficulty) { - self.postMessage({ error: 'Missing parameters' }); - return; - } + if (!challenge || !difficulty) { + self.postMessage({ error: 'Missing parameters' }); + return; + } - try { - const solution = await findSolution(challenge, difficulty); + const solution = findSolution(challenge, difficulty); self.postMessage({ solution: solution }); - } catch (error) { - self.postMessage({ error: error.message }); - } - }; + }; + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { Sha256, sha256Hex, toHex, meetsDifficulty, findSolution }; + } })(); diff --git a/includes/class-cardea-admin.php b/includes/class-cardea-admin.php index 1abfdb5..c946af1 100644 --- a/includes/class-cardea-admin.php +++ b/includes/class-cardea-admin.php @@ -164,7 +164,7 @@ class="small-text" />

diff --git a/includes/class-cardea-core.php b/includes/class-cardea-core.php index d4740d3..1c2dd68 100644 --- a/includes/class-cardea-core.php +++ b/includes/class-cardea-core.php @@ -29,8 +29,8 @@ * * Architecture: * - Page load: Generate HMAC-signed challenge (no DB write) - * - Comment submit: Verify signature + PoW solution, then store transient (DB write) - * - Replay prevention: Check transient before accepting, auto-expire via WordPress cron + * - Comment submit: Verify signature + PoW solution, then record the used signature (DB write) + * - Replay prevention: Capped, self-pruning store of used signatures checked before accepting * * @package Cardea */ @@ -45,6 +45,16 @@ class Cardea_Core { const OPTION_DIFFICULTY = 'cardea_difficulty'; const OPTION_TIME_WINDOW = 'cardea_time_window'; + /** + * Option name of the replay store (used signatures). + */ + const USED_OPTION = 'cardea_used'; + + /** + * Maximum number of stored used signatures (self-pruning cap). + */ + const USED_STORE_CAPACITY = 1024; + /** * User-facing verification failure message. * @@ -175,8 +185,7 @@ public function verify_solution( $challenge, $solution ) { ); } - $used_key = 'cardea_used_' . $challenge['signature']; - if ( get_transient( $used_key ) ) { + if ( $this->signature_is_used( $challenge['signature'] ) ) { return new WP_Error( 'cardea_replay', self::failure_message() @@ -193,11 +202,80 @@ public function verify_solution( $challenge, $solution ) { ); } - set_transient( $used_key, true, $time_window ); + $this->record_used_signature( $challenge['signature'], $time_window ); return true; } + /** + * Whether a challenge signature was already used within its validity window. + * + * @param string $signature Challenge signature. + * @return bool + */ + public function signature_is_used( $signature ) { + $now = time(); + + foreach ( $this->get_used_store()['signatures'] as $entry ) { + if ( $entry['signature'] === $signature ) { + return $now - $entry['time'] < $entry['window']; + } + } + + return false; + } + + /** + * Record a used signature in the replay store. + * + * Expired entries are pruned on write and the store is capped so the + * option size stays bounded under any load. + * + * @param string $signature Challenge signature. + * @param int $time_window Validity window in seconds at record time. + * @return void + */ + public function record_used_signature( $signature, $time_window ) { + $store = $this->get_used_store(); + $now = time(); + + $store['signatures'] = array_values( + array_filter( + $store['signatures'], + static function ( $entry ) use ( $now ) { + return $now - $entry['time'] < $entry['window']; + } + ) + ); + + $store['signatures'][] = array( + 'signature' => $signature, + 'time' => $now, + 'window' => $time_window, + ); + + if ( count( $store['signatures'] ) > self::USED_STORE_CAPACITY ) { + $store['signatures'] = array_slice( $store['signatures'], - self::USED_STORE_CAPACITY ); + } + + update_option( self::USED_OPTION, $store, false ); + } + + /** + * Fetch the replay store (used signatures). + * + * @return array + */ + public function get_used_store() { + $store = get_option( self::USED_OPTION, array() ); + + if ( ! is_array( $store ) || ! isset( $store['signatures'] ) || ! is_array( $store['signatures'] ) ) { + $store = array( 'signatures' => array() ); + } + + return $store; + } + /** * Check if a hash meets the difficulty requirement. * diff --git a/readme.txt b/readme.txt index 9ee3e8b..0122431 100644 --- a/readme.txt +++ b/readme.txt @@ -39,7 +39,7 @@ To view the source code, contribute, or report issues, visit the [Cardea GitHub 1. **Challenge Generation**: When a page with a comment form loads, the server generates a cryptographically signed challenge using HMAC-SHA256. No database write occurs at this stage. 2. **Client-Side Mining**: When a user focuses on the comment textarea, a JavaScript Web Worker begins mining in the background. 3. **Solution Discovery**: The worker repeatedly hashes the challenge string (nonce + timestamp + salt) with incrementing counter values until it finds a hash with the required number of leading zeros. -4. **Server Verification**: On submission, the server first verifies the HMAC signature (ensuring the challenge wasn't tampered with), then validates the PoW solution, and finally stores a transient to prevent replay attacks. +4. **Server Verification**: On submission, the server first verifies the HMAC signature (ensuring the challenge wasn't tampered with), then validates the PoW solution, and finally records the used signature in a capped, self-pruning replay store to prevent replay attacks. === Features === @@ -47,7 +47,7 @@ To view the source code, contribute, or report issues, visit the [Cardea GitHub * **Zero Dependencies** - No external APIs or services required. * **Client-Side Mining** - Heavy computation happens in the user's browser using Web Workers. * **Deferred Execution** - The cryptographic mining engine only spins up when a user interacts with the comment field, ensuring casual readers incur zero performance penalty. -* **Self-Cleaning Replay Protection** - Server-side state is only stored upon a successful comment submission to prevent bot replay attacks, and expired tokens are automatically swept by WordPress cron. +* **Self-Cleaning Replay Protection** - Server-side state is only stored upon a successful comment submission to prevent bot replay attacks; expired entries are pruned automatically and the store is capped (1024 signatures). * **Server-Side Verification** - Server verifies HMAC signature first, then performs SHA-256 PoW validation. * **Configurable Difficulty** - Adjust the number of leading zeros required (1-8). * **Configurable Time Window** - Set how long challenges remain valid (5-120 minutes). @@ -69,8 +69,8 @@ Cardea is built with an enterprise-grade engineering stack focused on reliabilit * Skip PoW for logged-in users (zero CPU overhead for authenticated commenters) **Backend Architecture:** -* Localized replay protection using WordPress transients -* Auto-cleaning expired tokens via WordPress cron +* Localized replay protection via a capped replay store (single option, no per-token rows) +* Self-pruning: expired entries are cleared automatically on write (no cron dependency) * Single verification pass: signature check + PoW validation * Single-use tokens: a challenge can be redeemed exactly once, which bounds any interception-style attack to a single comment (standard one-shot-token semantics) @@ -131,7 +131,7 @@ Cardea is built with an enterprise-grade engineering stack focused on reliabilit **Architecture:** * **Zero Database Bloat on Load** - Stateless HMAC signatures ensure zero database writes on page load -* **Self-Cleaning Replay Protection** - Uses WordPress transients that auto-expire via cron +* **Self-Cleaning Replay Protection** - Uses a capped replay store that prunes expired entries automatically * **Deferred Execution** - Mining only starts when user interacts with comment field **Testing Stack:** @@ -154,7 +154,7 @@ Cardea is built with an enterprise-grade engineering stack focused on reliabilit * Web Worker-based client-side mining * Admin settings page * Configurable difficulty and time window -* Self-cleaning replay protection via WordPress transients +* Self-cleaning replay protection via a capped, self-pruning store == Upgrade Notice == diff --git a/tests/js/pow-worker.test.js b/tests/js/pow-worker.test.js index a008853..96f0f24 100644 --- a/tests/js/pow-worker.test.js +++ b/tests/js/pow-worker.test.js @@ -10,196 +10,158 @@ * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. */ /** - * Jest Tests for PoW Worker Mining Logic + * Jest Tests for the PoW Worker + * + * These tests import the real production worker (assets/js/pow-worker.js) + * and verify its SHA-256 implementation and mining loop, cross-checked + * against Node's built-in crypto. The server remains the sole verifier of + * the hash; these tests guarantee the client mines a verifiable solution. * * @package Cardea */ -const crypto = require('crypto'); - -function stringToUint8Array(str) { - return Buffer.from(str, 'utf-8'); +// jsdom (jest) does not expose TextEncoder; browsers and workers always do. +const { TextEncoder, TextDecoder } = require('util'); +if (typeof globalThis.TextEncoder === 'undefined') { + globalThis.TextEncoder = TextEncoder; + globalThis.TextDecoder = TextDecoder; } -function bufferToHex(buffer) { - return Buffer.from(buffer).toString('hex'); -} +const crypto = require('crypto'); +const worker = require('../../assets/js/pow-worker.js'); -function meetsDifficulty(hash, difficulty) { - const prefix = '0'.repeat(difficulty); - return hash.startsWith(prefix); -} +const { sha256Hex, toHex, meetsDifficulty, findSolution } = worker; -async function findSolution(challenge, difficulty) { - let counter = 0; - const maxIterations = 1000000; - const batchSize = 1000; +function referenceSha256(str) { + return crypto.createHash('sha256').update(str, 'utf8').digest('hex'); +} - while (counter < maxIterations) { - const batch = []; - for (let i = 0; i < batchSize; i++) { - batch.push(counter + i); - } +describe('SHA-256 implementation (worker)', () => { + test('FIPS 180-4 known answer test: "abc"', () => { + expect(sha256Hex('abc')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' + ); + }); - const results = await Promise.all( - batch.map(c => { - const input = challenge + c; - return new Promise((resolve) => { - const hash = crypto.createHash('sha256').update(input).digest(); - resolve({ - counter: c, - hash: bufferToHex(hash) - }); - }); - }) + test('FIPS 180-4 known answer test: empty string', () => { + expect(sha256Hex('')).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' ); + }); - for (const result of results) { - if (meetsDifficulty(result.hash, difficulty)) { - return result.counter.toString(); - } + test('matches node:crypto across block boundary lengths', () => { + for (const n of [0, 1, 55, 56, 63, 64, 65, 119, 120, 200, 511, 512, 513, 1024]) { + const s = ('challenge_').repeat(Math.ceil(n / 10) + 1).slice(0, n); + expect(sha256Hex(s)).toBe(referenceSha256(s)); } + }); - counter += batchSize; - } - - throw new Error('Could not find solution within max iterations'); -} - -/** - * Simulate Web Worker message handler logic (extracted from pow-worker.js) - */ -async function handleWorkerMessage(data) { - const { challenge, difficulty } = data; - - if (!challenge || !difficulty) { - return { error: 'Missing parameters' }; - } - - try { - const solution = await findSolution(challenge, difficulty); - return { solution: solution }; - } catch (error) { - return { error: error.message }; - } -} - -describe('PoW Mining Logic', () => { - test('should find solution for difficulty 1', async () => { - const challenge = 'test_challenge_123'; - const difficulty = 1; - - const solution = await findSolution(challenge, difficulty); - - const hash = crypto.createHash('sha256').update(challenge + solution).digest('hex'); - expect(meetsDifficulty(hash, difficulty)).toBe(true); - }, 10000); - - test('should find solution for difficulty 2', async () => { - const challenge = 'test_challenge_456'; - const difficulty = 2; - - const solution = await findSolution(challenge, difficulty); + test('matches node:crypto on a production-shaped challenge (55 chars)', () => { + const challenge = 'AbC123defGHI456jklMNO789pqrsTUV012wxyzABC'; // 38 chars, 1-4 chars of padding variants below + for (const extra of ['0', '00', '0123456789', 'x'.repeat(20)]) { + const s = (challenge + extra).slice(0, 55); + expect(sha256Hex(s)).toBe(referenceSha256(s)); + } + }); - const hash = crypto.createHash('sha256').update(challenge + solution).digest('hex'); - expect(meetsDifficulty(hash, difficulty)).toBe(true); - }, 30000); + test('toHex renders bytes as lowercase hex', () => { + expect(toHex(new Uint8Array([0x00, 0x0a, 0x7f, 0xff]))).toBe('000a7fff'); + }); +}); - test('should verify solution correctly', () => { +describe('difficulty check (worker)', () => { + test('accepts hashes with the required leading zeros', () => { expect(meetsDifficulty('0abc123', 1)).toBe(true); expect(meetsDifficulty('00abc123', 2)).toBe(true); + }); + + test('rejects hashes without the required leading zeros', () => { expect(meetsDifficulty('0abc123', 2)).toBe(false); expect(meetsDifficulty('1abc123', 1)).toBe(false); }); +}); - test('should reject invalid hash for difficulty', () => { - const hash = '1a2b3c4d5e6f7890'; - - expect(meetsDifficulty(hash, 1)).toBe(false); - expect(meetsDifficulty(hash, 0)).toBe(true); - }); +describe('mining (worker findSolution)', () => { + /** + * Verify a worker solution the same way the PHP server does: + * SHA-256(challengeString + solution) must meet the difficulty. + */ + function assertServerVerifiable(challenge, solution, difficulty) { + const hash = referenceSha256(challenge + solution); + expect(hash).toMatch(new RegExp('^' + '0'.repeat(difficulty))); + expect(Number.isFinite(Number(solution))).toBe(true); + } - test('should handle string to buffer conversion', () => { - const str = 'hello'; - const arr = stringToUint8Array(str); - - expect(Buffer.isBuffer(arr)).toBe(true); - expect(arr.length).toBe(5); - expect(arr[0]).toBe(104); // 'h' ASCII + [1, 2, 3, 4].forEach((difficulty) => { + test(`finds a server-verifiable solution at difficulty ${difficulty}`, () => { + const challenge = 'test_nonce|1699999999|testsalt'; + const solution = findSolution(challenge, difficulty); + assertServerVerifiable(challenge, solution, difficulty); + }, 15000); }); - test('should handle buffer to hex conversion', () => { - const buffer = Buffer.from('abc', 'utf-8'); - const hex = bufferToHex(buffer); - - expect(hex).toBe('616263'); + test('produces deterministic solutions for the same challenge', () => { + const challenge = 'det_nonce|1700000000|detsalt'; + expect(findSolution(challenge, 2)).toBe(findSolution(challenge, 2)); }); }); -describe('Web Worker Message Interface', () => { - test('should return solution for valid challenge and difficulty', async () => { - const result = await handleWorkerMessage({ - challenge: 'test_challenge_789', - difficulty: 1 - }); - - expect(result).toHaveProperty('solution'); - expect(result).not.toHaveProperty('error'); - - // Verify the solution is valid - const hash = crypto.createHash('sha256') - .update('test_challenge_789' + result.solution) - .digest('hex'); - expect(meetsDifficulty(hash, 1)).toBe(true); - }, 10000); +describe('worker message interface', () => { + /** + * Simulate a Web Worker host: the production worker attaches its handler + * to self.onmessage and delivers results via self.postMessage. + */ + function runWorkerMessage(message) { + const posted = []; + const originalPostMessage = self.postMessage; + self.postMessage = (msg) => { posted.push(msg); }; + const handler = self.onmessage; + try { + expect(handler).toBeDefined(); + handler({ data: message }); + } finally { + self.postMessage = originalPostMessage; + } + return posted; + } - test('should return error for missing challenge', async () => { - const result = await handleWorkerMessage({ + test('posts a solution for a valid challenge and difficulty', () => { + const posted = runWorkerMessage({ + challenge: 'iface_nonce|1699999999|ifacesalt', difficulty: 1 }); - expect(result).toHaveProperty('error'); - expect(result.error).toBe('Missing parameters'); - }); + expect(posted).toHaveLength(1); + expect(posted[0]).toHaveProperty('solution'); + expect(posted[0]).not.toHaveProperty('error'); + assertServerVerifiableLocally(posted[0].solution); - test('should return error for missing difficulty', async () => { - const result = await handleWorkerMessage({ - challenge: 'test_challenge_123' - }); - - expect(result).toHaveProperty('error'); - expect(result.error).toBe('Missing parameters'); + function assertServerVerifiableLocally(solution) { + const hash = referenceSha256('iface_nonce|1699999999|ifacesalt' + solution); + expect(hash).toMatch(/^0/); + } }); - test('should return error for empty challenge', async () => { - const result = await handleWorkerMessage({ - challenge: '', - difficulty: 1 - }); - - expect(result).toHaveProperty('error'); - expect(result.error).toBe('Missing parameters'); + test('posts an error when the challenge is missing', () => { + const posted = runWorkerMessage({ difficulty: 1 }); + expect(posted).toHaveLength(1); + expect(posted[0]).toEqual({ error: 'Missing parameters' }); }); - test('should return error for empty difficulty', async () => { - const result = await handleWorkerMessage({ - challenge: 'test_challenge_123', - difficulty: 0 - }); - - expect(result).toHaveProperty('error'); - expect(result.error).toBe('Missing parameters'); + test('posts an error when the difficulty is missing', () => { + const posted = runWorkerMessage({ challenge: 'x' }); + expect(posted).toHaveLength(1); + expect(posted[0]).toEqual({ error: 'Missing parameters' }); }); - test('should handle empty data object', async () => { - const result = await handleWorkerMessage({}); - - expect(result).toHaveProperty('error'); - expect(result.error).toBe('Missing parameters'); + test('posts an error for an empty challenge', () => { + const posted = runWorkerMessage({ challenge: '', difficulty: 1 }); + expect(posted).toHaveLength(1); + expect(posted[0]).toEqual({ error: 'Missing parameters' }); }); }); diff --git a/uninstall.php b/uninstall.php index 3d59983..1eef0a9 100644 --- a/uninstall.php +++ b/uninstall.php @@ -10,12 +10,14 @@ exit; } -// 1. Delete the static plugin options. +// 1. Delete the plugin options (difficulty, time window, replay store). delete_option( 'cardea_difficulty' ); delete_option( 'cardea_time_window' ); +delete_option( 'cardea_used' ); -// 2. Delete all replay-protection transients. -// Transients are stored in the wp_options table with specific prefixes. +// 2. Delete all legacy replay-protection transients. +// Older versions stored one transient per used signature; those keys are +// still swept so uninstalling after an upgrade leaves no residue. global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching From 58046a5c7450b921372b90a945517b64c1917245 Mon Sep 17 00:00:00 2001 From: omikheev Date: Thu, 3 Sep 2026 17:35:55 -0400 Subject: [PATCH 2/2] test: settle pending post-submission redirect before replay-test navigation (CI flake) --- tests/e2e/pow-comment.spec.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/e2e/pow-comment.spec.js b/tests/e2e/pow-comment.spec.js index 5933408..5bd5b36 100644 --- a/tests/e2e/pow-comment.spec.js +++ b/tests/e2e/pow-comment.spec.js @@ -190,6 +190,12 @@ test('should reject tampered signature', async ({ page }) => { await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(2000); + // Settle the in-page post-submission redirect (WordPress sends the page + // to ?unapproved=... after a pending comment) before navigating, so + // page.goto below cannot collide with a navigation in flight on slow + // runners. + await page.waitForLoadState('load'); + await page.goto(`${cli.serverUrl}/?p=1`); await expect(page.locator('#commentform')).toBeVisible();