internal/store/secrets.go
242 lines · 7030 bytes
1package store
2
3import (
4 "crypto/sha256"
5 "database/sql"
6 "encoding/hex"
7 "errors"
8 "fmt"
9
10 "gitbay.org/gitbay/internal/seal"
11)
12
13// The additional data of a sealed value is "<table>.<column>:<row key>",
14// so a value copied into another column or another row does not open.
15// Each row key is known when the value is written and survives a
16// repository rename or transfer. Every read and write of a column builds
17// its additional data through the one function here.
18
19func buildSecretAAD(repoID int64, name string) string {
20 return fmt.Sprintf("build_secrets.value:%d/%s", repoID, name)
21}
22
23func webhookAAD(id int64) string { return fmt.Sprintf("webhooks.secret:%d", id) }
24
25func mirrorAAD(id int64) string { return fmt.Sprintf("mirrors.token:%d", id) }
26
27// pushTokenAAD names the owner as well as the token, so a handover to
28// another account reseals the token.
29func pushTokenAAD(userID int64, hash string) string {
30 return fmt.Sprintf("push_devices.token:%d/%s", userID, hash)
31}
32
33type secretColumn struct {
34 table, column string
35 // key selects the two parts of the row key, an integer and a text.
36 key string
37 aad func(n int64, s string) string
38}
39
40// secretColumns are the columns sealed under the key file (#273).
41var secretColumns = []secretColumn{
42 {"build_secrets", "value", "repo_id, name", buildSecretAAD},
43 {"webhooks", "secret", "id, ''", func(id int64, _ string) string { return webhookAAD(id) }},
44 {"mirrors", "token", "id, ''", func(id int64, _ string) string { return mirrorAAD(id) }},
45 {"push_devices", "token", "user_id, COALESCE(token_hash, '')", pushTokenAAD},
46}
47
48// SetKeyring sets the keys the secret columns are sealed under.
49func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k }
50
51// sealValue seals v for storage. An empty value stays empty: for
52// webhooks and mirrors it means there is no secret.
53func (s *Store) sealValue(aad, v string) (string, error) {
54 if s.secrets == nil || v == "" {
55 return v, nil
56 }
57 return s.secrets.Seal(aad, v)
58}
59
60// openValue returns a stored value in clear. A value not yet sealed is
61// returned as stored: rows from before sealing existed stay readable
62// until ResealSecrets reaches them.
63func (s *Store) openValue(aad, v string) (string, error) {
64 if !seal.IsSealed(v) {
65 return v, nil
66 }
67 if s.secrets == nil {
68 return "", errors.New("value is sealed and no secret key is loaded")
69 }
70 return s.secrets.Open(aad, v)
71}
72
73// tokenHash is the lookup key for a push device token.
74func tokenHash(token string) string {
75 sum := sha256.Sum256([]byte(token))
76 return hex.EncodeToString(sum[:])
77}
78
79type secretRow struct {
80 rowid int64
81 value string
82 aad string
83}
84
85type queryer interface {
86 Query(query string, args ...any) (*sql.Rows, error)
87}
88
89func secretRows(q queryer, c secretColumn) ([]secretRow, error) {
90 rows, err := q.Query(fmt.Sprintf("SELECT rowid, %s, %s FROM %s WHERE %s != ''", c.column, c.key, c.table, c.column))
91 if err != nil {
92 return nil, err
93 }
94 defer rows.Close()
95 var out []secretRow
96 for rows.Next() {
97 var r secretRow
98 var n int64
99 var k string
100 if err := rows.Scan(&r.rowid, &r.value, &n, &k); err != nil {
101 return nil, err
102 }
103 r.aad = c.aad(n, k)
104 out = append(out, r)
105 }
106 return out, rows.Err()
107}
108
109// ResealSecrets fills push_devices.token_hash where it is missing, then
110// seals every clear value in the secret columns and reseals every value
111// not under the key file's current key. It runs in one write
112// transaction: every store write of a secret seals inside its own
113// transaction, so a write either lands before this one and is resealed,
114// or after it and is sealed under the key this one saw. It returns how
115// many values it rewrote.
116func (s *Store) ResealSecrets() (int, error) {
117 if s.secrets == nil {
118 return 0, errors.New("no secret key loaded")
119 }
120 tx, err := s.DB.Begin()
121 if err != nil {
122 return 0, err
123 }
124 defer tx.Rollback()
125 cur, err := s.secrets.CurrentID()
126 if err != nil {
127 return 0, err
128 }
129
130 // A token without a hash was written before sealing, so it is clear.
131 rows, err := tx.Query("SELECT id, token FROM push_devices WHERE token_hash IS NULL")
132 if err != nil {
133 return 0, err
134 }
135 var missing []secretRow
136 for rows.Next() {
137 var r secretRow
138 if err := rows.Scan(&r.rowid, &r.value); err != nil {
139 rows.Close()
140 return 0, err
141 }
142 missing = append(missing, r)
143 }
144 rows.Close()
145 if err := rows.Err(); err != nil {
146 return 0, err
147 }
148 for _, r := range missing {
149 if seal.IsSealed(r.value) {
150 return 0, fmt.Errorf("push_devices row %d: sealed token without a token_hash", r.rowid)
151 }
152 if _, err := tx.Exec("UPDATE push_devices SET token_hash = ? WHERE id = ?", tokenHash(r.value), r.rowid); err != nil {
153 return 0, err
154 }
155 }
156
157 n := 0
158 for _, c := range secretColumns {
159 rows, err := secretRows(tx, c)
160 if err != nil {
161 return 0, err
162 }
163 for _, r := range rows {
164 if id, ok := seal.KeyID(r.value); ok && id == cur {
165 continue
166 }
167 plain, err := s.openValue(r.aad, r.value)
168 if err != nil {
169 return 0, fmt.Errorf("%s.%s row %d: %w", c.table, c.column, r.rowid, err)
170 }
171 sealed, err := s.secrets.Seal(r.aad, plain)
172 if err != nil {
173 return 0, err
174 }
175 if _, err := tx.Exec(fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", c.table, c.column), sealed, r.rowid); err != nil {
176 return 0, err
177 }
178 n++
179 }
180 }
181 return n, tx.Commit()
182}
183
184// SecretColumnUse is one secret column's values by the id of the key
185// that sealed them ("" for a value still in clear), and the values that
186// do not open under the loaded key file.
187type SecretColumnUse struct {
188 Column string // "<table>.<column>"
189 ByKey map[string]int
190 Failed []SecretFailure
191}
192
193// SecretFailure is a stored value that does not open.
194type SecretFailure struct {
195 RowID int64
196 Err error
197}
198
199// SecretReport opens every value in the secret columns and counts them
200// per column by key id. A value that does not open is listed rather than
201// ending the scan.
202func (s *Store) SecretReport() ([]SecretColumnUse, error) {
203 var out []SecretColumnUse
204 for _, c := range secretColumns {
205 rows, err := secretRows(s.DB, c)
206 if err != nil {
207 return nil, err
208 }
209 u := SecretColumnUse{Column: c.table + "." + c.column, ByKey: map[string]int{}}
210 for _, r := range rows {
211 if _, err := s.openValue(r.aad, r.value); err != nil {
212 u.Failed = append(u.Failed, SecretFailure{RowID: r.rowid, Err: err})
213 continue
214 }
215 id, _ := seal.KeyID(r.value)
216 u.ByKey[id]++
217 }
218 out = append(out, u)
219 }
220 return out, nil
221}
222
223// SecretKeyUse counts the values in the secret columns by the id of the
224// key that sealed them ("" for a value still in clear), opening each
225// one, so a wrong or incomplete key file is an error naming the row.
226func (s *Store) SecretKeyUse() (map[string]int, error) {
227 report, err := s.SecretReport()
228 if err != nil {
229 return nil, err
230 }
231 use := map[string]int{}
232 for _, u := range report {
233 if len(u.Failed) > 0 {
234 f := u.Failed[0]
235 return nil, fmt.Errorf("%s row %d: %w", u.Column, f.RowID, f.Err)
236 }
237 for id, n := range u.ByKey {
238 use[id] += n
239 }
240 }
241 return use, nil
242}