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.

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