add pow-captcha
This commit is contained in:
@@ -4,6 +4,7 @@ import { auth } from '../../services/auth.js'
|
||||
import { directus } from '../../services/directus.js'
|
||||
import { SUPPORTED_CURRENCIES } from '../../services/currency.js'
|
||||
import '../location-picker.js'
|
||||
import '../pow-captcha.js'
|
||||
|
||||
const STORAGE_KEY = 'dgray_create_draft'
|
||||
|
||||
@@ -247,6 +248,10 @@ class PageCreate extends HTMLElement {
|
||||
>
|
||||
<p class="field-hint">${t('create.moneroHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<pow-captcha id="pow-captcha"></pow-captcha>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-outline btn-lg" id="cancel-btn">
|
||||
@@ -380,6 +385,13 @@ class PageCreate extends HTMLElement {
|
||||
e.preventDefault()
|
||||
|
||||
if (this.submitting) return
|
||||
|
||||
// Validate PoW Captcha
|
||||
const captcha = this.querySelector('#pow-captcha')
|
||||
if (!captcha?.isSolved()) {
|
||||
this.showError(t('captcha.error'))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate Monero address
|
||||
if (this.formData.moneroAddress && !this.validateMoneroAddress(this.formData.moneroAddress)) {
|
||||
|
||||
156
js/components/pow-captcha.js
Normal file
156
js/components/pow-captcha.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// PoW Captcha Web Component
|
||||
import { generateChallenge, solveChallenge } from '../services/pow-captcha.js'
|
||||
import { t, i18n } from '../i18n.js'
|
||||
|
||||
export class PowCaptcha extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
this.solved = false
|
||||
this.solution = null
|
||||
this.unsubscribe = null
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render()
|
||||
this.unsubscribe = i18n.subscribe(() => this.render())
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.unsubscribe) this.unsubscribe()
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="pow-captcha">
|
||||
<div class="pow-captcha-status">
|
||||
${this.solved
|
||||
? `<span class="pow-captcha-success">✓ ${t('captcha.verified')}</span>`
|
||||
: `<button class="pow-captcha-btn" type="button">${t('captcha.verify')}</button>`
|
||||
}
|
||||
</div>
|
||||
<div class="pow-captcha-progress" style="display: none;">
|
||||
<div class="pow-captcha-progress-bar"></div>
|
||||
<span class="pow-captcha-progress-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
if (!this.solved) {
|
||||
this.querySelector('.pow-captcha-btn').addEventListener('click', () => this.solve())
|
||||
}
|
||||
}
|
||||
|
||||
async solve() {
|
||||
const btn = this.querySelector('.pow-captcha-btn')
|
||||
const progress = this.querySelector('.pow-captcha-progress')
|
||||
const progressBar = this.querySelector('.pow-captcha-progress-bar')
|
||||
const progressText = this.querySelector('.pow-captcha-progress-text')
|
||||
|
||||
btn.disabled = true
|
||||
btn.textContent = t('captcha.solving')
|
||||
progress.style.display = 'flex'
|
||||
|
||||
try {
|
||||
// Generate challenge (in production, fetch from server)
|
||||
const { challenge, difficulty, timestamp, signature } = generateChallenge()
|
||||
|
||||
// Solve with progress updates
|
||||
const result = await solveChallenge(challenge, difficulty, ({ attempts, elapsed }) => {
|
||||
const estimatedTotal = Math.pow(16, difficulty) / 2
|
||||
const percent = Math.min((attempts / estimatedTotal) * 100, 95)
|
||||
progressBar.style.width = `${percent}%`
|
||||
progressText.textContent = `${attempts.toLocaleString()} ${t('captcha.attempts')}`
|
||||
})
|
||||
|
||||
// Store solution for form submission
|
||||
this.solution = {
|
||||
challenge,
|
||||
nonce: result.nonce,
|
||||
signature,
|
||||
timestamp
|
||||
}
|
||||
|
||||
this.solved = true
|
||||
this.dispatchEvent(new CustomEvent('solved', { detail: this.solution }))
|
||||
this.render()
|
||||
|
||||
} catch (error) {
|
||||
btn.disabled = false
|
||||
btn.textContent = t('captcha.error')
|
||||
console.error('PoW Captcha error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get solution for form submission
|
||||
getSolution() {
|
||||
return this.solution
|
||||
}
|
||||
|
||||
// Check if solved
|
||||
isSolved() {
|
||||
return this.solved
|
||||
}
|
||||
|
||||
// Reset captcha
|
||||
reset() {
|
||||
this.solved = false
|
||||
this.solution = null
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('pow-captcha', PowCaptcha)
|
||||
|
||||
// Add styles
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
.pow-captcha {
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.pow-captcha-btn {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.pow-captcha-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.pow-captcha-success {
|
||||
color: var(--color-success, #22c55e);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pow-captcha-progress {
|
||||
margin-top: var(--space-sm);
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.pow-captcha-progress-bar {
|
||||
height: 4px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
width: 0%;
|
||||
transition: width 0.1s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pow-captcha-progress-text {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
min-width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
81
js/services/pow-captcha.js
Normal file
81
js/services/pow-captcha.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Proof-of-Work Captcha Service
|
||||
// Client must find nonce where SHA256(challenge + nonce) has N leading zeros
|
||||
|
||||
const DIFFICULTY = 4 // Number of leading zeros required (4 = ~65k attempts avg)
|
||||
const CHALLENGE_EXPIRY = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
// Generate a challenge (call this from your API/backend)
|
||||
export function generateChallenge() {
|
||||
const challenge = crypto.randomUUID()
|
||||
const timestamp = Date.now()
|
||||
return {
|
||||
challenge,
|
||||
difficulty: DIFFICULTY,
|
||||
timestamp,
|
||||
// Sign to prevent tampering (simple HMAC alternative)
|
||||
signature: btoa(`${challenge}:${timestamp}:${DIFFICULTY}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify solution (call this from your API/backend)
|
||||
export async function verifySolution(challenge, nonce, signature, timestamp) {
|
||||
// Check expiry
|
||||
if (Date.now() - timestamp > CHALLENGE_EXPIRY) {
|
||||
return { valid: false, error: 'Challenge expired' }
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const expectedSig = btoa(`${challenge}:${timestamp}:${DIFFICULTY}`)
|
||||
if (signature !== expectedSig) {
|
||||
return { valid: false, error: 'Invalid signature' }
|
||||
}
|
||||
|
||||
// Verify PoW
|
||||
const hash = await sha256(`${challenge}${nonce}`)
|
||||
const prefix = '0'.repeat(DIFFICULTY)
|
||||
|
||||
if (hash.startsWith(prefix)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return { valid: false, error: 'Invalid proof of work' }
|
||||
}
|
||||
|
||||
// Solve challenge (runs in browser)
|
||||
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++
|
||||
|
||||
// Report progress every 1000 attempts
|
||||
if (onProgress && nonce % 1000 === 0) {
|
||||
onProgress({ attempts: nonce, elapsed: Date.now() - startTime })
|
||||
}
|
||||
|
||||
// Yield to main thread every 100 attempts
|
||||
if (nonce % 100 === 0) {
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SHA256 helper
|
||||
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('')
|
||||
}
|
||||
Reference in New Issue
Block a user