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.

Code

396 commit(s) on main

Turn off the disk reserve for test-spawned runners Keenan Nemetz · 51m ago c284cb2c
history blame raw 8.3 KiB
1package secret
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9)
10
11func openStore(t *testing.T, dir string, audit string) *Store {
12 t.Helper()
13 s, err := Open(filepath.Join(dir, "secrets.db"), filepath.Join(dir, "secrets.key"))
14 if err != nil {
15 t.Fatalf("open: %v", err)
16 }
17 s.AuditPath = audit
18 t.Cleanup(func() { s.Close() })
19 return s
20}
21
22func TestSetReadListDelete(t *testing.T) {
23 dir := t.TempDir()
24 auditPath := filepath.Join(t.TempDir(), "audit.log")
25 s := openStore(t, dir, auditPath)
26
27 version, err := s.Set("repo-1", "DEPLOY_KEY", "s3cret", "alice")
28 if err != nil {
29 t.Fatalf("set: %v", err)
30 }
31 if version != 1 {
32 t.Fatalf("first version = %d, want 1", version)
33 }
34 if version, err = s.Set("repo-1", "DEPLOY_KEY", "rotated", "bob"); err != nil || version != 2 {
35 t.Fatalf("second set = %d, %v, want version 2", version, err)
36 }
37
38 infos, err := s.List("repo-1")
39 if err != nil {
40 t.Fatalf("list: %v", err)
41 }
42 if len(infos) != 1 {
43 t.Fatalf("list = %+v", infos)
44 }
45 info := infos[0]
46 switch {
47 case info.Name != "DEPLOY_KEY":
48 t.Fatalf("name = %q", info.Name)
49 case info.Version != 2:
50 t.Fatalf("version = %d", info.Version)
51 case info.CreatedBy != "alice" || info.UpdatedBy != "bob":
52 t.Fatalf("authors = %q / %q", info.CreatedBy, info.UpdatedBy)
53 case !info.LastRead.IsZero():
54 t.Fatalf("last read = %s before any read", info.LastRead)
55 }
56
57 value, version, err := s.Read("repo-1", "DEPLOY_KEY", "carol", "cli")
58 if err != nil {
59 t.Fatalf("read: %v", err)
60 }
61 if value != "rotated" || version != 2 {
62 t.Fatalf("read = %q v%d", value, version)
63 }
64 infos, _ = s.List("repo-1")
65 if infos[0].LastRead.IsZero() {
66 t.Fatal("reading a value did not stamp last_read")
67 }
68
69 if err := s.Delete("repo-1", "DEPLOY_KEY", "bob"); err != nil {
70 t.Fatalf("delete: %v", err)
71 }
72 if _, _, err := s.Read("repo-1", "DEPLOY_KEY", "carol", "cli"); err == nil {
73 t.Fatal("a deleted secret still reads")
74 }
75 if err := s.Delete("repo-1", "DEPLOY_KEY", "bob"); err != ErrNotFound {
76 t.Fatalf("second delete = %v, want ErrNotFound", err)
77 }
78
79 audit, err := os.ReadFile(auditPath)
80 if err != nil {
81 t.Fatal(err)
82 }
83 for _, want := range []string{
84 "secret.write\talice", "secret.write\tbob", "secret.read\tcarol", "secret.delete\tbob",
85 } {
86 if !strings.Contains(string(audit), want) {
87 t.Fatalf("audit log has no %q:\n%s", want, audit)
88 }
89 }
90 // The value is the one thing that must never reach the log.
91 if strings.Contains(string(audit), "rotated") || strings.Contains(string(audit), "s3cret") {
92 t.Fatalf("audit log holds a value:\n%s", audit)
93 }
94}
95
96func TestRepositoriesAreIsolated(t *testing.T) {
97 dir := t.TempDir()
98 s := openStore(t, dir, filepath.Join(t.TempDir(), "audit.log"))
99
100 if _, err := s.Set("repo-1", "TOKEN", "one", "alice"); err != nil {
101 t.Fatal(err)
102 }
103 if _, err := s.Set("repo-2", "TOKEN", "two", "alice"); err != nil {
104 t.Fatal(err)
105 }
106 one, _, err := s.Read("repo-1", "TOKEN", "alice", "cli")
107 if err != nil || one != "one" {
108 t.Fatalf("repo-1 = %q, %v", one, err)
109 }
110 two, _, err := s.Read("repo-2", "TOKEN", "alice", "cli")
111 if err != nil || two != "two" {
112 t.Fatalf("repo-2 = %q, %v", two, err)
113 }
114 // Deleting a repository's values is by id, so the other repository keeps its.
115 if err := s.RecordRepoDelete("repo-1", "alice"); err != nil {
116 t.Fatal(err)
117 }
118 if err := s.DeleteRepo("repo-1"); err != nil {
119 t.Fatal(err)
120 }
121 if _, _, err := s.Read("repo-1", "TOKEN", "alice", "cli"); err != ErrNotFound {
122 t.Fatalf("deleted repository still reads: %v", err)
123 }
124 if _, _, err := s.Read("repo-2", "TOKEN", "alice", "cli"); err != nil {
125 t.Fatalf("the other repository's value went with it: %v", err)
126 }
127}
128
129func TestValuesAreBoundToTheirRow(t *testing.T) {
130 dir := t.TempDir()
131 s := openStore(t, dir, filepath.Join(t.TempDir(), "audit.log"))
132
133 if _, err := s.Set("repo-1", "FIRST", "value", "alice"); err != nil {
134 t.Fatal(err)
135 }
136 // Move the sealed bytes to another name, which is what editing the database by
137 // hand would do. The additional data binds them to the name they were written
138 // for, so the copy must not open.
139 if _, err := s.db.Exec(`UPDATE secrets SET name = 'SECOND' WHERE name = 'FIRST'`); err != nil {
140 t.Fatal(err)
141 }
142 if _, _, err := s.Read("repo-1", "SECOND", "alice", "cli"); err == nil {
143 t.Fatal("a value moved to another name decrypted")
144 } else if !strings.Contains(err.Error(), "cannot be decrypted") {
145 t.Fatalf("error = %v", err)
146 }
147}
148
149func TestAnotherKeyCannotRead(t *testing.T) {
150 dir := t.TempDir()
151 s := openStore(t, dir, filepath.Join(t.TempDir(), "audit.log"))
152 if _, err := s.Set("repo-1", "TOKEN", "value", "alice"); err != nil {
153 t.Fatal(err)
154 }
155 s.Close()
156
157 // A restore that pairs the database with a newly generated key is the mistake
158 // this message exists for.
159 otherKey := filepath.Join(t.TempDir(), "secrets.key")
160 restored, err := Open(filepath.Join(dir, "secrets.db"), otherKey)
161 if err != nil {
162 t.Fatal(err)
163 }
164 defer restored.Close()
165 restored.AuditPath = filepath.Join(t.TempDir(), "audit.log")
166 _, _, err = restored.Read("repo-1", "TOKEN", "alice", "cli")
167 if err == nil {
168 t.Fatal("a value sealed with another key decrypted")
169 }
170 if !strings.Contains(err.Error(), otherKey) {
171 t.Fatalf("error does not name the key file: %v", err)
172 }
173}
174
175func TestKeyFileIsPrivateAndReused(t *testing.T) {
176 dir := t.TempDir()
177 keyPath := filepath.Join(dir, "secrets.key")
178 if _, err := Open(filepath.Join(dir, "secrets.db"), keyPath); err != nil {
179 t.Fatal(err)
180 }
181 st, err := os.Stat(keyPath)
182 if err != nil {
183 t.Fatal(err)
184 }
185 if perm := st.Mode().Perm(); perm != 0o600 {
186 t.Fatalf("key mode = %04o, want 0600", perm)
187 }
188 first, err := os.ReadFile(keyPath)
189 if err != nil {
190 t.Fatal(err)
191 }
192 if len(first) != KeyBytes {
193 t.Fatalf("key is %d bytes", len(first))
194 }
195
196 second, err := loadKey(keyPath)
197 if err != nil {
198 t.Fatal(err)
199 }
200 if string(second) != string(first) {
201 t.Fatal("opening the store again generated a new key")
202 }
203
204 if err := os.WriteFile(keyPath, []byte("short"), 0o600); err != nil {
205 t.Fatal(err)
206 }
207 if _, err := loadKey(keyPath); err == nil {
208 t.Fatal("a short key file was accepted")
209 }
210}
211
212func TestValuesThatCannotReachAStepAreRefused(t *testing.T) {
213 s := openStore(t, t.TempDir(), filepath.Join(t.TempDir(), "audit.log"))
214
215 cases := []struct {
216 name, value string
217 want string
218 }{
219 {"lower", "x", "is not a secret name"},
220 {"1LEADING", "x", "is not a secret name"},
221 {"NAME-WITH-DASH", "x", "is not a secret name"},
222 {strings.Repeat("A", 65), "x", "at most 64"},
223 {"HOME", "x", "set by the runner"},
224 {"NUL", "a\x00b", "NUL"},
225 {"BAD", string([]byte{0xff, 0xfe}), "UTF-8"},
226 {"HUGE", strings.Repeat("x", MaxEntryBytes), "at most"},
227 }
228 for _, c := range cases {
229 if _, err := s.Set("repo-1", c.name, c.value, "alice"); err == nil {
230 t.Fatalf("%q was accepted", c.name)
231 } else if !strings.Contains(err.Error(), c.want) {
232 t.Fatalf("%q error = %v, want %q", c.name, err, c.want)
233 }
234 }
235
236 // Exactly the limit is fine: the check is on NAME=value, so the name's bytes
237 // and the separator come out of the budget.
238 name := "K"
239 value := strings.Repeat("x", MaxEntryBytes-len(name)-1)
240 if _, err := s.Set("repo-1", name, value, "alice"); err != nil {
241 t.Fatalf("a value at the limit was refused: %v", err)
242 }
243}
244
245func TestMultiLineValuesRoundTrip(t *testing.T) {
246 s := openStore(t, t.TempDir(), filepath.Join(t.TempDir(), "audit.log"))
247 value := "-----BEGIN KEY-----\nline two\n\nline four\n-----END KEY-----\n"
248 if _, err := s.Set("repo-1", "PRIVATE_KEY", value, "alice"); err != nil {
249 t.Fatal(err)
250 }
251 got, _, err := s.Read("repo-1", "PRIVATE_KEY", "alice", "cli")
252 if err != nil {
253 t.Fatal(err)
254 }
255 if got != value {
256 t.Fatalf("value changed:\n%q\n%q", got, value)
257 }
258}
259
260func TestReadRequiresAnAuditSink(t *testing.T) {
261 s := openStore(t, t.TempDir(), "")
262 if _, err := s.Set("repo-1", "TOKEN", "value", "alice"); err == nil {
263 t.Fatal("a write with no audit log succeeded")
264 }
265 if _, _, err := s.Read("repo-1", "TOKEN", "alice", "cli"); err == nil {
266 t.Fatal("a read with no audit log succeeded")
267 }
268}
269
270func TestInfoTimesComeBackAsUTC(t *testing.T) {
271 s := openStore(t, t.TempDir(), filepath.Join(t.TempDir(), "audit.log"))
272 if _, err := s.Set("repo-1", "TOKEN", "value", "alice"); err != nil {
273 t.Fatal(err)
274 }
275 infos, err := s.List("repo-1")
276 if err != nil {
277 t.Fatal(err)
278 }
279 if got := infos[0].Updated.Location(); got != time.UTC {
280 t.Fatalf("updated in %v, want UTC", got)
281 }
282}
283