Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ Pending uploads expire after five minutes; the one-minute cron reclaims them, ex

## Deploy prerequisites

1. Node.js 22+ and a Cloudflare account with Workers Paid, D1 and R2 enabled. The hosted config sets a 1,000 ms CPU ceiling for scrypt password work; Cloudflare rejects that setting on Workers Free. Upgrading a plan requires operator approval. No email service or sender domain is required.
1. Node.js 22+ and a Cloudflare account with Workers, D1 and R2 enabled. The hosted config supports deployment on Workers Free without a custom CPU limit; measure authentication CPU on your deployment before opening registration. No email service or sender domain is required.
2. Dedicated Worker `shotsync-hosted`, R2 bucket `shotsync-hosted`, and D1 database `shotsync-hosted`. Never bind the personal or demo bucket. Put the returned D1 UUID into `wrangler.hosted.jsonc`.
3. Set `PUBLIC_ORIGIN` to the final HTTPS origin, and `TURNSTILE_SITE_KEY` to a widget restricted to that hostname. Store `TURNSTILE_SECRET_KEY` as a Worker secret. No other site's Turnstile keys are reused.
3. Set `PUBLIC_ORIGIN` to the final HTTPS origin, and `TURNSTILE_SITE_KEY` to a widget restricted to that hostname. Store `TURNSTILE_SECRET_KEY` and `PASSWORD_PEPPER` as Worker secrets. Generate the pepper as 32 cryptographically random bytes encoded as 64 lowercase hex characters; never commit it or store it in D1. Missing or malformed pepper disables password operations. No other site's Turnstile keys are reused.
4. Configure the bucket's eight-day lifecycle and observability/billing alerts. Review registration and upload caps. Use a custom domain if stronger edge rules are needed.
5. With explicit deployment authorization: `npm run deploy:hosted`. It checks placeholders, applies the new hosted database migrations, then deploys the Worker. Do not run any personal/demo setup or seed scripts.
6. Test registration, saving the recovery code, login, recovery-code rotation, rejection of old credentials, and cross-device transfer. Confirm Turnstile hostname validation, cron cleanup and dashboard metrics.
Expand All @@ -60,10 +60,10 @@ The checked-in config identifies the operator's dedicated hosted resources. For
- `npx playwright install chromium && npm run test:browser`: isolated temporary local D1/R2, HTTPS browser login, upload/preview, device token access, anonymous denial, share/revoke, deletion and logout. Never contacts production or sends mail.
- `npx wrangler deploy --config wrangler.hosted.jsonc --dry-run`.

To suspend new writes, set `UPLOADS_ENABLED=0` and deploy. Retain the hosted database/bucket; do not drop tables or remove user data during rollback. The original self-hosted app and demo are independent entry points. Recovery codes, session tokens and device tokens are stored as hashes; password hashes use scrypt N=16384/r=8/p=5 with random salts.
To suspend new writes, set `UPLOADS_ENABLED=0` and deploy. Retain the hosted database/bucket; do not drop tables or remove user data during rollback. The original self-hosted app and demo are independent entry points. Recovery codes, session tokens and device tokens are stored as hashes; password verifiers use PBKDF2-HMAC-SHA256 (100,000 iterations, random 32-byte salt), followed by HMAC-SHA256 with the independent pepper. Only the final HMAC is stored in D1. The versioned format is `pbkdf2-sha256:v1:100000:<salt>:<HMAC>`. This iteration count is below the OWASP PBKDF2 recommendation: it is an explicit free-tier tradeoff, not equivalent to the previous scrypt strength. A database-only leak does not include the pepper; a database plus pepper leak permits cheaper password guessing. Keep the pepper stable: replacing or losing it invalidates existing password verifiers, requiring users to reset with their recovery codes. Older scrypt verifiers require recovery-code reset; the operator confirmed zero hosted accounts before this switch.

Official references: [D1 transactions](https://developers.cloudflare.com/d1/worker-api/d1-database/), [R2 lifecycle](https://developers.cloudflare.com/r2/buckets/object-lifecycles/).

Toolchain note: the compatible Vitest/Workers test stack currently reports development-only npm advisories (8 at implementation time); these packages are not imported by the deployed Worker. Run development servers on loopback only. The package resolver rejected the newest advertised Wrangler version with a publication-date cutoff; this change uses the resolved lockfile and its supported compatibility date. Track the toolchain updates separately before exposing any development server.

Launch status (2026-09-19): the dedicated D1, R2 bucket and Turnstile widget have been created, all three migrations applied, and the eight-day `users/` R2 expiry configured. Worker deployment is blocked until Workers Paid is enabled; the Turnstile secret still needs to be installed. The service is not publicly available yet. Remote D1 rejected unparenthesized CASE expressions in trigger bodies; migration 0002 now parenthesizes those expressions without changing quota behavior.
Launch preparation (2026-09-19): dedicated D1/R2/Turnstile resources, all three migrations and eight-day object expiry are configured. Cron capacity has been freed. The password implementation now supports a Free-plan deployment; public availability and production CPU checks are recorded after deployment. Remote D1 required parenthesized CASE expressions in migration 0002 without changing quota behavior.
2 changes: 1 addition & 1 deletion scripts/check-hosted-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ if(!c.vars.TURNSTILE_SITE_KEY)problems.push('TURNSTILE_SITE_KEY is required');
if(!c.d1_databases?.[0]?.database_id || c.d1_databases[0].database_id==='00000000-0000-0000-0000-000000000000')problems.push('Set the dedicated hosted D1 database ID');
if(c.name!=='shotsync-hosted' || c.r2_buckets?.[0]?.bucket_name!=='shotsync-hosted')problems.push('Hosted Worker and bucket must remain separate from personal/demo instances');
if(problems.length){console.error(problems.join('\n'));process.exit(1);}
console.log('Hosted config ready. Confirm Turnstile secret, bucket lifecycle, and deployment authorization before publishing.');
console.log('Hosted config ready. Confirm PASSWORD_PEPPER and Turnstile secrets, bucket lifecycle, and deployment authorization before publishing.');
8 changes: 4 additions & 4 deletions scripts/test-hosted-browser.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { chromium, expect } from '@playwright/test';
import { execFileSync, spawn } from 'node:child_process';
import { scryptSync } from 'node:crypto';
import { pbkdf2Sync, createHmac } from 'node:crypto';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -14,12 +14,12 @@ try {
run(['d1','migrations','apply','shotsync-hosted','--local',...common]);
const password='browser-fixture-password';
// Valid format salt; test account exists only in the temporary local database.
const validSalt='a'.repeat(64);
const validHash='scrypt:16384:8:5:'+validSalt+':'+scryptSync(password,validSalt,32,{N:16384,r:8,p:5,maxmem:32*1024*1024}).toString('hex');
const validSalt='a'.repeat(64), pepper='b'.repeat(64);
const validHash='pbkdf2-sha256:v1:100000:'+validSalt+':'+createHmac('sha256',Buffer.from(pepper,'hex')).update(pbkdf2Sync(password,Buffer.from(validSalt,'hex'),100000,32,'sha256')).digest('hex');
const sql=join(temp,'fixture.sql');
writeFileSync(sql,`INSERT INTO users(id,email,password_hash,verified_at,created_at) VALUES('browser','browser@example.com','${validHash}',NULL,1);`);
run(['d1','execute','shotsync-hosted','--local','--file',sql,...common]);
server=spawn(process.execPath,[cli,'dev','--local','--ip','127.0.0.1','--local-protocol','https','--port','8788','--var','PUBLIC_ORIGIN:'+origin,'--var','TURNSTILE_SITE_KEY:',...common],{stdio:['ignore','pipe','pipe']});
server=spawn(process.execPath,[cli,'dev','--local','--ip','127.0.0.1','--local-protocol','https','--port','8788','--var','PUBLIC_ORIGIN:'+origin,'--var','TURNSTILE_SITE_KEY:','--var','PASSWORD_PEPPER:'+pepper,...common],{stdio:['ignore','pipe','pipe']});
let output='';server.stdout.on('data',x=>output+=x);server.stderr.on('data',x=>output+=x);
await new Promise((resolve,reject)=>{const started=Date.now();const timer=setInterval(()=>{if(output.includes('Ready on')){clearInterval(timer);resolve();}else if(server.exitCode!==null||Date.now()-started>30000){clearInterval(timer);reject(new Error(output));}},100);});
browser=await chromium.launch({headless:true});
Expand Down
37 changes: 26 additions & 11 deletions src/hosted/account-crypto.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
import { HttpError } from './http';
import { scrypt, timingSafeEqual } from 'node:crypto';
import { timingSafeEqual } from 'node:crypto';

export function randomToken(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, '0')).join('');
}
export async function tokenHash(value: string): Promise<string> {
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))), b => b.toString(16).padStart(2, '0')).join('');
}
function derive(password: string, salt: string): Promise<Uint8Array> {
return new Promise((resolve, reject) => scrypt(password, salt, 32,
{ N: 16384, r: 8, p: 5, maxmem: 32 * 1024 * 1024 },
(error, key) => error ? reject(error) : resolve(key)));
const HASH_PREFIX = 'pbkdf2-sha256:v1:100000';
const HEX_32 = /^[a-f0-9]{64}$/;
function decodeHex(value: string): Uint8Array {
return Uint8Array.from(value.match(/../g)!, b => parseInt(b, 16));
}
export async function hashPassword(password: string): Promise<string> {
export function validPasswordPepper(value: unknown): value is string {
return typeof value === 'string' && HEX_32.test(value);
}
function requirePepper(pepper: string): void {
if (!validPasswordPepper(pepper)) throw new HttpError(503, 'Password authentication is temporarily unavailable');
}
async function derive(password: string, salt: string, pepper: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
const derived = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: decodeHex(salt), iterations: 100_000 }, key, 256);
const pepperKey = await crypto.subtle.importKey('raw', decodeHex(pepper), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
// Persist only the peppered verifier, never the intermediate PBKDF2 result.
return new Uint8Array(await crypto.subtle.sign('HMAC', pepperKey, derived));
}
export async function hashPassword(password: string, pepper: string): Promise<string> {
requirePepper(pepper);
const salt = randomToken();
return `scrypt:16384:8:5:${salt}:${Array.from(await derive(password, salt), b => b.toString(16).padStart(2, '0')).join('')}`;
return `${HASH_PREFIX}:${salt}:${Array.from(await derive(password, salt, pepper), b => b.toString(16).padStart(2, '0')).join('')}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
export async function verifyPassword(password: string, stored: string, pepper: string): Promise<boolean> {
requirePepper(pepper);
const parts = stored.split(':');
if (parts.length !== 6 || parts.slice(0, 4).join(':') !== 'scrypt:16384:8:5' || !/^[a-f0-9]{64}$/.test(parts[4]) || !/^[a-f0-9]{64}$/.test(parts[5])) return false;
return timingSafeEqual(await derive(password, parts[4]), Uint8Array.from(parts[5].match(/../g)!, b => parseInt(b, 16)));
if (parts.length !== 5 || parts.slice(0, 3).join(':') !== HASH_PREFIX || !HEX_32.test(parts[3]) || !HEX_32.test(parts[4])) return false;
return timingSafeEqual(await derive(password, parts[3], pepper), decodeHex(parts[4]));
}

// Bound native scrypt memory across concurrent requests and recover abandoned work.
// Bound expensive password work across requests and recover abandoned leases.
export async function withPasswordWork<T>(db: D1Database, work: () => Promise<T>): Promise<T> {
const id = crypto.randomUUID(), now = Date.now();
const results = await db.batch([
Expand Down
9 changes: 5 additions & 4 deletions src/hosted/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { HostedEnv } from './types';
import { consumeRate } from './limits';
import { readJson } from './http';
import { hashPassword, randomToken, tokenHash, verifyPassword, withPasswordWork } from './account-crypto';
import { hashPassword, randomToken, tokenHash, validPasswordPepper, verifyPassword, withPasswordWork } from './account-crypto';

const DAY = 86_400_000;
const COOKIE = '__Host-shotsync';
Expand Down Expand Up @@ -101,6 +101,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise<
return reply({ ok: true }, 200, cookie('', 0));
}
if (!['register', 'login', 'reset-password'].includes(route)) return fail('Not found', 404);
if (!validPasswordPepper(env.PASSWORD_PEPPER)) return fail('Password authentication is temporarily unavailable', 503);
const ip = await ipKey(request);
if (await limited(env, `ip:${ip}`, 30, 600)) return fail('Try again later', 429);
const body = await readJson(request);
Expand All @@ -113,7 +114,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise<
if (await limited(env, 'password-global', 120, 60)) return fail('Authentication is busy. Try again shortly.', 429);
const user = await env.DB.prepare('SELECT * FROM users WHERE email=?').bind(email).first<UserRow>();
// Equal-cost password work also for unknown addresses.
const valid = await withPasswordWork(env.DB, async () => user ? verifyPassword(body.password as string, user.password_hash) : (await hashPassword(body.password as string), false));
const valid = await withPasswordWork(env.DB, async () => user ? verifyPassword(body.password as string, user.password_hash, env.PASSWORD_PEPPER) : (await hashPassword(body.password as string, env.PASSWORD_PEPPER), false));
if (!user || !valid) return fail('Invalid email or password', 401);
const token = randomToken(), now = Date.now();
await env.DB.batch([
Expand All @@ -133,7 +134,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise<
const user = await env.DB.prepare('SELECT id FROM users WHERE email=? AND recovery_hash=?').bind(email, hash).first();
if (!user) return fail('Invalid email or recovery code');
if (await limited(env, 'password-global', 120, 60)) return fail('Authentication is busy. Try again shortly.', 429);
const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string));
const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string, env.PASSWORD_PEPPER));
const recoveryCode = randomToken();
// Compare-and-swap makes recovery one-time even when requests race.
const updated = await env.DB.prepare('UPDATE users SET password_hash=?,recovery_hash=?,auth_version=auth_version+1 WHERE email=? AND recovery_hash=? RETURNING id')
Expand All @@ -146,7 +147,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise<
if (await env.DB.prepare('SELECT id FROM users WHERE email=?').bind(email).first()) return fail('Account already exists. Sign in or use your recovery code.', 409);
if ((await env.DB.prepare('SELECT COUNT(*) n FROM users').first<{ n: number }>())!.n >= cap) return fail('Trial is full. Please try again later.', 409);
if (await limited(env, 'password-global', 120, 60)) return fail('Authentication is busy. Try again shortly.', 429);
const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string));
const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string, env.PASSWORD_PEPPER));
const recoveryCode = randomToken();
const inserted = await createUser(env.DB, crypto.randomUUID(), email, password, await tokenHash(recoveryCode), cap);
if (!inserted) return fail('Account already exists or trial is full.', 409);
Expand Down
2 changes: 1 addition & 1 deletion src/hosted/types.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export type HostedEnv = HostedBindings & { TURNSTILE_SECRET_KEY: string };
export type HostedEnv = HostedBindings & { TURNSTILE_SECRET_KEY: string; PASSWORD_PEPPER: string };
export interface Account { id: string; email: string; verified: boolean; via: 'cookie' | 'token' }
Loading
Loading