Commit 1ec2c9cfb7

1ec2c9cfb740f50022aab56793c0491af64b1684

parent: b13a24703d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:54 UTC

store: seal CI secrets, webhook secrets, mirror tokens and device tokens

Values are AES-256-GCM under the key file, bound to their column and
row; push devices are looked up by token hash (migration 0061).

Ref #273

Layout: unified · split

internal/store/cisecrets.go +21 −5
@@ -1,13 +1,27 @@
1package store 1package store
2 2
3import "fmt"
4
3// SetBuildSecret stores or replaces one secret. The value never leaves the 5// SetBuildSecret stores or replaces one secret. The value never leaves the
4// server except inside a claimed build's environment. 6// server except inside a claimed build's environment. It is sealed inside
7// the write transaction; see ResealSecrets.
5func (s *Store) SetBuildSecret(repoID int64, name, value string) error { 8func (s *Store) SetBuildSecret(repoID int64, name, value string) error {
6 _, err := s.DB.Exec(` 9 tx, err := s.DB.Begin()
10 if err != nil {
11 return err
12 }
13 defer tx.Rollback()
14 sealed, err := s.sealValue(buildSecretAAD(repoID, name), value)
15 if err != nil {
16 return err
17 }
18 if _, err := tx.Exec(`
7 INSERT INTO build_secrets (repo_id, name, value) VALUES (?, ?, ?) 19 INSERT INTO build_secrets (repo_id, name, value) VALUES (?, ?, ?)
8 ON CONFLICT (repo_id, name) DO UPDATE SET value = excluded.value`, 20 ON CONFLICT (repo_id, name) DO UPDATE SET value = excluded.value`,
9 repoID, name, value) 21 repoID, name, sealed); err != nil {
10 return err 22 return err
23 }
24 return tx.Commit()
11} 25}
12 26
13func (s *Store) RemoveBuildSecret(repoID int64, name string) error { 27func (s *Store) RemoveBuildSecret(repoID int64, name string) error {
@@ -52,7 +66,9 @@ func (s *Store) BuildSecrets(repoID int64) (map[string]string, error) {
52 if err := rows.Scan(&n, &v); err != nil { 66 if err := rows.Scan(&n, &v); err != nil {
53 return nil, err 67 return nil, err
54 } 68 }
55 out[n] = v 69 if out[n], err = s.openValue(buildSecretAAD(repoID, n), v); err != nil {
70 return nil, fmt.Errorf("build secret %s: %w", n, err)
71 }
56 } 72 }
57 return out, rows.Err() 73 return out, rows.Err()
58} 74}
internal/store/migrations/0066_push_token_hash.down.sql added +2
@@ -0,0 +1,2 @@
1DROP INDEX push_devices_token_hash;
2ALTER TABLE push_devices DROP COLUMN token_hash;
internal/store/migrations/0066_push_token_hash.up.sql added +6
@@ -0,0 +1,6 @@
1-- APNs tokens are sealed with a random nonce (internal/seal), so two
2-- stores of one token differ; lookups and the re-registration upsert go
3-- by this SHA-256 of the token instead. Rows from before it are filled
4-- by Store.ResealSecrets.
5ALTER TABLE push_devices ADD COLUMN token_hash TEXT;
6CREATE UNIQUE INDEX push_devices_token_hash ON push_devices(token_hash);
internal/store/mirrors.go +39 −10
@@ -1,6 +1,9 @@
1package store 1package store
2 2
3import "errors" 3import (
4 "errors"
5 "fmt"
6)
4 7
5// ErrExists marks unique-constraint refusals callers turn into messages. 8// ErrExists marks unique-constraint refusals callers turn into messages.
6var ErrExists = errors.New("already exists") 9var ErrExists = errors.New("already exists")
@@ -20,28 +23,54 @@ type Mirror struct {
20 LastError string 23 LastError string
21} 24}
22 25
26// AddMirror stores the mirror, then seals its token under the new row's
27// id in the same transaction.
23func (s *Store) AddMirror(repoID int64, direction, url, username, token string) (int64, error) { 28func (s *Store) AddMirror(repoID int64, direction, url, username, token string) (int64, error) {
24 res, err := s.DB.Exec( 29 tx, err := s.DB.Begin()
25 "INSERT INTO mirrors (repo_id, direction, url, username, token) VALUES (?, ?, ?, ?, ?)", 30 if err != nil {
26 repoID, direction, url, username, token) 31 return 0, err
32 }
33 defer tx.Rollback()
34 res, err := tx.Exec(
35 "INSERT INTO mirrors (repo_id, direction, url, username, token) VALUES (?, ?, ?, ?, '')",
36 repoID, direction, url, username)
27 if err != nil { 37 if err != nil {
28 if isUniqueErr(err) { 38 if isUniqueErr(err) {
29 return 0, ErrExists 39 return 0, ErrExists
30 } 40 }
31 return 0, err 41 return 0, err
32 } 42 }
33 return res.LastInsertId() 43 id, err := res.LastInsertId()
44 if err != nil {
45 return 0, err
46 }
47 if token != "" {
48 sealed, err := s.sealValue(mirrorAAD(id), token)
49 if err != nil {
50 return 0, err
51 }
52 if _, err := tx.Exec("UPDATE mirrors SET token = ? WHERE id = ?", sealed, id); err != nil {
53 return 0, err
54 }
55 }
56 return id, tx.Commit()
34} 57}
35 58
36const mirrorSelect = ` 59const mirrorSelect = `
37 SELECT id, repo_id, direction, url, username, token, dirty, last_sync, last_error 60 SELECT id, repo_id, direction, url, username, token, dirty, last_sync, last_error
38 FROM mirrors` 61 FROM mirrors`
39 62
40func scanMirror(row interface{ Scan(...any) error }) (Mirror, error) { 63func (s *Store) scanMirror(row interface{ Scan(...any) error }) (Mirror, error) {
41 var m Mirror 64 var m Mirror
42 err := row.Scan(&m.ID, &m.RepoID, &m.Direction, &m.URL, &m.Username, &m.Token, 65 if err := row.Scan(&m.ID, &m.RepoID, &m.Direction, &m.URL, &m.Username, &m.Token,
43 &m.Dirty, &m.LastSync, &m.LastError) 66 &m.Dirty, &m.LastSync, &m.LastError); err != nil {
44 return m, err 67 return m, err
68 }
69 var err error
70 if m.Token, err = s.openValue(mirrorAAD(m.ID), m.Token); err != nil {
71 return m, fmt.Errorf("mirror %d: %w", m.ID, err)
72 }
73 return m, nil
45} 74}
46 75
47func (s *Store) mirrorQuery(q string, args ...any) ([]Mirror, error) { 76func (s *Store) mirrorQuery(q string, args ...any) ([]Mirror, error) {
@@ -52,7 +81,7 @@ func (s *Store) mirrorQuery(q string, args ...any) ([]Mirror, error) {
52 defer rows.Close() 81 defer rows.Close()
53 var out []Mirror 82 var out []Mirror
54 for rows.Next() { 83 for rows.Next() {
55 m, err := scanMirror(rows) 84 m, err := s.scanMirror(rows)
56 if err != nil { 85 if err != nil {
57 return nil, err 86 return nil, err
58 } 87 }
internal/store/push.go +37 −14
@@ -3,6 +3,7 @@ package store
3import ( 3import (
4 "database/sql" 4 "database/sql"
5 "errors" 5 "errors"
6 "fmt"
6 "time" 7 "time"
7) 8)
8 9
@@ -20,9 +21,11 @@ type PushDevice struct {
20 21
21// AddPushDevice registers a token to an account. A token already present 22// AddPushDevice registers a token to an account. A token already present
22// changes hands rather than erroring: Apple reuses tokens, and a reinstall 23// changes hands rather than erroring: Apple reuses tokens, and a reinstall
23// hands the same one to whichever account signs in next. The id is read 24// hands the same one to whichever account signs in next. The token is
24// back by token rather than taken from LastInsertId, which SQLite leaves 25// sealed (secrets.go), so the lookup and the upsert go by its hash, and a
25// unchanged when the DO UPDATE arm fires instead of the INSERT. 26// handover reseals it under the new owner. The id is read back by hash
27// rather than taken from LastInsertId, which SQLite leaves unchanged when
28// the DO UPDATE arm fires instead of the INSERT.
26// 29//
27// The row id survives that handover, so queue rows written for the 30// The row id survives that handover, so queue rows written for the
28// previous owner would still be delivered to the device — and an alert 31// previous owner would still be delivered to the device — and an alert
@@ -35,18 +38,27 @@ func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error)
35 return 0, err 38 return 0, err
36 } 39 }
37 defer tx.Rollback() 40 defer tx.Rollback()
41 h := tokenHash(token)
42 // A row written before token_hash existed holds its token in clear.
43 if _, err := tx.Exec("UPDATE push_devices SET token_hash = ? WHERE token_hash IS NULL AND token = ?", h, token); err != nil {
44 return 0, err
45 }
38 var prev int64 46 var prev int64
39 if err := tx.QueryRow("SELECT user_id FROM push_devices WHERE token = ?", token).Scan(&prev); err != nil && !errors.Is(err, sql.ErrNoRows) { 47 if err := tx.QueryRow("SELECT user_id FROM push_devices WHERE token_hash = ?", h).Scan(&prev); err != nil && !errors.Is(err, sql.ErrNoRows) {
48 return 0, err
49 }
50 sealed, err := s.sealValue(pushTokenAAD(userID, h), token)
51 if err != nil {
40 return 0, err 52 return 0, err
41 } 53 }
42 if _, err := tx.Exec(` 54 if _, err := tx.Exec(`
43 INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?) 55 INSERT INTO push_devices (user_id, token, token_hash, label) VALUES (?, ?, ?, ?)
44 ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`, 56 ON CONFLICT(token_hash) DO UPDATE SET user_id = excluded.user_id, token = excluded.token, label = excluded.label`,
45 userID, token, label); err != nil { 57 userID, sealed, h, label); err != nil {
46 return 0, err 58 return 0, err
47 } 59 }
48 var id int64 60 var id int64
49 if err := tx.QueryRow("SELECT id FROM push_devices WHERE token = ?", token).Scan(&id); err != nil { 61 if err := tx.QueryRow("SELECT id FROM push_devices WHERE token_hash = ?", h).Scan(&id); err != nil {
50 return 0, err 62 return 0, err
51 } 63 }
52 if prev != 0 && prev != userID { 64 if prev != 0 && prev != userID {
@@ -60,7 +72,7 @@ func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error)
60 72
61func (s *Store) PushDevices(userID int64) ([]PushDevice, error) { 73func (s *Store) PushDevices(userID int64) ([]PushDevice, error) {
62 rows, err := s.DB.Query(` 74 rows, err := s.DB.Query(`
63 SELECT id, user_id, token, label, created_at, COALESCE(last_seen_at, '') 75 SELECT id, user_id, token, COALESCE(token_hash, ''), label, created_at, COALESCE(last_seen_at, '')
64 FROM push_devices WHERE user_id = ? ORDER BY id`, userID) 76 FROM push_devices WHERE user_id = ? ORDER BY id`, userID)
65 if err != nil { 77 if err != nil {
66 return nil, err 78 return nil, err
@@ -69,9 +81,13 @@ func (s *Store) PushDevices(userID int64) ([]PushDevice, error) {
69 var out []PushDevice 81 var out []PushDevice
70 for rows.Next() { 82 for rows.Next() {
71 var d PushDevice 83 var d PushDevice
72 if err := rows.Scan(&d.ID, &d.UserID, &d.Token, &d.Label, &d.CreatedAt, &d.LastSeenAt); err != nil { 84 var h string
85 if err := rows.Scan(&d.ID, &d.UserID, &d.Token, &h, &d.Label, &d.CreatedAt, &d.LastSeenAt); err != nil {
73 return nil, err 86 return nil, err
74 } 87 }
88 if d.Token, err = s.openValue(pushTokenAAD(d.UserID, h), d.Token); err != nil {
89 return nil, fmt.Errorf("push device %d: %w", d.ID, err)
90 }
75 out = append(out, d) 91 out = append(out, d)
76 } 92 }
77 return out, rows.Err() 93 return out, rows.Err()
@@ -152,7 +168,7 @@ func (s *Store) EnqueuePush(userID int64, title, body, path string) error {
152 168
153func (s *Store) DuePush(limit int) ([]QueuedPush, error) { 169func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
154 rows, err := s.DB.Query(` 170 rows, err := s.DB.Query(`
155 SELECT q.id, q.device_id, d.token, u.username, q.title, q.body, q.path, q.attempts, 171 SELECT q.id, q.device_id, d.token, d.user_id, COALESCE(d.token_hash, ''), u.username, q.title, q.body, q.path, q.attempts,
156 (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL) 172 (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL)
157 FROM push_queue q 173 FROM push_queue q
158 JOIN push_devices d ON d.id = q.device_id 174 JOIN push_devices d ON d.id = q.device_id
@@ -167,9 +183,14 @@ func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
167 var out []QueuedPush 183 var out []QueuedPush
168 for rows.Next() { 184 for rows.Next() {
169 var p QueuedPush 185 var p QueuedPush
170 if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Username, &p.Title, &p.Body, &p.Path, &p.Attempts, &p.Badge); err != nil { 186 var uid int64
187 var h string
188 if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &uid, &h, &p.Username, &p.Title, &p.Body, &p.Path, &p.Attempts, &p.Badge); err != nil {
171 return nil, err 189 return nil, err
172 } 190 }
191 if p.Token, err = s.openValue(pushTokenAAD(uid, h), p.Token); err != nil {
192 return nil, fmt.Errorf("push device %d: %w", p.DeviceID, err)
193 }
173 out = append(out, p) 194 out = append(out, p)
174 } 195 }
175 return out, rows.Err() 196 return out, rows.Err()
@@ -195,8 +216,10 @@ func (s *Store) MarkPushFailed(id int64, errMsg string, nextAt *time.Time) error
195} 216}
196 217
197// DeletePushDeviceByToken drops a device Apple has told us is gone. The 218// DeletePushDeviceByToken drops a device Apple has told us is gone. The
198// queue rows cascade, so nothing is left retrying at a dead token. 219// queue rows cascade, so nothing is left retrying at a dead token. A row
220// without a hash predates sealing and holds its token in clear.
199func (s *Store) DeletePushDeviceByToken(token string) error { 221func (s *Store) DeletePushDeviceByToken(token string) error {
200 _, err := s.DB.Exec("DELETE FROM push_devices WHERE token = ?", token) 222 _, err := s.DB.Exec("DELETE FROM push_devices WHERE token_hash = ? OR (token_hash IS NULL AND token = ?)",
223 tokenHash(token), token)
201 return err 224 return err
202} 225}
internal/store/secrets.go added +203
@@ -0,0 +1,203 @@
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// SecretKeyUse counts the values in the secret columns by the id of the
185// key that sealed them ("" for a value still in clear), opening each
186// one, so a wrong or incomplete key file is an error naming the row.
187func (s *Store) SecretKeyUse() (map[string]int, error) {
188 use := map[string]int{}
189 for _, c := range secretColumns {
190 rows, err := secretRows(s.DB, c)
191 if err != nil {
192 return nil, err
193 }
194 for _, r := range rows {
195 if _, err := s.openValue(r.aad, r.value); err != nil {
196 return nil, fmt.Errorf("%s.%s row %d: %w", c.table, c.column, r.rowid, err)
197 }
198 id, _ := seal.KeyID(r.value)
199 use[id]++
200 }
201 }
202 return use, nil
203}
internal/store/secrets_test.go added +349
@@ -0,0 +1,349 @@
1package store
2
3import (
4 "path/filepath"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/seal"
9)
10
11// keyedStore is a migrated store with a key file of one key.
12func keyedStore(t *testing.T) (*Store, string, int64, int64) {
13 t.Helper()
14 s := open(t)
15 if err := s.MigrateUp(); err != nil {
16 t.Fatal(err)
17 }
18 path := filepath.Join(t.TempDir(), "secret.key")
19 k, err := seal.NewKey()
20 if err != nil {
21 t.Fatal(err)
22 }
23 if err := seal.WriteKeys(path, []seal.Key{k}); err != nil {
24 t.Fatal(err)
25 }
26 ring, err := seal.Load(path)
27 if err != nil {
28 t.Fatal(err)
29 }
30 s.SetKeyring(ring)
31 uid, err := s.CreateUser("alice", false)
32 if err != nil {
33 t.Fatal(err)
34 }
35 repoID, err := s.CreateRepo("user", uid, "app", "public")
36 if err != nil {
37 t.Fatal(err)
38 }
39 return s, path, uid, repoID
40}
41
42// raw reads every stored value of the secret columns.
43func raw(t *testing.T, s *Store) []string {
44 t.Helper()
45 var out []string
46 for _, sc := range secretColumns {
47 rows, err := s.DB.Query("SELECT " + sc.column + " FROM " + sc.table + " WHERE " + sc.column + " != ''")
48 if err != nil {
49 t.Fatal(err)
50 }
51 for rows.Next() {
52 var v string
53 if err := rows.Scan(&v); err != nil {
54 t.Fatal(err)
55 }
56 out = append(out, v)
57 }
58 rows.Close()
59 }
60 return out
61}
62
63func TestSecretColumnsAreSealed(t *testing.T) {
64 s, _, uid, repoID := keyedStore(t)
65 if err := s.SetBuildSecret(repoID, "DEPLOY", "ci-secret"); err != nil {
66 t.Fatal(err)
67 }
68 if _, err := s.AddWebhook(repoID, "https://hook.example/x", "hook-secret", "*"); err != nil {
69 t.Fatal(err)
70 }
71 if _, err := s.AddMirror(repoID, "push", "https://mirror.example/r.git", "u", "mirror-token"); err != nil {
72 t.Fatal(err)
73 }
74 if _, err := s.AddPushDevice(uid, "apns-token", "phone"); err != nil {
75 t.Fatal(err)
76 }
77
78 vals := raw(t, s)
79 if len(vals) != 4 {
80 t.Fatalf("stored %d values, want 4: %v", len(vals), vals)
81 }
82 for _, v := range vals {
83 if !seal.IsSealed(v) {
84 t.Errorf("stored in clear: %q", v)
85 }
86 for _, plain := range []string{"ci-secret", "hook-secret", "mirror-token", "apns-token"} {
87 if strings.Contains(v, plain) {
88 t.Errorf("%q carries %q", v, plain)
89 }
90 }
91 }
92
93 secrets, err := s.BuildSecrets(repoID)
94 if err != nil || secrets["DEPLOY"] != "ci-secret" {
95 t.Fatalf("BuildSecrets = %v, %v", secrets, err)
96 }
97 hooks, err := s.ListWebhooks(repoID)
98 if err != nil || len(hooks) != 1 || hooks[0].Secret != "hook-secret" {
99 t.Fatalf("ListWebhooks = %+v, %v", hooks, err)
100 }
101 ms, err := s.ListMirrors(repoID)
102 if err != nil || len(ms) != 1 || ms[0].Token != "mirror-token" {
103 t.Fatalf("ListMirrors = %+v, %v", ms, err)
104 }
105 due, err := s.DueMirrors(3600)
106 if err != nil || len(due) != 1 || due[0].Token != "mirror-token" {
107 t.Fatalf("DueMirrors = %+v, %v", due, err)
108 }
109 ds, err := s.PushDevices(uid)
110 if err != nil || len(ds) != 1 || ds[0].Token != "apns-token" {
111 t.Fatalf("PushDevices = %+v, %v", ds, err)
112 }
113
114 // An empty webhook secret or mirror token stays empty: it means none.
115 if _, err := s.AddWebhook(repoID, "https://hook.example/y", "", "*"); err != nil {
116 t.Fatal(err)
117 }
118 if _, err := s.AddMirror(repoID, "push", "https://mirror.example/s.git", "", ""); err != nil {
119 t.Fatal(err)
120 }
121 var empty int
122 s.DB.QueryRow("SELECT (SELECT COUNT(*) FROM webhooks WHERE secret = '') + (SELECT COUNT(*) FROM mirrors WHERE token = '')").Scan(&empty)
123 if empty != 2 {
124 t.Errorf("empty values stored as %d rows of '', want 2", empty)
125 }
126}
127
128// The queue readers open what they join.
129func TestSealedQueueReaders(t *testing.T) {
130 s, _, uid, repoID := keyedStore(t)
131 hook, err := s.AddWebhook(repoID, "https://hook.example/x", "hook-secret", "*")
132 if err != nil {
133 t.Fatal(err)
134 }
135 if _, err := s.DB.Exec("INSERT INTO events (repo_id, kind, data_json) VALUES (?, 'push', '{}')", repoID); err != nil {
136 t.Fatal(err)
137 }
138 if _, err := s.DB.Exec("INSERT INTO webhook_deliveries (webhook_id, event_id) SELECT ?, MAX(id) FROM events", hook); err != nil {
139 t.Fatal(err)
140 }
141 dd, err := s.DueDeliveries(10)
142 if err != nil || len(dd) != 1 || dd[0].Secret != "hook-secret" {
143 t.Fatalf("DueDeliveries = %+v, %v", dd, err)
144 }
145
146 if _, err := s.AddPushDevice(uid, "apns-token", "phone"); err != nil {
147 t.Fatal(err)
148 }
149 if err := s.EnqueuePush(uid, "t", "b", "/p"); err != nil {
150 t.Fatal(err)
151 }
152 qp, err := s.DuePush(10)
153 if err != nil || len(qp) != 1 || qp[0].Token != "apns-token" {
154 t.Fatalf("DuePush = %+v, %v", qp, err)
155 }
156}
157
158// A sealed value copied into another row of its column does not open:
159// the additional data names the row as well as the column.
160func TestSealedValueBoundToRow(t *testing.T) {
161 s, _, uid, repoID := keyedStore(t)
162 other, err := s.CreateRepo("user", uid, "other", "public")
163 if err != nil {
164 t.Fatal(err)
165 }
166 bob, err := s.CreateUser("bob", false)
167 if err != nil {
168 t.Fatal(err)
169 }
170 must := func(err error) {
171 t.Helper()
172 if err != nil {
173 t.Fatal(err)
174 }
175 }
176 must(s.SetBuildSecret(repoID, "A", "a"))
177 must(s.SetBuildSecret(repoID, "B", "b"))
178 must(s.SetBuildSecret(other, "A", "c"))
179 _, err = s.AddWebhook(repoID, "https://hook.example/1", "s1", "*")
180 must(err)
181 _, err = s.AddWebhook(repoID, "https://hook.example/2", "s2", "*")
182 must(err)
183 _, err = s.AddMirror(repoID, "push", "https://m.example/1.git", "", "t1")
184 must(err)
185 _, err = s.AddMirror(repoID, "pull", "https://m.example/2.git", "", "t2")
186 must(err)
187 _, err = s.AddPushDevice(uid, "d1", "")
188 must(err)
189 _, err = s.AddPushDevice(bob, "d2", "")
190 must(err)
191
192 // push_devices.token is unique, so alice's row goes before her
193 // sealed token is copied into bob's.
194 var aliceTok string
195 must(s.DB.QueryRow("SELECT token FROM push_devices WHERE user_id = ?", uid).Scan(&aliceTok))
196 _, err = s.DB.Exec("DELETE FROM push_devices WHERE user_id = ?", uid)
197 must(err)
198
199 cases := []struct {
200 name string
201 copy string
202 read func() error
203 }{
204 {"build secret to another name",
205 "UPDATE build_secrets SET value = (SELECT value FROM build_secrets WHERE repo_id = ?1 AND name = 'A') WHERE repo_id = ?1 AND name = 'B'",
206 func() error { _, err := s.BuildSecrets(repoID); return err }},
207 {"build secret to another repository",
208 "UPDATE build_secrets SET value = (SELECT value FROM build_secrets WHERE repo_id = ?1 AND name = 'A') WHERE repo_id = ?2 AND name = 'A'",
209 func() error { _, err := s.BuildSecrets(other); return err }},
210 {"webhook secret",
211 "UPDATE webhooks SET secret = (SELECT secret FROM webhooks WHERE url LIKE '%/1') WHERE url LIKE '%/2'",
212 func() error { _, err := s.ListWebhooks(repoID); return err }},
213 {"mirror token",
214 "UPDATE mirrors SET token = (SELECT token FROM mirrors WHERE direction = 'push') WHERE direction = 'pull'",
215 func() error { _, err := s.ListMirrors(repoID); return err }},
216 {"push token",
217 "UPDATE push_devices SET token = ?5 WHERE user_id = ?4",
218 func() error { _, err := s.PushDevices(bob); return err }},
219 }
220 for _, c := range cases {
221 if _, err := s.DB.Exec(c.copy, repoID, other, uid, bob, aliceTok); err != nil {
222 t.Fatalf("%s: %v", c.name, err)
223 }
224 if err := c.read(); err == nil {
225 t.Errorf("%s: a value copied from another row opened", c.name)
226 }
227 }
228}
229
230// A token re-registered under another account changes hands by its
231// hash, since two seals of one token differ.
232func TestPushDeviceUpsertBySealedToken(t *testing.T) {
233 s, _, uid, _ := keyedStore(t)
234 bob, err := s.CreateUser("bob", false)
235 if err != nil {
236 t.Fatal(err)
237 }
238 first, err := s.AddPushDevice(uid, "tok", "phone")
239 if err != nil {
240 t.Fatal(err)
241 }
242 second, err := s.AddPushDevice(bob, "tok", "ipad")
243 if err != nil {
244 t.Fatal(err)
245 }
246 if first != second {
247 t.Fatalf("re-registration made row %d beside %d", second, first)
248 }
249 d, err := s.PushDevices(bob)
250 if err != nil || len(d) != 1 || d[0].Token != "tok" {
251 t.Fatalf("PushDevices after handover = %+v, %v", d, err)
252 }
253 if err := s.DeletePushDeviceByToken("tok"); err != nil {
254 t.Fatal(err)
255 }
256 if d, _ := s.PushDevices(bob); len(d) != 0 {
257 t.Fatalf("device left after delete by token: %+v", d)
258 }
259}
260
261// A device row from before token_hash existed is found by its clear
262// token until ResealSecrets fills the hash.
263func TestPushDeviceUnhashedRow(t *testing.T) {
264 s, _, uid, _ := keyedStore(t)
265 if _, err := s.DB.Exec("INSERT INTO push_devices (user_id, token, label) VALUES (?, 'old', '')", uid); err != nil {
266 t.Fatal(err)
267 }
268 var first int64
269 s.DB.QueryRow("SELECT id FROM push_devices").Scan(&first)
270 id, err := s.AddPushDevice(uid, "old", "phone")
271 if err != nil || id != first {
272 t.Fatalf("AddPushDevice = %d, %v; want row %d", id, err, first)
273 }
274 if _, err := s.DB.Exec("INSERT INTO push_devices (user_id, token, label) VALUES (?, 'older', '')", uid); err != nil {
275 t.Fatal(err)
276 }
277 if err := s.DeletePushDeviceByToken("older"); err != nil {
278 t.Fatal(err)
279 }
280 if d, _ := s.PushDevices(uid); len(d) != 1 || d[0].Token != "old" {
281 t.Fatalf("PushDevices = %+v", d)
282 }
283}
284
285// Rows written before sealing existed, and rows under a retired key,
286// end up under the current key.
287func TestResealSecrets(t *testing.T) {
288 s, path, uid, repoID := keyedStore(t)
289 if _, err := s.DB.Exec("INSERT INTO build_secrets (repo_id, name, value) VALUES (?, 'OLD', 'clear-value')", repoID); err != nil {
290 t.Fatal(err)
291 }
292 if _, err := s.DB.Exec("INSERT INTO push_devices (user_id, token, label) VALUES (?, 'clear-token', '')", uid); err != nil {
293 t.Fatal(err)
294 }
295 n, err := s.ResealSecrets()
296 if err != nil || n != 2 {
297 t.Fatalf("ResealSecrets = %d, %v; want 2", n, err)
298 }
299 for _, v := range raw(t, s) {
300 if !seal.IsSealed(v) {
301 t.Errorf("still clear: %q", v)
302 }
303 }
304 var hash string
305 s.DB.QueryRow("SELECT COALESCE(token_hash, '') FROM push_devices").Scan(&hash)
306 if hash != tokenHash("clear-token") {
307 t.Errorf("token_hash = %q", hash)
308 }
309 if d, err := s.PushDevices(uid); err != nil || len(d) != 1 || d[0].Token != "clear-token" {
310 t.Fatalf("PushDevices after reseal = %+v, %v", d, err)
311 }
312 if n, _ := s.ResealSecrets(); n != 0 {
313 t.Errorf("second reseal rewrote %d values", n)
314 }
315
316 // Rotation: add a key, reseal, drop the old key; the value still opens.
317 old, err := seal.ReadKeys(path)
318 if err != nil {
319 t.Fatal(err)
320 }
321 next, _ := seal.NewKey()
322 if err := seal.WriteKeys(path, append(old, next)); err != nil {
323 t.Fatal(err)
324 }
325 if n, err := s.ResealSecrets(); err != nil || n != 2 {
326 t.Fatalf("reseal after rotation = %d, %v; want 2", n, err)
327 }
328 if err := seal.WriteKeys(path, []seal.Key{next}); err != nil {
329 t.Fatal(err)
330 }
331 use, err := s.SecretKeyUse()
332 if err != nil || use[next.ID] != 2 || len(use) != 1 {
333 t.Fatalf("SecretKeyUse = %v, %v", use, err)
334 }
335 if got, _ := s.BuildSecrets(repoID); got["OLD"] != "clear-value" {
336 t.Fatalf("value after rotation: %v", got)
337 }
338}
339
340func TestSealedValueWithoutKeyFails(t *testing.T) {
341 s, _, _, repoID := keyedStore(t)
342 if err := s.SetBuildSecret(repoID, "X", "v"); err != nil {
343 t.Fatal(err)
344 }
345 s.SetKeyring(nil)
346 if _, err := s.BuildSecrets(repoID); err == nil {
347 t.Fatal("opened a sealed value with no key loaded")
348 }
349}
internal/store/store.go +4
@@ -15,6 +15,7 @@ import (
15 "strings" 15 "strings"
16 "sync" 16 "sync"
17 17
18 "gitbay.org/gitbay/internal/seal"
18 "modernc.org/sqlite" 19 "modernc.org/sqlite"
19) 20)
20 21
@@ -37,6 +38,9 @@ type Store struct {
37 // daemon sets it to its own logger, whose output the service 38 // daemon sets it to its own logger, whose output the service
38 // journal keeps outside the database. 39 // journal keeps outside the database.
39 AuditJournal *slog.Logger 40 AuditJournal *slog.Logger
41 // secrets seals and opens the secret columns (secrets.go). Nil
42 // stores values as given and refuses to open sealed ones.
43 secrets *seal.Keyring
40} 44}
41 45
42// Open opens (creating if needed) the database at path with WAL mode and 46// Open opens (creating if needed) the database at path with WAL mode and
internal/store/webhooks.go +31 −4
@@ -1,6 +1,7 @@
1package store 1package store
2 2
3import ( 3import (
4 "fmt"
4 "time" 5 "time"
5) 6)
6 7
@@ -38,14 +39,34 @@ type DeliveryStatus struct {
38 CreatedAt string 39 CreatedAt string
39} 40}
40 41
42// AddWebhook stores the hook, then seals its secret under the new row's
43// id in the same transaction.
41func (s *Store) AddWebhook(repoID int64, url, secret, events string) (int64, error) { 44func (s *Store) AddWebhook(repoID int64, url, secret, events string) (int64, error) {
42 res, err := s.DB.Exec( 45 tx, err := s.DB.Begin()
43 "INSERT INTO webhooks (repo_id, url, secret, events) VALUES (?, ?, ?, ?)",
44 repoID, url, secret, events)
45 if err != nil { 46 if err != nil {
46 return 0, err 47 return 0, err
47 } 48 }
48 return res.LastInsertId() 49 defer tx.Rollback()
50 res, err := tx.Exec(
51 "INSERT INTO webhooks (repo_id, url, secret, events) VALUES (?, ?, '', ?)",
52 repoID, url, events)
53 if err != nil {
54 return 0, err
55 }
56 id, err := res.LastInsertId()
57 if err != nil {
58 return 0, err
59 }
60 if secret != "" {
61 sealed, err := s.sealValue(webhookAAD(id), secret)
62 if err != nil {
63 return 0, err
64 }
65 if _, err := tx.Exec("UPDATE webhooks SET secret = ? WHERE id = ?", sealed, id); err != nil {
66 return 0, err
67 }
68 }
69 return id, tx.Commit()
49} 70}
50 71
51func (s *Store) ListWebhooks(repoID int64) ([]Webhook, error) { 72func (s *Store) ListWebhooks(repoID int64) ([]Webhook, error) {
@@ -62,6 +83,9 @@ func (s *Store) ListWebhooks(repoID int64) ([]Webhook, error) {
62 if err := rows.Scan(&w.ID, &w.URL, &w.Secret, &w.Events, &active, &w.CreatedAt); err != nil { 83 if err := rows.Scan(&w.ID, &w.URL, &w.Secret, &w.Events, &active, &w.CreatedAt); err != nil {
63 return nil, err 84 return nil, err
64 } 85 }
86 if w.Secret, err = s.openValue(webhookAAD(w.ID), w.Secret); err != nil {
87 return nil, fmt.Errorf("webhook %d: %w", w.ID, err)
88 }
65 w.Active = active != 0 89 w.Active = active != 0
66 out = append(out, w) 90 out = append(out, w)
67 } 91 }
@@ -107,6 +131,9 @@ func (s *Store) DueDeliveries(limit int) ([]Delivery, error) {
107 &d.RepoPath, &d.Actor, &d.DataJSON, &d.EventAt, &d.Attempts); err != nil { 131 &d.RepoPath, &d.Actor, &d.DataJSON, &d.EventAt, &d.Attempts); err != nil {
108 return nil, err 132 return nil, err
109 } 133 }
134 if d.Secret, err = s.openValue(webhookAAD(d.WebhookID), d.Secret); err != nil {
135 return nil, fmt.Errorf("webhook %d: %w", d.WebhookID, err)
136 }
110 out = append(out, d) 137 out = append(out, d)
111 } 138 }
112 return out, rows.Err() 139 return out, rows.Err()