Skip to content

Commit

Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter

Every invite identity emails to an address (a person's invite, an owner's workspace or repository invite, staff's and an approved waitlist request's) now links to /invite/<code>?proof=<hmac>, an HMAC-SHA256 under IDENTITY_KEY of the invite's id and its bound address. The inviter sees the code, never the proof. The invite page passes the proof to check_invite (email_proven) and register; create_account starts the account confirmed only when the proof is this invite's and the account's address is the one it was sent to. A code typed in, a link passed on, an invite for anyone, a different address, or an email sent before this: confirmed after sign-up as before. No new secret and no migration. /register?invite= keeps the proof when it sends you to the invite's page. Docs: invites from your inbox.

syntaqxcommitted Parentfe7d232Browse files
16 files+419−460/16 viewed
+42−10
1212 hyphens, up to 39 characters.
1313
1414 Before you can do anything else, you [confirm your email
15−address](#confirming-your-email-address) with the code g1t emails you.
15+address](#confirming-your-email-address) with the code g1t emails you,
16+unless you signed up from the link in an invite g1t emailed to that address
17+(see [invites from your inbox](#invites-from-your-inbox)).
1618
1719 Accounts can only be created in a browser. There is no API for it, by
1820 design: it keeps passwords out of scripts and agents, and lets g1t protect
130132
131133 1. **No account yet**: sign up on the page. When the invite was sent to
132134 your address, the email field is filled in and locked. Choose a
133− username (one is suggested from your address) and a password, then
135+ username (one is suggested from your address) and a password. If you
136+ opened the page from the invite email itself, the address is already
137+ confirmed and you go straight in (see
138+ [invites from your inbox](#invites-from-your-inbox)); otherwise
134139 [confirm the address](#confirming-your-email-address) with the code g1t
135− emails it, even though the invite came there: an invite link can be
136− forwarded, so it does not prove the inbox is yours. Or select
137− **Continue with GitHub**: the invite rides along, and the account uses
138− the invited address when GitHub has verified it too, in which case no
139− confirmation is needed.
140+ emails it. Or select **Continue with GitHub**: the invite rides along,
141+ and the account uses the invited address when GitHub has verified it
142+ too, in which case no confirmation is needed.
140143 2. **The address already has an account**: select **Sign in to accept**.
141144 After you sign in, the invite is accepted for you.
142145 3. **Signed in as someone else**: an invite sent to one address works only
161164 An expired, revoked or used invite says which, and who sent it, so you
162165 can ask them for a new one; or ask for access from the same page.
163166
167+### Invites from your inbox
168+
169+When g1t emails an invite to an address (an invite you make for someone,
170+an owner's invite into a workspace or a repository, or an approved
171+[request for access](#asking-for-access)), the link in that email carries a
172+`proof` that only the email has: `g1t.sh/invite/<code>?proof=…`. Opening
173+the link shows that you can read that inbox, so:
174+
175+- the invite page says the address is confirmed because you came from the
176+ invite email, and the email field stays locked to it;
177+- your new account starts with the address confirmed: no code is sent, and
178+ you land in the workspace or repository the invite was for straight
179+ away.
180+
181+Anything else confirms the address the usual way, after you sign up: the
182+code typed at [g1t.sh/register](https://g1t.sh/register), an invite link
183+copied from **Settings → Invites** (whoever made the invite sees the code,
184+never the proof), an invite made for anyone with the link, or an invite
185+email sent before this existed. The proof is tied to one invite and its
186+address, and stops working when the invite is used, revoked or expires.
187+
164188 ### Invite links for a group
165189
166190 g1t sometimes hands one link to a group: an event's judges, readers of a
209233 An owner can invite an email address straight into a workspace from its
210234 People page; see [members and roles](/guides/workspaces/#members-and-roles).
211235 When the address has no g1t account, the invite makes the account, which
212−joins the workspace once it confirms its email address, and it uses one
213−invite. Inviting someone who is
236+joins the workspace once it confirms its email address (at once when it
237+was made from the invite email's link), and it uses one invite. Inviting someone who is
214238 already on g1t costs nothing.
215239
216240 ### Need more invites?
245269 ## Confirming your email address
246270
247271 A new account confirms its email address before it can do anything else on
248−g1t. Right after you sign up, g1t emails the address from `noreply@g1t.sh`
272+g1t, unless it already has (it was made with GitHub, or from the link in
273+its invite email). Right after you sign up, g1t emails the address from `noreply@g1t.sh`
249274 with two ways to confirm it, either one enough:
250275
251276 - a **six-digit code**, shown large in the email (and in its subject, so a
294319 is one GitHub has verified, so GitHub has already proved the inbox is
295320 yours, and no code is sent.
296321
322+### Addresses an invite email has confirmed
323+
324+An account made from the link in the invite g1t emailed to its address
325+starts confirmed the same way: following that link proved the inbox is
326+yours. It works only for the address the invite was sent to, and only from
327+the email's own link; see [invites from your inbox](#invites-from-your-inbox).
328+
297329 ### Accounts that never confirmed
298330
299331 Accounts are confirmed once and stay confirmed. An account made before this
+3−2
333333
334334 The email names you and the workspace and links to the invite's page.
335335 Someone new signs up right there, with the invited address filled in, and
336−joins once they confirm it with the code g1t emails them; someone with an
337−account signs in. Either way they land in the workspace as a member, with
336+joins at once when they opened the page from that email (it proves the
337+address is theirs), or otherwise once they confirm it with the code g1t
338+emails them; someone with an account signs in. Either way they land in the workspace as a member, with
338339 a one-time welcome. Until a new account confirms its address, its invite
339340 shows as **confirming their email** under the members, and you can still
340341 revoke it. See
+41−0
66 HAVE_AN_INVITE,
77 INVITES_CONTACT,
88 cleanCode,
9+ cleanProof,
10+ invitePath,
11+ inviteSignUpCopy,
912 inviteFor,
1013 inviteLink,
1114 inviteState,
4548 assert.equal(inviteLink(CODE, "http://localhost:8787/"), `http://localhost:8787/invite/${CODE}`);
4649 });
4750
51+const PROOF = "4f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c8";
52+
53+test("an invite email's proof is kept only when it looks like one, and goes along to the invite's page", () => {
54+ assert.equal(cleanProof(PROOF), PROOF);
55+ assert.equal(cleanProof(` ${PROOF.toUpperCase()} `), PROOF);
56+ assert.equal(cleanProof("not-a-proof"), null);
57+ assert.equal(cleanProof("abc"), null);
58+ assert.equal(cleanProof("a".repeat(500)), null);
59+ assert.equal(cleanProof(null), null);
60+ assert.equal(invitePath(CODE, PROOF), `/invite/${CODE}?proof=${PROOF}`);
61+ assert.equal(invitePath(CODE, null), `/invite/${CODE}`);
62+ assert.equal(invitePath(CODE), `/invite/${CODE}`);
63+});
64+
65+test("signing up from the invite email says the address is confirmed already; otherwise the code step applies", () => {
66+ const base = { address: "ada@example.com", emailProven: false, workspace: { name: "Flagon, Inc." }, repository: null };
67+ const proven = inviteSignUpCopy({ ...base, emailProven: true });
68+ assert.equal(proven.intro, "You join Flagon, Inc. as soon as you create it.");
69+ assert.match(proven.confirmed ?? "", /^ada@example\.com is confirmed: you came here from the invite we emailed to it/);
70+ assert.match(proven.hint, /confirmed already/);
71+ assert.doesNotMatch(proven.hint, /code/);
72+
73+ // No proof (a code typed in, or a link passed on): nothing new is said.
74+ const plain = inviteSignUpCopy(base);
75+ assert.equal(plain.intro, "You join Flagon, Inc. as soon as you confirm your email.");
76+ assert.equal(plain.confirmed, null);
77+ assert.equal(plain.hint, "Your invite was sent here. We email it a code to confirm it before you start.");
78+
79+ // An invite for anyone with the code has no address to prove.
80+ const open = inviteSignUpCopy({ ...base, address: null, emailProven: true, workspace: null });
81+ assert.equal(open.confirmed, null);
82+ assert.equal(open.intro, "It takes a minute.");
83+ assert.equal(open.hint, "We email it a code to confirm it before you start.");
84+
85+ const repo = inviteSignUpCopy({ ...base, workspace: null, repository: { name: "flagon-io/g1t" }, emailProven: true });
86+ assert.equal(repo.intro, "You get flagon-io/g1t as soon as you create it.");
87+});
88+
4889 test("a pasted link or code is tidied to the code", () => {
4990 assert.equal(cleanCode(CODE), CODE);
5091 assert.equal(cleanCode(` ${CODE} `), CODE);
+61−0
6565 return text.replace(/\s+/g, "").slice(0, 80);
6666 }
6767
68+/**
69+ * The `proof` an invite email's link carries, tidied: hex, or null for
70+ * anything else. Identity decides whether it is the invite's own; this only
71+ * keeps junk out of what is passed on and put back into a form.
72+ */
73+export function cleanProof(raw: string | null | undefined): string | null {
74+ const text = (raw ?? "").trim().toLowerCase();
75+ return /^[0-9a-f]{16,128}$/.test(text) ? text : null;
76+}
77+
78+/** An invite's page, keeping the email's proof when there is one. */
79+export function invitePath(code: string, proof?: string | null): string {
80+ const path = `/invite/${encodeURIComponent(code)}`;
81+ return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path;
82+}
83+
84+type Proven = {
85+ /** The bound address in full, or null for an invite to anyone with the code. */
86+ address: string | null;
87+ emailProven: boolean;
88+ workspace: { name: string } | null;
89+ repository: { name: string } | null;
90+};
91+
92+/**
93+ * What signing up on an invite's page says about the email address. Opened
94+ * from the invite's own email (`emailProven`), the address is confirmed
95+ * already, so there is no code to enter; otherwise the address is confirmed
96+ * after sign-up, as it always is.
97+ */
98+export function inviteSignUpCopy(invite: Proven): {
99+ /** Under "Create your account". */
100+ intro: string;
101+ /** Under the email field. */
102+ hint: string;
103+ /** Said plainly above the form when the address is confirmed already; null otherwise. */
104+ confirmed: string | null;
105+} {
106+ const proven = invite.emailProven && invite.address !== null;
107+ const when = proven ? "as soon as you create it" : "as soon as you confirm your email";
108+ const intro = invite.workspace
109+ ? `You join ${invite.workspace.name} ${when}.`
110+ : invite.repository
111+ ? `You get ${invite.repository.name} ${when}.`
112+ : "It takes a minute.";
113+ if (proven) {
114+ return {
115+ intro,
116+ hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.",
117+ confirmed: `${invite.address} is confirmed: you came here from the invite we emailed to it, so there is no code to enter after you sign up.`,
118+ };
119+ }
120+ return {
121+ intro,
122+ hint: invite.address
123+ ? "Your invite was sent here. We email it a code to confirm it before you start."
124+ : "We email it a code to confirm it before you start.",
125+ confirmed: null,
126+ };
127+}
128+
68129 type Listed = {
69130 status: "pending" | "awaiting_confirmation" | "redeemed" | "expired" | "revoked";
70131 redeemedBy: string | null;
+33−15
1−import { CircleAlert, Lock, Ticket } from "lucide-react";
1+import { CircleAlert, Lock, MailCheck, Ticket } from "lucide-react";
22 import { Form, Link, data, redirect } from "react-router";
33
44 import type { InvitePreview, User } from "@g1t/contracts";
1111 import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1212 import { githubSignInEnabled } from "../lib/github.server";
1313 import { identity } from "../lib/services.server";
14−import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites";
14+import { cleanCode, cleanProof, inviteSignUpCopy, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites";
1515 import { clientKey } from "../lib/registration.server";
1616 import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server";
1717 import { rememberWorkspace } from "../lib/workspace-choice";
2929 * an address that has an account), and the workspace or repository it
3030 * gives. Signing in or up elsewhere (GitHub, /login) comes back here with
3131 * `?accept=1`, which finishes the job.
32+ *
33+ * The link in the invite's own email also carries `?proof=`, which only
34+ * that email has: signing up from it makes the account with the address
35+ * confirmed already. The code alone (typed in, or a link passed on) does
36+ * not, and the address is confirmed after sign-up as usual.
3237 */
3338 export async function loader({ request, context, params }: Route.LoaderArgs) {
3439 const code = cleanCode(params.code);
3540 const viewer = getViewer(context);
36− const accepting = new URL(request.url).searchParams.get("accept") === "1";
37− const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true });
41+ const search = new URL(request.url).searchParams;
42+ const accepting = search.get("accept") === "1";
43+ const emailProof = cleanProof(search.get("proof"));
44+ const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true, emailProof });
3845 const invite = checked.ok ? checked.value : null;
3946 // A shared link for a group signs up on /register, which names the group.
4047 if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`);
6168 alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false,
6269 github: false,
6370 suggestion: suggestUsername(invite?.address),
71+ // Only a proof identity accepted goes back into the form.
72+ proof: invite?.emailProven ? emailProof : null,
6473 started: Date.now(),
6574 acceptError: null as string | null,
6675 };
95104 const code = cleanCode(params.code);
96105 const form = await request.formData();
97106 const client = clientKey(request);
98− const checked = await identity.checkInvite(code, client, { viewer: getViewer(context) });
107+ const emailProof = cleanProof(String(form.get("proof") ?? ""));
108+ const checked = await identity.checkInvite(code, client, { viewer: getViewer(context), emailProof });
99109 if (!checked.ok) return data({ error: checked.error.message }, { status: 422 });
100110 const invite = checked.value;
101111
110120 String(form.get("password") ?? ""),
111121 code,
112122 client,
123+ // Identity checks it again, against this invite and this address.
124+ emailProof,
113125 );
114126 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
115127 throw landIn(request, invite, [startSession(result.value.sessionToken)], true);
202214 const here = `/invite/${loaded.code}`;
203215 const back = `${here}?accept=1`;
204216 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
217+ const copy = inviteSignUpCopy(invite);
205218 return (
206219 <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6">
207220 <h2 id="sign-up" className="text-base font-semibold">
208221 Create your account
209222 </h2>
210− <p className="mt-1 text-sm text-muted">
211− {invite.workspace
212− ? `You join ${invite.workspace.name} as soon as you confirm your email.`
213− : invite.repository
214− ? `You get ${invite.repository.name} as soon as you confirm your email.`
215− : "It takes a minute."}
216− </p>
223+ <p className="mt-1 text-sm text-muted">{copy.intro}</p>
224+ {copy.confirmed && (
225+ <p className="mt-4 flex items-start gap-2 rounded-md border border-success/40 bg-success/5 p-3 text-sm" role="status">
226+ <MailCheck size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-success" />
227+ <span>{copy.confirmed}</span>
228+ </p>
229+ )}
217230 {loaded.github && (
218231 <div className="mt-5">
219232 <ContinueWithGithub href={github} />
223236 <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}>
224237 <input type="hidden" name="intent" value="register" />
225238 <Honeypot started={loaded.started} />
239+ {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />}
226240 {invite.address ? (
227− <Field label="Email" hint="Your invite was sent here. We email it a code to confirm it before you start.">
241+ <Field label="Email" hint={copy.hint}>
228242 <span className="relative block">
229243 <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" />
230− <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" />
244+ {copy.confirmed ? (
245+ <MailCheck size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-success" />
246+ ) : (
247+ <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" />
248+ )}
231249 </span>
232250 </Field>
233251 ) : (
234− <Field label="Email" hint="We email it a code to confirm it before you start.">
252+ <Field label="Email" hint={copy.hint}>
235253 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
236254 </Field>
237255 )}
+6−2
1111 import { githubSignInEnabled } from "../lib/github.server";
1212 import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1313 import { identity } from "../lib/services.server";
14−import { cleanCode, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites";
14+import { cleanCode, cleanProof, invitePath, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites";
1515 import { clientKey, registrationMode } from "../lib/registration.server";
1616 import {
1717 assertSameOrigin,
3636 // where it leads; it signs up, joins and lands in one go. A shared link
3737 // for a group signs up here: it joins nothing, and the form says which
3838 // group it is for.
39− if (invite && code && !invite.sharedLabel) throw redirect(`/invite/${encodeURIComponent(code)}`);
39+ // The invite email's proof goes with it, so the address it proves stays
40+ // confirmed there.
41+ if (invite && code && !invite.sharedLabel) {
42+ throw redirect(invitePath(code, cleanProof(new URL(request.url).searchParams.get("proof"))));
43+ }
4044 // Signing up with GitHub carries the invite code and `next` through it.
4145 const params = new URLSearchParams();
4246 if (code) params.set("invite", code);
+15−0
245245 /// registration is open.
246246 #[serde(default)]
247247 pub invite_code: Option<String>,
248+ /// The `proof` from the invite email's link. When it is the invite's
249+ /// own and `email` is the address the invite was sent to, the account
250+ /// starts with that address confirmed; otherwise it is ignored.
251+ #[serde(default)]
252+ pub email_proof: Option<String>,
248253 /// Who is asking, such as the visitor's IP address, for rate limits.
249254 #[serde(default)]
250255 pub client: Option<String>,
12781283 pub viewer: Option<User>,
12791284 #[serde(default)]
12801285 pub any_status: bool,
1286+ /// The `proof` from the invite email's link, if the page was opened
1287+ /// from it: sets `InvitePreview::email_proven`.
1288+ #[serde(default)]
1289+ pub email_proof: Option<String>,
12811290 }
12821291
12831292 /// Someone shown on an invite.
13331342 /// domains, such as `["cloudflare.com"]`. Empty for any address.
13341343 #[serde(default)]
13351344 pub shared_domains: Vec<String>,
1345+ /// Whether `email_proof` was this pending invite's own, from the email
1346+ /// it was sent in: the account made with it starts with `address`
1347+ /// confirmed. False without a proof, with a wrong one, and for an
1348+ /// invite bound to no address.
1349+ #[serde(default)]
1350+ pub email_proven: bool,
13361351 }
13371352
13381353 /// `accept_invite`: a signed-in person uses a workspace invite made for
+10−2
4141 export function identityClient(service: ServiceBinding): IdentityApi {
4242 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
4343 return {
44− register: (username, email, password, inviteCode, client) =>
45− call("register", { username, email, password, invite_code: inviteCode ?? null, client: client ?? null }),
44+ register: (username, email, password, inviteCode, client, emailProof) =>
45+ call("register", {
46+ username,
47+ email,
48+ password,
49+ invite_code: inviteCode ?? null,
50+ email_proof: emailProof ?? null,
51+ client: client ?? null,
52+ }),
4653 signIn: (username, password, client) => call("sign_in", { username, password, client: client ?? null }),
4754 twoFactorSignIn: (challenge, code, client) => call("two_factor_sign_in", { challenge, code, client: client ?? null }),
4855 signOut: (sessionToken) => call("sign_out", { sessionToken }),
153160 client: client ?? null,
154161 viewer: options.viewer ?? null,
155162 any_status: options.anyStatus ?? false,
163+ email_proof: options.emailProof ?? null,
156164 }),
157165 acceptInvite: (user, code) => call("accept_invite", { user, code }),
158166 inviteMember: (actor, slug, email) => call("invite_member", { actor, slug, email }),
+16−2
281281 sharedLabel: string | null;
282282 /** The email domains a shared invite link is limited to; empty for any address. */
283283 sharedDomains: string[];
284+ /**
285+ * Whether the page was opened from this pending invite's own email (its
286+ * `proof` checked out): the account made with it starts with `address`
287+ * confirmed. False without a proof, with a wrong one, or for an invite
288+ * bound to no address.
289+ */
290+ emailProven: boolean;
284291 };
285292
286293 export type WaitlistStatus = "waiting" | "invited" | "dismissed";
702709 inviteCode?: string | null,
703710 /** Who is asking, such as the visitor's IP address, for rate limits. */
704711 client?: string | null,
712+ /**
713+ * The `proof` from the invite email's link. When it is the invite's own
714+ * and `email` is the address it was sent to, the account starts with that
715+ * address confirmed; otherwise it is ignored.
716+ */
717+ emailProof?: string | null,
705718 ): Promise<Result<{ user: User; sessionToken: string }>>;
706719 /** Verifies a username and password for website sign-in. */
707720 /**
890903 /**
891904 * What a code is for. Unknown, used, revoked and expired codes all get the
892905 * same answer, unless `anyStatus`: then a real code that is spent is
893− * described, with its `status`. `viewer` sets `forViewer`.
906+ * described, with its `status`. `viewer` sets `forViewer`; `emailProof`,
907+ * the `proof` from the invite email's link, sets `emailProven`.
894908 */
895909 checkInvite(
896910 code: string,
897911 client?: string | null,
898− options?: { viewer?: User | null; anyStatus?: boolean },
912+ options?: { viewer?: User | null; anyStatus?: boolean; emailProof?: string | null },
899913 ): Promise<Result<InvitePreview>>;
900914 /**
901915 * A signed-in person uses a workspace invite sent to their address, or one
+4−0
759759 &full_name(&repo),
760760 a.role.label(),
761761 None,
762+ None,
762763 INVITATION_DAYS,
763764 )
764765 .await
804805 let id = self
805806 .insert_invitation(repo, workspace_id, None, Some(email), Some(&invite.id), role, &actor.id, days)
806807 .await?;
808+ // The email's link proves the address, as any invite email's does.
809+ let proof = self.email_proof_for(&invite.id, email);
807810 if let Some(code) = &invite.code
808811 && let Err(error) = crate::email::send_repo_invite(
809812 &self.env,
812815 &full_name(repo),
813816 role.label(),
814817 Some(code),
818+ proof.as_deref(),
815819 days,
816820 )
817821 .await
+15−0
4646 hex::encode(mac.finalize().into_bytes())
4747 }
4848
49+/// The proof an invite email's link carries that whoever follows it reads
50+/// that inbox: an HMAC-SHA256 under `key` (IDENTITY_KEY) of the invite's id
51+/// and the address it is bound to, trimmed and lowercased. Only the email
52+/// has it: the inviter sees the code, never this, and nobody can make one
53+/// without the key.
54+pub fn invite_proof(key: &[u8], invite_id: &str, email: &str) -> String {
55+ use hmac::{Hmac, Mac};
56+ let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC takes any key length");
57+ mac.update(b"g1t invite email proof\0");
58+ mac.update(invite_id.as_bytes());
59+ mac.update(b"\0");
60+ mac.update(email.trim().to_lowercase().as_bytes());
61+ hex::encode(mac.finalize().into_bytes())
62+}
63+
4964 /// Whether two strings are equal, in time that depends on their length only.
5065 pub fn same(a: &str, b: &str) -> bool {
5166 a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
+33−4
288288 pub workspace: Option<&'a str>,
289289 pub joins_existing_account: bool,
290290 pub code: &'a str,
291+ /// The proof that whoever follows the link reads this inbox
292+ /// (invites.rs, `email_proof`): the account made from it starts with
293+ /// the address confirmed. None for an invite to an existing account,
294+ /// or without IDENTITY_KEY.
295+ pub proof: Option<&'a str>,
291296 pub days: u64,
292297 /// A line from whoever sent it, such as staff approving a request.
293298 pub note: Option<&'a str>,
294299 }
295300
301+/// An invite's page, as its email links to it: with the email's proof
302+/// when it has one, so following it confirms the address (invites.rs).
303+/// The code alone is what the inviter can see and share.
304+pub fn invite_link(site: &str, code: &str, proof: Option<&str>) -> String {
305+ match proof {
306+ Some(proof) => format!("{site}/invite/{code}?proof={proof}"),
307+ None => format!("{site}/invite/{code}"),
308+ }
309+}
310+
296311 /// The subject and letter of an invite email.
297312 pub fn invite_letter(invite: &InviteEmail, site: &str) -> (String, Letter) {
298313 let (subject, intro) = invite_wording(invite.from, invite.workspace, invite.joins_existing_account);
314329 paragraphs: vec![intro],
315330 quotes,
316331 code: None,
317− action: Some((action, format!("{site}/invite/{}", invite.code))),
332+ action: Some((action, invite_link(site, invite.code, invite.proof))),
318333 footer: format!(
319334 "This invite works for {} days, only for this address. If you were not expecting it, you can ignore this message.",
320335 invite.days
413428 }
414429
415430 /// An invitation to collaborate on one repository. `code` is set when the
416−/// address has no account yet: the link then makes one and accepts; without
417−/// it, the link opens the invitation to accept or decline.
431+/// address has no account yet: the link then makes one and accepts, with
432+/// `proof` (see [`invite_link`]); without it, the link opens the
433+/// invitation to accept or decline.
418434 pub async fn send_repo_invite(
419435 env: &Env,
420436 to: &str,
422438 repo: &str,
423439 role: &str,
424440 code: Option<&str>,
441+ proof: Option<&str>,
425442 days: u64,
426443 ) -> Result<()> {
427444 let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some());
428445 let link = match code {
429− Some(code) => format!("{}/invite/{code}", site(env)),
446+ Some(code) => invite_link(&site(env), code, proof),
430447 None => format!("{}/{repo}/invitations", site(env)),
431448 };
432449 send_link(
574591 workspace: Some("Flagon, Inc."),
575592 joins_existing_account: false,
576593 code: "g1t-abcd",
594+ proof: None,
577595 days: 30,
578596 note,
579597 }
596614 }
597615
598616 #[test]
617+ fn an_invite_link_carries_the_emails_proof_when_it_has_one() {
618+ let proven = InviteEmail { proof: Some("4f9c2a"), ..invite(None, None) };
619+ let (_, letter) = invite_letter(&proven, SITE);
620+ assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd?proof=4f9c2a");
621+ // An invite sent before proofs, or for an existing account: the code alone.
622+ assert_eq!(invite_link(SITE, "g1t-abcd", None), "https://g1t.sh/invite/g1t-abcd");
623+ }
624+
625+ #[test]
599626 fn links_point_at_the_site_they_are_given() {
600627 let (_, letter) = invite_letter(&invite(None, None), "http://localhost:8787");
601628 assert_eq!(letter.action.as_ref().unwrap().1, "http://localhost:8787/invite/g1t-abcd");
688715 workspace: Some("Flagon, Inc."),
689716 joins_existing_account: false,
690717 code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk",
718+ proof: None,
691719 days: 30,
692720 note: None,
693721 }, SITE);
698726 workspace: None,
699727 joins_existing_account: false,
700728 code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk",
729+ proof: None,
701730 days: 30,
702731 note: Some("Thanks for waiting. We would love to see the compiler."),
703732 }, SITE);
+2−0
584584 password_hash: "",
585585 verified: true,
586586 invite_code,
587+ // GitHub has confirmed the address already.
588+ email_proof: None,
587589 client: None,
588590 })
589591 .await?
+136−9
233233 }
234234 }
235235
236+/// The proof for an invite's email link, or None when there is none to
237+/// make: no key (a development setup), or no address the invite is bound
238+/// to. See [`crypto::invite_proof`].
239+pub fn email_proof(key: &[u8], invite_id: &str, bound: Option<&str>) -> Option<String> {
240+ let bound = bound.map(str::trim).filter(|bound| !bound.is_empty())?;
241+ (!key.is_empty()).then(|| crypto::invite_proof(key, invite_id, bound))
242+}
243+
244+/// Whether `proof` shows that whoever brings it followed the invite's own
245+/// email: it is the proof for this invite and the address it is bound to,
246+/// and `email`, the address the account is made with, is that address.
247+/// Anything else (no proof, a wrong or altered one, another invite's, an
248+/// invite bound to no address, a different address) proves nothing, and
249+/// the address is confirmed as any other is.
250+pub fn proves_email(key: &[u8], invite_id: &str, bound: Option<&str>, email: &str, proof: Option<&str>) -> bool {
251+ let (Some(expected), Some(proof)) = (email_proof(key, invite_id, bound), proof.map(str::trim)) else {
252+ return false;
253+ };
254+ let same_address = bound.is_some_and(|bound| bound.trim().to_lowercase() == email.trim().to_lowercase());
255+ same_address && crypto::same(&expected, &proof.to_ascii_lowercase())
256+}
257+
258+/// Whether a new account starts with its address confirmed: GitHub
259+/// confirmed it (`verified`), or `invite`, the one-person invite that
260+/// admitted it, was followed from its own email with `proof` and `email` is
261+/// the address it was sent to. A shared link, a code typed in or passed on,
262+/// or an invite bound to no address: confirmed as any other is.
263+pub fn starts_confirmed(key: &[u8], verified: bool, invite: Option<&InviteRow>, email: &str, proof: Option<&str>) -> bool {
264+ verified
265+ || invite.is_some_and(|row| row.kind == "account" && proves_email(key, &row.id, row.email.as_deref(), email, proof))
266+}
267+
236268 /// What an invite used to sign up does once its account confirms its
237269 /// address.
238270 #[derive(Clone, Debug, PartialEq, Eq)]
439471 /// Whether the address is confirmed already (GitHub's verified email).
440472 pub verified: bool,
441473 pub invite_code: Option<&'a str>,
474+ /// The proof from the invite email's link ([`proves_email`]): when it
475+ /// is the invite's and `email` is the address the invite was sent to,
476+ /// the account starts with that address confirmed.
477+ pub email_proof: Option<&'a str>,
442478 /// Who is asking, for rate limits.
443479 pub client: Option<&'a str>,
444480 }
498534 Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string())
499535 }
500536
537+ /// The key invite email proofs are made under: IDENTITY_KEY, or none
538+ /// in a development setup without one (then no proof is made, and none
539+ /// is accepted).
540+ fn proof_key(&self) -> Vec<u8> {
541+ self.env.secret("IDENTITY_KEY").map(|key| key.to_string().into_bytes()).unwrap_or_default()
542+ }
543+
544+ /// The proof for the link of an invite emailed to `to`, the address it
545+ /// is bound to; never shown anywhere but in that email.
546+ pub(crate) fn email_proof_for(&self, invite_id: &str, to: &str) -> Option<String> {
547+ email_proof(&self.proof_key(), invite_id, Some(to))
548+ }
549+
550+ /// Whether `proof` shows the invite in `row` was followed from its own
551+ /// email, by someone making an account with `email`.
552+ fn proven(&self, row: &InviteRow, email: &str, proof: Option<&str>) -> bool {
553+ starts_confirmed(&self.proof_key(), false, Some(row), email, proof)
554+ }
555+
501556 // --- Rate limits ---
502557
503558 /// Counts one more hit on `key` this hour; false once past `limit`.
693748 /// invite-only, `invite_code` must admit `email`; the code is spent in
694749 /// the same transaction as the account is made. What the invite gives
695750 /// (a workspace, repository invitations) is applied once the address
696− /// is confirmed: at once for an address GitHub has confirmed, otherwise
751+ /// is confirmed: at once for an address GitHub has confirmed or one
752+ /// proven by the invite email's link ([`proves_email`]), otherwise
697753 /// in the transaction that confirms it (emails.rs, `confirm_address`).
698754 /// In open mode a code is used if it is good and otherwise ignored.
699755 pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> {
739795 }
740796 }
741797
742− // Only an address GitHub has confirmed starts confirmed. An invite
743− // bound to the address proves nothing: its link can be forwarded,
744− // so the new account confirms the address like any other.
745− let verified = new.verified;
798+ // An address GitHub has confirmed starts confirmed, and so does the
799+ // address an invite was emailed to, when the link followed was the
800+ // email's own: its proof is in no code the inviter sees or shares.
801+ // The code alone proves nothing (it can be passed on), so without
802+ // the proof the new account confirms the address like any other.
803+ let verified = starts_confirmed(&self.proof_key(), new.verified, invite.as_ref(), new.email, new.email_proof);
746804 let user = User {
747805 id: new_id("usr", now_ms()),
748806 username: new.username.to_owned(),
823881 self.count_failure(new.client).await?;
824882 return Ok(Outcome::fail(FailureCode::Forbidden, INVALID));
825883 }
826− // Confirmed already (GitHub): what the invite gives, now. Otherwise
884+ // Confirmed already (GitHub, or the invite email): what the invite gives, now. Otherwise
827885 // it waits, spent, for the address to be confirmed.
828886 if let Some(row) = invite
829887 && user.verified
11691227 };
11701228 if let (Some(email), Some(code)) = (&email, &invite.code) {
11711229 let from = self.display_name(&a.user).await;
1172− self.send_invite_email(email, Some(&from), None, false, code, None).await;
1230+ self.send_invite_email(email, Some(&from), None, false, code, &invite.id, None).await;
11731231 }
11741232 let logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
11751233 self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint))
11841242 workspace: Option<&str>,
11851243 existing: bool,
11861244 code: &str,
1245+ invite_id: &str,
11871246 note: Option<&str>,
11881247 ) {
1248+ // An invite that makes an account carries the proof that the link
1249+ // came from this email; one for an existing account has nothing
1250+ // to prove.
1251+ let proof = if existing { None } else { self.email_proof_for(invite_id, to) };
11891252 let invite = crate::email::InviteEmail {
11901253 to,
11911254 from,
11921255 workspace,
11931256 joins_existing_account: existing,
11941257 code,
1258+ proof: proof.as_deref(),
11951259 days: self.invite_ttl_days(),
11961260 note,
11971261 };
13491413 _ => false,
13501414 };
13511415 let repository = self.repository_of_code(&row.id).await?;
1416+ // Opened from the invite's own email: the account it makes starts
1417+ // with the address confirmed. Said only while it can make one.
1418+ let email_proven = pending
1419+ && !has_account
1420+ && row.email.as_deref().is_some_and(|bound| self.proven(&row, bound, a.email_proof.as_deref()));
13521421 #[derive(Deserialize)]
13531422 struct From {
13541423 username: String,
13911460 expires_at: row.expires_at,
13921461 shared_label: None,
13931462 shared_domains: Vec::new(),
1463+ email_proven,
13941464 }))
13951465 }
13961466
15501620 if let Some(code) = &invite.code {
15511621 let from = self.display_name(&a.actor).await;
15521622 let workspace = self.workspace_name(&workspace_id, &slug).await;
1553− self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, None).await;
1623+ self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, &invite.id, None).await;
15541624 }
15551625 self.audit_invites(
15561626 &a.actor,
19532023 return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again."));
19542024 };
19552025 if let (Some(email), Some(code)) = (&email, &invite.code) {
1956− self.send_invite_email(email, None, None, false, code, note).await;
2026+ self.send_invite_email(email, None, None, false, code, &invite.id, note).await;
19572027 }
19582028 invite.staff = Some(staff.to_owned());
19592029 Ok(Outcome::Ok(invite))
23962466 assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(()));
23972467 }
23982468
2469+ const KEY: &[u8] = b"identity key";
2470+
2471+ #[test]
2472+ fn an_invite_emails_proof_is_for_its_invite_and_address_only() {
2473+ let proof = email_proof(KEY, "inv_1", Some("ada@example.com")).unwrap();
2474+ assert_eq!(proof.len(), 64);
2475+ let proves = |id: &str, bound: Option<&str>, email: &str, proof: Option<&str>| proves_email(KEY, id, bound, email, proof);
2476+ // The right invite and address, however the address is written.
2477+ assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof)));
2478+ assert!(proves("inv_1", Some("Ada@Example.com"), " ADA@example.com ", Some(&proof)));
2479+ assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof.to_uppercase())));
2480+ // Another address: the account confirms that one itself.
2481+ assert!(!proves("inv_1", Some("ada@example.com"), "eve@example.com", Some(&proof)));
2482+ // Another invite's proof, even for the same address.
2483+ assert!(!proves("inv_2", Some("ada@example.com"), "ada@example.com", Some(&proof)));
2484+ // Tampered, cut short, empty or missing.
2485+ let mut tampered = proof.clone().into_bytes();
2486+ tampered[10] = if tampered[10] == b'0' { b'1' } else { b'0' };
2487+ let tampered = String::from_utf8(tampered).unwrap();
2488+ assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&tampered)));
2489+ assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof[..32])));
2490+ assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some("")));
2491+ assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", None));
2492+ // An invite bound to no address has no proof to give.
2493+ assert_eq!(email_proof(KEY, "inv_1", None), None);
2494+ assert!(!proves("inv_1", None, "ada@example.com", Some(&proof)));
2495+ // Made under another key: not ours.
2496+ let foreign = email_proof(b"another key", "inv_1", Some("ada@example.com")).unwrap();
2497+ assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&foreign)));
2498+ // Without a key (development) none is made, and none is taken.
2499+ assert_eq!(email_proof(b"", "inv_1", Some("ada@example.com")), None);
2500+ let unkeyed = crypto::invite_proof(b"", "inv_1", "ada@example.com");
2501+ assert!(!proves_email(b"", "inv_1", Some("ada@example.com"), "ada@example.com", Some(&unkeyed)));
2502+ }
2503+
2504+ #[test]
2505+ fn an_account_starts_confirmed_only_from_the_invite_email_to_its_address() {
2506+ let invite = row(None, false, LATER);
2507+ let proof = email_proof(KEY, &invite.id, invite.email.as_deref()).unwrap();
2508+ // From the invite email, with the address it was sent to.
2509+ assert!(starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some(&proof)));
2510+ // The code alone (typed in, or a link passed on), or a bad proof.
2511+ assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", None));
2512+ assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some("0123")));
2513+ // A different address than the invite's.
2514+ assert!(!starts_confirmed(KEY, false, Some(&invite), "eve@example.com", Some(&proof)));
2515+ // No invite (open registration, or a shared link), or one bound to no address.
2516+ assert!(!starts_confirmed(KEY, false, None, "ada@example.com", Some(&proof)));
2517+ let unbound = InviteRow { email: None, ..row(None, false, LATER) };
2518+ assert!(!starts_confirmed(KEY, false, Some(&unbound), "ada@example.com", Some(&proof)));
2519+ // A workspace invite makes no account.
2520+ let join = InviteRow { kind: "workspace".into(), ..row(None, false, LATER) };
2521+ assert!(!starts_confirmed(KEY, false, Some(&join), "ada@example.com", Some(&proof)));
2522+ // GitHub's confirmed address, whatever else.
2523+ assert!(starts_confirmed(KEY, true, None, "ada@example.com", None));
2524+ }
2525+
23992526 #[test]
24002527 fn addresses_are_checked_and_masked() {
24012528 assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com"));
+1−0
427427 password_hash: &password_hash,
428428 verified: false,
429429 invite_code,
430+ email_proof: a.email_proof.as_deref(),
430431 client: a.client.as_deref(),
431432 })
432433 .await?
+1−0
379379 expires_at: link.expires_at.clone(),
380380 shared_label: Some(link.label.clone()),
381381 shared_domains: link.domains(),
382+ email_proven: false,
382383 }
383384 }
384385