Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }