Commit 1ec2c9cfb7
Verified · cmc
Layout: unified · split
internal/store/cisecrets.go +21 −5
| @@ -1,13 +1,27 @@ | |||
| 1 | package store | 1 | package store |
| 2 | 2 | ||
| 3 | import "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. | ||
| 5 | func (s *Store) SetBuildSecret(repoID int64, name, value string) error { | 8 | func (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 | ||
| 13 | func (s *Store) RemoveBuildSecret(repoID int64, name string) error { | 27 | func (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 @@ | |||
| 1 | DROP INDEX push_devices_token_hash; | ||
| 2 | ALTER 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. | ||
| 5 | ALTER TABLE push_devices ADD COLUMN token_hash TEXT; | ||
| 6 | CREATE UNIQUE INDEX push_devices_token_hash ON push_devices(token_hash); | ||
internal/store/mirrors.go +39 −10
| @@ -1,6 +1,9 @@ | |||
| 1 | package store | 1 | package store |
| 2 | 2 | ||
| 3 | import "errors" | 3 | import ( |
| 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. |
| 6 | var ErrExists = errors.New("already exists") | 9 | var 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. | ||
| 23 | func (s *Store) AddMirror(repoID int64, direction, url, username, token string) (int64, error) { | 28 | func (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 | ||
| 36 | const mirrorSelect = ` | 59 | const 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 | ||
| 40 | func scanMirror(row interface{ Scan(...any) error }) (Mirror, error) { | 63 | func (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 | ||
| 47 | func (s *Store) mirrorQuery(q string, args ...any) ([]Mirror, error) { | 76 | func (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 | |||
| 3 | import ( | 3 | import ( |
| 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 | ||
| 61 | func (s *Store) PushDevices(userID int64) ([]PushDevice, error) { | 73 | func (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 | ||
| 153 | func (s *Store) DuePush(limit int) ([]QueuedPush, error) { | 169 | func (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. | ||
| 199 | func (s *Store) DeletePushDeviceByToken(token string) error { | 221 | func (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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 19 | func buildSecretAAD(repoID int64, name string) string { | ||
| 20 | return fmt.Sprintf("build_secrets.value:%d/%s", repoID, name) | ||
| 21 | } | ||
| 22 | |||
| 23 | func webhookAAD(id int64) string { return fmt.Sprintf("webhooks.secret:%d", id) } | ||
| 24 | |||
| 25 | func 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. | ||
| 29 | func pushTokenAAD(userID int64, hash string) string { | ||
| 30 | return fmt.Sprintf("push_devices.token:%d/%s", userID, hash) | ||
| 31 | } | ||
| 32 | |||
| 33 | type 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). | ||
| 41 | var 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. | ||
| 49 | func (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. | ||
| 53 | func (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. | ||
| 63 | func (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. | ||
| 74 | func tokenHash(token string) string { | ||
| 75 | sum := sha256.Sum256([]byte(token)) | ||
| 76 | return hex.EncodeToString(sum[:]) | ||
| 77 | } | ||
| 78 | |||
| 79 | type secretRow struct { | ||
| 80 | rowid int64 | ||
| 81 | value string | ||
| 82 | aad string | ||
| 83 | } | ||
| 84 | |||
| 85 | type queryer interface { | ||
| 86 | Query(query string, args ...any) (*sql.Rows, error) | ||
| 87 | } | ||
| 88 | |||
| 89 | func 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. | ||
| 116 | func (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. | ||
| 187 | func (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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 12 | func 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. | ||
| 43 | func 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 | |||
| 63 | func 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. | ||
| 129 | func 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. | ||
| 160 | func 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. | ||
| 232 | func 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. | ||
| 263 | func 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. | ||
| 287 | func 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 | |||
| 340 | func 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 @@ | |||
| 1 | package store | 1 | package store |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 41 | func (s *Store) AddWebhook(repoID int64, url, secret, events string) (int64, error) { | 44 | func (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 | ||
| 51 | func (s *Store) ListWebhooks(repoID int64) ([]Webhook, error) { | 72 | func (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() |