diff --git a/services/recordly-share/worker/src/accounts.js b/services/recordly-share/worker/src/accounts.js index 77b6ba9b..261fa517 100644 --- a/services/recordly-share/worker/src/accounts.js +++ b/services/recordly-share/worker/src/accounts.js @@ -69,7 +69,7 @@ async function createCommentSession(request, env, user) { export async function handleCommentRegister(request, env) { const ip = request.headers.get('CF-Connecting-IP') || 'unknown'; - if (!checkLoginRateLimit(`comment:${ip}`)) return errorResponse('Too many attempts', 429); + if (!checkLoginRateLimit(`register:${ip}`)) return errorResponse('Too many attempts', 429); const body = await request.json(); const email = String(body.email || '').trim().toLowerCase(); const displayName = String(body.displayName || '').trim(); @@ -89,7 +89,6 @@ export async function handleCommentRegister(request, env) { const result = await env.DB.prepare( 'INSERT INTO comment_users (email, display_name, password_hash, password_salt) VALUES (?, ?, ?, ?)' ).bind(email, displayName, passwordHash, salt).run(); - clearLoginRateLimit(`comment:${ip}`); return createCommentSession(request, env, { id: result.meta.last_row_id, email, display_name: displayName }); } catch (error) { if (/unique|constraint/i.test(error.message || '')) { diff --git a/services/recordly-share/worker/src/auth.js b/services/recordly-share/worker/src/auth.js index 7bb7e3a4..c036ab5c 100644 --- a/services/recordly-share/worker/src/auth.js +++ b/services/recordly-share/worker/src/auth.js @@ -1,7 +1,7 @@ // Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul. // See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution. -import { generateSalt, sha256Hex, timingSafeEqual } from './crypto.js'; +import { generateSalt, sha256Hex, timingSafeEqual, hashRecordingPassword } from './crypto.js'; import { errorResponse, jsonResponse, parseCookies } from './http.js'; export async function isAuthorized(request, env) { @@ -61,15 +61,15 @@ export function dashboardPassword(env) { return env.DASHBOARD_PASSWORD || env.API_SECRET; } -export async function expectedSessionToken(env) { +export async function expectedSessionToken(env, expiresAt = Math.floor(Date.now() / 1000) + 604800) { const password = dashboardPassword(env); if (!password) throw new Error('Dashboard sign-in is not configured'); const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(password), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); - const sig = await crypto.subtle.sign('HMAC', key, encoder.encode('voom-dashboard-v1')); - return Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, '0')).join(''); + const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(`voom-dashboard-v2:${expiresAt}`)); + return `${expiresAt}.` + Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, '0')).join(''); } export async function isDashboardAuthed(request, env) { @@ -80,8 +80,10 @@ export async function dashboardCookieAuthed(request, env) { if (!dashboardPassword(env)) return false; const cookies = parseCookies(request.headers.get('Cookie') || ''); const sessionToken = cookies['voom_session']; - if (!sessionToken) return false; - return timingSafeEqual(sessionToken, await expectedSessionToken(env)); + if (!sessionToken || !/^\d+\.[0-9a-f]{64}$/.test(sessionToken)) return false; + const expiresAt = Number(sessionToken.split('.')[0]); + if (!Number.isSafeInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) return false; + return timingSafeEqual(sessionToken, await expectedSessionToken(env, expiresAt)); } // best-effort, per-isolate login rate limiter (real protection is the password's entropy) @@ -123,21 +125,17 @@ export async function handleVerifyPassword(request, env, shareCode) { const body = await request.json(); const password = body.password || ''; - // The browser sends the raw password (HTTPS); the app stored it as a salted - // hash of SHA256(password). Legacy rows (pre-salt) hold bare SHA256(password). const clientHash = await sha256Hex(password); - let matches; - if (video.password_salt) { - matches = timingSafeEqual(await sha256Hex(video.password_salt + clientHash), video.password_hash); - } else { - matches = timingSafeEqual(clientHash, video.password_hash); - // Lazy upgrade: re-store the legacy unsalted hash as salted on success. - if (matches) { - const salt = generateSalt(); - const upgraded = await sha256Hex(salt + clientHash); - await env.DB.prepare('UPDATE videos SET password_hash = ?, password_salt = ? WHERE id = ?') - .bind(upgraded, salt, video.id).run(); - } + const slowHash = video.password_hash.startsWith('pbkdf2-sha256:210000:'); + const actual = slowHash + ? await hashRecordingPassword(clientHash, video.password_salt) + : video.password_salt ? await sha256Hex(video.password_salt + clientHash) : clientHash; + const matches = timingSafeEqual(actual, video.password_hash); + if (matches && !slowHash) { + const salt = generateSalt(); + const upgraded = await hashRecordingPassword(clientHash, salt); + await env.DB.prepare('UPDATE videos SET password_hash = ?, password_salt = ? WHERE id = ?') + .bind(upgraded, salt, video.id).run(); } if (!matches) { diff --git a/services/recordly-share/worker/src/crypto.js b/services/recordly-share/worker/src/crypto.js index d746fbf2..532ff105 100644 --- a/services/recordly-share/worker/src/crypto.js +++ b/services/recordly-share/worker/src/crypto.js @@ -27,3 +27,10 @@ export function generateSalt() { crypto.getRandomValues(bytes); return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); } + +// The prefix versions the algorithm and work factor without changing legacy rows. +export async function hashRecordingPassword(clientHash, salt) { + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(clientHash), 'PBKDF2', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: new TextEncoder().encode(salt), iterations: 210000 }, key, 256); + return 'pbkdf2-sha256:210000:' + Array.from(new Uint8Array(bits), b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/services/recordly-share/worker/src/uploads.js b/services/recordly-share/worker/src/uploads.js index 76a16268..37f91e42 100644 --- a/services/recordly-share/worker/src/uploads.js +++ b/services/recordly-share/worker/src/uploads.js @@ -3,16 +3,12 @@ import { errorResponse, jsonResponse } from './http.js'; import { EXPIRY_DAYS, finiteNonnegative } from './video.js'; -import { generateSalt, sha256Hex } from './crypto.js'; - -const SHARE_CODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789'; - -const SHARE_CODE_LENGTH = 10; +import { generateSalt, hashRecordingPassword } from './crypto.js'; export function generateShareCode() { - const bytes = new Uint8Array(SHARE_CODE_LENGTH); + const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); - return Array.from(bytes, b => SHARE_CODE_CHARS[b % SHARE_CODE_CHARS.length]).join(''); + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); } export async function handleUpload(request, env) { @@ -31,14 +27,12 @@ export async function handleUpload(request, env) { const shareCode = generateShareCode(); const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000).toISOString(); - // Store password hashes salted: hash = SHA256(salt + clientHash). The client - // sends SHA256(password) so the raw password never leaves the user's machine; - // salting server-side makes a leaked D1 dump useless against rainbow tables. + // Versioned slow hash of the client digest, with a unique per-record salt. let storedHash = null; let salt = null; if (password_hash) { salt = generateSalt(); - storedHash = await sha256Hex(salt + password_hash); + storedHash = await hashRecordingPassword(password_hash, salt); } await env.DB.prepare( diff --git a/services/recordly-share/worker/test/api.test.js b/services/recordly-share/worker/test/api.test.js index 51614f3f..52a18339 100644 --- a/services/recordly-share/worker/test/api.test.js +++ b/services/recordly-share/worker/test/api.test.js @@ -134,7 +134,7 @@ describe('upload validation', () => { it('accepts https CTA URLs and returns a well-formed share code', async () => { const data = await createShare({ cta_url: 'https://example.com', cta_text: 'Visit' }); - expect(data.shareCode).toMatch(/^[a-z0-9]{10}$/); + expect(data.shareCode).toMatch(/^[0-9a-f]{64}$/); expect(data.shareURL).toContain(`/s/${data.shareCode}`); }); @@ -338,7 +338,7 @@ describe('password protection', () => { const row = await env.DB.prepare('SELECT password_hash, password_salt FROM videos WHERE share_code = ?') .bind(shareCode).first(); expect(row.password_salt).toMatch(/^[0-9a-f]{32}$/); - expect(row.password_hash).not.toBe(clientHash); + expect(row.password_hash).toMatch(/^pbkdf2-sha256:210000:[0-9a-f]{64}$/); }); it('lazily upgrades legacy unsalted rows on successful verify', async () => { @@ -357,7 +357,7 @@ describe('password protection', () => { const row = await env.DB.prepare('SELECT password_hash, password_salt FROM videos WHERE share_code = ?') .bind(shareCode).first(); expect(row.password_salt).toMatch(/^[0-9a-f]{32}$/); - expect(row.password_hash).not.toBe(clientHash); // re-stored salted + expect(row.password_hash).toMatch(/^pbkdf2-sha256:210000:[0-9a-f]{64}$/); // re-stored salted }); it('rate-limits brute-force attempts (429 after 10 failures)', async () => { @@ -562,3 +562,42 @@ it('clamps a bounded video range to the actual object size', async () => { expect(response.headers.get('Content-Length')).toBe('2'); expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([6, 7]); }); + +it('counts successful registrations without allowing login to reset their quota', async () => { + const headers = { 'Content-Type': 'application/json', 'CF-Connecting-IP': '192.0.2.201' }; + for (let i = 0; i < 10; i++) { + const response = await SELF.fetch(`${BASE}/auth/register`, { + method: 'POST', headers, + body: JSON.stringify({ email: `quota-${i}@example.com`, displayName: 'Quota Test', password: 'test-password' }), + }); + expect(response.status).toBe(200); + } + const login = await SELF.fetch(`${BASE}/auth/login`, { + method: 'POST', headers, + body: JSON.stringify({ email: 'quota-0@example.com', password: 'test-password' }), + }); + expect(login.status).toBe(200); + const blocked = await SELF.fetch(`${BASE}/auth/register`, { + method: 'POST', headers, + body: JSON.stringify({ email: 'quota-extra@example.com', displayName: 'Quota Test', password: 'test-password' }), + }); + expect(blocked.status).toBe(429); +}); + +it('upgrades salted legacy recording passwords only after correct verification', async () => { + const password = 'legacy-recording-password'; + const { shareCode } = await createShare(); + await completeUpload(shareCode); + const salt = 'legacy-salt'; + const hash = await sha256Hex(salt + await sha256Hex(password)); + await env.DB.prepare('UPDATE videos SET password_hash = ?, password_salt = ? WHERE share_code = ?').bind(hash, salt, shareCode).run(); + const verify = (value) => SELF.fetch(`${BASE}/s/${shareCode}/verify-password`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: value }), + }); + expect((await verify('wrong')).status).toBe(403); + expect((await env.DB.prepare('SELECT password_hash FROM videos WHERE share_code = ?').bind(shareCode).first()).password_hash).toBe(hash); + expect((await verify(password)).status).toBe(200); + const row = await env.DB.prepare('SELECT password_hash FROM videos WHERE share_code = ?').bind(shareCode).first(); + expect(row.password_hash).toMatch(/^pbkdf2-sha256:210000:[0-9a-f]{64}$/); + expect((await verify(password)).status).toBe(200); +}); diff --git a/services/recordly-share/worker/test/helpers.test.js b/services/recordly-share/worker/test/helpers.test.js index b3cadfc0..42cec600 100644 --- a/services/recordly-share/worker/test/helpers.test.js +++ b/services/recordly-share/worker/test/helpers.test.js @@ -9,8 +9,8 @@ import { formatVTTTime, } from '../src/index.js'; -const SHARE_CODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789'; -const SHARE_CODE_LENGTH = 10; +const SHARE_CODE_CHARS = '0123456789abcdef'; +const SHARE_CODE_LENGTH = 64; describe('generateShareCode', () => { it('produces codes of the documented length and charset', () => { @@ -22,8 +22,8 @@ describe('generateShareCode', () => { } }); - it('excludes ambiguous characters (i, l, o, 0, 1)', () => { - for (const ch of 'ilo01') expect(SHARE_CODE_CHARS).not.toContain(ch); + it('generates distinct random codes', () => { + expect(new Set(Array.from({ length: 100 }, generateShareCode)).size).toBe(100); }); }); diff --git a/services/recordly-share/worker/test/library.test.js b/services/recordly-share/worker/test/library.test.js index b92e5571..dbe34508 100644 --- a/services/recordly-share/worker/test/library.test.js +++ b/services/recordly-share/worker/test/library.test.js @@ -153,3 +153,13 @@ describe('GET /library (dashboard gate)', () => { expect([200, 304]).toContain(res.status); }); }); + +it('rejects expired and tampered dashboard sessions', async () => { + const expired = await expectedSessionToken(env, Math.floor(Date.now() / 1000) - 1); + const valid = await expectedSessionToken(env); + const tampered = `${Number(valid.split('.')[0]) + 604800}.${valid.split('.')[1]}`; + for (const token of [expired, tampered, valid.split('.')[1]]) { + const res = await SELF.fetch(`${BASE}/api/videos`, { headers: { Cookie: `voom_session=${token}` } }); + expect(res.status).toBe(401); + } +});