package console // The email sign-in challenge (spec §5): one emailed link + code, for one // purpose. A sign-in challenge belongs to the browser's sign-in flow (its // flow cookie's hash), a step-up challenge to the session that asked; the // code box finds the newest live challenge of either by that binding, so no // browser ever holds a challenge's own id. Its own file, with its own // migration, in the authstore/step-up precedent. // // Only hashes are stored, so a database read is not a sign-in. The six-digit // code is stored as an HMAC under a key that lives only in this process // (Store.codeKey): six digits bound to a row anyone reading the database can // see would fall to an offline search in a blink if they were merely hashed. The price is that a // console restart invalidates codes in flight; links still work, and asking // again sends a fresh code. import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "fmt" "math/big" "strings" "time" ) const ( // ChallengeSignIn and ChallengeStepUp are the two purposes of a challenge. ChallengeSignIn = "signin" ChallengeStepUp = "stepup" // MethodKeymail and MethodEmail are how a session was proved (spec §5.4). MethodKeymail = "keymail" MethodEmail = "email" // emailCodeAttempts is how many wrong codes burn a challenge. emailCodeAttempts = 5 ) // EmailChallenge is what a caller supplies to create a challenge and what a // read or a successful take hands back. FlowHash and Next are sign-in only; // SessionHash and PendingHash are step-up only. type EmailChallenge struct { Purpose, Address, SessionHash, PendingHash, Next, FlowHash string ExpiresAt time.Time // row is the challenge's row key (id_hash), set by LiveEmailChallenge, so // TakeEmailCode checks the very challenge that was read. row string } // CodeResult is the verdict of TakeEmailCode. type CodeResult int const ( // CodeOK: the code matched; the challenge is spent. CodeOK CodeResult = iota // CodeWrong: it did not match and the challenge still stands. CodeWrong // CodeGone: there is no usable challenge (unknown, expired, wrong // purpose, burned earlier, or minted under an earlier process's key). CodeGone // CodeBurned: the code did not match and that was the last attempt, so // the challenge is gone. It is a wrong code for the guessing budget. CodeBurned ) func (s *Store) migrateEmailSignin() error { _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS email_challenges ( id_hash TEXT PRIMARY KEY, link_hash TEXT UNIQUE, code_mac TEXT NOT NULL, purpose TEXT NOT NULL, address TEXT NOT NULL, session_hash TEXT NOT NULL DEFAULT '', pending TEXT NOT NULL DEFAULT '', next TEXT NOT NULL DEFAULT '', attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, flow_hash TEXT NOT NULL DEFAULT '' );`) if err != nil { return err } // Additive: the sign-in flow a challenge belongs to. Forgiving "duplicate // column", so re-running is always safe. (An earlier build's alias_hashes // column, where one exists, is no longer read or written.) if _, err := s.db.Exec(`ALTER TABLE email_challenges ADD COLUMN flow_hash TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column") { return err } return nil } // challengeBinding is the column that ties together the challenges of one // purpose: a sign-in flow's (flow_hash) or a session's step-ups // (session_hash). Fixed names only, so it is safe to build into SQL. func challengeBinding(purpose string) (column string, ok bool) { switch purpose { case ChallengeSignIn: return "flow_hash", true case ChallengeStepUp: return "session_hash", true } return "", false } // bindingOf is c's value in its purpose's binding column. func bindingOf(c EmailChallenge) string { if c.Purpose == ChallengeStepUp { return c.SessionHash } return c.FlowHash } // codeMAC is epoch ":" hex(HMAC-SHA256(process key, link_hash ":" code)). The // row's link hash binds the code to its challenge. The epoch is a random // per-process label, not secret; see TakeEmailCode. func (s *Store) codeMAC(linkHash, code string) string { m := hmac.New(sha256.New, s.codeKey) m.Write([]byte(linkHash + ":" + code)) return s.codeEpoch + ":" + hex.EncodeToString(m.Sum(nil)) } // CreateEmailChallenge stores a challenge and returns the emailed link token // and the six-digit code. A zero ExpiresAt means ConfirmationTTL from now. In // the same transaction it deletes every earlier challenge of the same purpose // and binding (the flow's sign-ins, or the session's step-ups), so only the // newest email's link and code work. An empty binding replaces nothing. func (s *Store) CreateEmailChallenge(c EmailChallenge, now time.Time) (link, code string, err error) { tx, err := s.db.Begin() if err != nil { return "", "", err } defer tx.Rollback() if err := deleteBound(tx, c.Purpose, bindingOf(c)); err != nil { return "", "", err } if link, code, err = s.insertEmailChallenge(tx, c, now); err != nil { return "", "", err } if err := tx.Commit(); err != nil { return "", "", err } return link, code, nil } // HoldEmailChallenge is a refused request's challenge (spec §15, rev 6): when // c's binding already has a live challenge of c's purpose it changes nothing, // so the email already sent keeps working; otherwise it stores c with a link // and code nobody is ever told, so a typed code reads exactly as it would // against a sent one, and nothing the mailbox could redeem exists. func (s *Store) HoldEmailChallenge(c EmailChallenge, now time.Time) error { col, ok := challengeBinding(c.Purpose) if !ok { return fmt.Errorf("console: no binding for challenge purpose %q", c.Purpose) } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() var live int if err := tx.QueryRow(`SELECT COUNT(*) FROM email_challenges WHERE purpose = ? AND `+col+` = ? AND expires_at >= ?`, c.Purpose, bindingOf(c), now.Unix()).Scan(&live); err != nil { return err } if live > 0 { return nil } if err := deleteBound(tx, c.Purpose, bindingOf(c)); err != nil { return err } if _, _, err := s.insertEmailChallenge(tx, c, now); err != nil { return err } return tx.Commit() } // deleteBound deletes every challenge of purpose bound to bind; an empty bind // deletes nothing. func deleteBound(tx *sql.Tx, purpose, bind string) error { col, ok := challengeBinding(purpose) if !ok || bind == "" { return nil } _, err := tx.Exec(`DELETE FROM email_challenges WHERE purpose = ? AND `+col+` = ?`, purpose, bind) return err } // insertEmailChallenge mints a challenge's row key, link and code and stores // c under them. The row key is internal: no browser is given it. func (s *Store) insertEmailChallenge(tx *sql.Tx, c EmailChallenge, now time.Time) (link, code string, err error) { row, err := randomToken() if err != nil { return "", "", err } if link, err = randomToken(); err != nil { return "", "", err } n, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) if err != nil { return "", "", err } code = fmt.Sprintf("%06d", n.Int64()) exp := c.ExpiresAt if exp.IsZero() { exp = now.Add(ConfirmationTTL) } if _, err := tx.Exec(`INSERT INTO email_challenges (id_hash, link_hash, code_mac, purpose, address, session_hash, pending, next, flow_hash, attempts, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, hashToken(row), hashToken(link), s.codeMAC(hashToken(link), code), c.Purpose, c.Address, c.SessionHash, c.PendingHash, c.Next, c.FlowHash, now.Unix(), exp.Unix()); err != nil { return "", "", err } return link, code, nil } const challengeColumns = `purpose, address, session_hash, pending, next, flow_hash, expires_at` // PeekEmailLink reads a challenge by its emailed link without spending it, so // a mail scanner's GET leaves the link good for the person's own click. It // reports false for unknown or expired links and deletes nothing. func (s *Store) PeekEmailLink(link string, now time.Time) (*EmailChallenge, bool) { c, exp, err := scanEmailChallenge(s.db.QueryRow(`SELECT `+challengeColumns+` FROM email_challenges WHERE link_hash = ?`, hashToken(link))) if err != nil || now.Unix() > exp { return nil, false } return c, true } // LiveEmailChallenge is the newest live challenge of purpose bound to bind // (a sign-in flow's hash, or a session's hash for a step-up), without // spending it or counting an attempt. It reports false for an empty binding // or when there is none. func (s *Store) LiveEmailChallenge(purpose, bind string, now time.Time) (*EmailChallenge, bool) { col, ok := challengeBinding(purpose) if !ok || bind == "" { return nil, false } var row string var c EmailChallenge var exp int64 err := s.db.QueryRow(`SELECT id_hash, `+challengeColumns+` FROM email_challenges WHERE purpose = ? AND `+col+` = ? AND expires_at >= ? ORDER BY created_at DESC, rowid DESC LIMIT 1`, purpose, bind, now.Unix()). Scan(&row, &c.Purpose, &c.Address, &c.SessionHash, &c.PendingHash, &c.Next, &c.FlowHash, &exp) if err != nil { return nil, false } c.ExpiresAt = time.Unix(exp, 0) c.row = row return &c, true } // TakeEmailLink spends a challenge by its emailed link. The row is deleted // before the expiry verdict (burn-then-check, as TakeConfirmation), so a link // can never be retried into validity. A live one also takes its binding's // other challenges with it: a successful sign-in ends the flow's others. func (s *Store) TakeEmailLink(link string, now time.Time) (*EmailChallenge, bool) { tx, err := s.db.Begin() if err != nil { return nil, false } defer tx.Rollback() c, exp, err := scanEmailChallenge(tx.QueryRow(`SELECT `+challengeColumns+` FROM email_challenges WHERE link_hash = ?`, hashToken(link))) if err != nil { return nil, false } if _, err := tx.Exec(`DELETE FROM email_challenges WHERE link_hash = ?`, hashToken(link)); err != nil { return nil, false } if now.Unix() > exp { if err := tx.Commit(); err != nil { return nil, false } return nil, false } if err := deleteBound(tx, c.Purpose, bindingOf(*c)); err != nil { return nil, false } if err := tx.Commit(); err != nil { return nil, false } return c, true } // TakeEmailCode checks a typed code against the challenge read by // LiveEmailChallenge. One transaction: a challenge gone, expired or changed // meanwhile is reported CodeGone (an expired one is deleted); a wrong code // adds an attempt, and the fifth burns the challenge (CodeBurned); a right // code deletes it, with its binding's other challenges, and reports CodeOK. func (s *Store) TakeEmailCode(read *EmailChallenge, code string, now time.Time) (*EmailChallenge, CodeResult) { if read == nil || read.row == "" { return nil, CodeGone } tx, err := s.db.Begin() if err != nil { return nil, CodeGone } defer tx.Rollback() var mac, linkHash string var attempts int var c EmailChallenge var exp int64 if err := tx.QueryRow(`SELECT `+challengeColumns+`, code_mac, link_hash, attempts FROM email_challenges WHERE id_hash = ?`, read.row). Scan(&c.Purpose, &c.Address, &c.SessionHash, &c.PendingHash, &c.Next, &c.FlowHash, &exp, &mac, &linkHash, &attempts); err != nil { return nil, CodeGone } c.ExpiresAt = time.Unix(exp, 0) if now.Unix() > exp || c.Purpose != read.Purpose { if _, err := tx.Exec(`DELETE FROM email_challenges WHERE link_hash = ?`, linkHash); err != nil { return nil, CodeGone } if err := tx.Commit(); err != nil { return nil, CodeGone } return nil, CodeGone } // A code minted under an earlier process's key can never match: report it // used up, but leave the row and its attempts alone so the link still works. if !strings.HasPrefix(mac, s.codeEpoch+":") { return nil, CodeGone } if !hmac.Equal([]byte(mac), []byte(s.codeMAC(linkHash, code))) { attempts++ res := CodeWrong if attempts >= emailCodeAttempts { res = CodeBurned _, err = tx.Exec(`DELETE FROM email_challenges WHERE link_hash = ?`, linkHash) } else { _, err = tx.Exec(`UPDATE email_challenges SET attempts = ? WHERE link_hash = ?`, attempts, linkHash) } if err != nil { return nil, CodeGone } if err := tx.Commit(); err != nil { return nil, CodeGone } return nil, res } if _, err := tx.Exec(`DELETE FROM email_challenges WHERE link_hash = ?`, linkHash); err != nil { return nil, CodeGone } if err := deleteBound(tx, c.Purpose, bindingOf(c)); err != nil { return nil, CodeGone } if err := tx.Commit(); err != nil { return nil, CodeGone } return &c, CodeOK } func scanEmailChallenge(row *sql.Row) (*EmailChallenge, int64, error) { var c EmailChallenge var exp int64 if err := row.Scan(&c.Purpose, &c.Address, &c.SessionHash, &c.PendingHash, &c.Next, &c.FlowHash, &exp); err != nil { return nil, 0, err } c.ExpiresAt = time.Unix(exp, 0) return &c, exp, nil }