vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Store repository secrets encrypted, with a CLI and a settings form
0c40a1d3b48eb0ade37334f62ee526c371ecb61c · Keenan Nemetz · 7d ago · parent 122cbe4f
Store repository secrets encrypted, with a CLI and a settings form A repository has values that cannot live in its code: a deploy key, a registry token, a staging password. They are now stored in data/secrets.db, sealed with AES-256-GCM under data/secrets.key, and read through one store that records every read and every change. The key is a separate file, and secrets_key can move it, so a backup of the data directory can be a backup of ciphertext rather than of plaintext. `vole secret list|set|get|rm` asks the instance over the key the caller already pushes with, since the store is server-side. set reads the value from stdin or a file and never from the argument list; get writes the value and nothing else. The maintainer role is the capability: the accounts that approve work and allow CI own the credentials those jobs will use. The repository's Settings tab gains the same list — names, versions, who changed one, when a value was last read — with set, replace and delete. It never renders a value, and a maintainer who is not an admin now reaches that page for this section alone. Values are limited to what one environment variable can carry: 128 KiB for NAME=value, valid UTF-8 and no NUL. A longer value could never reach a step, and the refusal says so. Slice 1 of #154.
server/secret_test.go +236
| @@ -0,0 +1,236 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "net/url" | ||
| 6 | "os" | ||
| 7 | "os/exec" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // cliAs runs the built binary in dir with a chosen key and stdin, so a test can | ||
| 14 | // ask the instance a question as somebody other than the fixture's owner. | ||
| 15 | func (f *fixture) cliAs(key, dir, stdin string, args ...string) (string, string, error) { | ||
| 16 | f.t.Helper() | ||
| 17 | cmd := exec.Command(binPath, args...) | ||
| 18 | cmd.Dir = dir | ||
| 19 | cmd.Env = append(os.Environ(), | ||
| 20 | "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR", | ||
| 21 | "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", | ||
| 22 | "USER=keenan") | ||
| 23 | cmd.Stdin = strings.NewReader(stdin) | ||
| 24 | var out, errb bytes.Buffer | ||
| 25 | cmd.Stdout, cmd.Stderr = &out, &errb | ||
| 26 | err := cmd.Run() | ||
| 27 | return out.String(), errb.String(), err | ||
| 28 | } | ||
| 29 | |||
| 30 | // secretWorkTree is a checkout whose origin points at the instance, which is all | ||
| 31 | // the secret verbs need: they ask the server, not the clone. | ||
| 32 | func (f *fixture) secretWorkTree(t *testing.T) string { | ||
| 33 | t.Helper() | ||
| 34 | work := workRepo(t, map[string]string{"readme.md": "hello\n"}) | ||
| 35 | if _, errs, err := f.git(work, "remote", "add", "origin", f.sshURL(f.repo)); err != nil { | ||
| 36 | t.Fatalf("add remote: %v\n%s", err, errs) | ||
| 37 | } | ||
| 38 | return work | ||
| 39 | } | ||
| 40 | |||
| 41 | func TestSecretRoundTripOverTheCLI(t *testing.T) { | ||
| 42 | t.Parallel() | ||
| 43 | f := newFixture(t) | ||
| 44 | f.publish(f.repo) | ||
| 45 | work := f.secretWorkTree(t) | ||
| 46 | |||
| 47 | out, errs, err := f.cliAs(f.keyPath, work, "s3cret\n", "secret", "set", f.repo, "DEPLOY_KEY") | ||
| 48 | if err != nil { | ||
| 49 | t.Fatalf("secret set: %v\n%s", err, errs) | ||
| 50 | } | ||
| 51 | if want := "saved DEPLOY_KEY v1 for " + f.repo + "\n"; out != want { | ||
| 52 | t.Fatalf("set said %q, want %q", out, want) | ||
| 53 | } | ||
| 54 | |||
| 55 | // get writes the value and nothing else, with no line ending added: a redirect | ||
| 56 | // must produce the bytes that were stored. | ||
| 57 | out, errs, err = f.cli(work, "secret", "get", f.repo, "DEPLOY_KEY") | ||
| 58 | if err != nil { | ||
| 59 | t.Fatalf("secret get: %v\n%s", err, errs) | ||
| 60 | } | ||
| 61 | if out != "s3cret" { | ||
| 62 | t.Fatalf("get = %q, want %q", out, "s3cret") | ||
| 63 | } | ||
| 64 | |||
| 65 | out, errs, err = f.cli(work, "secret", "list", f.repo) | ||
| 66 | if err != nil { | ||
| 67 | t.Fatalf("secret list: %v\n%s", err, errs) | ||
| 68 | } | ||
| 69 | for _, want := range []string{"DEPLOY_KEY", "v1", "last read"} { | ||
| 70 | if !strings.Contains(out, want) { | ||
| 71 | t.Fatalf("list has no %q:\n%s", want, out) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | if strings.Contains(out, "s3cret") { | ||
| 75 | t.Fatalf("list shows a value:\n%s", out) | ||
| 76 | } | ||
| 77 | |||
| 78 | // Saving the same name again rotates it, and the version says so. | ||
| 79 | out, errs, err = f.cliAs(f.keyPath, work, "rotated", "secret", "set", f.repo, "DEPLOY_KEY") | ||
| 80 | if err != nil { | ||
| 81 | t.Fatalf("second secret set: %v\n%s", err, errs) | ||
| 82 | } | ||
| 83 | if want := "saved DEPLOY_KEY v2 for " + f.repo + "\n"; out != want { | ||
| 84 | t.Fatalf("set said %q, want %q", out, want) | ||
| 85 | } | ||
| 86 | if out, _, err := f.cli(work, "secret", "get", f.repo, "DEPLOY_KEY"); err != nil || out != "rotated" { | ||
| 87 | t.Fatalf("get after rotation = %q, %v", out, err) | ||
| 88 | } | ||
| 89 | |||
| 90 | audit, err := os.ReadFile(f.cfg.AuditLog()) | ||
| 91 | if err != nil { | ||
| 92 | t.Fatal(err) | ||
| 93 | } | ||
| 94 | for _, want := range []string{"secret.write", "secret.read"} { | ||
| 95 | if !strings.Contains(string(audit), want) { | ||
| 96 | t.Fatalf("audit log has no %q:\n%s", want, audit) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | if strings.Contains(string(audit), "s3cret") || strings.Contains(string(audit), "rotated") { | ||
| 100 | t.Fatalf("audit log holds a value:\n%s", audit) | ||
| 101 | } | ||
| 102 | |||
| 103 | out, errs, err = f.cli(work, "secret", "rm", f.repo, "DEPLOY_KEY") | ||
| 104 | if err != nil { | ||
| 105 | t.Fatalf("secret rm: %v\n%s", err, errs) | ||
| 106 | } | ||
| 107 | if want := "deleted DEPLOY_KEY from " + f.repo + "\n"; out != want { | ||
| 108 | t.Fatalf("rm said %q, want %q", out, want) | ||
| 109 | } | ||
| 110 | if _, errs, err := f.cli(work, "secret", "get", f.repo, "DEPLOY_KEY"); err == nil { | ||
| 111 | t.Fatalf("a deleted secret still reads:\n%s", errs) | ||
| 112 | } else if !strings.Contains(errs, "has no secret DEPLOY_KEY") { | ||
| 113 | t.Fatalf("get after delete = %s", errs) | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | func TestSecretVerbsNeedTheMaintainerRole(t *testing.T) { | ||
| 118 | t.Parallel() | ||
| 119 | f := newFixture(t) | ||
| 120 | f.publish(f.repo) | ||
| 121 | work := f.secretWorkTree(t) | ||
| 122 | |||
| 123 | pub, key := keypair(t, filepath.Join(f.cfg.DataDir, "id_reader")) | ||
| 124 | if _, err := f.srv.Identity.Create("robin", pub, "test"); err != nil { | ||
| 125 | t.Fatal(err) | ||
| 126 | } | ||
| 127 | |||
| 128 | // No ACL entry, so the repository's default role is all this account has. | ||
| 129 | if _, errs, err := f.cliAs(key, work, "", "secret", "list", f.repo); err == nil { | ||
| 130 | t.Fatalf("an account without maintain read the list:\n%s", errs) | ||
| 131 | } else if !strings.Contains(errs, "may not manage secrets") { | ||
| 132 | t.Fatalf("refusal = %s", errs) | ||
| 133 | } | ||
| 134 | if _, errs, err := f.cliAs(key, work, "value", "secret", "set", f.repo, "TOKEN"); err == nil { | ||
| 135 | t.Fatalf("an account without maintain wrote a secret:\n%s", errs) | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | func TestSecretsOnTheSettingsPage(t *testing.T) { | ||
| 140 | t.Parallel() | ||
| 141 | f := browserFixture(t) | ||
| 142 | owner, cookie := f.browserSession(t, f.ownerID) | ||
| 143 | |||
| 144 | // The owner is a repository admin, and an admin is a maintainer, so the | ||
| 145 | // section is theirs too. | ||
| 146 | resp, body := f.browserGet(t, "/r/"+f.repo+"/settings", cookie) | ||
| 147 | if resp.StatusCode != 200 || !strings.Contains(body, `id="secrets"`) { | ||
| 148 | t.Fatalf("settings page has no secrets section: %d\n%s", resp.StatusCode, body) | ||
| 149 | } | ||
| 150 | |||
| 151 | value := "-----BEGIN KEY-----\nline two\n-----END KEY-----" | ||
| 152 | form := url.Values{ | ||
| 153 | "csrf": {owner.CSRF}, | ||
| 154 | "secret_name": {"PRIVATE_KEY"}, | ||
| 155 | "secret_value": {strings.ReplaceAll(value, "\n", "\r\n")}, | ||
| 156 | } | ||
| 157 | resp, body = f.browserPost(t, "/r/"+f.repo+"/settings/secrets", cookie, form) | ||
| 158 | if resp.StatusCode != 303 { | ||
| 159 | t.Fatalf("save = %d: %s", resp.StatusCode, body) | ||
| 160 | } | ||
| 161 | if loc := resp.Header.Get("Location"); !strings.Contains(loc, "secret_saved=PRIVATE_KEY+v1") { | ||
| 162 | t.Fatalf("save redirected to %q", loc) | ||
| 163 | } | ||
| 164 | |||
| 165 | resp, body = f.browserGet(t, "/r/"+f.repo+"/settings?secret_saved=PRIVATE_KEY+v1", cookie) | ||
| 166 | if resp.StatusCode != 200 { | ||
| 167 | t.Fatalf("settings after save = %d", resp.StatusCode) | ||
| 168 | } | ||
| 169 | for _, want := range []string{"Saved PRIVATE_KEY v1.", "PRIVATE_KEY", "v1", "never"} { | ||
| 170 | if !strings.Contains(body, want) { | ||
| 171 | t.Fatalf("the page has no %q:\n%s", want, body) | ||
| 172 | } | ||
| 173 | } | ||
| 174 | // A value never reaches a page: not the whole of it, and not its distinctive | ||
| 175 | // line. | ||
| 176 | for _, secret := range []string{"BEGIN KEY", "line two"} { | ||
| 177 | if strings.Contains(body, secret) { | ||
| 178 | t.Fatalf("the page rendered %q:\n%s", secret, body) | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | // The stored value is the textarea's value with its line ends normalised, so a | ||
| 183 | // pasted key keeps the bytes a file would have. | ||
| 184 | m, err := f.srv.Repos.Open(f.repo) | ||
| 185 | if err != nil { | ||
| 186 | t.Fatal(err) | ||
| 187 | } | ||
| 188 | got, version, err := f.srv.Secrets.Read(m.ID, "PRIVATE_KEY", f.ownerID, "test") | ||
| 189 | if err != nil { | ||
| 190 | t.Fatal(err) | ||
| 191 | } | ||
| 192 | if got != value || version != 1 { | ||
| 193 | t.Fatalf("stored %q v%d", got, version) | ||
| 194 | } | ||
| 195 | |||
| 196 | deleteForm := url.Values{"csrf": {owner.CSRF}, "secret_name": {"PRIVATE_KEY"}} | ||
| 197 | resp, body = f.browserPost(t, "/r/"+f.repo+"/settings/secrets/delete", cookie, deleteForm) | ||
| 198 | if resp.StatusCode != 303 || !strings.Contains(resp.Header.Get("Location"), "secret_deleted=PRIVATE_KEY") { | ||
| 199 | t.Fatalf("delete = %d %q: %s", resp.StatusCode, resp.Header.Get("Location"), body) | ||
| 200 | } | ||
| 201 | if _, _, err := f.srv.Secrets.Read(m.ID, "PRIVATE_KEY", f.ownerID, "test"); err == nil { | ||
| 202 | t.Fatal("the value survived the delete") | ||
| 203 | } | ||
| 204 | } | ||
| 205 | |||
| 206 | func TestAMaintainerSeesTheSettingsTabForSecrets(t *testing.T) { | ||
| 207 | t.Parallel() | ||
| 208 | f := browserFixture(t) | ||
| 209 | f.addWriter(t) | ||
| 210 | _, cookie := f.browserSession(t, f.writerID) | ||
| 211 | |||
| 212 | resp, body := f.browserGet(t, "/r/"+f.repo+"/settings", cookie) | ||
| 213 | if resp.StatusCode != 200 { | ||
| 214 | t.Fatalf("a maintainer's settings page = %d\n%s", resp.StatusCode, body) | ||
| 215 | } | ||
| 216 | if !strings.Contains(body, `id="secrets"`) { | ||
| 217 | t.Fatalf("a maintainer cannot see the secrets section:\n%s", body) | ||
| 218 | } | ||
| 219 | // The parts of the page only an admin owns must not be offered. | ||
| 220 | for _, adminOnly := range []string{"Default branch", "Danger zone", `id="access"`} { | ||
| 221 | if strings.Contains(body, adminOnly) { | ||
| 222 | t.Fatalf("a maintainer was offered %q:\n%s", adminOnly, body) | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | // An account with no role in the repository gets no page at all. | ||
| 227 | pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_robin")) | ||
| 228 | robin, err := f.srv.Identity.Create("robin", pub, "test") | ||
| 229 | if err != nil { | ||
| 230 | t.Fatal(err) | ||
| 231 | } | ||
| 232 | _, robinCookie := f.browserSession(t, robin.Name) | ||
| 233 | if resp, _ := f.browserGet(t, "/r/"+f.repo+"/settings", robinCookie); resp.StatusCode != 404 { | ||
| 234 | t.Fatalf("a non-maintainer's settings page = %d, want 404", resp.StatusCode) | ||
| 235 | } | ||
| 236 | } | ||