store: seal secret columns at rest !495

merged merged by cmc on 2026-09-28 22:37 UTC · krz/gitbay:secrets-at-rest into main

30 files changed, +1970 −56

Layout: unified · split

.gitbay/wiki/Admin.org +42
@@ -18,8 +18,14 @@ install -m 755 gitbayd /usr/local/bin/
1818adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay
1919install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay
2020gitbayd --config /etc/gitbay/config.toml check-config
21gitbayd --config /etc/gitbay/config.toml admin secrets init
22chown gitbay:gitbay /etc/gitbay/secret.key
2123#+end_src
2224
25=admin secrets init= refuses when the key file already exists, so a
26reinstall on the same host should skip it — =deploy/install.sh= does
27this with a file check before running it.
28
2329=deploy/= in the source tree has a cloud-init file, a hardened systemd
2430unit, and a nightly backup timer. Run as the unprivileged =gitbay= user;
2531the unit's =AmbientCapabilities=CAP_NET_BIND_SERVICE= covers ports
@@ -57,6 +63,10 @@ validation still prints, followed by the contradiction.
5763 keys, ACME cache all live here.
5864- =site_url= (required) — canonical =https://host=; drives ACME, clone
5965 URLs, mail links.
66- =secret_key_file= (default =/etc/gitbay/secret.key=) — the keys that
67 seal CI secrets, webhook secrets, mirror tokens and push device
68 tokens in the database. Must be outside =root=, mode 0600, readable
69 by the daemon's user. See "Secret key" below.
6070- =source_repo= (optional, =owner/name=) — the repository this instance
6171 develops itself in. Startup warns when the running build's commit is
6272 not on that repository's default branch, which is how a binary built
@@ -503,6 +513,38 @@ snapshots sit beside them and the data stays referenced. Snapshot IDs
503513change; their times do not. Done for krz/keycask (formerly rust-pass)
504514on 2026-09-18, across 22 snapshots.
505515
516** Secret key
517
518CI secrets, webhook secrets, mirror tokens and APNs device tokens are
519stored sealed: AES-256-GCM under a key in =server.secret_key_file=,
520each value prefixed with the id of the key that sealed it
521(=gbs1:<id>:=). The key file is not in the database, not under
522=server.root=, and therefore in neither the local archives nor the
523main restic repository. It must be copied off the host separately;
524without it a restored database's secrets cannot be opened, and
525gitbayd refuses to start against them.
526
527#+begin_src sh
528gitbayd admin secrets init # once; deploy/install.sh does it on first install
529gitbayd admin secrets check # open every value, count by key
530gitbayd admin secrets rotate # new key, reseal, retire the old one (as root)
531#+end_src
532
533- Missing file: every gitbayd process that opens the database refuses
534 to run and names the path, including =serve= and, in system mode,
535 =authorized-keys=. =migrate= does not need it.
536- Wrong key: =serve= stops at startup naming the first row that does
537 not open; =secrets check= does the same without starting anything.
538- Upgrade: the first start after the upgrade seals every value still
539 in clear and logs =sealed secret values=.
540- Rotation: =rotate= adds a key, reseals every value under it in one
541 transaction, then removes the old keys. Run it as root, since it
542 replaces the key file in =/etc/gitbay=; the file keeps its owner.
543 The daemon re-reads the file when it changes, so it needs no
544 restart. Copy the new file off the host afterwards.
545- Push devices are looked up by the SHA-256 of their token
546 (=push_devices.token_hash=), since two seals of one token differ.
547
506548* Upgrades
507549
508550Replace the binary, restart the unit. Migrations apply automatically and
.gitbay/wiki/Architecture/03-Deployment.org +1
@@ -50,6 +50,7 @@ a database check.
5050| =<root>/acme= | ACME account key and certificates | autocert defaults |
5151| =<root>/hooks= | generated hook scripts | 0755 |
5252| =/etc/gitbay/config.toml= | configuration, including SMTP password | 0640 (cloud-init) |
53| =/etc/gitbay/secret.key= | keys sealing secret columns | 0600, owner =gitbay= (=deploy/install.sh=) |
5354| =/var/backups/gitbay= | backup archives | 0750 (cloud-init) |
5455
5556* Outbound connections from gitbayd
.gitbay/wiki/Architecture/06-Data-and-Cryptography.org +10 −8
@@ -2,7 +2,7 @@
22
33* Data inventory
44
5Schema: =internal/store/migrations/=, 59 migrations. Classification:
5Schema: =internal/store/migrations/=, 66 migrations. Classification:
66*C* credential or secret, *P* personal data, *R* private repository
77content (as confidential as the repository), *O* operational.
88
@@ -14,9 +14,9 @@ content (as confidential as the repository), *O* operational.
1414| Collaboration | =issues=, =issue_*=, =merge_requests=, =mr_*=, =labels=, =milestones=, =mentions= | R | bodies of issues, comments and reviews |
1515| Releases, snippets | =releases=, =release_assets=, =snippets=, =snippet_files= | R | |
1616| CI | =builds= (includes logs), =build_schedules=, =runner_repos=, =runner_seen= | R | build logs can echo anything a step prints |
17| CI secrets | =build_secrets= | C | *plaintext* |
18| Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | *plaintext* secrets and tokens |
19| Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue= | P | device tokens in clear |
17| CI secrets | =build_secrets= | C | sealed (AES-256-GCM) |
18| Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | webhook secret and mirror token sealed |
19| Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue= | P | device tokens sealed; looked up by SHA-256 |
2020| Signatures | =commit_signatures=, =settings.key_epoch= | O | verification cache |
2121| Audit and feed | =audit_log=, =events= | O, P | actor ids, pruned argv, fingerprints and IPs in some audit rows, a hash chain (=prev_hash=, =hash=) |
2222| Dependencies | =dep_checks=, =dep_reports= | O | |
@@ -31,6 +31,7 @@ Outside the database:
3131| TLS keys (ACME) | =<root>/acme= | C |
3232| SMTP password | =/etc/gitbay/config.toml= | C |
3333| APNs signing key (.p8) | path in =push.key_file= | C |
34| Secret key file | =server.secret_key_file= (=/etc/gitbay/secret.key=) | C |
3435| Backups | =/var/backups/gitbay=, offsite | all of the above |
3536
3637No table stores client IP addresses as a column. The daemon writes a
@@ -42,14 +43,15 @@ throttling (=internal/sshd/sshd.go=).
4243| Item | Protection |
4344|---------------------------------------+----------------------------------------------------------------|
4445| API tokens, sessions, login links, email codes, invites | SHA-256 of a 256-bit random value; the value is shown once and never stored (=internal/store/sessions.go=) |
45| CI secrets, webhook secrets, mirror tokens, APNs device tokens | stored in clear in SQLite; protection is filesystem permissions and the rule that values are write-only through the interface |
46| CI secrets, webhook secrets, mirror tokens, APNs device tokens | AES-256-GCM under a key file outside the database and outside =server.root=; additional data binds table, column and row (=internal/seal=, =internal/store/secrets.go=) |
4647| SQLite file | mode 0640, directory 0750 |
4748| Backups | the local archive is not encrypted; restic encrypts the offsite copy |
4849| Disk | no application-level encryption; any disk encryption is the host's |
4950
50The code base contains no symmetric encryption. A database or backup
51file read by anyone other than the =gitbay= user discloses every CI
52secret, webhook secret and mirror token.
51The database file or a backup read by anyone other than the =gitbay=
52user discloses no CI secret, webhook secret, mirror token or device
53token without the key file, which neither carries. Rotation:
54=gitbayd admin secrets rotate= (Admin wiki).
5355
5456* In transit
5557
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -56,7 +56,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
5656| Control | Status | Evidence |
5757|---------------------------------------------+----------+------------------------------------------------------------------|
5858| TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS |
59| Secrets encrypted at rest | gap | CI secrets, webhook secrets, mirror tokens stored in clear (#273) |
59| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) |
6060| Secrets kept out of argv, logs and output | in place | =ReadsStdin=, pruned audit argv, write-only secret commands |
6161| Local backups encrypted | gap | tar.gz in clear; offsite copy encrypted by restic (#274) |
6262| Data retention configurable | in place | =[retention]= (=internal/config/config.go=) |
.gitbay/wiki/Architecture/10-Known-Gaps.org −1
@@ -14,7 +14,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
1414| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
1515| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
1616| #262 | Availability | No limit on concurrent git pack generation | high |
17| #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high |
1817| #274 | Backups | The local backup archive is not encrypted | medium |
1918| #298 | SSRF | =repo import --from= fetches without an address check | medium |
2019| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
CHANGELOG.org +13
@@ -123,6 +123,19 @@ for the eighteen commands whose CLI path differs from the registry's
123123 browser (#269).
124124- Untrusted builds (merge requests from forks) get a fresh HOME removed after the build and no secrets; trusted builds keep a per-repository home under =<workdir>/trusted-home=. Deploy gitbayd before the runner; the old shared homes under the runner's workdir can be deleted. (#255)
125125- =status set= refuses =ci/= contexts, which belong to the instance's builds. Build results are reused only from trusted builds on the same image. =repo settings require-contexts= names status contexts that must report green; setting any turns require-checks on, and one not yet reported counts as pending. (#258)
126*Upgrade note.* gitbayd needs =server.secret_key_file= (default
127=/etc/gitbay/secret.key=) and refuses to start without it. Before
128replacing the binary, run =gitbayd admin secrets init= as root and
129=chown gitbay:gitbay /etc/gitbay/secret.key= (=deploy/install.sh= does
130both when the file is missing). The first start seals the stored
131secrets. Back the key file up separately: =admin backup= archives do
132not carry it (see the Admin wiki, "Secret key"). Downgrading to an
133earlier release after values are sealed is not supported: an older
134gitbayd reads a sealed value's =gbs1:...= prefix as the literal
135secret.
136- CI secrets, webhook secrets, mirror tokens and push device tokens are
137 stored sealed with AES-256-GCM (#273). =gitbayd admin secrets
138 init|rotate|check=.
126139- Untrusted builds (merge requests from forks) get a fresh HOME
127140 removed after the build and no secrets; trusted builds keep a
128141 per-repository home under =<workdir>/trusted-home=. Deploy gitbayd
cmd/gitbayd/backup_test.go +2 −4
@@ -8,8 +8,6 @@ import (
88 "path/filepath"
99 "sort"
1010 "testing"
11
12 "gitbay.org/gitbay/internal/config"
1311)
1412
1513// members lists the archive's entries by name.
@@ -43,8 +41,8 @@ func members(t *testing.T, path string) []string {
4341// --db-only is what makes an hourly schedule affordable, so it has to leave
4442// the repositories out and still carry a restorable database.
4543func TestBackupDBOnlyOmitsRepositories(t *testing.T) {
46 root := t.TempDir()
47 cfg := config.Config{Server: config.Server{Root: root}}
44 cfg := testConfig(t)
45 root := cfg.Server.Root
4846 s, err := openStore(cfg)
4947 if err != nil {
5048 t.Fatal(err)
cmd/gitbayd/main.go +24
@@ -4,8 +4,10 @@ package main
44
55import (
66 "context"
7 "errors"
78 "fmt"
89 "io"
10 "io/fs"
911 "log/slog"
1012 "net"
1113 "net/http"
@@ -31,6 +33,7 @@ import (
3133 "gitbay.org/gitbay/internal/mirror"
3234 "gitbay.org/gitbay/internal/notify"
3335 "gitbay.org/gitbay/internal/push"
36 "gitbay.org/gitbay/internal/seal"
3437 "gitbay.org/gitbay/internal/sshd"
3538 "gitbay.org/gitbay/internal/store"
3639 "gitbay.org/gitbay/internal/toolpath"
@@ -38,10 +41,21 @@ import (
3841)
3942
4043func openStore(cfg config.Config) (*store.Store, error) {
44 // The key file seals the secret columns (#273). Without it the
45 // database's secrets cannot be read or written, so nothing that
46 // opens the database runs.
47 keys, err := seal.Load(cfg.Server.SecretKeyFile)
48 if errors.Is(err, fs.ErrNotExist) {
49 return nil, fmt.Errorf("secret key file %s does not exist: create it with gitbayd admin secrets init, or restore it from its off-host copy (backups do not carry it)", cfg.Server.SecretKeyFile)
50 }
51 if err != nil {
52 return nil, fmt.Errorf("secret key file: %w", err)
53 }
4154 s, err := store.Open(filepath.Join(cfg.Server.Root, "gitbay.db"))
4255 if err != nil {
4356 return nil, err
4457 }
58 s.SetKeyring(keys)
4559 // Say so when the schema moves. A restart migrates in silence otherwise,
4660 // which makes an unexpected schema version hard to attribute to the deploy
4761 // that caused it.
@@ -144,6 +158,15 @@ func serveCmd() *cobra.Command {
144158 // audit row outside the database the daemon can write. Rows
145159 // are logged at Info, which the default handler always emits.
146160 st.AuditJournal = slog.Default()
161 // Values stored before sealing existed, or under a key a
162 // rotation retired, are sealed under the current key before
163 // anything reads them. A value the key file cannot open
164 // stops the start here rather than failing each delivery.
165 if n, err := st.ResealSecrets(); err != nil {
166 return fmt.Errorf("sealing secrets under %s: %w (gitbayd admin secrets check lists every value that does not open)", cfg.Server.SecretKeyFile, err)
167 } else if n > 0 {
168 slog.Info("sealed secret values", "count", n)
169 }
147170
148171 // Regenerate hook scripts so a moved binary self-heals, then
149172 // start the hook policy socket.
@@ -422,6 +445,7 @@ func adminCmd() *cobra.Command {
422445 hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"),
423446 auditCmd,
424447 backupCmd(),
448 secretsCmd(),
425449 gcCmd(),
426450 adminMigrateCommitRefsCmd(),
427451 adminMigrateProfileAboutCmd(),
cmd/gitbayd/main_test.go +1 −3
@@ -6,15 +6,13 @@ import (
66 "strconv"
77 "strings"
88 "testing"
9
10 "gitbay.org/gitbay/internal/config"
119)
1210
1311// A restart that moves the schema says so. Migrations used to run in silence,
1412// which left an unexpected user_version with nothing in the journal tying it to
1513// the deploy that applied it.
1614func TestOpenStoreLogsSchemaMigration(t *testing.T) {
17 cfg := config.Config{Server: config.Server{Root: t.TempDir()}}
15 cfg := testConfig(t)
1816
1917 var buf bytes.Buffer
2018 prev := slog.Default()
cmd/gitbayd/secrets.go added +196
@@ -0,0 +1,196 @@
1package main
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "io/fs"
8 "os"
9 "sort"
10 "strings"
11 "syscall"
12
13 "github.com/spf13/cobra"
14
15 "gitbay.org/gitbay/internal/config"
16 "gitbay.org/gitbay/internal/seal"
17)
18
19// secretsCmd manages the key file that seals CI secrets, webhook
20// secrets, mirror tokens and push device tokens in the database. No
21// subcommand prints key material, only key ids.
22func secretsCmd() *cobra.Command {
23 cmd := &cobra.Command{
24 Use: "secrets",
25 Short: "the key file that seals secrets stored in the database",
26 }
27 run := func(f func(config.Config, io.Writer) error) func(*cobra.Command, []string) error {
28 return func(cmd *cobra.Command, args []string) error {
29 cfg, err := config.Load(configPath)
30 if err != nil {
31 return err
32 }
33 return f(cfg, os.Stdout)
34 }
35 }
36 cmd.AddCommand(
37 &cobra.Command{
38 Use: "init",
39 Short: "create the key file (server.secret_key_file) with one new key",
40 Long: `Creates server.secret_key_file, mode 0600, holding one new key. Run as
41root, the file is given to the owner of server.root, the daemon's user.
42Refuses when the file exists.`,
43 RunE: run(initSecrets),
44 },
45 &cobra.Command{
46 Use: "rotate",
47 Short: "seal every secret under a new key and retire the old ones",
48 Long: `Adds a new key to the key file, reseals every value under it in one
49transaction, then removes the old keys from the file. A running daemon
50re-reads the file when it changes, so no restart is needed. Run as the
51user that can replace the key file (root, for /etc/gitbay); the file
52keeps its owner. Copy the new file off the host afterwards.`,
53 RunE: run(rotateSecrets),
54 },
55 &cobra.Command{
56 Use: "check",
57 Short: "open every stored secret and count them per column by key; exit 1 if any does not open",
58 RunE: run(checkSecrets),
59 },
60 )
61 return cmd
62}
63
64// initSecrets writes a new key file. Run as root, it hands the file to
65// the owner of server.root, since the daemon reads it as that user.
66func initSecrets(cfg config.Config, w io.Writer) error {
67 path := cfg.Server.SecretKeyFile
68 if _, err := os.Lstat(path); err == nil {
69 return fmt.Errorf("%s already exists; gitbayd admin secrets rotate replaces its key", path)
70 } else if !errors.Is(err, fs.ErrNotExist) {
71 return err
72 }
73 uid, gid := -1, -1
74 if os.Geteuid() == 0 {
75 fi, err := os.Stat(cfg.Server.Root)
76 if err != nil {
77 return fmt.Errorf("the key file is given to the owner of server.root: %w", err)
78 }
79 st, ok := fi.Sys().(*syscall.Stat_t)
80 if !ok {
81 return fmt.Errorf("cannot read the owner of %s", cfg.Server.Root)
82 }
83 uid, gid = int(st.Uid), int(st.Gid)
84 }
85 k, err := seal.NewKey()
86 if err != nil {
87 return err
88 }
89 if err := seal.WriteKeys(path, []seal.Key{k}); err != nil {
90 return err
91 }
92 if uid >= 0 {
93 if err := os.Chown(path, uid, gid); err != nil {
94 // A root-owned file left behind would make a re-run refuse.
95 os.Remove(path)
96 return fmt.Errorf("could not give %s to the owner of %s, so it was removed: %w", path, cfg.Server.Root, err)
97 }
98 }
99 fmt.Fprintf(w, "wrote %s (key %s). Copy it off this host: backups do not carry it, and a restored database's secrets do not open without it.\n", path, k.ID)
100 return nil
101}
102
103// rotateSecrets adds a key, reseals under it, then drops the old keys.
104// Each step leaves a file that opens every stored value: after the first
105// write the file holds old and new keys; the reseal is one transaction;
106// the last write happens only after the reseal committed and every value
107// is confirmed under the new key. Interrupted anywhere, running it again
108// finishes the job.
109func rotateSecrets(cfg config.Config, w io.Writer) error {
110 path := cfg.Server.SecretKeyFile
111 old, err := seal.ReadKeys(path)
112 if err != nil {
113 return err
114 }
115 next, err := seal.NewKey()
116 if err != nil {
117 return err
118 }
119 if err := seal.WriteKeys(path, append(old, next)); err != nil {
120 return err
121 }
122 st, err := openStore(cfg)
123 if err != nil {
124 return err
125 }
126 defer st.Close()
127 keep := fmt.Sprintf("the key file holds the old keys and %s; run rotate again", next.ID)
128 n, err := st.ResealSecrets()
129 if err != nil {
130 return fmt.Errorf("resealing: %w (%s)", err, keep)
131 }
132 // Guards against a value sealed outside the reseal transaction under
133 // an old key; no test reaches it, since that needs a hook between the
134 // two calls.
135 use, err := st.SecretKeyUse()
136 if err != nil {
137 return fmt.Errorf("checking the reseal: %w (%s)", err, keep)
138 }
139 for id, c := range use {
140 if id != next.ID {
141 return fmt.Errorf("%d values are not under %s after the reseal (%s)", c, next.ID, keep)
142 }
143 }
144 if err := seal.WriteKeys(path, []seal.Key{next}); err != nil {
145 return err
146 }
147 retired := make([]string, len(old))
148 for i, k := range old {
149 retired[i] = k.ID
150 }
151 fmt.Fprintf(w, "key %s: resealed %d values; retired %s. Copy %s off this host.\n", next.ID, n, strings.Join(retired, ", "), path)
152 return nil
153}
154
155// checkSecrets opens every stored secret and prints, per column, how
156// many values each key sealed and every value that does not open. Any
157// such value is an error.
158func checkSecrets(cfg config.Config, w io.Writer) error {
159 st, err := openStore(cfg)
160 if err != nil {
161 return err
162 }
163 defer st.Close()
164 report, err := st.SecretReport()
165 if err != nil {
166 return err
167 }
168 failed := 0
169 for _, u := range report {
170 ids := make([]string, 0, len(u.ByKey))
171 for id := range u.ByKey {
172 ids = append(ids, id)
173 }
174 sort.Strings(ids)
175 var parts []string
176 for _, id := range ids {
177 if id == "" {
178 parts = append(parts, fmt.Sprintf("clear %d (sealed when the daemon next starts)", u.ByKey[id]))
179 } else {
180 parts = append(parts, fmt.Sprintf("key %s %d", id, u.ByKey[id]))
181 }
182 }
183 if len(parts) == 0 {
184 parts = []string{"none"}
185 }
186 fmt.Fprintf(w, "%s: %s\n", u.Column, strings.Join(parts, ", "))
187 for _, f := range u.Failed {
188 fmt.Fprintf(w, "%s row %d: %s\n", u.Column, f.RowID, f.Err)
189 failed++
190 }
191 }
192 if failed > 0 {
193 return fmt.Errorf("%s does not open %d stored values", cfg.Server.SecretKeyFile, failed)
194 }
195 return nil
196}
cmd/gitbayd/secrets_test.go added +176
@@ -0,0 +1,176 @@
1package main
2
3import (
4 "bytes"
5 "encoding/base64"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/seal"
13 "gitbay.org/gitbay/internal/store"
14)
15
16func TestOpenStoreRefusesWithoutKeyFile(t *testing.T) {
17 cfg := testConfig(t)
18 cfg.Server.SecretKeyFile = filepath.Join(t.TempDir(), "absent.key")
19 _, err := openStore(cfg)
20 if err == nil || !strings.Contains(err.Error(), "gitbayd admin secrets init") || !strings.Contains(err.Error(), cfg.Server.SecretKeyFile) {
21 t.Fatalf("openStore without a key file: %v", err)
22 }
23}
24
25// storeWithSecret opens cfg's store and stores one build secret.
26func storeWithSecret(t *testing.T, cfg config.Config) (*store.Store, int64) {
27 t.Helper()
28 st, err := openStore(cfg)
29 if err != nil {
30 t.Fatal(err)
31 }
32 uid, err := st.CreateUser("alice", false)
33 if err != nil {
34 t.Fatal(err)
35 }
36 repoID, err := st.CreateRepo("user", uid, "app", "public")
37 if err != nil {
38 t.Fatal(err)
39 }
40 if err := st.SetBuildSecret(repoID, "TOKEN", "v1"); err != nil {
41 t.Fatal(err)
42 }
43 return st, repoID
44}
45
46func TestRotateSecrets(t *testing.T) {
47 cfg := testConfig(t)
48 before, err := seal.ReadKeys(cfg.Server.SecretKeyFile)
49 if err != nil {
50 t.Fatal(err)
51 }
52 st, repoID := storeWithSecret(t, cfg)
53 st.Close()
54
55 var out bytes.Buffer
56 if err := rotateSecrets(cfg, &out); err != nil {
57 t.Fatalf("rotate: %v", err)
58 }
59 after, err := seal.ReadKeys(cfg.Server.SecretKeyFile)
60 if err != nil {
61 t.Fatal(err)
62 }
63 if len(after) != 1 || after[0].ID == before[0].ID {
64 t.Fatalf("key file after rotation holds %v, before %v", after, before)
65 }
66 assertNoKeyMaterial(t, out.String(), append(before, after...))
67 st, err = openStore(cfg)
68 if err != nil {
69 t.Fatal(err)
70 }
71 defer st.Close()
72 use, err := st.SecretKeyUse()
73 if err != nil || use[after[0].ID] != 1 || len(use) != 1 {
74 t.Fatalf("SecretKeyUse after rotation = %v, %v", use, err)
75 }
76 if got, _ := st.BuildSecrets(repoID); got["TOKEN"] != "v1" {
77 t.Fatalf("value after rotation: %v", got)
78 }
79}
80
81// A reseal that fails leaves the old keys in the file, so every value
82// still opens.
83func TestRotateSecretsKeepsOldKeysWhenResealFails(t *testing.T) {
84 cfg := testConfig(t)
85 before, err := seal.ReadKeys(cfg.Server.SecretKeyFile)
86 if err != nil {
87 t.Fatal(err)
88 }
89 st, repoID := storeWithSecret(t, cfg)
90 // A second value sealed under a key the file does not hold.
91 if _, err := st.DB.Exec("INSERT INTO build_secrets (repo_id, name, value) VALUES (?, 'BAD', 'gbs1:deadbeef:AAAA')", repoID); err != nil {
92 t.Fatal(err)
93 }
94 st.Close()
95
96 if err := rotateSecrets(cfg, &bytes.Buffer{}); err == nil {
97 t.Fatal("rotate succeeded over a value that does not open")
98 }
99 after, err := seal.ReadKeys(cfg.Server.SecretKeyFile)
100 if err != nil {
101 t.Fatal(err)
102 }
103 if len(after) != 2 || after[0].ID != before[0].ID {
104 t.Fatalf("key file after a failed rotation holds %v", after)
105 }
106}
107
108func TestInitSecrets(t *testing.T) {
109 cfg := testConfig(t)
110 cfg.Server.SecretKeyFile = filepath.Join(t.TempDir(), "secret.key")
111 var out bytes.Buffer
112 if err := initSecrets(cfg, &out); err != nil {
113 t.Fatal(err)
114 }
115 fi, err := os.Stat(cfg.Server.SecretKeyFile)
116 if err != nil {
117 t.Fatal(err)
118 }
119 if fi.Mode().Perm() != 0o600 {
120 t.Fatalf("mode %04o", fi.Mode().Perm())
121 }
122 keys, err := seal.ReadKeys(cfg.Server.SecretKeyFile)
123 if err != nil || len(keys) != 1 {
124 t.Fatalf("keys %v, %v", keys, err)
125 }
126 if !strings.Contains(out.String(), keys[0].ID) {
127 t.Fatalf("output does not name the key id: %q", out.String())
128 }
129 assertNoKeyMaterial(t, out.String(), keys)
130 if err := initSecrets(cfg, &out); err == nil || !strings.Contains(err.Error(), "already exists") {
131 t.Fatalf("second init: %v", err)
132 }
133 if again, _ := seal.ReadKeys(cfg.Server.SecretKeyFile); again[0].ID != keys[0].ID {
134 t.Fatal("second init replaced the key")
135 }
136}
137
138func TestCheckSecrets(t *testing.T) {
139 cfg := testConfig(t)
140 keys, err := seal.ReadKeys(cfg.Server.SecretKeyFile)
141 if err != nil {
142 t.Fatal(err)
143 }
144 st, repoID := storeWithSecret(t, cfg)
145
146 var out bytes.Buffer
147 if err := checkSecrets(cfg, &out); err != nil {
148 t.Fatalf("check: %v\n%s", err, out.String())
149 }
150 if !strings.Contains(out.String(), "build_secrets.value: key "+keys[0].ID+" 1") {
151 t.Fatalf("check output:\n%s", out.String())
152 }
153 assertNoKeyMaterial(t, out.String(), keys)
154
155 if _, err := st.DB.Exec("INSERT INTO build_secrets (repo_id, name, value) VALUES (?, 'BAD', 'gbs1:deadbeef:AAAA')", repoID); err != nil {
156 t.Fatal(err)
157 }
158 st.Close()
159 out.Reset()
160 err = checkSecrets(cfg, &out)
161 if err == nil || !strings.Contains(err.Error(), "does not open 1 stored value") {
162 t.Fatalf("check over a value that does not open: %v", err)
163 }
164 if !strings.Contains(out.String(), "deadbeef") || !strings.Contains(out.String(), "build_secrets.value row") {
165 t.Fatalf("check output does not name the failing row:\n%s", out.String())
166 }
167}
168
169func assertNoKeyMaterial(t *testing.T, out string, keys []seal.Key) {
170 t.Helper()
171 for _, k := range keys {
172 if strings.Contains(out, base64.StdEncoding.EncodeToString(k.Secret)) {
173 t.Fatalf("output carries key %s's secret", k.ID)
174 }
175 }
176}
cmd/gitbayd/testconfig_test.go added +24
@@ -0,0 +1,24 @@
1package main
2
3import (
4 "path/filepath"
5 "testing"
6
7 "gitbay.org/gitbay/internal/config"
8 "gitbay.org/gitbay/internal/seal"
9)
10
11// testConfig is a config with a fresh root and a key file outside it,
12// the minimum openStore accepts.
13func testConfig(t *testing.T) config.Config {
14 t.Helper()
15 key := filepath.Join(t.TempDir(), "secret.key")
16 k, err := seal.NewKey()
17 if err != nil {
18 t.Fatal(err)
19 }
20 if err := seal.WriteKeys(key, []seal.Key{k}); err != nil {
21 t.Fatal(err)
22 }
23 return config.Config{Server: config.Server{Root: t.TempDir(), SecretKeyFile: key}}
24}
deploy/install.sh +6
@@ -14,6 +14,12 @@ ssh -p "$port" "root@$host" '
1414 chmod 755 /usr/local/bin/gitbayd.new
1515 mv /usr/local/bin/gitbayd.new /usr/local/bin/gitbayd
1616 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml check-config --no-host-checks
17 # The key that seals secrets in the database. Created on the first
18 # install, never replaced here; gitbayd refuses to start without it.
19 if [ ! -e /etc/gitbay/secret.key ]; then
20 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin secrets init
21 chown gitbay:gitbay /etc/gitbay/secret.key
22 fi
1723 systemctl restart gitbayd
1824 sleep 1
1925 systemctl --no-pager --lines=5 status gitbayd
e2e/acme_test.go +2 −1
@@ -29,6 +29,7 @@ func TestACMEServe(t *testing.T) {
2929[server]
3030root = %q
3131site_url = "https://gitbay.example"
32secret_key_file = %q
3233[ssh]
3334port = %d
3435[http]
@@ -36,7 +37,7 @@ addr = "127.0.0.1:%d"
3637tls = "acme"
3738acme_email = "noreply@gitbay.example"
3839acme_http_addr = "127.0.0.1:%d"
39`, inst.root, inst.port, httpsPort, acmeHTTPPort)
40`, inst.root, inst.keyFile, inst.port, httpsPort, acmeHTTPPort)
4041 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
4142 t.Fatal(err)
4243 }
e2e/backup_test.go +41 −1
@@ -1,16 +1,23 @@
11package e2e
22
33import (
4 "bytes"
45 "fmt"
56 "net"
67 "os"
78 "os/exec"
89 "path/filepath"
10 "regexp"
911 "strings"
1012 "testing"
1113 "time"
1214)
1315
16// secretsCheckOneSealed matches "admin secrets check" reporting the one
17// build secret set in TestAdminBackup as sealed under some key, e.g.
18// "build_secrets.value: key 98e412e4 1".
19var secretsCheckOneSealed = regexp.MustCompile(`(?m)build_secrets\.value: key \S+ 1$`)
20
1421func TestAdminBackup(t *testing.T) {
1522 t.Parallel()
1623 inst := startInstance(t)
@@ -35,6 +42,11 @@ func TestAdminBackup(t *testing.T) {
3542 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/keep", "--title", "'survives backup'"); code != 0 {
3643 t.Fatal("issue create failed")
3744 }
45 // A build secret, to show the archive carries it sealed and the key
46 // file not at all.
47 if _, errOut, code := inst.ssh(t, aliceKey, "hunter2-at-rest", "repo", "secret", "set", "alice/keep", "DEPLOY_TOKEN"); code != 0 {
48 t.Fatalf("secret set: %s", errOut)
49 }
3850
3951 // Back up while the daemon is running.
4052 archive := filepath.Join(t.TempDir(), "backup.tar.gz")
@@ -64,6 +76,16 @@ func TestAdminBackup(t *testing.T) {
6476 }
6577 }
6678 }
79 if strings.Contains(names, "secret.key") {
80 t.Fatalf("archive carries the key file:\n%s", names)
81 }
82 db, err := exec.Command("tar", "-xzOf", archive, "gitbay.db").Output()
83 if err != nil {
84 t.Fatal(err)
85 }
86 if bytes.Contains(db, []byte("hunter2-at-rest")) {
87 t.Fatal("the archived database carries the build secret in clear")
88 }
6789
6890 // Restore: extract into a fresh root and serve from it.
6991 root2 := t.TempDir()
@@ -77,12 +99,13 @@ func TestAdminBackup(t *testing.T) {
7799[server]
78100root = %q
79101site_url = "https://gitbay.test"
102secret_key_file = %q
80103[ssh]
81104port = %d
82105[http]
83106addr = "127.0.0.1:%d"
84107tls = "off"
85`, root2, port2, httpPort2)
108`, root2, inst.keyFile, port2, httpPort2)
86109 if err := os.WriteFile(config2, []byte(cfg), 0o600); err != nil {
87110 t.Fatal(err)
88111 }
@@ -149,6 +172,23 @@ tls = "off"
149172 if code != 0 || strings.TrimSpace(out2) != "alice" {
150173 t.Fatalf("whoami on restored instance: exit %d, %q, %s", code, out2, errOut)
151174 }
175 // With the original key the restored secrets open; with another key
176 // they do not.
177 if out, err := exec.Command(inst.gitbayd, "--config", config2, "admin", "secrets", "check").CombinedOutput(); err != nil || !secretsCheckOneSealed.Match(out) {
178 t.Fatalf("secrets check on the restored instance: %v\n%s", err, out)
179 }
180 config3 := filepath.Join(root2, "config-wrong-key.toml")
181 wrong := strings.Replace(cfg, fmt.Sprintf("secret_key_file = %q", inst.keyFile),
182 fmt.Sprintf("secret_key_file = %q", filepath.Join(t.TempDir(), "other.key")), 1)
183 if err := os.WriteFile(config3, []byte(wrong), 0o600); err != nil {
184 t.Fatal(err)
185 }
186 if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "init").CombinedOutput(); err != nil {
187 t.Fatalf("init the wrong key: %v\n%s", err, out)
188 }
189 if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "check").CombinedOutput(); err == nil || !strings.Contains(string(out), "does not hold") {
190 t.Fatalf("secrets check with the wrong key: %v\n%s", err, out)
191 }
152192 if out2, _, code = ssh2("repo", "log", "alice/keep"); code != 0 || !strings.Contains(out2, "keep me") {
153193 t.Fatalf("restored log: %d\n%s", code, out2)
154194 }
e2e/ssh_test.go +6 −1
@@ -24,6 +24,7 @@ type instance struct {
2424 gitPort int
2525 proc *exec.Cmd
2626 sshDir string // per-user client keys live here
27 keyFile string // server.secret_key_file, outside root
2728}
2829
2930// nextPort hands out candidate ports. Seeded randomly so two test processes
@@ -89,11 +90,13 @@ func startInstanceWith(t *testing.T, extra string) *instance {
8990 gitPort: ports[2],
9091 sshDir: t.TempDir(),
9192 }
93 inst.keyFile = filepath.Join(t.TempDir(), "secret.key")
9294 inst.config = filepath.Join(inst.root, "config.toml")
9395 cfg := fmt.Sprintf(`
9496[server]
9597root = %q
9698site_url = "https://gitbay.test"
99secret_key_file = %q
97100[ssh]
98101port = %d
99102[http]
@@ -102,12 +105,14 @@ tls = "off"
102105[git_daemon]
103106enabled = true
104107port = %d
105`, inst.root, inst.port, inst.httpPort, inst.gitPort)
108`, inst.root, inst.keyFile, inst.port, inst.httpPort, inst.gitPort)
106109 cfg += extra + "\n"
107110 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
108111 t.Fatal(err)
109112 }
110113
114 inst.admin(t, "admin", "secrets", "init")
115
111116 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
112117 inst.proc.Stderr = os.Stderr
113118 if err := inst.proc.Start(); err != nil {
e2e/system_test.go +2 −1
@@ -33,12 +33,13 @@ func TestSystemSSHMode(t *testing.T) {
3333[server]
3434root = %q
3535site_url = "https://gitbay.test"
36secret_key_file = %q
3637[ssh]
3738mode = "system"
3839[http]
3940addr = "127.0.0.1:%d"
4041tls = "off"
41`, inst.root, inst.httpPort)
42`, inst.root, inst.keyFile, inst.httpPort)
4243 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
4344 t.Fatal(err)
4445 }
internal/config/config.go +54 −1
@@ -9,6 +9,7 @@ import (
99 "fmt"
1010 "net"
1111 "os"
12 "path/filepath"
1213 "strconv"
1314 "strings"
1415 "time"
@@ -54,6 +55,11 @@ type Server struct {
5455 // not on that repository's default branch. Empty disables the check, which
5556 // is right for any instance that does not host its own source.
5657 SourceRepo string `toml:"source_repo"`
58
59 // SecretKeyFile holds the keys that seal the secret columns of the
60 // database (internal/seal). It lives outside Root, so neither a
61 // backup archive nor a snapshot of Root carries it.
62 SecretKeyFile string `toml:"secret_key_file"`
5763}
5864
5965type SSH struct {
@@ -294,7 +300,7 @@ func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) {
294300// Default returns the configuration used when a key is absent from the file.
295301func Default() Config {
296302 return Config{
297 Server: Server{Root: "/var/lib/gitbay"},
303 Server: Server{Root: "/var/lib/gitbay", SecretKeyFile: "/etc/gitbay/secret.key"},
298304 SSH: SSH{Mode: "embedded", Port: 22},
299305 HTTP: HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"},
300306 Web: Web{Mode: "view_only"},
@@ -330,6 +336,47 @@ func Load(path string) (Config, error) {
330336 return cfg, cfg.Validate()
331337}
332338
339// within reports whether path is dir or below it. Both are compared as
340// cleaned absolute paths (a relative path resolves against the working
341// directory, same as every other path in this config), with symlinks
342// resolved where the path exists on disk, so a path that reaches into dir
343// through a symlink, or through "..", is still reported as inside.
344func within(dir, path string) bool {
345 dir, path = resolvePath(dir), resolvePath(path)
346 rel, err := filepath.Rel(dir, path)
347 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
348}
349
350// resolvePath returns path as a cleaned absolute path, resolving symlinks in
351// it. The secret key file commonly does not exist yet (it is created by
352// `gitbayd admin secrets init`), and on this platform /var itself is a
353// symlink, so a whole-path resolution is tried first and, failing that, each
354// ancestor directory in turn, walking up to the nearest one that exists and
355// reattaching the missing suffix — a symlinked ancestor still resolves even
356// though the leaf, or several levels above it, does not exist.
357func resolvePath(path string) string {
358 abs, err := filepath.Abs(path)
359 if err != nil {
360 return filepath.Clean(path)
361 }
362 dir := abs
363 var suffix []string
364 for {
365 if resolved, err := filepath.EvalSymlinks(dir); err == nil {
366 for i := len(suffix) - 1; i >= 0; i-- {
367 resolved = filepath.Join(resolved, suffix[i])
368 }
369 return resolved
370 }
371 parent := filepath.Dir(dir)
372 if parent == dir {
373 return abs
374 }
375 suffix = append(suffix, filepath.Base(dir))
376 dir = parent
377 }
378}
379
333380func oneOf(field, val string, allowed ...string) error {
334381 for _, a := range allowed {
335382 if val == a {
@@ -357,6 +404,12 @@ func (c Config) Validate() error {
357404 if c.Server.SiteURL == "" {
358405 errs = append(errs, errors.New("server.site_url is required"))
359406 }
407 switch {
408 case c.Server.SecretKeyFile == "":
409 errs = append(errs, errors.New("server.secret_key_file is required"))
410 case within(c.Server.Root, c.Server.SecretKeyFile):
411 errs = append(errs, fmt.Errorf("server.secret_key_file %q is inside server.root: backups of the root would carry the key beside the values it seals", c.Server.SecretKeyFile))
412 }
360413 if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
361414 errs = append(errs, err)
362415 }
internal/config/config_test.go +91
@@ -275,3 +275,94 @@ func TestMailTLSRequired(t *testing.T) {
275275 }
276276 }
277277}
278
279func TestSecretKeyFile(t *testing.T) {
280 cfg, err := Load(writeConfig(t, minimal))
281 if err != nil {
282 t.Fatal(err)
283 }
284 if cfg.Server.SecretKeyFile != "/etc/gitbay/secret.key" {
285 t.Errorf("default secret_key_file = %q", cfg.Server.SecretKeyFile)
286 }
287 for body, want := range map[string]string{
288 minimal + "secret_key_file = \"/var/lib/gitbay/secret.key\"\n": "inside server.root",
289 minimal + "secret_key_file = \"/var/lib/gitbay\"\n": "inside server.root",
290 minimal + "secret_key_file = \"\"\n": "server.secret_key_file is required",
291 } {
292 if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) {
293 t.Errorf("%q: got %v, want an error containing %q", body, err, want)
294 }
295 }
296 if _, err := Load(writeConfig(t, minimal+"secret_key_file = \"/var/lib/gitbay-keys/secret.key\"\n")); err != nil {
297 t.Errorf("a sibling directory of the root is outside it: %v", err)
298 }
299}
300
301// TestSecretKeyFileSymlinks exercises resolvePath's symlink resolution: a
302// key path or root reached through a symlink is still compared on its
303// resolved location, not its literal spelling.
304func TestSecretKeyFileSymlinks(t *testing.T) {
305 valid := func(root, keyFile string) Config {
306 cfg := Default()
307 cfg.Server.SiteURL = "https://gitbay.example"
308 cfg.Server.Root = root
309 cfg.Server.SecretKeyFile = keyFile
310 return cfg
311 }
312
313 t.Run("key path reaches into root through a symlink", func(t *testing.T) {
314 tmp := t.TempDir()
315 root := filepath.Join(tmp, "root")
316 if err := os.Mkdir(root, 0o700); err != nil {
317 t.Fatal(err)
318 }
319 link := filepath.Join(tmp, "link-into-root")
320 if err := os.Symlink(root, link); err != nil {
321 t.Fatal(err)
322 }
323 // The key file itself need not exist yet; only the symlinked
324 // directory component does.
325 keyFile := filepath.Join(link, "secret.key")
326 if err := valid(root, keyFile).Validate(); err == nil || !strings.Contains(err.Error(), "inside server.root") {
327 t.Errorf("got %v, want an error containing %q", err, "inside server.root")
328 }
329 })
330
331 t.Run("root itself is reached through a symlinked parent", func(t *testing.T) {
332 tmp := t.TempDir()
333 actualRoot := filepath.Join(tmp, "actual", "root")
334 if err := os.MkdirAll(actualRoot, 0o700); err != nil {
335 t.Fatal(err)
336 }
337 rootLink := filepath.Join(tmp, "root-link")
338 if err := os.Symlink(actualRoot, rootLink); err != nil {
339 t.Fatal(err)
340 }
341 // server.root is configured as the symlink; the key file is given
342 // by its real, unsymlinked path under the same directory.
343 keyFile := filepath.Join(actualRoot, "secret.key")
344 if err := valid(rootLink, keyFile).Validate(); err == nil || !strings.Contains(err.Error(), "inside server.root") {
345 t.Errorf("got %v, want an error containing %q", err, "inside server.root")
346 }
347 })
348
349 t.Run("symlink points outside root", func(t *testing.T) {
350 tmp := t.TempDir()
351 root := filepath.Join(tmp, "root")
352 outside := filepath.Join(tmp, "outside")
353 if err := os.Mkdir(root, 0o700); err != nil {
354 t.Fatal(err)
355 }
356 if err := os.Mkdir(outside, 0o700); err != nil {
357 t.Fatal(err)
358 }
359 escape := filepath.Join(root, "escape")
360 if err := os.Symlink(outside, escape); err != nil {
361 t.Fatal(err)
362 }
363 keyFile := filepath.Join(escape, "secret.key")
364 if err := valid(root, keyFile).Validate(); err != nil {
365 t.Errorf("a symlink leading outside server.root should be accepted: %v", err)
366 }
367 })
368}
internal/seal/seal.go added +310
@@ -0,0 +1,310 @@
1// Package seal encrypts the secret columns of the database with
2// AES-256-GCM under keys held in a file outside the database and outside
3// server.root, so neither a copy of the database nor a backup opens them
4// (#273). A key file that cannot be re-read after it changes fails
5// closed: Seal and Open return errors until the file is fixed.
6package seal
7
8import (
9 "bufio"
10 "bytes"
11 "crypto/aes"
12 "crypto/cipher"
13 "crypto/rand"
14 "encoding/base64"
15 "encoding/hex"
16 "errors"
17 "fmt"
18 "io"
19 "os"
20 "path/filepath"
21 "strings"
22 "sync"
23 "syscall"
24)
25
26// Prefix marks a sealed value: "gbs1:<key id>:<base64 nonce||ciphertext>".
27const Prefix = "gbs1:"
28
29// maxKeyFile is the largest key file ReadKeys accepts.
30const maxKeyFile = 1 << 20
31
32// Key is one line of the key file.
33type Key struct {
34 ID string // 8 lowercase hex characters
35 Secret []byte // 32 bytes
36}
37
38// NewKey returns a key with a random id and secret.
39func NewKey() (Key, error) {
40 id := make([]byte, 4)
41 secret := make([]byte, 32)
42 if _, err := rand.Read(id); err != nil {
43 return Key{}, err
44 }
45 if _, err := rand.Read(secret); err != nil {
46 return Key{}, err
47 }
48 return Key{ID: hex.EncodeToString(id), Secret: secret}, nil
49}
50
51// ReadKeys reads the key file. The last key seals; every key opens.
52func ReadKeys(path string) ([]Key, error) {
53 f, err := os.Open(path)
54 if err != nil {
55 return nil, err
56 }
57 defer f.Close()
58 fi, err := f.Stat()
59 if err != nil {
60 return nil, err
61 }
62 if !fi.Mode().IsRegular() {
63 return nil, fmt.Errorf("%s is not a regular file", path)
64 }
65 if perm := fi.Mode().Perm(); perm&0o077 != 0 {
66 return nil, fmt.Errorf("%s is mode %04o; it must be readable by its owner alone (0600)", path, perm)
67 }
68 data, err := io.ReadAll(io.LimitReader(f, maxKeyFile+1))
69 if err != nil {
70 return nil, err
71 }
72 if len(data) > maxKeyFile {
73 return nil, fmt.Errorf("%s is larger than %d bytes", path, maxKeyFile)
74 }
75 var keys []Key
76 seen := map[string]bool{}
77 sc := bufio.NewScanner(bytes.NewReader(data))
78 for n := 1; sc.Scan(); n++ {
79 line := strings.TrimSpace(sc.Text())
80 if line == "" || strings.HasPrefix(line, "#") {
81 continue
82 }
83 f := strings.Fields(line)
84 if len(f) != 2 || !validID(f[0]) {
85 return nil, fmt.Errorf("%s:%d: want \"<8 hex id> <base64 32-byte key>\"", path, n)
86 }
87 secret, err := base64.StdEncoding.DecodeString(f[1])
88 if err != nil || len(secret) != 32 {
89 return nil, fmt.Errorf("%s:%d: key is not 32 bytes of base64", path, n)
90 }
91 if seen[f[0]] {
92 return nil, fmt.Errorf("%s:%d: key id %s appears twice", path, n, f[0])
93 }
94 seen[f[0]] = true
95 keys = append(keys, Key{ID: f[0], Secret: secret})
96 }
97 if err := sc.Err(); err != nil {
98 return nil, err
99 }
100 if len(keys) == 0 {
101 return nil, fmt.Errorf("%s holds no keys", path)
102 }
103 return keys, nil
104}
105
106// WriteKeys replaces the key file: a temporary file in the same
107// directory, mode 0600, given the existing file's owner when there is
108// one (rotation runs as root; the daemon reads the file as its own
109// user), then renamed over it. Keys ReadKeys would refuse are refused
110// before anything is written.
111func WriteKeys(path string, keys []Key) error {
112 if len(keys) == 0 {
113 return errors.New("no keys to write")
114 }
115 seen := map[string]bool{}
116 for _, k := range keys {
117 if !validID(k.ID) || len(k.Secret) != 32 || seen[k.ID] {
118 return fmt.Errorf("key %q is not an 8-hex-id, 32-byte key or appears twice", k.ID)
119 }
120 seen[k.ID] = true
121 }
122 var b strings.Builder
123 b.WriteString("# gitbay secret keys, \"<id> <base64 key>\" per line. The last line seals\n")
124 b.WriteString("# new values; the others open values sealed before a rotation.\n")
125 b.WriteString("# Keep a copy off this host: backups do not carry this file.\n")
126 for _, k := range keys {
127 fmt.Fprintf(&b, "%s %s\n", k.ID, base64.StdEncoding.EncodeToString(k.Secret))
128 }
129 dir := filepath.Dir(path)
130 tmp, err := os.CreateTemp(dir, ".secret-key-*")
131 if err != nil {
132 return err
133 }
134 defer os.Remove(tmp.Name())
135 fail := func(err error) error {
136 tmp.Close()
137 return err
138 }
139 if err := tmp.Chmod(0o600); err != nil {
140 return fail(err)
141 }
142 if fi, err := os.Stat(path); err == nil {
143 if st, ok := fi.Sys().(*syscall.Stat_t); ok {
144 if err := tmp.Chown(int(st.Uid), int(st.Gid)); err != nil {
145 return fail(err)
146 }
147 }
148 }
149 if _, err := tmp.WriteString(b.String()); err != nil {
150 return fail(err)
151 }
152 if err := tmp.Sync(); err != nil {
153 return fail(err)
154 }
155 if err := tmp.Close(); err != nil {
156 return err
157 }
158 if err := os.Rename(tmp.Name(), path); err != nil {
159 return err
160 }
161 // Losing the file loses every sealed value, so the rename is made
162 // durable before returning.
163 d, err := os.Open(dir)
164 if err == nil {
165 err = d.Sync()
166 d.Close()
167 }
168 if err != nil {
169 return fmt.Errorf("%s was replaced, but syncing %s failed: %w", path, dir, err)
170 }
171 return nil
172}
173
174// Keyring is the loaded key file. It re-reads the file whenever the file
175// changes, so a running daemon follows a rotation without a restart.
176type Keyring struct {
177 path string
178
179 mu sync.Mutex
180 fi os.FileInfo
181 cur string
182 aead map[string]cipher.AEAD
183}
184
185// Load reads the key file at path and returns a Keyring over it. It
186// fails when ReadKeys would.
187func Load(path string) (*Keyring, error) {
188 k := &Keyring{path: path}
189 if err := k.refresh(); err != nil {
190 return nil, err
191 }
192 return k, nil
193}
194
195// refresh reloads the file unless it is the one last read. Callers hold k.mu.
196func (k *Keyring) refresh() error {
197 fi, err := os.Stat(k.path)
198 if err != nil {
199 return err
200 }
201 if k.fi != nil && os.SameFile(k.fi, fi) && fi.ModTime().Equal(k.fi.ModTime()) && fi.Size() == k.fi.Size() {
202 return nil
203 }
204 keys, err := ReadKeys(k.path)
205 if err != nil {
206 return err
207 }
208 aead := make(map[string]cipher.AEAD, len(keys))
209 for _, key := range keys {
210 block, err := aes.NewCipher(key.Secret)
211 if err != nil {
212 return err
213 }
214 g, err := cipher.NewGCM(block)
215 if err != nil {
216 return err
217 }
218 aead[key.ID] = g
219 }
220 k.fi, k.cur, k.aead = fi, keys[len(keys)-1].ID, aead
221 return nil
222}
223
224// CurrentID is the id of the key that seals new values.
225func (k *Keyring) CurrentID() (string, error) {
226 k.mu.Lock()
227 defer k.mu.Unlock()
228 if err := k.refresh(); err != nil {
229 return "", err
230 }
231 return k.cur, nil
232}
233
234// Seal encrypts plain under the current key with a random nonce. aad
235// names the column, so a value copied into another column does not open
236// there.
237func (k *Keyring) Seal(aad, plain string) (string, error) {
238 if aad == "" {
239 return "", errNoAAD
240 }
241 k.mu.Lock()
242 defer k.mu.Unlock()
243 if err := k.refresh(); err != nil {
244 return "", err
245 }
246 g := k.aead[k.cur]
247 nonce := make([]byte, g.NonceSize())
248 if _, err := rand.Read(nonce); err != nil {
249 return "", err
250 }
251 ct := g.Seal(nonce, nonce, []byte(plain), []byte(aad))
252 return Prefix + k.cur + ":" + base64.RawStdEncoding.EncodeToString(ct), nil
253}
254
255// Open decrypts a value Seal produced under any key the file holds.
256func (k *Keyring) Open(aad, sealed string) (string, error) {
257 if aad == "" {
258 return "", errNoAAD
259 }
260 id, body, ok := split(sealed)
261 if !ok {
262 return "", errors.New("not a sealed value")
263 }
264 k.mu.Lock()
265 defer k.mu.Unlock()
266 if err := k.refresh(); err != nil {
267 return "", err
268 }
269 g, ok := k.aead[id]
270 if !ok {
271 return "", fmt.Errorf("sealed with key %s, which %s does not hold", id, k.path)
272 }
273 ct, err := base64.RawStdEncoding.DecodeString(body)
274 if err != nil || len(ct) < g.NonceSize()+g.Overhead() {
275 return "", fmt.Errorf("value sealed with key %s is malformed", id)
276 }
277 plain, err := g.Open(nil, ct[:g.NonceSize()], ct[g.NonceSize():], []byte(aad))
278 if err != nil {
279 return "", fmt.Errorf("value sealed with key %s does not open: wrong key, wrong column or altered value", id)
280 }
281 return string(plain), nil
282}
283
284var errNoAAD = errors.New("seal: additional data (table.column) is required")
285
286// IsSealed reports whether v carries the sealed prefix.
287func IsSealed(v string) bool { return strings.HasPrefix(v, Prefix) }
288
289// KeyID is the id of the key that sealed v.
290func KeyID(v string) (string, bool) {
291 id, _, ok := split(v)
292 return id, ok
293}
294
295func split(v string) (id, body string, ok bool) {
296 rest, ok := strings.CutPrefix(v, Prefix)
297 if !ok {
298 return "", "", false
299 }
300 id, body, ok = strings.Cut(rest, ":")
301 return id, body, ok && validID(id)
302}
303
304func validID(s string) bool {
305 if len(s) != 8 || strings.ToLower(s) != s {
306 return false
307 }
308 _, err := hex.DecodeString(s)
309 return err == nil
310}
internal/seal/seal_test.go added +234
@@ -0,0 +1,234 @@
1package seal
2
3import (
4 "encoding/base64"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func keyFile(t *testing.T, keys ...Key) string {
12 t.Helper()
13 path := filepath.Join(t.TempDir(), "secret.key")
14 if err := WriteKeys(path, keys); err != nil {
15 t.Fatal(err)
16 }
17 return path
18}
19
20func newKey(t *testing.T) Key {
21 t.Helper()
22 k, err := NewKey()
23 if err != nil {
24 t.Fatal(err)
25 }
26 return k
27}
28
29func TestSealOpenRoundTrip(t *testing.T) {
30 k := newKey(t)
31 ring, err := Load(keyFile(t, k))
32 if err != nil {
33 t.Fatal(err)
34 }
35 v, err := ring.Seal("build_secrets.value", "hunter2")
36 if err != nil {
37 t.Fatal(err)
38 }
39 if !strings.HasPrefix(v, Prefix+k.ID+":") || strings.Contains(v, "hunter2") {
40 t.Fatalf("sealed value %q", v)
41 }
42 if id, ok := KeyID(v); !ok || id != k.ID {
43 t.Fatalf("KeyID = %q, %v", id, ok)
44 }
45 got, err := ring.Open("build_secrets.value", v)
46 if err != nil || got != "hunter2" {
47 t.Fatalf("Open = %q, %v", got, err)
48 }
49 // Two seals of one value differ: the nonce is random.
50 if w, _ := ring.Seal("build_secrets.value", "hunter2"); w == v {
51 t.Fatal("two seals produced the same value")
52 }
53}
54
55// A value moved to another column does not open there.
56func TestOpenChecksAdditionalData(t *testing.T) {
57 ring, err := Load(keyFile(t, newKey(t)))
58 if err != nil {
59 t.Fatal(err)
60 }
61 v, _ := ring.Seal("mirrors.token", "tok")
62 if _, err := ring.Open("webhooks.secret", v); err == nil {
63 t.Fatal("opened under the wrong column")
64 }
65}
66
67func TestOpenRefusesAnAlteredValue(t *testing.T) {
68 ring, err := Load(keyFile(t, newKey(t)))
69 if err != nil {
70 t.Fatal(err)
71 }
72 v, _ := ring.Seal("mirrors.token", "tok")
73 // A character in the middle: the last one may carry only padding bits.
74 i := len(v) - 10
75 alt := byte('A')
76 if v[i] == 'A' {
77 alt = 'B'
78 }
79 if _, err := ring.Open("mirrors.token", v[:i]+string(alt)+v[i+1:]); err == nil {
80 t.Fatal("opened an altered value")
81 }
82 if _, err := ring.Open("mirrors.token", "tok"); err == nil {
83 t.Fatal("opened a clear value")
84 }
85}
86
87// A running daemon sees a rotation without a restart: the ring re-reads
88// the file when it changes.
89func TestKeyringFollowsTheFile(t *testing.T) {
90 old, next := newKey(t), newKey(t)
91 path := keyFile(t, old)
92 ring, err := Load(path)
93 if err != nil {
94 t.Fatal(err)
95 }
96 before, _ := ring.Seal("webhooks.secret", "s")
97 if err := WriteKeys(path, []Key{old, next}); err != nil {
98 t.Fatal(err)
99 }
100 after, err := ring.Seal("webhooks.secret", "s")
101 if err != nil {
102 t.Fatal(err)
103 }
104 if id, _ := KeyID(after); id != next.ID {
105 t.Fatalf("sealed under %s after rotation, want %s", id, next.ID)
106 }
107 if got, err := ring.Open("webhooks.secret", before); err != nil || got != "s" {
108 t.Fatalf("old value after rotation: %q, %v", got, err)
109 }
110 if err := WriteKeys(path, []Key{next}); err != nil {
111 t.Fatal(err)
112 }
113 if _, err := ring.Open("webhooks.secret", before); err == nil || !strings.Contains(err.Error(), old.ID) {
114 t.Fatalf("a retired key's value opened, or the error does not name the key: %v", err)
115 }
116}
117
118func TestReadKeysRefusesAReadableFile(t *testing.T) {
119 path := keyFile(t, newKey(t))
120 if err := os.Chmod(path, 0o640); err != nil {
121 t.Fatal(err)
122 }
123 if _, err := ReadKeys(path); err == nil || !strings.Contains(err.Error(), "0600") {
124 t.Fatalf("group-readable key file: %v", err)
125 }
126}
127
128func TestWriteKeysMode(t *testing.T) {
129 path := keyFile(t, newKey(t))
130 fi, err := os.Stat(path)
131 if err != nil {
132 t.Fatal(err)
133 }
134 if fi.Mode().Perm() != 0o600 {
135 t.Fatalf("mode %04o", fi.Mode().Perm())
136 }
137}
138
139func TestWriteKeysRefusesABadKey(t *testing.T) {
140 path := filepath.Join(t.TempDir(), "secret.key")
141 good := newKey(t)
142 for _, keys := range [][]Key{
143 nil,
144 {{ID: good.ID, Secret: good.Secret[:16]}},
145 {{ID: "XYZ12345", Secret: good.Secret}},
146 {good, good},
147 } {
148 if err := WriteKeys(path, keys); err == nil {
149 t.Errorf("wrote %d keys that do not read back", len(keys))
150 }
151 }
152 if _, err := os.Stat(path); !os.IsNotExist(err) {
153 t.Fatalf("a refused write left a file: %v", err)
154 }
155}
156
157func TestReadKeysRejectsMalformedLines(t *testing.T) {
158 for _, body := range []string{
159 "",
160 "# only a comment\n",
161 "XYZ12345 AAAA\n",
162 "0123abcd bm90IDMyIGJ5dGVz\n",
163 } {
164 path := filepath.Join(t.TempDir(), "k")
165 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
166 t.Fatal(err)
167 }
168 if _, err := ReadKeys(path); err == nil {
169 t.Errorf("accepted %q", body)
170 }
171 }
172}
173
174func TestOpenRefusesMalformedInput(t *testing.T) {
175 k := newKey(t)
176 ring, err := Load(keyFile(t, k))
177 if err != nil {
178 t.Fatal(err)
179 }
180 for _, v := range []string{
181 "gbs1:",
182 "gbs1:" + k.ID + ":",
183 "gbs1:" + strings.ToUpper(k.ID) + ":AAAA",
184 "gbs1:" + k.ID[:7] + ":AAAA",
185 "gbs1:" + k.ID + ":!!!not base64!!!",
186 "gbs1:" + k.ID + ":AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // 29 bytes
187 "gbs1:0badf00d:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
188 } {
189 if got, err := ring.Open("mirrors.token", v); err == nil {
190 t.Errorf("Open(%q) = %q, want an error", v, got)
191 }
192 }
193}
194
195func TestEmptyAdditionalDataRefused(t *testing.T) {
196 ring, err := Load(keyFile(t, newKey(t)))
197 if err != nil {
198 t.Fatal(err)
199 }
200 if _, err := ring.Seal("", "s"); err == nil {
201 t.Fatal("sealed with no column")
202 }
203 v, _ := ring.Seal("mirrors.token", "s")
204 if _, err := ring.Open("", v); err == nil {
205 t.Fatal("opened with no column")
206 }
207}
208
209func TestReadKeysRefusesDuplicatesDirectoriesAndLargeFiles(t *testing.T) {
210 k := newKey(t)
211 line := k.ID + " " + base64.StdEncoding.EncodeToString(k.Secret) + "\n"
212 dup := filepath.Join(t.TempDir(), "dup")
213 if err := os.WriteFile(dup, []byte(line+line), 0o600); err != nil {
214 t.Fatal(err)
215 }
216 if _, err := ReadKeys(dup); err == nil || !strings.Contains(err.Error(), "twice") {
217 t.Errorf("duplicate id: %v", err)
218 }
219 dir := filepath.Join(t.TempDir(), "d")
220 if err := os.Mkdir(dir, 0o700); err != nil {
221 t.Fatal(err)
222 }
223 if _, err := ReadKeys(dir); err == nil {
224 t.Error("read a directory")
225 }
226 big := filepath.Join(t.TempDir(), "big")
227 body := line + "#" + strings.Repeat("x", maxKeyFile) + "\n"
228 if err := os.WriteFile(big, []byte(body), 0o600); err != nil {
229 t.Fatal(err)
230 }
231 if _, err := ReadKeys(big); err == nil {
232 t.Error("read a file over the size limit")
233 }
234}
internal/store/cisecrets.go +21 −5
@@ -1,13 +1,27 @@
11package store
22
3import "fmt"
4
35// 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.
58func (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(`
719 INSERT INTO build_secrets (repo_id, name, value) VALUES (?, ?, ?)
820 ON CONFLICT (repo_id, name) DO UPDATE SET value = excluded.value`,
9 repoID, name, value)
10 return err
21 repoID, name, sealed); err != nil {
22 return err
23 }
24 return tx.Commit()
1125}
1226
1327func (s *Store) RemoveBuildSecret(repoID int64, name string) error {
@@ -52,7 +66,9 @@ func (s *Store) BuildSecrets(repoID int64) (map[string]string, error) {
5266 if err := rows.Scan(&n, &v); err != nil {
5367 return nil, err
5468 }
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 }
5672 }
5773 return out, rows.Err()
5874}
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 @@
11package store
22
3import "errors"
3import (
4 "errors"
5 "fmt"
6)
47
58// ErrExists marks unique-constraint refusals callers turn into messages.
69var ErrExists = errors.New("already exists")
@@ -20,28 +23,54 @@ type Mirror struct {
2023 LastError string
2124}
2225
26// AddMirror stores the mirror, then seals its token under the new row's
27// id in the same transaction.
2328func (s *Store) AddMirror(repoID int64, direction, url, username, token string) (int64, error) {
24 res, err := s.DB.Exec(
25 "INSERT INTO mirrors (repo_id, direction, url, username, token) VALUES (?, ?, ?, ?, ?)",
26 repoID, direction, url, username, token)
29 tx, err := s.DB.Begin()
30 if err != nil {
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)
2737 if err != nil {
2838 if isUniqueErr(err) {
2939 return 0, ErrExists
3040 }
3141 return 0, err
3242 }
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()
3457}
3558
3659const mirrorSelect = `
3760 SELECT id, repo_id, direction, url, username, token, dirty, last_sync, last_error
3861 FROM mirrors`
3962
40func scanMirror(row interface{ Scan(...any) error }) (Mirror, error) {
63func (s *Store) scanMirror(row interface{ Scan(...any) error }) (Mirror, error) {
4164 var m Mirror
42 err := row.Scan(&m.ID, &m.RepoID, &m.Direction, &m.URL, &m.Username, &m.Token,
43 &m.Dirty, &m.LastSync, &m.LastError)
44 return m, err
65 if err := row.Scan(&m.ID, &m.RepoID, &m.Direction, &m.URL, &m.Username, &m.Token,
66 &m.Dirty, &m.LastSync, &m.LastError); err != nil {
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
4574}
4675
4776func (s *Store) mirrorQuery(q string, args ...any) ([]Mirror, error) {
@@ -52,7 +81,7 @@ func (s *Store) mirrorQuery(q string, args ...any) ([]Mirror, error) {
5281 defer rows.Close()
5382 var out []Mirror
5483 for rows.Next() {
55 m, err := scanMirror(rows)
84 m, err := s.scanMirror(rows)
5685 if err != nil {
5786 return nil, err
5887 }
internal/store/push.go +37 −14
@@ -3,6 +3,7 @@ package store
33import (
44 "database/sql"
55 "errors"
6 "fmt"
67 "time"
78)
89
@@ -20,9 +21,11 @@ type PushDevice struct {
2021
2122// AddPushDevice registers a token to an account. A token already present
2223// 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// back by token rather than taken from LastInsertId, which SQLite leaves
25// unchanged when the DO UPDATE arm fires instead of the INSERT.
24// hands the same one to whichever account signs in next. The token is
25// sealed (secrets.go), so the lookup and the upsert go by its hash, and a
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.
2629//
2730// The row id survives that handover, so queue rows written for the
2831// 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)
3538 return 0, err
3639 }
3740 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 }
3846 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 {
4052 return 0, err
4153 }
4254 if _, err := tx.Exec(`
43 INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?)
44 ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`,
45 userID, token, label); err != nil {
55 INSERT INTO push_devices (user_id, token, token_hash, label) VALUES (?, ?, ?, ?)
56 ON CONFLICT(token_hash) DO UPDATE SET user_id = excluded.user_id, token = excluded.token, label = excluded.label`,
57 userID, sealed, h, label); err != nil {
4658 return 0, err
4759 }
4860 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 {
5062 return 0, err
5163 }
5264 if prev != 0 && prev != userID {
@@ -60,7 +72,7 @@ func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error)
6072
6173func (s *Store) PushDevices(userID int64) ([]PushDevice, error) {
6274 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, '')
6476 FROM push_devices WHERE user_id = ? ORDER BY id`, userID)
6577 if err != nil {
6678 return nil, err
@@ -69,9 +81,13 @@ func (s *Store) PushDevices(userID int64) ([]PushDevice, error) {
6981 var out []PushDevice
7082 for rows.Next() {
7183 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 {
7386 return nil, err
7487 }
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 }
7591 out = append(out, d)
7692 }
7793 return out, rows.Err()
@@ -152,7 +168,7 @@ func (s *Store) EnqueuePush(userID int64, title, body, path string) error {
152168
153169func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
154170 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,
156172 (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL)
157173 FROM push_queue q
158174 JOIN push_devices d ON d.id = q.device_id
@@ -167,9 +183,14 @@ func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
167183 var out []QueuedPush
168184 for rows.Next() {
169185 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 {
171189 return nil, err
172190 }
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 }
173194 out = append(out, p)
174195 }
175196 return out, rows.Err()
@@ -195,8 +216,10 @@ func (s *Store) MarkPushFailed(id int64, errMsg string, nextAt *time.Time) error
195216}
196217
197218// 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.
199221func (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)
201224 return err
202225}
internal/store/secrets.go added +242
@@ -0,0 +1,242 @@
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}
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 +7 −1
@@ -15,6 +15,7 @@ import (
1515 "strings"
1616 "sync"
1717
18 "gitbay.org/gitbay/internal/seal"
1819 "modernc.org/sqlite"
1920)
2021
@@ -37,6 +38,9 @@ type Store struct {
3738 // daemon sets it to its own logger, whose output the service
3839 // journal keeps outside the database.
3940 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
4044}
4145
4246// Open opens (creating if needed) the database at path with WAL mode and
@@ -54,7 +58,9 @@ type Store struct {
5458// no failures, and readers, which WAL keeps out of the way, are
5559// unaffected (#121).
5660func Open(path string) (*Store, error) {
57 dsn := path + "?_txlock=immediate&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)"
61 // synchronous(FULL): a commit is durable before it returns, which
62 // secret key rotation needs before it drops the old keys (#273).
63 dsn := path + "?_txlock=immediate&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)"
5864 if path == ":memory:" {
5965 dsn = ":memory:?_txlock=immediate&_pragma=foreign_keys(ON)"
6066 }
internal/store/webhooks.go +31 −4
@@ -1,6 +1,7 @@
11package store
22
33import (
4 "fmt"
45 "time"
56)
67
@@ -38,14 +39,34 @@ type DeliveryStatus struct {
3839 CreatedAt string
3940}
4041
42// AddWebhook stores the hook, then seals its secret under the new row's
43// id in the same transaction.
4144func (s *Store) AddWebhook(repoID int64, url, secret, events string) (int64, error) {
42 res, err := s.DB.Exec(
43 "INSERT INTO webhooks (repo_id, url, secret, events) VALUES (?, ?, ?, ?)",
44 repoID, url, secret, events)
45 tx, err := s.DB.Begin()
4546 if err != nil {
4647 return 0, err
4748 }
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()
4970}
5071
5172func (s *Store) ListWebhooks(repoID int64) ([]Webhook, error) {
@@ -62,6 +83,9 @@ func (s *Store) ListWebhooks(repoID int64) ([]Webhook, error) {
6283 if err := rows.Scan(&w.ID, &w.URL, &w.Secret, &w.Events, &active, &w.CreatedAt); err != nil {
6384 return nil, err
6485 }
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 }
6589 w.Active = active != 0
6690 out = append(out, w)
6791 }
@@ -107,6 +131,9 @@ func (s *Store) DueDeliveries(limit int) ([]Delivery, error) {
107131 &d.RepoPath, &d.Actor, &d.DataJSON, &d.EventAt, &d.Attempts); err != nil {
108132 return nil, err
109133 }
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 }
110137 out = append(out, d)
111138 }
112139 return out, rows.Err()