feat(auth): sign in with an email address, keeping only a keyed hash of it
People without GitHub can now keep an anonymous account, and sign in on
another device, with an email address and a code mailed to it each time;
no password. The instance keeps only HMAC-SHA256 of the address, keyed
with its emailSignInPepper (ADR 0010): from what someone types it finds
the person and tells whether an address is free, it mails only an address
someone has just typed, and it can't write to anybody otherwise or show
anybody their address. The device remembers the address it signed in
with, in the person's folder, to show it.
It works as GitHub does. A code for a new address makes it the signed-in
person's, who becomes identified and keeps everything. A code for another
person's address gives a switch token, which signs in as them without a
session; a device holding studysets asks first. Asking for a code answers
alike for every address. Codes are eight Crockford characters, valid 15
minutes and three tries (Serverpod's code challenges); three codes an
hour per address, ten per network. In development the code goes to the
server's log; elsewhere there is no email sign-in until a mail service
is set up (instance.info().emailSignIn).
Someone identified may add the other way in: the Profile tab lists how
they sign in and adds GitHub or an address. They are never switched to
another account: an address or GitHub account that is another's is said
to be so. Switching now deletes the account left behind only when it was
anonymous; it deleted any, which only anonymous people could reach so far.
The migration adds email_identity and email_code_request with their
indexes and foreign keys to Serverpod's challenges; nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-15 19:42:21 +02:00
|
|
|
# ADR 0010: Signing in with an email address, keeping only a keyed hash of it
|
|
|
|
|
|
|
|
|
|
- **Status:** accepted
|
|
|
|
|
- **Date:** 2026-10-15
|
|
|
|
|
|
|
|
|
|
## Context
|
|
|
|
|
|
|
|
|
|
People keep an anonymous account by linking an identity, which makes them `identified`:
|
|
|
|
|
they can sign in on another device, publish, report and suggest. Until now the only
|
|
|
|
|
identity was GitHub, which many learners don't have. The developer asked for email
|
|
|
|
|
sign-up first among the features after the hackathon (11 Oct).
|
|
|
|
|
|
|
|
|
|
GitHub sign-in keeps only the numeric GitHub ID (decided 2026-09-30): the instance holds
|
|
|
|
|
no personal data of anybody beyond what they write. Serverpod's email sign-in (checked
|
|
|
|
|
with v4.0.4) doesn't fit that. It keeps the address in plain text in three tables (the
|
|
|
|
|
account, the pending registration, and a user profile it creates), works with passwords
|
|
|
|
|
(Argon2id) and resets by mailed codes, and finishing a registration always creates a new
|
|
|
|
|
person, so an anonymous account can't be upgraded in place with it. Its building blocks
|
|
|
|
|
are public, though: code challenges kept as Argon2id hashes, with expiry, a limit on
|
|
|
|
|
tries and completion tokens (`SecretChallengeUtil`), and a rate limiter
|
|
|
|
|
(`DatabaseRateLimiter`).
|
|
|
|
|
|
|
|
|
|
## Decision
|
|
|
|
|
|
|
|
|
|
1. **The instance keeps a keyed hash of the address, never the address.** An address is
|
|
|
|
|
trimmed and put in lower case; the instance keeps HMAC-SHA256 of it, keyed with its
|
|
|
|
|
own `emailSignInPepper` (in `passwords.yaml`, `SERVERPOD_PASSWORD_emailSignInPepper`
|
|
|
|
|
in production), in hex (`addressHash` in `lib/src/auth/email.dart`). `email_identity`
|
|
|
|
|
holds the person and the hash, one address per person and each address one
|
|
|
|
|
person's. From what someone types, the instance can still find the person and tell
|
|
|
|
|
whether an address is free; it mails only an address someone has just typed, to send
|
|
|
|
|
a code; it can't write to anybody otherwise, nor show anybody their address. The
|
|
|
|
|
device that signed in remembers the address, to show it (`sign_in.json` in the
|
|
|
|
|
person's folder, which goes with the rest of their data).
|
|
|
|
|
2. **A code by mail each time, no password.** A code is eight characters of Crockford's
|
|
|
|
|
base 32 (`K7QX-M2PD`, as the assistant's connect codes), valid 15 minutes and 3
|
|
|
|
|
tries, kept as Serverpod's `SecretChallenge` (Argon2id with the same pepper); a newer
|
|
|
|
|
code for an address replaces the older one. No password is stored, reset or leaked.
|
|
|
|
|
3. **The same answer for every address.** Mailing a code answers alike whether anybody
|
|
|
|
|
has the address. Only once the code proves the mailbox does the instance say whose
|
|
|
|
|
it is.
|
|
|
|
|
4. **As GitHub works.** A code for an address nobody has makes it the signed-in
|
|
|
|
|
person's, in one transaction: they become `identified`, on their sessions too, and
|
|
|
|
|
lose their anonymous identity, keeping everything. A code for another person's
|
|
|
|
|
address changes nothing and gives a switch token (Serverpod's completion token),
|
|
|
|
|
which signs in as that person without a session (`emailSignIn.signIn`, as Serverpod
|
|
|
|
|
issues no session for another person inside a signed-in call), once, within the 15
|
|
|
|
|
minutes; the device then deletes the anonymous account it leaves behind, as nobody
|
|
|
|
|
could sign into it again. Someone identified is never switched, by GitHub either:
|
|
|
|
|
the app tells them the address or GitHub account is another account's, as their own
|
|
|
|
|
account would otherwise be left behind (and the app's switch deleted only what it
|
|
|
|
|
left behind, whoever that was; now only an anonymous account).
|
|
|
|
|
5. **Two ways in.** A person may sign in with GitHub and with an address, one of each
|
|
|
|
|
(`account.signInWays`); the Profile tab lists them and adds the one missing.
|
|
|
|
|
6. **Limits.** At most 3 codes an hour for one address (counted by its hash) and 10 from
|
|
|
|
|
one network (IP address); the rate limiter's notes, which hold the IP address, go
|
|
|
|
|
hourly with the anonymous sign-ups' (`forgetOldSignUpAttempts`), and codes past their
|
|
|
|
|
time hourly too (`forgetExpiredEmailCodes`).
|
feat(auth): codes go by mail, through the SMTP service an instance sets up
Email sign-in's codes are mailed over SMTP (pub.dev's mailer), through
whichever service the passwords name: emailSmtpHost, emailSmtpPort,
emailSmtpUser, emailSmtpPassword and emailFrom (EMAIL_SMTP_* and
EMAIL_FROM in deploy/.env, with EMAIL_SIGN_IN_PEPPER). TLS only:
STARTTLS, or TLS directly on 465. learn.halkyon.eu sends through Proton's
SMTP submission. Without a service, codes still go to the log in
development, and elsewhere there is no email sign-in.
The mail is plain text, in English or German: the code first and in the
subject, so it shows in the notification and the system can offer it,
the instance it signs in to, its 15 minutes, and that it can be ignored;
no links, no tracking, no HTML. A mail that couldn't be sent is a refusal
of its own (mailFailed): nothing of the request is kept, and the log
names the error's type only, as the service's answer may name the
address. A fake SMTP server on loopback drives the real mailer in the
tests: it delivers, and nothing goes to a server without TLS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-15 20:00:52 +02:00
|
|
|
7. **Mail.** Codes go over SMTP, through whichever service the operator sets up in the
|
|
|
|
|
passwords (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`,
|
|
|
|
|
`emailFrom`; `deploy/.env` in production), with pub.dev's `mailer` (MIT), over TLS
|
|
|
|
|
only (STARTTLS, or TLS directly on port 465). learn.halkyon.eu sends through Proton's
|
|
|
|
|
SMTP submission (decided 2026-10-15, over Scaleway TEM): a token for an address on
|
|
|
|
|
its own domain. The mail is plain text: the code first and in the subject, so it
|
|
|
|
|
shows in the notification and the system can offer it, which instance it signs in
|
|
|
|
|
to, its 15 minutes, and that it can be ignored; no links, no tracking, no HTML. A
|
|
|
|
|
mail that couldn't be sent is a refusal of its own (`mailFailed`), keeping nothing
|
|
|
|
|
of the request; the log names the error's type only, as the service's answer may
|
|
|
|
|
name the address. Without a service, codes go to the server's log in development
|
|
|
|
|
(`LogCodeMailer`), and elsewhere the instance offers no email sign-in
|
|
|
|
|
(`instance.info().emailSignIn`).
|
feat(auth): sign in with an email address, keeping only a keyed hash of it
People without GitHub can now keep an anonymous account, and sign in on
another device, with an email address and a code mailed to it each time;
no password. The instance keeps only HMAC-SHA256 of the address, keyed
with its emailSignInPepper (ADR 0010): from what someone types it finds
the person and tells whether an address is free, it mails only an address
someone has just typed, and it can't write to anybody otherwise or show
anybody their address. The device remembers the address it signed in
with, in the person's folder, to show it.
It works as GitHub does. A code for a new address makes it the signed-in
person's, who becomes identified and keeps everything. A code for another
person's address gives a switch token, which signs in as them without a
session; a device holding studysets asks first. Asking for a code answers
alike for every address. Codes are eight Crockford characters, valid 15
minutes and three tries (Serverpod's code challenges); three codes an
hour per address, ten per network. In development the code goes to the
server's log; elsewhere there is no email sign-in until a mail service
is set up (instance.info().emailSignIn).
Someone identified may add the other way in: the Profile tab lists how
they sign in and adds GitHub or an address. They are never switched to
another account: an address or GitHub account that is another's is said
to be so. Switching now deletes the account left behind only when it was
anonymous; it deleted any, which only anonymous people could reach so far.
The migration adds email_identity and email_code_request with their
indexes and foreign keys to Serverpod's challenges; nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-15 19:42:21 +02:00
|
|
|
8. **The pepper never changes** once anybody signs in with an address: every hash would
|
|
|
|
|
be lost, and with it those people's way in. Rotating it would need a list of earlier
|
|
|
|
|
peppers tried in turn, which isn't built.
|
|
|
|
|
|
|
|
|
|
## Consequences
|
|
|
|
|
|
|
|
|
|
- The instance can send no notices: no "your sign-in changed", no newsletter. A breach
|
|
|
|
|
would be communicated publicly (GDPR Article 34(3)(c)), as the instance holds no way
|
|
|
|
|
to reach people.
|
|
|
|
|
- Whoever has both the database and the pepper can check whether a guessed address
|
|
|
|
|
signs in here, but can't list the addresses. Without the pepper, the hashes tell
|
|
|
|
|
nothing.
|
|
|
|
|
- Someone who loses their mailbox loses that way in, as someone who loses their GitHub
|
|
|
|
|
account loses that one; a second way in is the remedy.
|
|
|
|
|
- Every sign-in on a new device needs a mail. Sessions last until 90 days unused, so
|
|
|
|
|
that is rare.
|
|
|
|
|
- Deleting a person deletes their hash (`deletePerson`); a restore from backup brings
|
|
|
|
|
it back with the rest.
|