carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The email sign-in challenge (spec §5): one emailed link + code, for one
4// purpose. A sign-in challenge belongs to the browser's sign-in flow (its
5// flow cookie's hash), a step-up challenge to the session that asked; the
6// code box finds the newest live challenge of either by that binding, so no
7// browser ever holds a challenge's own id. Its own file, with its own
8// migration, in the authstore/step-up precedent.
9//
10// Only hashes are stored, so a database read is not a sign-in. The six-digit
11// code is stored as an HMAC under a key that lives only in this process
12// (Store.codeKey): six digits bound to a row anyone reading the database can
13// see would fall to an offline search in a blink if they were merely hashed. The price is that a
14// console restart invalidates codes in flight; links still work, and asking
15// again sends a fresh code.
16
17import (
18 "crypto/hmac"
19 "crypto/rand"
20 "crypto/sha256"
21 "database/sql"
22 "encoding/hex"
23 "fmt"
24 "math/big"
25 "strings"
26 "time"
27)
28
29const (
30 // ChallengeSignIn and ChallengeStepUp are the two purposes of a challenge.
31 ChallengeSignIn = "signin"
32 ChallengeStepUp = "stepup"
33
34 // MethodKeymail and MethodEmail are how a session was proved (spec §5.4).
35 MethodKeymail = "keymail"
36 MethodEmail = "email"
37
38 // emailCodeAttempts is how many wrong codes burn a challenge.
39 emailCodeAttempts = 5
40)
41
42// EmailChallenge is what a caller supplies to create a challenge and what a
43// read or a successful take hands back. FlowHash and Next are sign-in only;
44// SessionHash and PendingHash are step-up only.
45type EmailChallenge struct {
46 Purpose, Address, SessionHash, PendingHash, Next, FlowHash string
47 ExpiresAt time.Time
48
49 // row is the challenge's row key (id_hash), set by LiveEmailChallenge, so
50 // TakeEmailCode checks the very challenge that was read.
51 row string
52}
53
54// CodeResult is the verdict of TakeEmailCode.
55type CodeResult int
56
57const (
58 // CodeOK: the code matched; the challenge is spent.
59 CodeOK CodeResult = iota
60 // CodeWrong: it did not match and the challenge still stands.
61 CodeWrong
62 // CodeGone: there is no usable challenge (unknown, expired, wrong
63 // purpose, burned earlier, or minted under an earlier process's key).
64 CodeGone
65 // CodeBurned: the code did not match and that was the last attempt, so
66 // the challenge is gone. It is a wrong code for the guessing budget.
67 CodeBurned
68)
69
70func (s *Store) migrateEmailSignin() error {
71 _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS email_challenges (
72 id_hash TEXT PRIMARY KEY,
73 link_hash TEXT UNIQUE,
74 code_mac TEXT NOT NULL,
75 purpose TEXT NOT NULL,
76 address TEXT NOT NULL,
77 session_hash TEXT NOT NULL DEFAULT '',
78 pending TEXT NOT NULL DEFAULT '',
79 next TEXT NOT NULL DEFAULT '',
80 attempts INTEGER NOT NULL DEFAULT 0,
81 created_at INTEGER NOT NULL,
82 expires_at INTEGER NOT NULL,
83 flow_hash TEXT NOT NULL DEFAULT ''
84);`)
85 if err != nil {
86 return err
87 }
88 // Additive: the sign-in flow a challenge belongs to. Forgiving "duplicate
89 // column", so re-running is always safe. (An earlier build's alias_hashes
90 // column, where one exists, is no longer read or written.)
91 if _, err := s.db.Exec(`ALTER TABLE email_challenges ADD COLUMN flow_hash TEXT NOT NULL DEFAULT ''`); err != nil &&
92 !strings.Contains(err.Error(), "duplicate column") {
93 return err
94 }
95 return nil
96}
97
98// challengeBinding is the column that ties together the challenges of one
99// purpose: a sign-in flow's (flow_hash) or a session's step-ups
100// (session_hash). Fixed names only, so it is safe to build into SQL.
101func challengeBinding(purpose string) (column string, ok bool) {
102 switch purpose {
103 case ChallengeSignIn:
104 return "flow_hash", true
105 case ChallengeStepUp:
106 return "session_hash", true
107 }
108 return "", false
109}
110
111// bindingOf is c's value in its purpose's binding column.
112func bindingOf(c EmailChallenge) string {
113 if c.Purpose == ChallengeStepUp {
114 return c.SessionHash
115 }
116 return c.FlowHash
117}
118
119// codeMAC is epoch ":" hex(HMAC-SHA256(process key, link_hash ":" code)). The
120// row's link hash binds the code to its challenge. The epoch is a random
121// per-process label, not secret; see TakeEmailCode.
122func (s *Store) codeMAC(linkHash, code string) string {
123 m := hmac.New(sha256.New, s.codeKey)
124 m.Write([]byte(linkHash + ":" + code))
125 return s.codeEpoch + ":" + hex.EncodeToString(m.Sum(nil))
126}
127
128// CreateEmailChallenge stores a challenge and returns the emailed link token
129// and the six-digit code. A zero ExpiresAt means ConfirmationTTL from now. In
130// the same transaction it deletes every earlier challenge of the same purpose
131// and binding (the flow's sign-ins, or the session's step-ups), so only the
132// newest email's link and code work. An empty binding replaces nothing.
133func (s *Store) CreateEmailChallenge(c EmailChallenge, now time.Time) (link, code string, err error) {
134 tx, err := s.db.Begin()
135 if err != nil {
136 return "", "", err
137 }
138 defer tx.Rollback()
139 if err := deleteBound(tx, c.Purpose, bindingOf(c)); err != nil {
140 return "", "", err
141 }
142 if link, code, err = s.insertEmailChallenge(tx, c, now); err != nil {
143 return "", "", err
144 }
145 if err := tx.Commit(); err != nil {
146 return "", "", err
147 }
148 return link, code, nil
149}
150
151// HoldEmailChallenge is a refused request's challenge (spec §15, rev 6): when
152// c's binding already has a live challenge of c's purpose it changes nothing,
153// so the email already sent keeps working; otherwise it stores c with a link
154// and code nobody is ever told, so a typed code reads exactly as it would
155// against a sent one, and nothing the mailbox could redeem exists.
156func (s *Store) HoldEmailChallenge(c EmailChallenge, now time.Time) error {
157 col, ok := challengeBinding(c.Purpose)
158 if !ok {
159 return fmt.Errorf("console: no binding for challenge purpose %q", c.Purpose)
160 }
161 tx, err := s.db.Begin()
162 if err != nil {
163 return err
164 }
165 defer tx.Rollback()
166 var live int
167 if err := tx.QueryRow(`SELECT COUNT(*) FROM email_challenges WHERE purpose = ? AND `+col+` = ? AND expires_at >= ?`,
168 c.Purpose, bindingOf(c), now.Unix()).Scan(&live); err != nil {
169 return err
170 }
171 if live > 0 {
172 return nil
173 }
174 if err := deleteBound(tx, c.Purpose, bindingOf(c)); err != nil {
175 return err
176 }
177 if _, _, err := s.insertEmailChallenge(tx, c, now); err != nil {
178 return err
179 }
180 return tx.Commit()
181}
182
183// deleteBound deletes every challenge of purpose bound to bind; an empty bind
184// deletes nothing.
185func deleteBound(tx *sql.Tx, purpose, bind string) error {
186 col, ok := challengeBinding(purpose)
187 if !ok || bind == "" {
188 return nil
189 }
190 _, err := tx.Exec(`DELETE FROM email_challenges WHERE purpose = ? AND `+col+` = ?`, purpose, bind)
191 return err
192}
193
194// insertEmailChallenge mints a challenge's row key, link and code and stores
195// c under them. The row key is internal: no browser is given it.
196func (s *Store) insertEmailChallenge(tx *sql.Tx, c EmailChallenge, now time.Time) (link, code string, err error) {
197 row, err := randomToken()
198 if err != nil {
199 return "", "", err
200 }
201 if link, err = randomToken(); err != nil {
202 return "", "", err
203 }
204 n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
205 if err != nil {
206 return "", "", err
207 }
208 code = fmt.Sprintf("%06d", n.Int64())
209 exp := c.ExpiresAt
210 if exp.IsZero() {
211 exp = now.Add(ConfirmationTTL)
212 }
213 if _, err := tx.Exec(`INSERT INTO email_challenges
214(id_hash, link_hash, code_mac, purpose, address, session_hash, pending, next, flow_hash, attempts, created_at, expires_at)
215VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
216 hashToken(row), hashToken(link), s.codeMAC(hashToken(link), code), c.Purpose, c.Address,
217 c.SessionHash, c.PendingHash, c.Next, c.FlowHash, now.Unix(), exp.Unix()); err != nil {
218 return "", "", err
219 }
220 return link, code, nil
221}
222
223const challengeColumns = `purpose, address, session_hash, pending, next, flow_hash, expires_at`
224
225// PeekEmailLink reads a challenge by its emailed link without spending it, so
226// a mail scanner's GET leaves the link good for the person's own click. It
227// reports false for unknown or expired links and deletes nothing.
228func (s *Store) PeekEmailLink(link string, now time.Time) (*EmailChallenge, bool) {
229 c, exp, err := scanEmailChallenge(s.db.QueryRow(`SELECT `+challengeColumns+`
230FROM email_challenges WHERE link_hash = ?`, hashToken(link)))
231 if err != nil || now.Unix() > exp {
232 return nil, false
233 }
234 return c, true
235}
236
237// LiveEmailChallenge is the newest live challenge of purpose bound to bind
238// (a sign-in flow's hash, or a session's hash for a step-up), without
239// spending it or counting an attempt. It reports false for an empty binding
240// or when there is none.
241func (s *Store) LiveEmailChallenge(purpose, bind string, now time.Time) (*EmailChallenge, bool) {
242 col, ok := challengeBinding(purpose)
243 if !ok || bind == "" {
244 return nil, false
245 }
246 var row string
247 var c EmailChallenge
248 var exp int64
249 err := s.db.QueryRow(`SELECT id_hash, `+challengeColumns+` FROM email_challenges
250WHERE purpose = ? AND `+col+` = ? AND expires_at >= ?
251ORDER BY created_at DESC, rowid DESC LIMIT 1`, purpose, bind, now.Unix()).
252 Scan(&row, &c.Purpose, &c.Address, &c.SessionHash, &c.PendingHash, &c.Next, &c.FlowHash, &exp)
253 if err != nil {
254 return nil, false
255 }
256 c.ExpiresAt = time.Unix(exp, 0)
257 c.row = row
258 return &c, true
259}
260
261// TakeEmailLink spends a challenge by its emailed link. The row is deleted
262// before the expiry verdict (burn-then-check, as TakeConfirmation), so a link
263// can never be retried into validity. A live one also takes its binding's
264// other challenges with it: a successful sign-in ends the flow's others.
265func (s *Store) TakeEmailLink(link string, now time.Time) (*EmailChallenge, bool) {
266 tx, err := s.db.Begin()
267 if err != nil {
268 return nil, false
269 }
270 defer tx.Rollback()
271 c, exp, err := scanEmailChallenge(tx.QueryRow(`SELECT `+challengeColumns+`
272FROM email_challenges WHERE link_hash = ?`, hashToken(link)))
273 if err != nil {
274 return nil, false
275 }
276 if _, err := tx.Exec(`DELETE FROM email_challenges WHERE link_hash = ?`, hashToken(link)); err != nil {
277 return nil, false
278 }
279 if now.Unix() > exp {
280 if err := tx.Commit(); err != nil {
281 return nil, false
282 }
283 return nil, false
284 }
285 if err := deleteBound(tx, c.Purpose, bindingOf(*c)); err != nil {
286 return nil, false
287 }
288 if err := tx.Commit(); err != nil {
289 return nil, false
290 }
291 return c, true
292}
293
294// TakeEmailCode checks a typed code against the challenge read by
295// LiveEmailChallenge. One transaction: a challenge gone, expired or changed
296// meanwhile is reported CodeGone (an expired one is deleted); a wrong code
297// adds an attempt, and the fifth burns the challenge (CodeBurned); a right
298// code deletes it, with its binding's other challenges, and reports CodeOK.
299func (s *Store) TakeEmailCode(read *EmailChallenge, code string, now time.Time) (*EmailChallenge, CodeResult) {
300 if read == nil || read.row == "" {
301 return nil, CodeGone
302 }
303 tx, err := s.db.Begin()
304 if err != nil {
305 return nil, CodeGone
306 }
307 defer tx.Rollback()
308 var mac, linkHash string
309 var attempts int
310 var c EmailChallenge
311 var exp int64
312 if err := tx.QueryRow(`SELECT `+challengeColumns+`, code_mac, link_hash, attempts
313FROM email_challenges WHERE id_hash = ?`, read.row).
314 Scan(&c.Purpose, &c.Address, &c.SessionHash, &c.PendingHash, &c.Next, &c.FlowHash, &exp, &mac, &linkHash, &attempts); err != nil {
315 return nil, CodeGone
316 }
317 c.ExpiresAt = time.Unix(exp, 0)
318 if now.Unix() > exp || c.Purpose != read.Purpose {
319 if _, err := tx.Exec(`DELETE FROM email_challenges WHERE link_hash = ?`, linkHash); err != nil {
320 return nil, CodeGone
321 }
322 if err := tx.Commit(); err != nil {
323 return nil, CodeGone
324 }
325 return nil, CodeGone
326 }
327 // A code minted under an earlier process's key can never match: report it
328 // used up, but leave the row and its attempts alone so the link still works.
329 if !strings.HasPrefix(mac, s.codeEpoch+":") {
330 return nil, CodeGone
331 }
332 if !hmac.Equal([]byte(mac), []byte(s.codeMAC(linkHash, code))) {
333 attempts++
334 res := CodeWrong
335 if attempts >= emailCodeAttempts {
336 res = CodeBurned
337 _, err = tx.Exec(`DELETE FROM email_challenges WHERE link_hash = ?`, linkHash)
338 } else {
339 _, err = tx.Exec(`UPDATE email_challenges SET attempts = ? WHERE link_hash = ?`, attempts, linkHash)
340 }
341 if err != nil {
342 return nil, CodeGone
343 }
344 if err := tx.Commit(); err != nil {
345 return nil, CodeGone
346 }
347 return nil, res
348 }
349 if _, err := tx.Exec(`DELETE FROM email_challenges WHERE link_hash = ?`, linkHash); err != nil {
350 return nil, CodeGone
351 }
352 if err := deleteBound(tx, c.Purpose, bindingOf(c)); err != nil {
353 return nil, CodeGone
354 }
355 if err := tx.Commit(); err != nil {
356 return nil, CodeGone
357 }
358 return &c, CodeOK
359}
360
361func scanEmailChallenge(row *sql.Row) (*EmailChallenge, int64, error) {
362 var c EmailChallenge
363 var exp int64
364 if err := row.Scan(&c.Purpose, &c.Address, &c.SessionHash, &c.PendingHash, &c.Next, &c.FlowHash, &exp); err != nil {
365 return nil, 0, err
366 }
367 c.ExpiresAt = time.Unix(exp, 0)
368 return &c, exp, nil
369}
370