repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

4.7 KB · 183 lines · Go Raw History
  1package main
  2
  3import (
  4	"crypto/rand"
  5	"crypto/subtle"
  6	"database/sql"
  7	"errors"
  8	"math/big"
  9	"strings"
 10	"time"
 11
 12	"golang.org/x/crypto/argon2"
 13)
 14
 15// Argon2id parameters, the same shape repos uses for its push tokens. What is
 16// hashed here is either a 30 bit login code with a ten minute life and five
 17// attempts, or a 60 bit recovery code, so this exists to keep a leaked database
 18// from being a credential rather than to survive an offline dictionary attack.
 19const (
 20	argonTime    = 1
 21	argonMemory  = 64 * 1024
 22	argonThreads = 4
 23	argonKeyLen  = 32
 24	saltLen      = 16
 25)
 26
 27func hashSecret(secret string) (hash, salt []byte, err error) {
 28	salt = make([]byte, saltLen)
 29	if _, err := rand.Read(salt); err != nil {
 30		return nil, nil, err
 31	}
 32	return argon2.IDKey([]byte(secret), salt, argonTime, argonMemory, argonThreads, argonKeyLen), salt, nil
 33}
 34
 35func secretMatches(secret string, hash, salt []byte) bool {
 36	candidate := argon2.IDKey([]byte(secret), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
 37	return subtle.ConstantTimeCompare(candidate, hash) == 1
 38}
 39
 40// newCode returns six digits, zero padded, from crypto/rand. Not math/rand: the
 41// whole value of this is that it cannot be predicted from the last one.
 42func newCode() (string, error) {
 43	n, err := rand.Int(rand.Reader, big.NewInt(1000000))
 44	if err != nil {
 45		return "", err
 46	}
 47	s := n.String()
 48	return strings.Repeat("0", 6-len(s)) + s, nil
 49}
 50
 51// recoveryAlphabet drops the characters that are read wrong off a screen and
 52// typed wrong from paper: i, l, o, u, 0 and 1.
 53const recoveryAlphabet = "abcdefghjkmnpqrstvwxyz23456789"
 54
 55const (
 56	recoveryCount  = 10
 57	recoveryGroups = 3
 58	recoveryPerRun = 4
 59	// The first four characters, stored in clear, which finds the row without
 60	// hashing all ten. Four of a 30 character alphabet is not enough to guess.
 61	recoveryPrefix = 4
 62)
 63
 64// newRecoveryCode returns something like "k7pq-hm3n-wxbf".
 65func newRecoveryCode() (string, error) {
 66	var b strings.Builder
 67	for g := 0; g < recoveryGroups; g++ {
 68		if g > 0 {
 69			b.WriteByte('-')
 70		}
 71		for i := 0; i < recoveryPerRun; i++ {
 72			n, err := rand.Int(rand.Reader, big.NewInt(int64(len(recoveryAlphabet))))
 73			if err != nil {
 74				return "", err
 75			}
 76			b.WriteByte(recoveryAlphabet[n.Int64()])
 77		}
 78	}
 79	return b.String(), nil
 80}
 81
 82// regenerateRecoveryCodes replaces the whole set in one transaction and returns
 83// the new codes, which is the only time they exist anywhere readable. An old
 84// code stops working the moment this returns, so a half-applied set would leave
 85// the account with no break-glass at all.
 86func regenerateRecoveryCodes(db *sql.DB) ([]string, error) {
 87	codes := make([]string, 0, recoveryCount)
 88	for i := 0; i < recoveryCount; i++ {
 89		code, err := newRecoveryCode()
 90		if err != nil {
 91			return nil, err
 92		}
 93		codes = append(codes, code)
 94	}
 95
 96	tx, err := db.Begin()
 97	if err != nil {
 98		return nil, err
 99	}
100	defer tx.Rollback()
101
102	if _, err := tx.Exec(`DELETE FROM recovery_codes`); err != nil {
103		return nil, err
104	}
105	now := time.Now().Unix()
106	for _, code := range codes {
107		hash, salt, err := hashSecret(code)
108		if err != nil {
109			return nil, err
110		}
111		if _, err := tx.Exec(`
112            INSERT INTO recovery_codes (prefix, hash, salt, created)
113            VALUES (?, ?, ?, ?)`,
114			code[:recoveryPrefix], hash, salt, now); err != nil {
115			return nil, err
116		}
117	}
118	if err := tx.Commit(); err != nil {
119		return nil, err
120	}
121	return codes, nil
122}
123
124var errBadRecovery = errors.New("that recovery code is not valid")
125
126// useRecoveryCode spends one code, reporting how many are left. Marking it used
127// is part of the same statement that finds it, so the same code arriving twice
128// at once cannot be spent twice.
129func useRecoveryCode(db *sql.DB, code string) (remaining int, err error) {
130	code = strings.ToLower(strings.TrimSpace(code))
131	if len(code) < recoveryPrefix {
132		return 0, errBadRecovery
133	}
134
135	rows, err := db.Query(`
136        SELECT id, hash, salt FROM recovery_codes
137        WHERE prefix = ? AND used_at = 0`, code[:recoveryPrefix])
138	if err != nil {
139		return 0, err
140	}
141	defer rows.Close()
142
143	var matched int64 = -1
144	for rows.Next() {
145		var (
146			id         int64
147			hash, salt []byte
148		)
149		if err := rows.Scan(&id, &hash, &salt); err != nil {
150			return 0, err
151		}
152		if secretMatches(code, hash, salt) {
153			matched = id
154			break
155		}
156	}
157	if err := rows.Err(); err != nil {
158		return 0, err
159	}
160	rows.Close()
161
162	if matched < 0 {
163		return 0, errBadRecovery
164	}
165
166	res, err := db.Exec(`UPDATE recovery_codes SET used_at = ? WHERE id = ? AND used_at = 0`,
167		time.Now().Unix(), matched)
168	if err != nil {
169		return 0, err
170	}
171	if n, _ := res.RowsAffected(); n == 0 {
172		return 0, errBadRecovery
173	}
174
175	return countRecoveryCodes(db)
176}
177
178func countRecoveryCodes(db *sql.DB) (int, error) {
179	var n int
180	err := db.QueryRow(`SELECT COUNT(*) FROM recovery_codes WHERE used_at = 0`).Scan(&n)
181	return n, err
182}