security: encrypt NaCl keypair at rest with AES-GCM and harden PoW captcha signature with HMAC-SHA256

This commit is contained in:
2026-02-08 14:15:23 +01:00
parent ce2b8657bb
commit 2f02df4910
6 changed files with 151 additions and 45 deletions

View File

@@ -635,11 +635,7 @@ class PageListing extends HTMLElement {
formatDescription(text) {
if (!text) return ''
return text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
return escapeHTML(text).replace(/\n/g, '<br>')
}
}

View File

@@ -53,7 +53,7 @@ export class PowCaptcha extends HTMLElement {
this.render()
try {
const { challenge, difficulty, timestamp, signature } = await generateChallenge()
const { challenge, difficulty, timestamp, signature, source } = await generateChallenge()
const result = await solveChallenge(challenge, difficulty)
@@ -62,7 +62,8 @@ export class PowCaptcha extends HTMLElement {
difficulty,
nonce: result.nonce,
signature,
timestamp
timestamp,
source
}
const verification = await verifySolution(solution)

View File

@@ -207,7 +207,7 @@ class SearchBox extends HTMLElement {
badges.push(/* html */`
<button type="button" class="filter-badge" data-filter="category">
<span class="filter-badge-text">${categoryLabel}</span>
<span class="filter-badge-text">${escapeHTML(categoryLabel)}</span>
<svg class="filter-badge-close" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>

View File

@@ -8,6 +8,7 @@
import { directus } from './directus.js'
import { setPersist, getPersist } from './directus/client.js'
import { cryptoService } from './crypto.js'
import { i18n } from '../i18n.js'
const AUTH_DOMAIN = 'dgray.io'
@@ -114,6 +115,7 @@ class AuthService {
try {
await directus.login(email, uuid)
await cryptoService.unlock(uuid)
this.currentUser = await directus.getCurrentUser()
this.notifyListeners()
this.storeUuid(uuid)
@@ -142,6 +144,7 @@ class AuthService {
this.clearStoredUuid()
localStorage.removeItem('dgray_remember')
setPersist(false)
cryptoService.lock()
this.resetPreferencesToDefaults()
this.notifyListeners()
}
@@ -289,6 +292,8 @@ class AuthService {
async tryRestoreSession() {
if (directus.isAuthenticated()) {
try {
const uuid = this.getStoredUuid()
if (uuid) await cryptoService.unlock(uuid)
this.currentUser = await directus.getCurrentUser()
this.syncPreferencesToLocal()
this.notifyListeners()

View File

@@ -1,9 +1,13 @@
/**
* E2E Encryption Service using TweetNaCl
* https://tweetnacl.js.org/
*
* Secret keys are encrypted at rest using AES-GCM with a key
* derived from the user's UUID via PBKDF2.
*/
const STORAGE_KEY = 'dgray_keypair'
const SALT_KEY = 'dgray_keypair_salt'
class CryptoService {
constructor() {
@@ -18,11 +22,9 @@ class CryptoService {
await this.loadScript('/js/vendor/nacl-fast.min.js')
await this.loadScript('/js/vendor/nacl-util.min.js')
}
this.nacl = window.nacl
this.naclUtil = window.nacl.util
this.loadOrCreateKeyPair()
}
loadScript(src) {
@@ -39,34 +41,103 @@ class CryptoService {
})
}
loadOrCreateKeyPair() {
async deriveKey(uuid) {
const enc = new TextEncoder()
let salt = localStorage.getItem(SALT_KEY)
if (!salt) {
const saltBytes = crypto.getRandomValues(new Uint8Array(16))
salt = Array.from(saltBytes).map(b => b.toString(16).padStart(2, '0')).join('')
localStorage.setItem(SALT_KEY, salt)
}
const saltBytes = new Uint8Array(salt.match(/.{2}/g).map(h => parseInt(h, 16)))
const baseKey = await crypto.subtle.importKey(
'raw', enc.encode(uuid), 'PBKDF2', false, ['deriveKey']
)
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: 100000, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
async encryptForStorage(data, wrappingKey) {
const iv = crypto.getRandomValues(new Uint8Array(12))
const enc = new TextEncoder()
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
wrappingKey,
enc.encode(JSON.stringify(data))
)
return {
iv: Array.from(iv).map(b => b.toString(16).padStart(2, '0')).join(''),
ct: Array.from(new Uint8Array(ciphertext)).map(b => b.toString(16).padStart(2, '0')).join('')
}
}
async decryptFromStorage(stored, wrappingKey) {
const iv = new Uint8Array(stored.iv.match(/.{2}/g).map(h => parseInt(h, 16)))
const ct = new Uint8Array(stored.ct.match(/.{2}/g).map(h => parseInt(h, 16)))
const plainBuf = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
wrappingKey,
ct
)
return JSON.parse(new TextDecoder().decode(plainBuf))
}
async unlock(uuid) {
await this.ready
const wrappingKey = await this.deriveKey(uuid)
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const parsed = JSON.parse(stored)
this.keyPair = {
publicKey: this.naclUtil.decodeBase64(parsed.publicKey),
secretKey: this.naclUtil.decodeBase64(parsed.secretKey)
if (parsed.ct && parsed.iv) {
const data = await this.decryptFromStorage(parsed, wrappingKey)
this.keyPair = {
publicKey: this.naclUtil.decodeBase64(data.publicKey),
secretKey: this.naclUtil.decodeBase64(data.secretKey)
}
return
}
if (parsed.publicKey && parsed.secretKey) {
this.keyPair = {
publicKey: this.naclUtil.decodeBase64(parsed.publicKey),
secretKey: this.naclUtil.decodeBase64(parsed.secretKey)
}
await this.saveKeyPair(wrappingKey)
return
}
return
} catch (e) {
console.warn('Failed to load keypair, generating new one')
}
}
this.generateKeyPair()
await this.generateKeyPair(wrappingKey)
}
generateKeyPair() {
async generateKeyPair(wrappingKey) {
this.keyPair = this.nacl.box.keyPair()
const toStore = {
await this.saveKeyPair(wrappingKey)
}
async saveKeyPair(wrappingKey) {
const data = {
publicKey: this.naclUtil.encodeBase64(this.keyPair.publicKey),
secretKey: this.naclUtil.encodeBase64(this.keyPair.secretKey)
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore))
const encrypted = await this.encryptForStorage(data, wrappingKey)
localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted))
}
lock() {
this.keyPair = null
}
getPublicKey() {
@@ -85,9 +156,9 @@ class CryptoService {
const messageUint8 = this.naclUtil.decodeUTF8(message)
const recipientKey = this.naclUtil.decodeBase64(recipientPublicKey)
const sharedKey = this.nacl.box.before(recipientKey, this.keyPair.secretKey)
const encrypted = this.nacl.secretbox(messageUint8, nonce, sharedKey)
return {
nonce: this.naclUtil.encodeBase64(nonce),
ciphertext: this.naclUtil.encodeBase64(encrypted)
@@ -105,13 +176,13 @@ class CryptoService {
try {
const otherKey = this.naclUtil.decodeBase64(otherPublicKey)
const sharedKey = this.nacl.box.before(otherKey, this.keyPair.secretKey)
const decrypted = this.nacl.secretbox.open(
this.naclUtil.decodeBase64(ciphertext),
this.naclUtil.decodeBase64(nonce),
sharedKey
)
if (!decrypted) return null
return this.naclUtil.encodeUTF8(decrypted)
} catch (e) {

View File

@@ -6,14 +6,31 @@ const POW_SERVER = 'https://pow.dgray.io'
const DIFFICULTY = 4
const SERVER_TIMEOUT_MS = 1500
function localGenerateChallenge() {
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: btoa(`${challenge}:${timestamp}:${DIFFICULTY}`),
signature,
source: 'local'
}
}
@@ -39,27 +56,43 @@ export async function generateChallenge() {
}
export async function verifySolution(solution) {
try {
if (solution.source === 'server') {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SERVER_TIMEOUT_MS)
const response = await fetch(`${POW_SERVER}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(solution),
signal: controller.signal
})
clearTimeout(timeout)
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}`)
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 {
const hash = await sha256(`${solution.challenge}${solution.nonce}`)
const prefix = '0'.repeat(solution.difficulty || DIFFICULTY)
return { ok: hash.startsWith(prefix), token: null }
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) {