132 lines
3.7 KiB
JavaScript
132 lines
3.7 KiB
JavaScript
// Proof-of-Work Captcha Service
|
|
// Client must find nonce where SHA256(challenge + nonce) has N leading zeros
|
|
// Server-first: tries /pow/challenge endpoint, falls back to local generation
|
|
|
|
const POW_SERVER = 'https://pow.dgray.io'
|
|
const DIFFICULTY = 4
|
|
const SERVER_TIMEOUT_MS = 1500
|
|
|
|
const LOCAL_HMAC_KEY = 'dgray-pow-local-v1'
|
|
|
|
async function hmacSign(message) {
|
|
const enc = new TextEncoder()
|
|
const key = await crypto.subtle.importKey(
|
|
'raw', enc.encode(LOCAL_HMAC_KEY), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
|
)
|
|
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message))
|
|
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
}
|
|
|
|
async function hmacVerify(message, signature) {
|
|
const expected = await hmacSign(message)
|
|
return expected === signature
|
|
}
|
|
|
|
async function localGenerateChallenge() {
|
|
const challenge = crypto.randomUUID()
|
|
const timestamp = Date.now()
|
|
const signature = await hmacSign(`${challenge}:${timestamp}:${DIFFICULTY}`)
|
|
return {
|
|
challenge,
|
|
difficulty: DIFFICULTY,
|
|
timestamp,
|
|
signature,
|
|
source: 'local'
|
|
}
|
|
}
|
|
|
|
export async function generateChallenge() {
|
|
try {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), SERVER_TIMEOUT_MS)
|
|
|
|
const response = await fetch(`${POW_SERVER}/challenge`, {
|
|
method: 'GET',
|
|
signal: controller.signal
|
|
})
|
|
clearTimeout(timeout)
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
|
|
const data = await response.json()
|
|
return { ...data, source: 'server' }
|
|
} catch {
|
|
return localGenerateChallenge()
|
|
}
|
|
}
|
|
|
|
export async function verifySolution(solution) {
|
|
if (solution.source === 'server') {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), SERVER_TIMEOUT_MS)
|
|
|
|
try {
|
|
const response = await fetch(`${POW_SERVER}/verify`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(solution),
|
|
signal: controller.signal
|
|
})
|
|
clearTimeout(timeout)
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
|
|
const data = await response.json()
|
|
return { ok: data.ok === true, token: data.captcha_token || null }
|
|
} catch {
|
|
clearTimeout(timeout)
|
|
return { ok: false, token: null }
|
|
}
|
|
}
|
|
|
|
const maxAge = 5 * 60 * 1000
|
|
if (!solution.timestamp || Date.now() - solution.timestamp > maxAge) {
|
|
return { ok: false, token: null }
|
|
}
|
|
|
|
const sigValid = await hmacVerify(
|
|
`${solution.challenge}:${solution.timestamp}:${solution.difficulty || DIFFICULTY}`,
|
|
solution.signature
|
|
)
|
|
if (!sigValid) return { ok: false, token: null }
|
|
|
|
const hash = await sha256(`${solution.challenge}${solution.nonce}`)
|
|
const prefix = '0'.repeat(solution.difficulty || DIFFICULTY)
|
|
return { ok: hash.startsWith(prefix), token: null }
|
|
}
|
|
|
|
export async function solveChallenge(challenge, difficulty, onProgress) {
|
|
let nonce = 0
|
|
const prefix = '0'.repeat(difficulty)
|
|
const startTime = Date.now()
|
|
|
|
while (true) {
|
|
const hash = await sha256(`${challenge}${nonce}`)
|
|
|
|
if (hash.startsWith(prefix)) {
|
|
return {
|
|
nonce,
|
|
hash,
|
|
duration: Date.now() - startTime
|
|
}
|
|
}
|
|
|
|
nonce++
|
|
|
|
if (onProgress && nonce % 1000 === 0) {
|
|
onProgress({ attempts: nonce, elapsed: Date.now() - startTime })
|
|
}
|
|
|
|
if (nonce % 100 === 0) {
|
|
await new Promise(r => setTimeout(r, 0))
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sha256(message) {
|
|
const msgBuffer = new TextEncoder().encode(message)
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
}
|