Proper PoW, unified verification WS etc

This commit is contained in:
Leo Vasanko committed 2025-12-07 04:12:11 +00:00
1 parent 9485cbd201
commit 4bb64863f9
8 files changed
+1014 -179

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
import { solvePoW, verifyPoW, nonceToNumber } from './pow.js'
const TRIALS = 20
const REQUIRED_BITS = 18
async function test() {
console.log(`Running ${TRIALS} trials with ${REQUIRED_BITS} required zero bits...\n`)
const times = []
const iterations = []
for (let trial = 1; trial <= TRIALS; trial++) {
const challenge = crypto.getRandomValues(new Uint8Array(8))
const start = performance.now()
let lastIterations = 0
const nonce = await solvePoW(challenge, REQUIRED_BITS, {
onProgress: (n) => { lastIterations = n }
})
const elapsed = performance.now() - start
const valid = await verifyPoW(challenge, nonce, REQUIRED_BITS)
const nonceVal = nonceToNumber(nonce)
times.push(elapsed)
iterations.push(Number(nonceVal))
console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, nonce=${nonceVal}, valid=${valid}`)
}
const avgTime = times.reduce((a, b) => a + b, 0) / times.length
const avgIter = iterations.reduce((a, b) => a + b, 0) / iterations.length
const minTime = Math.min(...times)
const maxTime = Math.max(...times)
console.log('\n--- Summary ---')
console.log(`Trials: ${TRIALS}`)
console.log(`Required bits: ${REQUIRED_BITS}`)
console.log(`Avg time: ${(avgTime / 1000).toFixed(3)}s`)
console.log(`Min time: ${(minTime / 1000).toFixed(3)}s`)
console.log(`Max time: ${(maxTime / 1000).toFixed(3)}s`)
console.log(`Avg iterations: ${Math.round(avgIter)}`)
console.log(`Expected iterations: ~${Math.pow(2, REQUIRED_BITS)}`)
}
test()
+142
View File
@@ -0,0 +1,142 @@
/**
* Proof of Work utility using SHA-512
*
* The PoW challenge requires finding a nonce such that
* SHA-512(challenge || nonce) has a specified number of leading zero bits.
*/
/**
* Solve a Proof of Work challenge
*
* @param {Uint8Array|ArrayBuffer} challenge - 8-byte server-provided challenge
* @param {number} requiredZeroBits - Number of leading zero bits required (1-32)
* @param {object} [options] - Optional parameters
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the operation
* @param {function} [options.onProgress] - Callback for progress updates (iterations count)
* @returns {Promise<Uint8Array>} The successful 8-byte nonce (little-endian)
* @throws {Error} If challenge is invalid or operation is aborted
*/
export async function solvePoW(challenge, requiredZeroBits, options = {}) {
const { signal, onProgress } = options
const startTime = performance.now()
// Validate inputs
const challengeBytes = challenge instanceof ArrayBuffer
? new Uint8Array(challenge)
: challenge
if (!(challengeBytes instanceof Uint8Array) || challengeBytes.length !== 8) {
throw new Error('Challenge must be exactly 8 bytes')
}
if (requiredZeroBits < 1 || requiredZeroBits > 32) {
throw new Error('Required zero bits must be between 1 and 32')
}
// Prepare the buffer: challenge (8 bytes) || nonce (8 bytes)
const data = new Uint8Array(16)
data.set(challengeBytes, 0)
// Nonce area data[8..15] starts at zero
// Precalculate mask and byte count for the required zero bits
const fullZeroBytes = requiredZeroBits >>> 3
const remainingBits = requiredZeroBits & 7
const partialMask = remainingBits ? (0xFF << (8 - remainingBits)) & 0xFF : 0
while (true) {
// Increment 64-bit little-endian nonce
for (let i = 8; i < 16; i++) if (++data[i] !== 256) break
// Calculate SHA-512 hash and verify leading zero bits
const hash = new Uint8Array(await crypto.subtle.digest('SHA-512', data))
if (hash[0]) continue // Quick check - most hashes fail here
// Yield to UI periodically and check abort signal
if (!(hash[1] & 0x0F)) {
await new Promise(r => setTimeout(r, 0))
if (signal?.aborted) throw new DOMException('PoW operation aborted', 'AbortError')
}
// Check all full zero bytes
let valid = true
for (let i = 1; i < fullZeroBytes; i++) {
if (hash[i]) { valid = false; break }
}
if (!valid) continue
if (partialMask && (hash[fullZeroBytes] & partialMask)) continue
break
}
const iterations = Number(nonceToNumber(data.slice(8, 16)))
const elapsed = (performance.now() - startTime) / 1000
const iterPerSec = Math.round(iterations / elapsed)
console.log(`PoW solved in ${elapsed.toFixed(1)}s (${(iterPerSec / 1e3).toFixed(0)} k-it/s)`)
return data.slice(8, 16)
}
/**
* Verify a PoW solution
*
* @param {Uint8Array|ArrayBuffer} challenge - 8-byte server-provided challenge
* @param {Uint8Array|ArrayBuffer} nonce - 8-byte client-provided nonce
* @param {number} requiredZeroBits - Number of leading zero bits required
* @returns {Promise<boolean>} True if the solution is valid
*/
export async function verifyPoW(challenge, nonce, requiredZeroBits) {
const challengeBytes = challenge instanceof ArrayBuffer
? new Uint8Array(challenge)
: challenge
const nonceBytes = nonce instanceof ArrayBuffer
? new Uint8Array(nonce)
: nonce
if (challengeBytes.length !== 8 || nonceBytes.length !== 8) {
return false
}
const data = new Uint8Array(16)
data.set(challengeBytes, 0)
data.set(nonceBytes, 8)
const hashBuffer = await crypto.subtle.digest('SHA-512', data)
const hash = new Uint8Array(hashBuffer)
// Check leading zero bits using same logic as solvePoW
const fullZeroBytes = requiredZeroBits >>> 3
const remainingBits = requiredZeroBits & 7
const partialMask = remainingBits ? (0xFF << (8 - remainingBits)) & 0xFF : 0
for (let i = 0; i < fullZeroBytes; i++) {
if (hash[i] !== 0) return false
}
if (partialMask && (hash[fullZeroBytes] & partialMask) !== 0) {
return false
}
return true
}
/**
* Convert a nonce Uint8Array to a BigInt (little-endian)
* @param {Uint8Array} nonce - 8-byte little-endian nonce
* @returns {bigint} The nonce as a BigInt
*/
export function nonceToNumber(nonce) {
let value = 0n
for (let i = 7; i >= 0; i--) {
value = (value << 8n) | BigInt(nonce[i])
}
return value
}
/**
* Convert a BigInt to a nonce Uint8Array (little-endian)
* @param {bigint} value - The value to convert
* @returns {Uint8Array} 8-byte little-endian representation
*/
export function numberToNonce(value) {
const nonce = new Uint8Array(8)
let v = BigInt(value)
for (let i = 0; i < 8; i++) {
nonce[i] = Number(v & 0xFFn)
v >>= 8n
}
return nonce
}
+61
View File
@@ -0,0 +1,61 @@
// Auto-generated from paskia/util/wordlist.py - do not edit manually
// A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words
export const words = ["able", "about", "absent", "abuse", "access", "acid", "across", "act", "adapt", "add", "adjust", "admit", "adult", "advice", "affair", "afraid", "again", "age", "agree", "ahead", "aim", "air", "aisle", "alarm", "album", "alert", "alien", "all", "almost", "alone", "alpha", "also", "alter", "always", "amazed", "among", "amused", "anchor", "angle", "animal", "ankle", "annual", "answer", "any", "apart", "appear", "april", "arch", "are", "argue", "army", "around", "array", "art", "ascent", "ash", "ask", "aspect", "assume", "asthma", "atom", "attack", "audit", "august", "aunt", "author", "avoid", "away", "awful", "axis", "baby", "back", "bad", "bag", "ball", "bamboo", "bank", "bar", "base", "battle", "beach", "become", "beef", "before", "begin", "behind", "below", "bench", "best", "better", "beyond", "bid", "bike", "bind", "bio", "birth", "bitter", "black", "bleak", "blind", "blood", "blue", "board", "body", "boil", "bomb", "bone", "book", "border", "boss", "bottom", "bounce", "bowl", "box", "boy", "brain", "bread", "bring", "brown", "brush", "bubble", "buck", "budget", "build", "bulk", "bundle", "burden", "bus", "but", "buyer", "buzz", "cable", "cache", "cage", "cake", "call", "came", "can", "car", "case", "catch", "cause", "cave", "celery", "cement", "census", "cereal", "change", "check", "child", "choice", "chunk", "cigar", "circle", "city", "civil", "class", "clean", "client", "close", "club", "coast", "code", "coffee", "coil", "cold", "come", "cool", "copy", "core", "cost", "cotton", "couch", "cover", "coyote", "craft", "cream", "crime", "cross", "cruel", "cry", "cube", "cue", "cult", "cup", "curve", "custom", "cute", "cycle", "dad", "damage", "danger", "daring", "dash", "dawn", "day", "deal", "debate", "decide", "deer", "define", "degree", "deity", "delay", "demand", "denial", "depth", "derive", "design", "detail", "device", "dial", "dice", "die", "differ", "dim", "dinner", "direct", "dish", "divert", "dizzy", "doctor", "dog", "dollar", "domain", "donate", "door", "dose", "double", "dove", "draft", "dream", "drive", "drop", "drum", "dry", "duck", "dumb", "dune", "during", "dust", "dutch", "dwarf", "eager", "early", "east", "echo", "eco", "edge", "edit", "effort", "egg", "eight", "either", "elbow", "elder", "elite", "else", "embark", "emerge", "emily", "employ", "enable", "end", "enemy", "engine", "enjoy", "enlist", "enough", "enrich", "ensure", "entire", "envy", "equal", "era", "erode", "error", "erupt", "escape", "essay", "estate", "ethics", "evil", "evoke", "exact", "excess", "exist", "exotic", "expect", "extent", "eye", "fabric", "face", "fade", "faith", "fall", "family", "fan", "far", "father", "fault", "feel", "female", "fence", "fetch", "fever", "few", "fiber", "field", "figure", "file", "find", "first", "fish", "fit", "fix", "flat", "flesh", "flight", "float", "fluid", "fly", "foam", "focus", "fog", "foil", "follow", "food", "force", "fossil", "found", "fox", "frame", "fresh", "friend", "frog", "fruit", "fuel", "fun", "fury", "future", "gadget", "gain", "galaxy", "game", "gap", "garden", "gas", "gate", "gauge", "gaze", "genius", "ghost", "giant", "gift", "giggle", "ginger", "girl", "give", "glass", "glide", "globe", "glue", "goal", "god", "gold", "good", "gospel", "govern", "gown", "grant", "great", "grid", "group", "grunt", "guard", "guess", "guide", "gulf", "gun", "gym", "habit", "hair", "half", "hammer", "hand", "happy", "hard", "hat", "have", "hawk", "hay", "hazard", "head", "hedge", "height", "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", "hire", "hobby", "hockey", "hold", "home", "honey", "hood", "hope", "horse", "host", "hotel", "hour", "hover", "how", "hub", "huge", "human", "hungry", "hurt", "hybrid", "ice", "icon", "idea", "idle", "ignore", "ill", "image", "immune", "impact", "income", "index", "infant", "inhale", "inject", "inmate", "inner", "input", "inside", "into", "invest", "iron", "island", "issue", "italy", "item", "ivory", "jacket", "jaguar", "james", "jar", "jazz", "jeans", "jelly", "jewel", "job", "joe", "joke", "joy", "judge", "juice", "july", "jump", "june", "just", "kansas", "kate", "keep", "kernel", "key", "kick", "kid", "kind", "kiss", "kit", "kiwi", "knee", "knife", "know", "labor", "lady", "lag", "lake", "lamp", "laptop", "large", "later", "laugh", "lava", "law", "layer", "lazy", "leader", "left", "legal", "lemon", "length", "lesson", "letter", "level", "liar", "libya", "lid", "life", "light", "like", "limit", "line", "lion", "liquid", "list", "little", "live", "lizard", "load", "local", "logic", "long", "loop", "lost", "loud", "love", "low", "loyal", "lucky", "lumber", "lunch", "lust", "luxury", "lyrics", "mad", "magic", "main", "major", "make", "male", "mammal", "man", "map", "market", "mass", "matter", "maze", "mccoy", "meadow", "media", "meet", "melt", "member", "men", "mercy", "mesh", "method", "middle", "milk", "mimic", "mind", "mirror", "miss", "mix", "mobile", "model", "mom", "monkey", "moon", "more", "mother", "mouse", "move"Line truncated
// Pre-computed prefix map for autocomplete (maps 1-3 char prefixes to matching words)
const prefixMap = new Map();
// Build prefix map on module load
for (const word of words) {
for (let i = 1; i <= Math.min(word.length, 6); i++) {
const prefix = word.slice(0, i);
if (!prefixMap.has(prefix)) {
prefixMap.set(prefix, []);
}
prefixMap.get(prefix).push(word);
}
}
/**
* Find words matching the given prefix.
* @param {string} prefix - The prefix to match (case-insensitive)
* @returns {string[]} Array of matching words
*/
export function findMatches(prefix) {
if (!prefix) return [];
const normalized = prefix.toLowerCase();
return prefixMap.get(normalized) || [];
}
/**
* Get unique autocomplete suggestion if prefix matches exactly one word.
* @param {string} prefix - The prefix to match (case-insensitive)
* @returns {string|null} The matching word if unique, null otherwise
*/
export function getUniqueMatch(prefix) {
const matches = findMatches(prefix);
return matches.length === 1 ? matches[0] : null;
}
/**
* Check if a word is valid (exists in the wordlist).
* @param {string} word - The word to check (case-insensitive)
* @returns {boolean} True if the word is valid
*/
export function isValidWord(word) {
if (!word) return false;
const normalized = word.toLowerCase();
return words.includes(normalized);
}
/**
* Check if a string is a valid prefix (could be the start of a valid word).
* @param {string} prefix - The prefix to check (case-insensitive)
* @returns {boolean} True if the prefix matches at least one word
*/
export function isValidPrefix(prefix) {
if (!prefix) return true; // Empty is always valid
const normalized = prefix.toLowerCase();
return prefixMap.has(normalized);
}