carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// Package auth is signup and passphrase sign-in, server side.
2//
3// Everything expensive and everything secret happens in the browser. What
4// reaches here is public keys, sealed blobs, and an opaque index of an email
5// address — so this package can be read as an answer to one question: is
6// there anything here that could open a vault? There is not, and there is no
7// code path that could be extended to add one without deleting something.
8//
9// Sign-in is a signature, not a verifier. The client derives a deterministic
10// P-256 keypair from the login branch of its passphrase and we keep only the
11// public half. Better than a stored password verifier in three ways: a fully
12// compromised server holds something that cannot decrypt, cannot be replayed
13// to impersonate, and adds no offline attack surface it did not already have
14// through the wraps sitting in the bucket.
15//
16// The forbidden shortcut, named so it stays refused: email-gated escrow of
17// any key. An identity anchor must never be able to decrypt anything. A
18// compromised server plus a passed email gate must still yield nothing.
19package auth
20
21import (
22 "context"
23 "crypto/ecdsa"
24 "crypto/elliptic"
25 "crypto/hmac"
26 "crypto/rand"
27 "crypto/sha256"
28 "encoding/hex"
29 "encoding/json"
30 "errors"
31 "fmt"
32 "log/slog"
33 "math/big"
34 "net/http"
35 "strings"
36 "time"
37
38 "gorm.io/gorm"
39
40 "github.com/carlosframework/rastrillo/sessions"
41
42 "github.com/carlosframework/contra/internal/contra/mail"
43 "github.com/carlosframework/contra/internal/contra/model"
44 "github.com/carlosframework/contra/internal/contra/store"
45)
46
47// The KDF floor, mirroring the client's. PROTOCOL.md puts the real defence in
48// the browser — a malicious server is the threat, so a server-side check
49// cannot protect anyone from us — but a client bug must not be able to
50// persist parameters that a later, honest client would then trust.
51const (
52 MinMemoryKiB = 64 * 1024
53 MinTime = 3
54 Lanes = 1
55)
56
57// ChallengeTTL is how long a login challenge is good for. Short, because its
58// only job is to be alive between two requests of the same ceremony.
59const ChallengeTTL = 2 * time.Minute
60
61// challengeContext is prepended to the bytes a client signs, so a signature
62// made for this ceremony cannot be replayed into another one, and a challenge
63// issued for the recovery branch cannot be spent on the passphrase branch.
64const challengeContext = "contra/v1/login-challenge"
65
66var (
67 ErrWeakParams = errors.New("auth: KDF parameters below the floor")
68 ErrTaken = errors.New("auth: that identity is already registered")
69)
70
71// Auth holds what the handlers need. The pepper is the only secret here, and
72// it is an identity secret: it blinds the email index and can decrypt
73// nothing.
74type Auth struct {
75 db *gorm.DB
76 store *store.Store
77 sess *sessions.Sessions
78 pepper []byte
79
80 // mailer sends the signup link and nothing else. Nil is allowed and
81 // logged loudly rather than refused, so a local run without SMTP still
82 // boots — but a deployment without it cannot sign anybody up, which is
83 // why New says so at startup instead of at the first attempt.
84 mailer mail.Sender
85
86 // origin is what goes in the link. It has to be the address the person
87 // will actually open, not a guess.
88 origin string
89 logger *slog.Logger
90}
91
92// New refuses a missing or short pepper rather than inventing one.
93//
94// An ephemeral pepper would be worse than no pepper: every account's blind
95// index would change on the next restart, and every one of them would become
96// unreachable — data loss dressed as a default. PROTOCOL.md wants this moved
97// off the instance entirely, to the routing layer or a KMS, so that a
98// database-plus-bucket leak alone cannot unblind addresses.
99func New(db *gorm.DB, sess *sessions.Sessions, pepper []byte, mailer mail.Sender,
100 origin string, logger *slog.Logger) (*Auth, error) {
101 if len(pepper) < 32 {
102 return nil, errors.New(
103 "auth: the email pepper must be at least 32 bytes; set CONTRA_EMAIL_PEPPER")
104 }
105 if logger == nil {
106 logger = slog.Default()
107 }
108 if mailer == nil {
109 logger.Warn("auth: no mailer; signup links cannot be sent",
110 "fix", "carlos email enable --app contra")
111 }
112 return &Auth{
113 db: db, store: store.New(db), sess: sess, pepper: pepper,
114 mailer: mailer, origin: origin, logger: logger,
115 }, nil
116}
117
118// NormalizeEmail is deliberately minimal: trim and lowercase, nothing more.
119// Anything cleverer — stripping dots, cutting +tags — would silently merge
120// two addresses a person believes are separate accounts, and there is no
121// recovery path from having merged two vaults.
122func NormalizeEmail(email string) string {
123 return strings.ToLower(strings.TrimSpace(email))
124}
125
126// BlindIndex is the whole of what the server knows about an address.
127//
128// It proves identity for sign-in and can never decrypt anything. There is no
129// stored address column and no provider user-id.
130func (a *Auth) BlindIndex(email string) []byte {
131 m := hmac.New(sha256.New, a.pepper)
132 m.Write([]byte("contra/v1/email-index"))
133 m.Write([]byte{0})
134 m.Write([]byte(NormalizeEmail(email)))
135 return m.Sum(nil)
136}
137
138// decoySalt is the deterministic answer for an address with no account.
139//
140// Deterministic is the whole point: a prober who asks twice must get the same
141// answer, or the variance itself says "no account here". Domain-separated
142// from the blind index so the two can never be confused for one another.
143func (a *Auth) decoySalt(email string) []byte {
144 m := hmac.New(sha256.New, a.pepper)
145 m.Write([]byte("contra/v1/decoy-salt"))
146 m.Write([]byte{0})
147 m.Write([]byte(NormalizeEmail(email)))
148 return m.Sum(nil)[:16]
149}
150
151// CheckParams is the server-side half of the KDF floor.
152func CheckParams(raw string) error {
153 var p struct {
154 Alg string `json:"alg"`
155 MKiB uint32 `json:"m_kib"`
156 T uint32 `json:"t"`
157 P uint32 `json:"p"`
158 }
159 if err := json.Unmarshal([]byte(raw), &p); err != nil {
160 return fmt.Errorf("%w: unreadable parameters", ErrWeakParams)
161 }
162 if p.Alg != "argon2id" {
163 return fmt.Errorf("%w: algorithm %q", ErrWeakParams, p.Alg)
164 }
165 if p.MKiB < MinMemoryKiB || p.T < MinTime || p.P != Lanes {
166 return fmt.Errorf("%w: m=%d t=%d p=%d", ErrWeakParams, p.MKiB, p.T, p.P)
167 }
168 return nil
169}
170
171// ---------------------------------------------------------------- the wire
172
173type saltRequest struct {
174 Email string `json:"email"`
175}
176
177type saltResponse struct {
178 SaltHex string `json:"salt_hex"`
179 KDFParams string `json:"kdf_params"`
180}
181
182type signupRequest struct {
183 Email string `json:"email"`
184 // AccountIDHex is minted by the client, because the client binds it into
185 // the AAD of the wraps below before the server has seen any of them.
186 AccountIDHex string `json:"account_id_hex"`
187 SaltHex string `json:"salt_hex"`
188 LoginKeys []struct {
189 Branch string `json:"branch"`
190 PublicKeyHex string `json:"public_key_hex"`
191 } `json:"login_keys"`
192 Wraps []struct {
193 Kind string `json:"kind"`
194 WrapIDHex string `json:"wrap_id_hex"`
195 KDFParams string `json:"kdf_params"`
196 Version uint64 `json:"version"`
197 KCVHex string `json:"kcv_hex"`
198 CredSaltHex string `json:"cred_salt_hex"`
199 NonceHex string `json:"nonce_hex"`
200 CiphertextHex string `json:"ciphertext_hex"`
201 } `json:"wraps"`
202}
203
204type challengeRequest struct {
205 Email string `json:"email"`
206 Branch string `json:"branch"`
207}
208
209type challengeResponse struct {
210 ChallengeHex string `json:"challenge_hex"`
211 SaltHex string `json:"salt_hex"`
212 KDFParams string `json:"kdf_params"`
213}
214
215type verifyRequest struct {
216 Email string `json:"email"`
217 Branch string `json:"branch"`
218 ChallengeHex string `json:"challenge_hex"`
219 SignatureHex string `json:"signature_hex"`
220}
221
222// DefaultKDFParams is what a new client is told to use. Served, not trusted:
223// the client pins its own floor and refuses anything weaker, because a
224// malicious server offering m=8KiB is exactly the attack.
225const DefaultKDFParams = `{"alg":"argon2id","m_kib":65536,"t":3,"p":1}`
226
227func writeJSON(w http.ResponseWriter, status int, v any) {
228 w.Header().Set("Content-Type", "application/json")
229 w.Header().Set("Cache-Control", "no-store")
230 w.WriteHeader(status)
231 _ = json.NewEncoder(w).Encode(v)
232}
233
234func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
235 dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
236 dec.DisallowUnknownFields()
237 if err := dec.Decode(v); err != nil {
238 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed request"})
239 return false
240 }
241 return true
242}
243
244// Salt answers the pre-auth question "what do I stretch my passphrase
245// against?" — and answers it for every address, whether or not an account
246// exists, so that account existence does not leak from it.
247func (a *Auth) Salt(w http.ResponseWriter, r *http.Request) {
248 var req saltRequest
249 if !readJSON(w, r, &req) {
250 return
251 }
252
253 var acct model.Account
254 err := a.db.WithContext(r.Context()).
255 Where("email_index = ?", a.BlindIndex(req.Email)).First(&acct).Error
256
257 salt := a.decoySalt(req.Email)
258 if err == nil {
259 salt = acct.KDFSalt
260 } else if !errors.Is(err, gorm.ErrRecordNotFound) {
261 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"})
262 return
263 }
264 writeJSON(w, http.StatusOK, saltResponse{
265 SaltHex: hex.EncodeToString(salt), KDFParams: DefaultKDFParams})
266}
267
268// storeCredentials writes the public halves and the sealed wraps a completed
269// signup produces. Shared by the flow that creates an account so that there
270// is one definition of what a credential set is, rather than two that drift.
271func (a *Auth) storeCredentials(ctx context.Context, accountID []byte, req signupRequest) error {
272 for _, k := range req.LoginKeys {
273 pub, err := hex.DecodeString(k.PublicKeyHex)
274 if err != nil || len(pub) != 65 {
275 return errors.New("bad public key")
276 }
277 if _, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), pub); err != nil {
278 return errors.New("public key is not on the curve")
279 }
280 if err := a.store.PutLoginKey(ctx, accountID, k.Branch, pub); err != nil {
281 return errors.New("could not store a login key")
282 }
283 }
284 for _, wr := range req.Wraps {
285 kcv, e1 := hex.DecodeString(wr.KCVHex)
286 nonce, e2 := hex.DecodeString(wr.NonceHex)
287 ct, e3 := hex.DecodeString(wr.CiphertextHex)
288 credSalt, e4 := hex.DecodeString(wr.CredSaltHex)
289 wrapID, e5 := hex.DecodeString(wr.WrapIDHex)
290 if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil {
291 return errors.New("bad wrap")
292 }
293 if err := a.store.PutWrap(ctx, accountID, wrapID, wr.Kind, wr.KDFParams,
294 wr.Version, kcv, credSalt, store.Sealed{Nonce: nonce, Ciphertext: ct}); err != nil {
295 return err
296 }
297 }
298 return nil
299}
300
301// Challenge issues the bytes a client proves possession over — and issues
302// them for an unknown address too, from the same code path and the same
303// randomness, so a prober cannot tell the two apart from the response.
304func (a *Auth) Challenge(w http.ResponseWriter, r *http.Request) {
305 var req challengeRequest
306 if !readJSON(w, r, &req) {
307 return
308 }
309 if req.Branch != "pw" && req.Branch != "rc" {
310 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown branch"})
311 return
312 }
313
314 nonce := make([]byte, 32)
315 if _, err := rand.Read(nonce); err != nil {
316 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"})
317 return
318 }
319
320 resp := challengeResponse{
321 ChallengeHex: hex.EncodeToString(nonce),
322 SaltHex: hex.EncodeToString(a.decoySalt(req.Email)),
323 KDFParams: DefaultKDFParams,
324 }
325
326 var acct model.Account
327 err := a.db.WithContext(r.Context()).
328 Where("email_index = ?", a.BlindIndex(req.Email)).First(&acct).Error
329 if err == nil {
330 id, err := store.NewID()
331 if err != nil {
332 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"})
333 return
334 }
335 if err := a.db.WithContext(r.Context()).Create(&model.LoginChallenge{
336 ID: id,
337 AccountID: acct.ID,
338 Branch: req.Branch,
339 Challenge: nonce,
340 ExpiresAt: time.Now().UTC().Add(ChallengeTTL),
341 CreatedAt: time.Now().UTC(),
342 }).Error; err != nil {
343 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not issue"})
344 return
345 }
346 resp.SaltHex = hex.EncodeToString(acct.KDFSalt)
347 } else if !errors.Is(err, gorm.ErrRecordNotFound) {
348 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"})
349 return
350 }
351 // An unknown address gets a challenge that was never recorded, so the
352 // ceremony fails one step later — at Verify, where every failure looks
353 // the same — instead of here, where the shape of the failure would say
354 // whether the account exists.
355 writeJSON(w, http.StatusOK, resp)
356}
357
358// SignedMessage is what a client signs: the ceremony's context, the branch,
359// and the challenge. Binding the branch stops a challenge issued for the
360// recovery credential being spent on the passphrase one.
361func SignedMessage(branch string, challenge []byte) []byte {
362 out := make([]byte, 0, len(challengeContext)+2+len(branch)+len(challenge))
363 out = append(out, challengeContext...)
364 out = append(out, 0)
365 out = append(out, branch...)
366 out = append(out, 0)
367 return append(out, challenge...)
368}
369
370// Verify checks the signature and, on success, mints a session.
371//
372// Every failure answers the same thing. An unknown address, an expired
373// challenge, a challenge already spent, and a wrong signature are four very
374// different facts, and telling them apart is the caller's problem, not ours.
375func (a *Auth) Verify(w http.ResponseWriter, r *http.Request) {
376 var req verifyRequest
377 if !readJSON(w, r, &req) {
378 return
379 }
380 refuse := func() {
381 writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "could not sign in"})
382 }
383
384 challenge, err := hex.DecodeString(req.ChallengeHex)
385 if err != nil || len(challenge) != 32 {
386 refuse()
387 return
388 }
389 sig, err := hex.DecodeString(req.SignatureHex)
390 if err != nil || len(sig) != 64 {
391 refuse()
392 return
393 }
394
395 ctx := r.Context()
396 var acct model.Account
397 if err := a.db.WithContext(ctx).
398 Where("email_index = ?", a.BlindIndex(req.Email)).First(&acct).Error; err != nil {
399 refuse()
400 return
401 }
402
403 var row model.LoginChallenge
404 if err := a.db.WithContext(ctx).
405 Where("account_id = ? AND branch = ? AND challenge = ?",
406 acct.ID, req.Branch, challenge).First(&row).Error; err != nil {
407 refuse()
408 return
409 }
410 // Single use, spent whether or not the signature turns out to be good:
411 // a challenge that survives a failed attempt is a challenge an attacker
412 // can grind against.
413 if err := a.db.WithContext(ctx).Delete(&model.LoginChallenge{}, "id = ?", row.ID).Error; err != nil {
414 refuse()
415 return
416 }
417 if time.Now().UTC().After(row.ExpiresAt) {
418 refuse()
419 return
420 }
421
422 var key model.LoginKey
423 if err := a.db.WithContext(ctx).
424 Where("account_id = ? AND branch = ?", acct.ID, req.Branch).First(&key).Error; err != nil {
425 refuse()
426 return
427 }
428 pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), key.PublicKey)
429 if err != nil {
430 refuse()
431 return
432 }
433
434 digest := sha256.Sum256(SignedMessage(req.Branch, challenge))
435 rr := new(big.Int).SetBytes(sig[:32])
436 ss := new(big.Int).SetBytes(sig[32:])
437 if !ecdsa.Verify(pub, digest[:], rr, ss) {
438 refuse()
439 return
440 }
441
442 if err := a.sess.SignIn(w, r, sessions.Session{
443 Subject: hex.EncodeToString(acct.ID),
444 Method: "passphrase/" + req.Branch,
445 AuthTime: time.Now().UTC(),
446 }); err != nil {
447 refuse()
448 return
449 }
450 writeJSON(w, http.StatusOK, map[string]string{
451 "account_id_hex": hex.EncodeToString(acct.ID)})
452}
453
454// SignOut ends the session. Sessions are rows, so this is a real revocation
455// rather than a forgotten cookie.
456func (a *Auth) SignOut(w http.ResponseWriter, r *http.Request) {
457 a.sess.SignOut(w, r)
458 http.Redirect(w, r, "/", http.StatusSeeOther)
459}
460
461// SweepChallenges deletes expired challenges. Cheap, and it keeps a table
462// that only ever holds live state from becoming a log of when people signed
463// in — which would be exactly the transport-shape residue we do not have to
464// keep.
465func (a *Auth) SweepChallenges(now time.Time) error {
466 return a.db.Where("expires_at < ?", now.UTC()).Delete(&model.LoginChallenge{}).Error
467}
468