store: seal secret columns at rest !495
30 files changed, +1970 −56
Layout: unified · split
.gitbay/wiki/Admin.org +42
| @@ -18,8 +18,14 @@ install -m 755 gitbayd /usr/local/bin/ | ||
| 18 | 18 | adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay |
| 19 | 19 | install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay |
| 20 | 20 | gitbayd --config /etc/gitbay/config.toml check-config |
| 21 | gitbayd --config /etc/gitbay/config.toml admin secrets init | |
| 22 | chown gitbay:gitbay /etc/gitbay/secret.key | |
| 21 | 23 | #+end_src |
| 22 | 24 | |
| 25 | =admin secrets init= refuses when the key file already exists, so a | |
| 26 | reinstall on the same host should skip it — =deploy/install.sh= does | |
| 27 | this with a file check before running it. | |
| 28 | ||
| 23 | 29 | =deploy/= in the source tree has a cloud-init file, a hardened systemd |
| 24 | 30 | unit, and a nightly backup timer. Run as the unprivileged =gitbay= user; |
| 25 | 31 | the unit's =AmbientCapabilities=CAP_NET_BIND_SERVICE= covers ports |
| @@ -57,6 +63,10 @@ validation still prints, followed by the contradiction. | ||
| 57 | 63 | keys, ACME cache all live here. |
| 58 | 64 | - =site_url= (required) — canonical =https://host=; drives ACME, clone |
| 59 | 65 | 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. | |
| 60 | 70 | - =source_repo= (optional, =owner/name=) — the repository this instance |
| 61 | 71 | develops itself in. Startup warns when the running build's commit is |
| 62 | 72 | 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 | ||
| 503 | 513 | change; their times do not. Done for krz/keycask (formerly rust-pass) |
| 504 | 514 | on 2026-09-18, across 22 snapshots. |
| 505 | 515 | |
| 516 | ** Secret key | |
| 517 | ||
| 518 | CI secrets, webhook secrets, mirror tokens and APNs device tokens are | |
| 519 | stored sealed: AES-256-GCM under a key in =server.secret_key_file=, | |
| 520 | each 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 | |
| 523 | main restic repository. It must be copied off the host separately; | |
| 524 | without it a restored database's secrets cannot be opened, and | |
| 525 | gitbayd refuses to start against them. | |
| 526 | ||
| 527 | #+begin_src sh | |
| 528 | gitbayd admin secrets init # once; deploy/install.sh does it on first install | |
| 529 | gitbayd admin secrets check # open every value, count by key | |
| 530 | gitbayd 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 | ||
| 506 | 548 | * Upgrades |
| 507 | 549 | |
| 508 | 550 | Replace the binary, restart the unit. Migrations apply automatically and |
.gitbay/wiki/Architecture/03-Deployment.org +1
| @@ -50,6 +50,7 @@ a database check. | ||
| 50 | 50 | | =<root>/acme= | ACME account key and certificates | autocert defaults | |
| 51 | 51 | | =<root>/hooks= | generated hook scripts | 0755 | |
| 52 | 52 | | =/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=) | | |
| 53 | 54 | | =/var/backups/gitbay= | backup archives | 0750 (cloud-init) | |
| 54 | 55 | |
| 55 | 56 | * Outbound connections from gitbayd |
.gitbay/wiki/Architecture/06-Data-and-Cryptography.org +10 −8
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | * Data inventory |
| 4 | 4 | |
| 5 | Schema: =internal/store/migrations/=, 59 migrations. Classification: | |
| 5 | Schema: =internal/store/migrations/=, 66 migrations. Classification: | |
| 6 | 6 | *C* credential or secret, *P* personal data, *R* private repository |
| 7 | 7 | content (as confidential as the repository), *O* operational. |
| 8 | 8 | |
| @@ -14,9 +14,9 @@ content (as confidential as the repository), *O* operational. | ||
| 14 | 14 | | Collaboration | =issues=, =issue_*=, =merge_requests=, =mr_*=, =labels=, =milestones=, =mentions= | R | bodies of issues, comments and reviews | |
| 15 | 15 | | Releases, snippets | =releases=, =release_assets=, =snippets=, =snippet_files= | R | | |
| 16 | 16 | | 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 | | |
| 20 | 20 | | Signatures | =commit_signatures=, =settings.key_epoch= | O | verification cache | |
| 21 | 21 | | 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=) | |
| 22 | 22 | | Dependencies | =dep_checks=, =dep_reports= | O | | |
| @@ -31,6 +31,7 @@ Outside the database: | ||
| 31 | 31 | | TLS keys (ACME) | =<root>/acme= | C | |
| 32 | 32 | | SMTP password | =/etc/gitbay/config.toml= | C | |
| 33 | 33 | | APNs signing key (.p8) | path in =push.key_file= | C | |
| 34 | | Secret key file | =server.secret_key_file= (=/etc/gitbay/secret.key=) | C | | |
| 34 | 35 | | Backups | =/var/backups/gitbay=, offsite | all of the above | |
| 35 | 36 | |
| 36 | 37 | No table stores client IP addresses as a column. The daemon writes a |
| @@ -42,14 +43,15 @@ throttling (=internal/sshd/sshd.go=). | ||
| 42 | 43 | | Item | Protection | |
| 43 | 44 | |---------------------------------------+----------------------------------------------------------------| |
| 44 | 45 | | 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=) | | |
| 46 | 47 | | SQLite file | mode 0640, directory 0750 | |
| 47 | 48 | | Backups | the local archive is not encrypted; restic encrypts the offsite copy | |
| 48 | 49 | | Disk | no application-level encryption; any disk encryption is the host's | |
| 49 | 50 | |
| 50 | The code base contains no symmetric encryption. A database or backup | |
| 51 | file read by anyone other than the =gitbay= user discloses every CI | |
| 52 | secret, webhook secret and mirror token. | |
| 51 | The database file or a backup read by anyone other than the =gitbay= | |
| 52 | user discloses no CI secret, webhook secret, mirror token or device | |
| 53 | token without the key file, which neither carries. Rotation: | |
| 54 | =gitbayd admin secrets rotate= (Admin wiki). | |
| 53 | 55 | |
| 54 | 56 | * In transit |
| 55 | 57 | |
.gitbay/wiki/Architecture/09-Controls.org +1 −1
| @@ -56,7 +56,7 @@ chapter names of OWASP ASVS 4.0 where one fits. | ||
| 56 | 56 | | Control | Status | Evidence | |
| 57 | 57 | |---------------------------------------------+----------+------------------------------------------------------------------| |
| 58 | 58 | | 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=) | | |
| 60 | 60 | | Secrets kept out of argv, logs and output | in place | =ReadsStdin=, pruned audit argv, write-only secret commands | |
| 61 | 61 | | Local backups encrypted | gap | tar.gz in clear; offsite copy encrypted by restic (#274) | |
| 62 | 62 | | 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. | ||
| 14 | 14 | | #260 | CI network | Builds share the runner's source address; no egress policy | medium | |
| 15 | 15 | | #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | |
| 16 | 16 | | #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 | | |
| 18 | 17 | | #274 | Backups | The local backup archive is not encrypted | medium | |
| 19 | 18 | | #298 | SSRF | =repo import --from= fetches without an address check | medium | |
| 20 | 19 | | #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 | ||
| 123 | 123 | browser (#269). |
| 124 | 124 | - 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) |
| 125 | 125 | - =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 | |
| 128 | replacing the binary, run =gitbayd admin secrets init= as root and | |
| 129 | =chown gitbay:gitbay /etc/gitbay/secret.key= (=deploy/install.sh= does | |
| 130 | both when the file is missing). The first start seals the stored | |
| 131 | secrets. Back the key file up separately: =admin backup= archives do | |
| 132 | not carry it (see the Admin wiki, "Secret key"). Downgrading to an | |
| 133 | earlier release after values are sealed is not supported: an older | |
| 134 | gitbayd reads a sealed value's =gbs1:...= prefix as the literal | |
| 135 | secret. | |
| 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=. | |
| 126 | 139 | - Untrusted builds (merge requests from forks) get a fresh HOME |
| 127 | 140 | removed after the build and no secrets; trusted builds keep a |
| 128 | 141 | per-repository home under =<workdir>/trusted-home=. Deploy gitbayd |
cmd/gitbayd/backup_test.go +2 −4
| @@ -8,8 +8,6 @@ import ( | ||
| 8 | 8 | "path/filepath" |
| 9 | 9 | "sort" |
| 10 | 10 | "testing" |
| 11 | ||
| 12 | "gitbay.org/gitbay/internal/config" | |
| 13 | 11 | ) |
| 14 | 12 | |
| 15 | 13 | // members lists the archive's entries by name. |
| @@ -43,8 +41,8 @@ func members(t *testing.T, path string) []string { | ||
| 43 | 41 | // --db-only is what makes an hourly schedule affordable, so it has to leave |
| 44 | 42 | // the repositories out and still carry a restorable database. |
| 45 | 43 | func 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 | |
| 48 | 46 | s, err := openStore(cfg) |
| 49 | 47 | if err != nil { |
| 50 | 48 | t.Fatal(err) |
cmd/gitbayd/main.go +24
| @@ -4,8 +4,10 @@ package main | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "context" |
| 7 | "errors" | |
| 7 | 8 | "fmt" |
| 8 | 9 | "io" |
| 10 | "io/fs" | |
| 9 | 11 | "log/slog" |
| 10 | 12 | "net" |
| 11 | 13 | "net/http" |
| @@ -31,6 +33,7 @@ import ( | ||
| 31 | 33 | "gitbay.org/gitbay/internal/mirror" |
| 32 | 34 | "gitbay.org/gitbay/internal/notify" |
| 33 | 35 | "gitbay.org/gitbay/internal/push" |
| 36 | "gitbay.org/gitbay/internal/seal" | |
| 34 | 37 | "gitbay.org/gitbay/internal/sshd" |
| 35 | 38 | "gitbay.org/gitbay/internal/store" |
| 36 | 39 | "gitbay.org/gitbay/internal/toolpath" |
| @@ -38,10 +41,21 @@ import ( | ||
| 38 | 41 | ) |
| 39 | 42 | |
| 40 | 43 | func 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 | } | |
| 41 | 54 | s, err := store.Open(filepath.Join(cfg.Server.Root, "gitbay.db")) |
| 42 | 55 | if err != nil { |
| 43 | 56 | return nil, err |
| 44 | 57 | } |
| 58 | s.SetKeyring(keys) | |
| 45 | 59 | // Say so when the schema moves. A restart migrates in silence otherwise, |
| 46 | 60 | // which makes an unexpected schema version hard to attribute to the deploy |
| 47 | 61 | // that caused it. |
| @@ -144,6 +158,15 @@ func serveCmd() *cobra.Command { | ||
| 144 | 158 | // audit row outside the database the daemon can write. Rows |
| 145 | 159 | // are logged at Info, which the default handler always emits. |
| 146 | 160 | 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 | } | |
| 147 | 170 | |
| 148 | 171 | // Regenerate hook scripts so a moved binary self-heals, then |
| 149 | 172 | // start the hook policy socket. |
| @@ -422,6 +445,7 @@ func adminCmd() *cobra.Command { | ||
| 422 | 445 | hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"), |
| 423 | 446 | auditCmd, |
| 424 | 447 | backupCmd(), |
| 448 | secretsCmd(), | |
| 425 | 449 | gcCmd(), |
| 426 | 450 | adminMigrateCommitRefsCmd(), |
| 427 | 451 | adminMigrateProfileAboutCmd(), |
cmd/gitbayd/main_test.go +1 −3
| @@ -6,15 +6,13 @@ import ( | ||
| 6 | 6 | "strconv" |
| 7 | 7 | "strings" |
| 8 | 8 | "testing" |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/config" | |
| 11 | 9 | ) |
| 12 | 10 | |
| 13 | 11 | // A restart that moves the schema says so. Migrations used to run in silence, |
| 14 | 12 | // which left an unexpected user_version with nothing in the journal tying it to |
| 15 | 13 | // the deploy that applied it. |
| 16 | 14 | func TestOpenStoreLogsSchemaMigration(t *testing.T) { |
| 17 | cfg := config.Config{Server: config.Server{Root: t.TempDir()}} | |
| 15 | cfg := testConfig(t) | |
| 18 | 16 | |
| 19 | 17 | var buf bytes.Buffer |
| 20 | 18 | prev := slog.Default() |
cmd/gitbayd/secrets.go added +196
| @@ -0,0 +1,196 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 22 | func 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 | |
| 41 | root, the file is given to the owner of server.root, the daemon's user. | |
| 42 | Refuses 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 | |
| 49 | transaction, then removes the old keys from the file. A running daemon | |
| 50 | re-reads the file when it changes, so no restart is needed. Run as the | |
| 51 | user that can replace the key file (root, for /etc/gitbay); the file | |
| 52 | keeps 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. | |
| 66 | func 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. | |
| 109 | func 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. | |
| 158 | func 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 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 16 | func 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. | |
| 26 | func 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 | ||
| 46 | func 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. | |
| 83 | func 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 | ||
| 108 | func 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 | ||
| 138 | func 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 | ||
| 169 | func 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 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 13 | func 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" ' | ||
| 14 | 14 | chmod 755 /usr/local/bin/gitbayd.new |
| 15 | 15 | mv /usr/local/bin/gitbayd.new /usr/local/bin/gitbayd |
| 16 | 16 | /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 | |
| 17 | 23 | systemctl restart gitbayd |
| 18 | 24 | sleep 1 |
| 19 | 25 | systemctl --no-pager --lines=5 status gitbayd |
e2e/acme_test.go +2 −1
| @@ -29,6 +29,7 @@ func TestACMEServe(t *testing.T) { | ||
| 29 | 29 | [server] |
| 30 | 30 | root = %q |
| 31 | 31 | site_url = "https://gitbay.example" |
| 32 | secret_key_file = %q | |
| 32 | 33 | [ssh] |
| 33 | 34 | port = %d |
| 34 | 35 | [http] |
| @@ -36,7 +37,7 @@ addr = "127.0.0.1:%d" | ||
| 36 | 37 | tls = "acme" |
| 37 | 38 | acme_email = "noreply@gitbay.example" |
| 38 | 39 | acme_http_addr = "127.0.0.1:%d" |
| 39 | `, inst.root, inst.port, httpsPort, acmeHTTPPort) | |
| 40 | `, inst.root, inst.keyFile, inst.port, httpsPort, acmeHTTPPort) | |
| 40 | 41 | if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { |
| 41 | 42 | t.Fatal(err) |
| 42 | 43 | } |
e2e/backup_test.go +41 −1
| @@ -1,16 +1,23 @@ | ||
| 1 | 1 | package e2e |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "bytes" | |
| 4 | 5 | "fmt" |
| 5 | 6 | "net" |
| 6 | 7 | "os" |
| 7 | 8 | "os/exec" |
| 8 | 9 | "path/filepath" |
| 10 | "regexp" | |
| 9 | 11 | "strings" |
| 10 | 12 | "testing" |
| 11 | 13 | "time" |
| 12 | 14 | ) |
| 13 | 15 | |
| 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". | |
| 19 | var secretsCheckOneSealed = regexp.MustCompile(`(?m)build_secrets\.value: key \S+ 1$`) | |
| 20 | ||
| 14 | 21 | func TestAdminBackup(t *testing.T) { |
| 15 | 22 | t.Parallel() |
| 16 | 23 | inst := startInstance(t) |
| @@ -35,6 +42,11 @@ func TestAdminBackup(t *testing.T) { | ||
| 35 | 42 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/keep", "--title", "'survives backup'"); code != 0 { |
| 36 | 43 | t.Fatal("issue create failed") |
| 37 | 44 | } |
| 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 | } | |
| 38 | 50 | |
| 39 | 51 | // Back up while the daemon is running. |
| 40 | 52 | archive := filepath.Join(t.TempDir(), "backup.tar.gz") |
| @@ -64,6 +76,16 @@ func TestAdminBackup(t *testing.T) { | ||
| 64 | 76 | } |
| 65 | 77 | } |
| 66 | 78 | } |
| 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 | } | |
| 67 | 89 | |
| 68 | 90 | // Restore: extract into a fresh root and serve from it. |
| 69 | 91 | root2 := t.TempDir() |
| @@ -77,12 +99,13 @@ func TestAdminBackup(t *testing.T) { | ||
| 77 | 99 | [server] |
| 78 | 100 | root = %q |
| 79 | 101 | site_url = "https://gitbay.test" |
| 102 | secret_key_file = %q | |
| 80 | 103 | [ssh] |
| 81 | 104 | port = %d |
| 82 | 105 | [http] |
| 83 | 106 | addr = "127.0.0.1:%d" |
| 84 | 107 | tls = "off" |
| 85 | `, root2, port2, httpPort2) | |
| 108 | `, root2, inst.keyFile, port2, httpPort2) | |
| 86 | 109 | if err := os.WriteFile(config2, []byte(cfg), 0o600); err != nil { |
| 87 | 110 | t.Fatal(err) |
| 88 | 111 | } |
| @@ -149,6 +172,23 @@ tls = "off" | ||
| 149 | 172 | if code != 0 || strings.TrimSpace(out2) != "alice" { |
| 150 | 173 | t.Fatalf("whoami on restored instance: exit %d, %q, %s", code, out2, errOut) |
| 151 | 174 | } |
| 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 | } | |
| 152 | 192 | if out2, _, code = ssh2("repo", "log", "alice/keep"); code != 0 || !strings.Contains(out2, "keep me") { |
| 153 | 193 | t.Fatalf("restored log: %d\n%s", code, out2) |
| 154 | 194 | } |
e2e/ssh_test.go +6 −1
| @@ -24,6 +24,7 @@ type instance struct { | ||
| 24 | 24 | gitPort int |
| 25 | 25 | proc *exec.Cmd |
| 26 | 26 | sshDir string // per-user client keys live here |
| 27 | keyFile string // server.secret_key_file, outside root | |
| 27 | 28 | } |
| 28 | 29 | |
| 29 | 30 | // nextPort hands out candidate ports. Seeded randomly so two test processes |
| @@ -89,11 +90,13 @@ func startInstanceWith(t *testing.T, extra string) *instance { | ||
| 89 | 90 | gitPort: ports[2], |
| 90 | 91 | sshDir: t.TempDir(), |
| 91 | 92 | } |
| 93 | inst.keyFile = filepath.Join(t.TempDir(), "secret.key") | |
| 92 | 94 | inst.config = filepath.Join(inst.root, "config.toml") |
| 93 | 95 | cfg := fmt.Sprintf(` |
| 94 | 96 | [server] |
| 95 | 97 | root = %q |
| 96 | 98 | site_url = "https://gitbay.test" |
| 99 | secret_key_file = %q | |
| 97 | 100 | [ssh] |
| 98 | 101 | port = %d |
| 99 | 102 | [http] |
| @@ -102,12 +105,14 @@ tls = "off" | ||
| 102 | 105 | [git_daemon] |
| 103 | 106 | enabled = true |
| 104 | 107 | port = %d |
| 105 | `, inst.root, inst.port, inst.httpPort, inst.gitPort) | |
| 108 | `, inst.root, inst.keyFile, inst.port, inst.httpPort, inst.gitPort) | |
| 106 | 109 | cfg += extra + "\n" |
| 107 | 110 | if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { |
| 108 | 111 | t.Fatal(err) |
| 109 | 112 | } |
| 110 | 113 | |
| 114 | inst.admin(t, "admin", "secrets", "init") | |
| 115 | ||
| 111 | 116 | inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve") |
| 112 | 117 | inst.proc.Stderr = os.Stderr |
| 113 | 118 | if err := inst.proc.Start(); err != nil { |
e2e/system_test.go +2 −1
| @@ -33,12 +33,13 @@ func TestSystemSSHMode(t *testing.T) { | ||
| 33 | 33 | [server] |
| 34 | 34 | root = %q |
| 35 | 35 | site_url = "https://gitbay.test" |
| 36 | secret_key_file = %q | |
| 36 | 37 | [ssh] |
| 37 | 38 | mode = "system" |
| 38 | 39 | [http] |
| 39 | 40 | addr = "127.0.0.1:%d" |
| 40 | 41 | tls = "off" |
| 41 | `, inst.root, inst.httpPort) | |
| 42 | `, inst.root, inst.keyFile, inst.httpPort) | |
| 42 | 43 | if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { |
| 43 | 44 | t.Fatal(err) |
| 44 | 45 | } |
internal/config/config.go +54 −1
| @@ -9,6 +9,7 @@ import ( | ||
| 9 | 9 | "fmt" |
| 10 | 10 | "net" |
| 11 | 11 | "os" |
| 12 | "path/filepath" | |
| 12 | 13 | "strconv" |
| 13 | 14 | "strings" |
| 14 | 15 | "time" |
| @@ -54,6 +55,11 @@ type Server struct { | ||
| 54 | 55 | // not on that repository's default branch. Empty disables the check, which |
| 55 | 56 | // is right for any instance that does not host its own source. |
| 56 | 57 | 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"` | |
| 57 | 63 | } |
| 58 | 64 | |
| 59 | 65 | type SSH struct { |
| @@ -294,7 +300,7 @@ func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) { | ||
| 294 | 300 | // Default returns the configuration used when a key is absent from the file. |
| 295 | 301 | func Default() Config { |
| 296 | 302 | return Config{ |
| 297 | Server: Server{Root: "/var/lib/gitbay"}, | |
| 303 | Server: Server{Root: "/var/lib/gitbay", SecretKeyFile: "/etc/gitbay/secret.key"}, | |
| 298 | 304 | SSH: SSH{Mode: "embedded", Port: 22}, |
| 299 | 305 | HTTP: HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"}, |
| 300 | 306 | Web: Web{Mode: "view_only"}, |
| @@ -330,6 +336,47 @@ func Load(path string) (Config, error) { | ||
| 330 | 336 | return cfg, cfg.Validate() |
| 331 | 337 | } |
| 332 | 338 | |
| 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. | |
| 344 | func 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. | |
| 357 | func 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 | ||
| 333 | 380 | func oneOf(field, val string, allowed ...string) error { |
| 334 | 381 | for _, a := range allowed { |
| 335 | 382 | if val == a { |
| @@ -357,6 +404,12 @@ func (c Config) Validate() error { | ||
| 357 | 404 | if c.Server.SiteURL == "" { |
| 358 | 405 | errs = append(errs, errors.New("server.site_url is required")) |
| 359 | 406 | } |
| 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 | } | |
| 360 | 413 | if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil { |
| 361 | 414 | errs = append(errs, err) |
| 362 | 415 | } |
internal/config/config_test.go +91
| @@ -275,3 +275,94 @@ func TestMailTLSRequired(t *testing.T) { | ||
| 275 | 275 | } |
| 276 | 276 | } |
| 277 | 277 | } |
| 278 | ||
| 279 | func 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. | |
| 304 | func 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. | |
| 6 | package seal | |
| 7 | ||
| 8 | import ( | |
| 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>". | |
| 27 | const Prefix = "gbs1:" | |
| 28 | ||
| 29 | // maxKeyFile is the largest key file ReadKeys accepts. | |
| 30 | const maxKeyFile = 1 << 20 | |
| 31 | ||
| 32 | // Key is one line of the key file. | |
| 33 | type 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. | |
| 39 | func 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. | |
| 52 | func 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. | |
| 111 | func 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. | |
| 176 | type 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. | |
| 187 | func 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. | |
| 196 | func (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. | |
| 225 | func (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. | |
| 237 | func (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. | |
| 256 | func (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 | ||
| 284 | var errNoAAD = errors.New("seal: additional data (table.column) is required") | |
| 285 | ||
| 286 | // IsSealed reports whether v carries the sealed prefix. | |
| 287 | func IsSealed(v string) bool { return strings.HasPrefix(v, Prefix) } | |
| 288 | ||
| 289 | // KeyID is the id of the key that sealed v. | |
| 290 | func KeyID(v string) (string, bool) { | |
| 291 | id, _, ok := split(v) | |
| 292 | return id, ok | |
| 293 | } | |
| 294 | ||
| 295 | func 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 | ||
| 304 | func 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 @@ | ||
| 1 | package seal | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/base64" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func 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 | ||
| 20 | func 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 | ||
| 29 | func 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. | |
| 56 | func 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 | ||
| 67 | func 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. | |
| 89 | func 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 | ||
| 118 | func 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 | ||
| 128 | func 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 | ||
| 139 | func 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 | ||
| 157 | func 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 | ||
| 174 | func 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 | ||
| 195 | func 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 | ||
| 209 | func 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 @@ | ||
| 1 | 1 | package store |
| 2 | 2 | |
| 3 | import "fmt" | |
| 4 | ||
| 3 | 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 | 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 | 19 | INSERT INTO build_secrets (repo_id, name, value) VALUES (?, ?, ?) |
| 8 | 20 | 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() | |
| 11 | 25 | } |
| 12 | 26 | |
| 13 | 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 | 66 | if err := rows.Scan(&n, &v); err != nil { |
| 53 | 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 | 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 | 1 | package store |
| 2 | 2 | |
| 3 | import "errors" | |
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | ) | |
| 4 | 7 | |
| 5 | 8 | // ErrExists marks unique-constraint refusals callers turn into messages. |
| 6 | 9 | var ErrExists = errors.New("already exists") |
| @@ -20,28 +23,54 @@ type Mirror struct { | ||
| 20 | 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 | 28 | func (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) | |
| 27 | 37 | if err != nil { |
| 28 | 38 | if isUniqueErr(err) { |
| 29 | 39 | return 0, ErrExists |
| 30 | 40 | } |
| 31 | 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 | 59 | const mirrorSelect = ` |
| 37 | 60 | SELECT id, repo_id, direction, url, username, token, dirty, last_sync, last_error |
| 38 | 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 | 64 | 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 | |
| 45 | 74 | } |
| 46 | 75 | |
| 47 | 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 | 81 | defer rows.Close() |
| 53 | 82 | var out []Mirror |
| 54 | 83 | for rows.Next() { |
| 55 | m, err := scanMirror(rows) | |
| 84 | m, err := s.scanMirror(rows) | |
| 56 | 85 | if err != nil { |
| 57 | 86 | return nil, err |
| 58 | 87 | } |
internal/store/push.go +37 −14
| @@ -3,6 +3,7 @@ package store | ||
| 3 | 3 | import ( |
| 4 | 4 | "database/sql" |
| 5 | 5 | "errors" |
| 6 | "fmt" | |
| 6 | 7 | "time" |
| 7 | 8 | ) |
| 8 | 9 | |
| @@ -20,9 +21,11 @@ type PushDevice struct { | ||
| 20 | 21 | |
| 21 | 22 | // AddPushDevice registers a token to an account. A token already present |
| 22 | 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 | // 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. | |
| 26 | 29 | // |
| 27 | 30 | // The row id survives that handover, so queue rows written for the |
| 28 | 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 | 38 | return 0, err |
| 36 | 39 | } |
| 37 | 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 | 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 | 52 | return 0, err |
| 41 | 53 | } |
| 42 | 54 | 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 { | |
| 46 | 58 | return 0, err |
| 47 | 59 | } |
| 48 | 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 | 62 | return 0, err |
| 51 | 63 | } |
| 52 | 64 | if prev != 0 && prev != userID { |
| @@ -60,7 +72,7 @@ func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error) | ||
| 60 | 72 | |
| 61 | 73 | func (s *Store) PushDevices(userID int64) ([]PushDevice, error) { |
| 62 | 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 | 76 | FROM push_devices WHERE user_id = ? ORDER BY id`, userID) |
| 65 | 77 | if err != nil { |
| 66 | 78 | return nil, err |
| @@ -69,9 +81,13 @@ func (s *Store) PushDevices(userID int64) ([]PushDevice, error) { | ||
| 69 | 81 | var out []PushDevice |
| 70 | 82 | for rows.Next() { |
| 71 | 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 | 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 | 91 | out = append(out, d) |
| 76 | 92 | } |
| 77 | 93 | return out, rows.Err() |
| @@ -152,7 +168,7 @@ func (s *Store) EnqueuePush(userID int64, title, body, path string) error { | ||
| 152 | 168 | |
| 153 | 169 | func (s *Store) DuePush(limit int) ([]QueuedPush, error) { |
| 154 | 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 | 172 | (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL) |
| 157 | 173 | FROM push_queue q |
| 158 | 174 | JOIN push_devices d ON d.id = q.device_id |
| @@ -167,9 +183,14 @@ func (s *Store) DuePush(limit int) ([]QueuedPush, error) { | ||
| 167 | 183 | var out []QueuedPush |
| 168 | 184 | for rows.Next() { |
| 169 | 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 | 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 | 194 | out = append(out, p) |
| 174 | 195 | } |
| 175 | 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 | 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 | 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 | 224 | return err |
| 202 | 225 | } |
internal/store/secrets.go added +242
| @@ -0,0 +1,242 @@ | ||
| 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 | // 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. | |
| 187 | type 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. | |
| 194 | type 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. | |
| 202 | func (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. | |
| 226 | func (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 @@ | ||
| 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 +7 −1
| @@ -15,6 +15,7 @@ import ( | ||
| 15 | 15 | "strings" |
| 16 | 16 | "sync" |
| 17 | 17 | |
| 18 | "gitbay.org/gitbay/internal/seal" | |
| 18 | 19 | "modernc.org/sqlite" |
| 19 | 20 | ) |
| 20 | 21 | |
| @@ -37,6 +38,9 @@ type Store struct { | ||
| 37 | 38 | // daemon sets it to its own logger, whose output the service |
| 38 | 39 | // journal keeps outside the database. |
| 39 | 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 | 46 | // Open opens (creating if needed) the database at path with WAL mode and |
| @@ -54,7 +58,9 @@ type Store struct { | ||
| 54 | 58 | // no failures, and readers, which WAL keeps out of the way, are |
| 55 | 59 | // unaffected (#121). |
| 56 | 60 | func 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)" | |
| 58 | 64 | if path == ":memory:" { |
| 59 | 65 | dsn = ":memory:?_txlock=immediate&_pragma=foreign_keys(ON)" |
| 60 | 66 | } |
internal/store/webhooks.go +31 −4
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package store |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "fmt" | |
| 4 | 5 | "time" |
| 5 | 6 | ) |
| 6 | 7 | |
| @@ -38,14 +39,34 @@ type DeliveryStatus struct { | ||
| 38 | 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 | 44 | func (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() | |
| 45 | 46 | if err != nil { |
| 46 | 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 | 72 | func (s *Store) ListWebhooks(repoID int64) ([]Webhook, error) { |
| @@ -62,6 +83,9 @@ func (s *Store) ListWebhooks(repoID int64) ([]Webhook, error) { | ||
| 62 | 83 | if err := rows.Scan(&w.ID, &w.URL, &w.Secret, &w.Events, &active, &w.CreatedAt); err != nil { |
| 63 | 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 | 89 | w.Active = active != 0 |
| 66 | 90 | out = append(out, w) |
| 67 | 91 | } |
| @@ -107,6 +131,9 @@ func (s *Store) DueDeliveries(limit int) ([]Delivery, error) { | ||
| 107 | 131 | &d.RepoPath, &d.Actor, &d.DataJSON, &d.EventAt, &d.Attempts); err != nil { |
| 108 | 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 | 137 | out = append(out, d) |
| 111 | 138 | } |
| 112 | 139 | return out, rows.Err() |