Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

179 lines · 9.0 KBRaw History
1import { loopback } from "./dev.js";
2import type { Env } from "../env.js";
3import type { Account } from "../lib/directory.js";
4import { hmacSha256, matchesAnyKey, signingSecrets } from "../lib/crypto.js";
5import { HttpError, b64url, newId, now, randomToken, sha256 } from "../lib/util.js";
6
7/**
8 * Open sign-up (SIGNUP=open). Someone asks for a code at /signup, proves they read mail at
9 * the address by entering it, and gets a short-lived ticket that lets them pick a username
10 * and create a passkey, the way an invite link does. Off, the instance stays invite-only.
11 * Codes and tickets are stored only as hashes.
12 */
13
14export const CODE_MINUTES = 10;
15export const TICKET_MINUTES = 30;
16/** Codes entered for one code, right or wrong; after this many it stops working. */
17export const MAX_CODE_ATTEMPTS = 5;
18/** Sign-up emails (a code, or the "you already have an account" note) per address and per IP address in 24 hours. */
19export const CODES_PER_EMAIL_PER_DAY = 5;
20export const CODES_PER_IP_PER_DAY = 20;
21
22export const TURNSTILE_SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js";
23export const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
24const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
25
26export function signupOpen(env: Pick<Env, "SIGNUP">): boolean {
27 return env.SIGNUP === "open";
28}
29
30/**
31 * How sign-up, and a security report from someone not signed in (web/security-reports.tsx), check
32 * for a person: "verify" with Turnstile, "skip" in local development
33 * (PUBLIC_URL on this machine) without it, and "missing" when a deployed instance has no
34 * Turnstile keys, which refuses sign-up rather than leave it open to scripts.
35 */
36export function turnstileMode(env: Pick<Env, "TURNSTILE_SITE_KEY" | "TURNSTILE_SECRET" | "PUBLIC_URL">): "verify" | "skip" | "missing" {
37 if (env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET) return "verify";
38 return loopback(env.PUBLIC_URL) ? "skip" : "missing";
39}
40
41/** Ask Cloudflare whether the widget's token is good. Any failure counts as no. */
42export async function verifyTurnstile(secret: string, token: string, ip: string | null): Promise<boolean> {
43 if (!token || token.length > 2048) return false;
44 const body = new URLSearchParams({ secret, response: token });
45 if (ip) body.set("remoteip", ip);
46 try {
47 const r = await fetch(SITEVERIFY, { method: "POST", body });
48 if (!r.ok) return false;
49 return ((await r.json()) as { success?: boolean }).success === true;
50 } catch (e) {
51 console.error("turnstile siteverify failed", e);
52 return false;
53 }
54}
55
56/** An address worth sending a code to, lower-cased; null when it isn't one. */
57export function normalEmail(v: string): string | null {
58 const e = v.trim().toLowerCase();
59 return e.length <= 254 && /^[^\s@<>()",;]+@[^\s@<>()",;]+\.[^\s@<>()",;]+$/.test(e) ? e : null;
60}
61
62/** Six random digits, each code equally likely. */
63export function newCode(): string {
64 const buf = new Uint32Array(1);
65 // 4,294,000,000 is the largest multiple of a million below 2^32: drawing below it avoids modulo bias.
66 for (;;) {
67 crypto.getRandomValues(buf);
68 if (buf[0]! < 4_294_000_000) return String(buf[0]! % 1_000_000).padStart(6, "0");
69 }
70}
71
72/**
73 * The stored form of a code: an HMAC keyed from MASTER_KEY over the code's row id and the
74 * code, so a copy of the database alone can't try the million codes.
75 */
76export async function codeHash(env: Pick<Env, "MASTER_KEY" | "MASTER_KEY_PREV" | "PUBLIC_URL">, id: string, code: string): Promise<string> {
77 return codeHashWith(codeSecrets(env)[0]!, id, code);
78}
79
80/** Whether `code` is the one stored as `hash`, keyed with MASTER_KEY or, while it is being rotated, MASTER_KEY_PREV. */
81export async function codeMatches(env: Pick<Env, "MASTER_KEY" | "MASTER_KEY_PREV" | "PUBLIC_URL">, id: string, code: string, hash: string): Promise<boolean> {
82 return matchesAnyKey(codeSecrets(env), hash, (s) => codeHashWith(s, id, code));
83}
84
85function codeSecrets(env: Pick<Env, "MASTER_KEY" | "MASTER_KEY_PREV" | "PUBLIC_URL">): string[] {
86 // Local development has no MASTER_KEY unless one is passed; production always does.
87 return signingSecrets(env, loopback(env.PUBLIC_URL) ? "diffinery-dev-signup" : null, "MASTER_KEY is not set, so sign-up codes can't be stored");
88}
89
90const codeHashWith = async (secret: string, id: string, code: string): Promise<string> => b64url(await hmacSha256(`diffinery-signup-code-v1:${secret}`, `${id}:${code}`));
91
92const minutes = (n: number): string => new Date(Date.now() + n * 60_000).toISOString();
93
94export type CodeCheck = { ok: true } | { ok: false; left: number };
95
96/** Sign-up codes and tickets in D1. */
97export class Signups {
98 constructor(private env: Env) {}
99
100 private get db() {
101 return this.env.DB;
102 }
103
104 /** The person account using this address, whatever its case. */
105 async accountFor(email: string): Promise<Account | null> {
106 return this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' AND lower(email) = ? LIMIT 1").bind(email.toLowerCase()).first<Account>();
107 }
108
109 /**
110 * A new code for `email`, requested from `ip`, after the daily limits; older codes for the
111 * address stop working. An address that already has an account gets a code too, which is
112 * never sent: wrong guesses then answer exactly as they would for a new address.
113 */
114 async issue(email: string, ip: string): Promise<{ code: string; expiresAt: string }> {
115 const since = new Date(Date.now() - 86400_000).toISOString();
116 const sent = await this.db
117 .prepare("SELECT (SELECT count(*) FROM signup_codes WHERE email = ? AND created_at > ?) AS email, (SELECT count(*) FROM signup_codes WHERE ip = ? AND created_at > ?) AS ip")
118 .bind(email, since, ip, since)
119 .first<{ email: number; ip: number }>();
120 if ((sent?.email ?? 0) >= CODES_PER_EMAIL_PER_DAY) throw new HttpError(429, `We have sent several codes to ${email} today. Check that inbox (and its spam folder), or try again tomorrow.`);
121 if ((sent?.ip ?? 0) >= CODES_PER_IP_PER_DAY) throw new HttpError(429, "Too many sign-ups from your network today. Try again tomorrow.");
122 const id = newId();
123 const code = newCode();
124 const at = now();
125 const expiresAt = minutes(CODE_MINUTES);
126 await this.db.batch([
127 this.db.prepare("UPDATE signup_codes SET expires_at = ? WHERE email = ? AND used_at IS NULL AND expires_at > ?").bind(at, email, at),
128 this.db.prepare("INSERT INTO signup_codes (id, email, ip, code_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)").bind(id, email, ip, await codeHash(this.env, id, code), at, expiresAt),
129 // Rows older than a day no longer count towards any limit.
130 this.db.prepare("DELETE FROM signup_codes WHERE created_at < ?").bind(since),
131 ]);
132 return { code, expiresAt };
133 }
134
135 /**
136 * Check a code against the address's live one. Every try counts, so at most
137 * MAX_CODE_ATTEMPTS codes are ever compared; a right one works once.
138 */
139 async check(email: string, code: string): Promise<CodeCheck> {
140 if (!/^\d{6}$/.test(code)) throw new HttpError(422, "Enter the 6-digit code from the email.");
141 const t = now();
142 const row = await this.db
143 .prepare(
144 "UPDATE signup_codes SET attempts = attempts + 1 WHERE id = (SELECT id FROM signup_codes WHERE email = ? AND used_at IS NULL AND expires_at > ? ORDER BY created_at DESC LIMIT 1) AND attempts < ? RETURNING id, code_hash, attempts",
145 )
146 .bind(email, t, MAX_CODE_ATTEMPTS)
147 .first<{ id: string; code_hash: string; attempts: number }>();
148 if (!row) return { ok: false, left: 0 };
149 if (await codeMatches(this.env, row.id, code, row.code_hash)) {
150 const r = await this.db.prepare("UPDATE signup_codes SET used_at = ? WHERE id = ? AND used_at IS NULL").bind(t, row.id).run();
151 if ((r.meta.changes ?? 0) > 0) return { ok: true };
152 }
153 return { ok: false, left: Math.max(0, MAX_CODE_ATTEMPTS - row.attempts) };
154 }
155
156 /** A single-use ticket for a verified address; returns the token for the passkey form. */
157 async createTicket(email: string): Promise<string> {
158 const token = randomToken(32);
159 await this.db.batch([
160 this.db.prepare("DELETE FROM signup_tickets WHERE expires_at < ?").bind(now()),
161 this.db.prepare("INSERT INTO signup_tickets (id, token_hash, email, created_at, expires_at) VALUES (?, ?, ?, ?, ?)").bind(newId(), await sha256(token), email, now(), minutes(TICKET_MINUTES)),
162 ]);
163 return token;
164 }
165
166 /** An unused, unexpired ticket by its token. */
167 async ticket(token: string): Promise<{ hash: string; email: string } | null> {
168 if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null;
169 const hash = await sha256(token);
170 const row = await this.db.prepare("SELECT email FROM signup_tickets WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(hash, now()).first<{ email: string }>();
171 return row ? { hash, email: row.email } : null;
172 }
173
174 /** Mark a ticket used; false when it was used first or expired. */
175 async useTicket(hash: string): Promise<boolean> {
176 const r = await this.db.prepare("UPDATE signup_tickets SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(now(), hash, now()).run();
177 return (r.meta.changes ?? 0) > 0;
178 }
179}