security: encrypt NaCl keypair at rest with AES-GCM and harden PoW captcha signature with HMAC-SHA256
This commit is contained in:
@@ -635,11 +635,7 @@ class PageListing extends HTMLElement {
|
||||
|
||||
formatDescription(text) {
|
||||
if (!text) return ''
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
return escapeHTML(text).replace(/\n/g, '<br>')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user