Various improvements to remote link login. PoW algorithm tuned. Added base64url and helper modules.
This commit is contained in:
@@ -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(/=+$/, '')
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user