orchard
mirrorEvery 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
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}