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 · 1h ago c284cb2c
history blame raw 13.9 KiB
1// Package secret stores one encrypted value per name, per repository.
2//
3// A repository's secrets are the values its jobs read and its maintainers share:
4// a deploy key, a registry token, a staging password. They live in
5// data/secrets.db rather than in Git, because a value committed to a clone is a
6// value every clone has, and a rotation is then a commit that every branch has to
7// be rebased onto.
8//
9// Each value is sealed with AES-256-GCM under the key in data/secrets.key. The
10// file mode is the real boundary: the key sits beside the database by default, so
11// what the encryption protects is a copy of the data directory that leaves the
12// host — a backup, an rsync, a stray tarball. It does not protect a host somebody
13// already owns, and saying so here keeps the guarantee from growing into a claim
14// it cannot meet.
15//
16// Rows are keyed by the repository's stable id, not its name, so a rename keeps
17// the secrets and a new repository that takes the old name does not inherit them.
18package secret
19
20import (
21 "crypto/aes"
22 "crypto/cipher"
23 "crypto/rand"
24 "database/sql"
25 "errors"
26 "fmt"
27 "io"
28 "os"
29 "strconv"
30 "strings"
31 "sync"
32 "time"
33 "unicode/utf8"
34
35 _ "github.com/mattn/go-sqlite3"
36
37 "rodent.software/vole/ci"
38 "rodent.software/vole/internal/audit"
39 "rodent.software/vole/internal/secure"
40)
41
42const (
43 // KeyBytes is the size of the key file: one AES-256 key.
44 KeyBytes = 32
45 // MaxEntryBytes is the most one NAME=value pair may hold. It is the kernel's
46 // MAX_ARG_STRLEN — 32 pages — which is the limit on a single string in a
47 // process's environment. A longer value could never reach a step, so refusing
48 // it here says why instead of failing deep inside a container.
49 MaxEntryBytes = ci.MaxSecretBytes
50 // formatVersion is the first byte of a sealed value. It names the cipher and
51 // the layout, so a later key or algorithm can be recognised without guessing.
52 formatVersion = 1
53)
54
55// ErrNotFound means the repository has no secret with that name. Callers decide
56// what it looks like: the CLI refuses the command, and a job runs without the
57// variable.
58var ErrNotFound = errors.New("secret: no such secret")
59
60// Info is one secret's metadata. It never holds a value: the settings page and
61// `vole secret list` show what a secret is, not what it says.
62type Info struct {
63 Name string
64 Version int
65 CreatedBy string
66 Created time.Time
67 UpdatedBy string
68 Updated time.Time
69 // LastRead is when a value was last handed to a job or a person. The zero time
70 // means never.
71 LastRead time.Time
72}
73
74// Store is a repository's secrets, sealed at rest and recorded on every read.
75type Store struct {
76 db *sql.DB
77 // key is the AES-256 key every value is sealed with, and KeyPath is where it
78 // came from, so a restore that paired the database with a new key can say which
79 // file was wrong. The key is held for the life of the process, so a copy of the
80 // database file in a backup is not enough to read a value.
81 key []byte
82 KeyPath string
83 // AuditPath receives one line per read and per change. A read that cannot be
84 // recorded is refused, the same as an access change: reading a value is the
85 // privileged act this log exists for.
86 AuditPath string
87 // mu serialises writes, because a version is read before the row that carries
88 // it is written. One instance is one process, so this is the whole concurrency
89 // control a secret needs; the database's own lock would otherwise be enough to
90 // make a concurrent read observe a version that does not match its value.
91 mu sync.Mutex
92}
93
94// Open opens (and creates) the secret database and its key.
95func Open(dbPath, keyPath string) (*Store, error) {
96 key, err := loadKey(keyPath)
97 if err != nil {
98 return nil, err
99 }
100 dsn := dbPath + "?_busy_timeout=5000&_journal_mode=WAL"
101 db, err := sql.Open("sqlite3", dsn)
102 if err != nil {
103 return nil, err
104 }
105 db.SetMaxOpenConns(2)
106 if _, err := db.Exec(`
107CREATE TABLE IF NOT EXISTS secrets (
108 repo_id TEXT NOT NULL,
109 name TEXT NOT NULL,
110 version INTEGER NOT NULL,
111 sealed BLOB NOT NULL,
112 created_by TEXT NOT NULL,
113 created INTEGER NOT NULL,
114 updated_by TEXT NOT NULL,
115 updated INTEGER NOT NULL,
116 last_read INTEGER NOT NULL DEFAULT 0,
117 PRIMARY KEY (repo_id, name)
118);`); err != nil {
119 db.Close()
120 return nil, err
121 }
122 return &Store{db: db, key: key, KeyPath: keyPath}, nil
123}
124
125func (s *Store) Close() error { return s.db.Close() }
126
127// ValidateName reports why a name cannot be a secret. The rule lives in the ci
128// package because it is the workflow language's rule too: the store refuses a name
129// no workflow could declare, so a value that cannot be used is not stored.
130func ValidateName(name string) error {
131 if err := ci.ValidateSecretName(name); err != nil {
132 return err
133 }
134 if ci.ReservedEnvName(name) {
135 return fmt.Errorf("%s is set by the runner, so it cannot be a secret", name)
136 }
137 return nil
138}
139
140// checkValue reports why a value cannot be stored. Both rules come from what a
141// container environment can carry: a NUL ends a string in execve, and a value
142// that is not UTF-8 would be corrupted by the JSON that carries it to a runner.
143func checkValue(name, value string) error {
144 if name == "" {
145 return errors.New("a secret needs a name")
146 }
147 if !utf8.ValidString(value) {
148 return errors.New("the value is not text; a secret has to be valid UTF-8 to reach a step unchanged")
149 }
150 if strings.ContainsRune(value, 0) {
151 return errors.New("the value contains a NUL byte, which no process environment can carry")
152 }
153 if len(name)+1+len(value) > MaxEntryBytes {
154 return fmt.Errorf("%s would be %d bytes; one environment variable is at most %d bytes "+
155 "(32 pages, the kernel's limit for a single NAME=value string). "+
156 "Store a path to the material and read it in the step instead.",
157 name, len(name)+1+len(value), MaxEntryBytes)
158 }
159 return nil
160}
161
162// Set stores a value and returns its new version. Every write bumps the version,
163// so a run record that names a version says which value it used.
164func (s *Store) Set(repoID, name, value, actor string) (int, error) {
165 if err := ValidateName(name); err != nil {
166 return 0, err
167 }
168 if err := checkValue(name, value); err != nil {
169 return 0, err
170 }
171 s.mu.Lock()
172 defer s.mu.Unlock()
173
174 version, err := s.version(repoID, name)
175 if err != nil {
176 return 0, err
177 }
178 version++
179 sealed, err := seal(s.key, aad(repoID, name, version), []byte(value))
180 if err != nil {
181 return 0, err
182 }
183 now := time.Now().UTC().Unix()
184 if err := s.audit(audit.Record{
185 Action: "secret.write",
186 Actor: actor,
187 Subject: repoID,
188 Detail: fmt.Sprintf("%s v%d", name, version),
189 }); err != nil {
190 return 0, err
191 }
192 _, err = s.db.Exec(`
193INSERT INTO secrets (repo_id, name, version, sealed, created_by, created, updated_by, updated, last_read)
194VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
195ON CONFLICT (repo_id, name) DO UPDATE SET
196 version = excluded.version,
197 sealed = excluded.sealed,
198 updated_by = excluded.updated_by,
199 updated = excluded.updated,
200 last_read = 0`,
201 repoID, name, version, sealed, actor, now, actor, now)
202 if err != nil {
203 return 0, err
204 }
205 return version, nil
206}
207
208// version answers which version a name is at, or zero when it does not exist.
209func (s *Store) version(repoID, name string) (int, error) {
210 var v int
211 err := s.db.QueryRow(`SELECT version FROM secrets WHERE repo_id = ? AND name = ?`, repoID, name).Scan(&v)
212 switch {
213 case errors.Is(err, sql.ErrNoRows):
214 return 0, nil
215 case err != nil:
216 return 0, err
217 }
218 return v, nil
219}
220
221// Read returns a value and its version, stamps the read, and records it. The
222// detail says who asked and why: "run=<id>" for a job, "cli" or "web" for a
223// person.
224func (s *Store) Read(repoID, name, actor, detail string) (string, int, error) {
225 if err := ValidateName(name); err != nil {
226 return "", 0, err
227 }
228 var (
229 version int
230 sealed []byte
231 )
232 err := s.db.QueryRow(`SELECT version, sealed FROM secrets WHERE repo_id = ? AND name = ?`,
233 repoID, name).Scan(&version, &sealed)
234 switch {
235 case errors.Is(err, sql.ErrNoRows):
236 return "", 0, ErrNotFound
237 case err != nil:
238 return "", 0, err
239 }
240 value, err := s.unseal(sealed, aad(repoID, name, version))
241 if err != nil {
242 return "", 0, err
243 }
244 if err := s.audit(audit.Record{
245 Action: "secret.read",
246 Actor: actor,
247 Subject: repoID,
248 Detail: fmt.Sprintf("%s v%d %s", name, version, detail),
249 }); err != nil {
250 return "", 0, err
251 }
252 if _, err := s.db.Exec(`UPDATE secrets SET last_read = ? WHERE repo_id = ? AND name = ?`,
253 time.Now().UTC().Unix(), repoID, name); err != nil {
254 return "", 0, err
255 }
256 return value, version, nil
257}
258
259// List returns a repository's secrets, ordered by name.
260func (s *Store) List(repoID string) ([]Info, error) {
261 rows, err := s.db.Query(`SELECT name, version, created_by, created, updated_by, updated, last_read
262FROM secrets WHERE repo_id = ? ORDER BY name`, repoID)
263 if err != nil {
264 return nil, err
265 }
266 defer rows.Close()
267 var out []Info
268 for rows.Next() {
269 var (
270 info Info
271 created, updated, lastRead int64
272 )
273 if err := rows.Scan(&info.Name, &info.Version, &info.CreatedBy, &created,
274 &info.UpdatedBy, &updated, &lastRead); err != nil {
275 return nil, err
276 }
277 info.Created = unix(created)
278 info.Updated = unix(updated)
279 info.LastRead = unix(lastRead)
280 out = append(out, info)
281 }
282 return out, rows.Err()
283}
284
285// Delete forgets one secret. It is recorded before the row goes, so a deletion
286// that cannot be logged does not happen.
287func (s *Store) Delete(repoID, name, actor string) error {
288 if err := ValidateName(name); err != nil {
289 return err
290 }
291 // The name is checked before the line is written, so a deletion that removes
292 // nothing is not recorded as one that did.
293 version, err := s.version(repoID, name)
294 if err != nil {
295 return err
296 }
297 if version == 0 {
298 return ErrNotFound
299 }
300 if err := s.audit(audit.Record{
301 Action: "secret.delete",
302 Actor: actor,
303 Subject: repoID,
304 Detail: name,
305 }); err != nil {
306 return err
307 }
308 _, err = s.db.Exec(`DELETE FROM secrets WHERE repo_id = ? AND name = ?`, repoID, name)
309 return err
310}
311
312// RecordRepoDelete writes the line for a repository deletion before anything is
313// removed. Deleting a repository is the one act that cannot be undone, so a
314// deletion that cannot be recorded does not start.
315func (s *Store) RecordRepoDelete(repoID, actor string) error {
316 return s.audit(audit.Record{
317 Action: "secret.delete",
318 Actor: actor,
319 Subject: repoID,
320 Detail: "repository deleted",
321 })
322}
323
324// DeleteRepo forgets every secret a repository had. RecordRepoDelete has already
325// recorded the deletion, and the rows go only once the content is gone, so a
326// removal that fails leaves a repository that can still be read against them.
327func (s *Store) DeleteRepo(repoID string) error {
328 _, err := s.db.Exec(`DELETE FROM secrets WHERE repo_id = ?`, repoID)
329 return err
330}
331
332func (s *Store) audit(r audit.Record) error { return audit.Append(s.AuditPath, r) }
333
334// aad binds a sealed value to one repository, one name and one version, so a row
335// copied to another name, or replayed as an older version, does not decrypt.
336func aad(repoID, name string, version int) []byte {
337 return []byte(repoID + "\x00" + name + "\x00" + strconv.Itoa(version))
338}
339
340// seal encrypts a value for one row. The first byte names the format so a future
341// key or cipher can be recognised without a schema change.
342func seal(key, additional, plaintext []byte) ([]byte, error) {
343 gcm, err := aead(key)
344 if err != nil {
345 return nil, err
346 }
347 out := make([]byte, 1+gcm.NonceSize(), 1+gcm.NonceSize()+len(plaintext)+gcm.Overhead())
348 out[0] = formatVersion
349 nonce := out[1:]
350 if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
351 return nil, err
352 }
353 return gcm.Seal(out, nonce, plaintext, additional), nil
354}
355
356// unseal decrypts one value, reporting a key that does not match rather than a
357// generic authentication failure: a restored database with a newly generated key
358// is the likely cause, and the message should say so.
359func (s *Store) unseal(sealed []byte, additional []byte) (string, error) {
360 if len(sealed) < 1 || sealed[0] != formatVersion {
361 return "", errors.New("the stored value is not in a format this instance understands")
362 }
363 gcm, err := aead(s.key)
364 if err != nil {
365 return "", err
366 }
367 body := sealed[1:]
368 if len(body) < gcm.NonceSize() {
369 return "", errors.New("the stored value is truncated")
370 }
371 nonce, ciphertext := body[:gcm.NonceSize()], body[gcm.NonceSize():]
372 plain, err := gcm.Open(nil, nonce, ciphertext, additional)
373 if err != nil {
374 return "", fmt.Errorf("the value cannot be decrypted with this instance's key; "+
375 "the key in %s is not the one it was sealed with", s.KeyPath)
376 }
377 return string(plain), nil
378}
379
380func aead(key []byte) (cipher.AEAD, error) {
381 if len(key) != KeyBytes {
382 return nil, fmt.Errorf("secret key is %d bytes, expected %d", len(key), KeyBytes)
383 }
384 block, err := aes.NewCipher(key)
385 if err != nil {
386 return nil, err
387 }
388 return cipher.NewGCM(block)
389}
390
391// loadKey reads the key file, creating it the first time. Creation is exclusive:
392// two instances starting against the same directory must agree on one key, not
393// overwrite each other's.
394func loadKey(path string) ([]byte, error) {
395 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, secure.FileMode)
396 if err == nil {
397 key := make([]byte, KeyBytes)
398 if _, err := io.ReadFull(rand.Reader, key); err != nil {
399 f.Close()
400 return nil, err
401 }
402 if _, err := f.Write(key); err != nil {
403 f.Close()
404 // A half-written key file would fail the next start in a way that looks
405 // like a restore problem, so it is removed rather than left behind.
406 os.Remove(path)
407 return nil, err
408 }
409 if err := f.Close(); err != nil {
410 return nil, err
411 }
412 if _, err := secure.HardenFile(path); err != nil {
413 return nil, err
414 }
415 return key, nil
416 }
417 if !os.IsExist(err) {
418 return nil, err
419 }
420 key, err := os.ReadFile(path)
421 if err != nil {
422 return nil, err
423 }
424 if len(key) != KeyBytes {
425 return nil, fmt.Errorf("%s holds %d bytes; a secret key is %d bytes. Restore the original key, "+
426 "or the values it sealed cannot be read", path, len(key), KeyBytes)
427 }
428 return key, nil
429}
430
431func unix(v int64) time.Time {
432 if v == 0 {
433 return time.Time{}
434 }
435 return time.Unix(v, 0).UTC()
436}
437