Various improvements to remote link login. PoW algorithm tuned. Added base64url and helper modules.

This commit is contained in:
Leo Vasanko
2025-12-07 20:12:38 +00:00
parent 4bb64863f9
commit a54d872b46
12 changed files with 665 additions and 703 deletions
+33
View File
@@ -0,0 +1,33 @@
/**
* URL-safe Base64 encoding/decoding utilities.
*
* These functions handle base64url format (RFC 4648) which uses:
* - '-' instead of '+'
* - '_' instead of '/'
* - No padding '=' characters
*/
/**
* Decode a base64url string to Uint8Array.
* Handles both standard base64 and URL-safe base64 (with or without padding).
* @param {string} str - Base64url encoded string
* @returns {Uint8Array} - Decoded bytes
*/
export function dec(str) {
// Convert URL-safe characters to standard base64
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
// Add padding if needed
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
return Uint8Array.from(atob(padded), c => c.charCodeAt(0))
}
/**
* Encode a Uint8Array to base64url string.
* @param {Uint8Array} bytes - Bytes to encode
* @returns {string} - Base64url encoded string (no padding)
*/
export function enc(bytes) {
const base64 = btoa(String.fromCharCode(...bytes))
// Convert to URL-safe and remove padding
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
+8 -17
View File
@@ -1,46 +1,37 @@
import { solvePoW, verifyPoW, nonceToNumber } from './pow.js'
import { solvePoW, verifyPoW } from './pow.js'
const TRIALS = 20
const REQUIRED_BITS = 18
const TRIALS = 5
const WORK = 10
async function test() {
console.log(`Running ${TRIALS} trials with ${REQUIRED_BITS} required zero bits...\n`)
console.log(`Running ${TRIALS} trials with ${WORK} work units...\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 solution = await solvePoW(challenge, WORK)
const elapsed = performance.now() - start
const valid = await verifyPoW(challenge, nonce, REQUIRED_BITS)
const nonceVal = nonceToNumber(nonce)
const valid = await verifyPoW(challenge, solution, WORK)
times.push(elapsed)
iterations.push(Number(nonceVal))
console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, nonce=${nonceVal}, valid=${valid}`)
console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, 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(`Work units: ${WORK}`)
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()
+41 -115
View File
@@ -1,23 +1,23 @@
/**
* Proof of Work utility using SHA-512
* Proof of Work utility using PBKDF2-SHA512
*
* The PoW challenge requires finding a nonce such that
* SHA-512(challenge || nonce) has a specified number of leading zero bits.
* The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
* output with a zero first byte. Each work unit requires finding one such nonce.
* All valid nonces are concatenated into a solution for server verification.
*/
/**
* 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 {number} work - Number of PBKDF2 work units required
* @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)
* @returns {Promise<Uint8Array>} Solution: concatenated 8-byte nonces (8 * work bytes)
* @throws {Error} If challenge is invalid or operation is aborted
*/
export async function solvePoW(challenge, requiredZeroBits, options = {}) {
const { signal, onProgress } = options
export async function solvePoW(challenge, work, options = {}) {
const { signal } = options
const startTime = performance.now()
// Validate inputs
@@ -29,114 +29,40 @@ export async function solvePoW(challenge, requiredZeroBits, options = {}) {
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')
// Import challenge as PBKDF2 key material
const baseKey = await crypto.subtle.importKey('raw', challengeBytes, 'PBKDF2', false, ['deriveBits'])
// Build solution from found nonces
const solution = new Uint8Array(8 * work)
let totalIterations = 0
const mask = 0x7FF // The client must work 2048x harder than the server
// Sequential nonce starting at zero (little-endian, using Uint32Array for efficient increment)
const nonce = new Uint32Array(2)
for (let i = 0; i < work; i++) {
if (signal?.aborted) {
throw new DOMException('PoW operation aborted', 'AbortError')
}
// Find a nonce where PBKDF2 output passes the mask check
let result
do {
totalIterations++
if (++nonce[0] === 0x100000000) ++nonce[1] // Increment 64-bit little-endian nonce
result = new Uint32Array(await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: nonce, iterations: 128, hash: 'SHA-512'},
baseKey,
32
))
} while (result[0] & mask)
solution.set(new Uint8Array(nonce.buffer), i * 8)
}
// 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
const expectedIterations = work * (mask + 1)
const luckRatio = (totalIterations / expectedIterations).toFixed(1)
const bench = totalIterations / ((mask + 1) * elapsed)
console.log(`PoW work=${work} solved in ${elapsed.toFixed(2)}s (${luckRatio}x expected ${bench.toFixed(1)} work/s)`)
return solution
}