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.

forge/secret/secret.go +440

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