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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0: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/web/secrets.go +125

@@ -0,0 +1,125 @@
1 package web
2
3 // Repository secrets: the settings page half.
4 //
5 // The page lists what a repository has — names, versions, who last changed one,
6 // and when a value was last read — and it sets, replaces and deletes. It never
7 // renders a value, because there is no way to show one that does not end up in a
8 // screenshot, a browser cache, or a shoulder. A person who needs the value itself
9 // runs `vole secret get`.
10 //
11 // The capability is the maintainer one, the same as the CLI verb: the accounts
12 // that can approve work and allow CI are the accounts that own the credentials the
13 // repository's jobs use.
14
15 import (
16 "errors"
17 "fmt"
18 "net/http"
19 "net/url"
20 "strings"
21
22 "github.com/rodent-software/vole/forge/secret"
23 )
24
25 // maxSecretForm bounds the set form. The store's limit is one environment
26 // variable, but a form body is URL-encoded: a newline is six bytes and a space is
27 // three, so the body can be several times the value. This is the ceiling, not the
28 // limit the reader is told about.
29 const maxSecretForm = 2 << 20
30
31 // SaveSecret stores one value, replacing whatever the name held. The form is
32 // write-only: the response redirects to the list, which shows the name and the new
33 // version and never the value.
34 func (w *Web) SaveSecret(rw http.ResponseWriter, r *http.Request) {
35 ctx, ok := w.settingsContext(rw, r)
36 if !ok {
37 return
38 }
39 if !w.readForm(rw, r, maxSecretForm) {
40 return
41 }
42 name := strings.TrimSpace(r.PostFormValue("secret_name"))
43 // A browser submits a textarea with CRLF line ends. A value with a stray CR in
44 // it is a different value from the one the person pasted, so the form's line
45 // ends become the ones a file has.
46 value := strings.ReplaceAll(r.PostFormValue("secret_value"), "\r\n", "\n")
47 value = strings.ReplaceAll(value, "\r", "\n")
48
49 if err := secret.ValidateName(name); err != nil {
50 w.secretFailure(rw, r, ctx, sentence(err.Error()))
51 return
52 }
53 if w.Secrets == nil {
54 w.secretFailure(rw, r, ctx, "This instance has no secret store.")
55 return
56 }
57 version, err := w.Secrets.Set(ctx.m.ID, name, value, ctx.pr.account.Name)
58 if err != nil {
59 w.secretFailure(rw, r, ctx, sentence(err.Error()))
60 return
61 }
62 w.redirectSecrets(rw, r, ctx, fmt.Sprintf("%s v%d", name, version), "")
63 }
64
65 // DeleteSecret forgets one value.
66 func (w *Web) DeleteSecret(rw http.ResponseWriter, r *http.Request) {
67 ctx, ok := w.settingsContext(rw, r)
68 if !ok {
69 return
70 }
71 if !w.readForm(rw, r, maxSmallForm) {
72 return
73 }
74 name := strings.TrimSpace(r.PostFormValue("secret_name"))
75 if err := secret.ValidateName(name); err != nil {
76 w.secretFailure(rw, r, ctx, sentence(err.Error()))
77 return
78 }
79 if w.Secrets == nil {
80 w.secretFailure(rw, r, ctx, "This instance has no secret store.")
81 return
82 }
83 if err := w.Secrets.Delete(ctx.m.ID, name, ctx.pr.account.Name); err != nil {
84 if errors.Is(err, secret.ErrNotFound) {
85 w.secretFailure(rw, r, ctx, "There is no secret named "+name+" in this repository.")
86 return
87 }
88 w.secretFailure(rw, r, ctx, sentence(err.Error()))
89 return
90 }
91 w.redirectSecrets(rw, r, ctx, "", name)
92 }
93
94 func (w *Web) redirectSecrets(rw http.ResponseWriter, r *http.Request, ctx *settingsContext, saved, deleted string) {
95 target := "/r/" + ctx.m.Name + "/settings#secrets"
96 switch {
97 case saved != "":
98 target = "/r/" + ctx.m.Name + "/settings?secret_saved=" + url.QueryEscape(saved) + "#secrets"
99 case deleted != "":
100 target = "/r/" + ctx.m.Name + "/settings?secret_deleted=" + url.QueryEscape(deleted) + "#secrets"
101 }
102 http.Redirect(rw, r, target, http.StatusSeeOther)
103 }
104
105 // secretFailure re-renders the page with the message beside the form. The value is
106 // not echoed back: a refused value is not put into the page that just refused it.
107 func (w *Web) secretFailure(rw http.ResponseWriter, r *http.Request, ctx *settingsContext, msg string) {
108 v := w.settingsView(r, ctx)
109 v.SecretError = msg
110 w.render(rw, "settings", v, http.StatusBadRequest)
111 }
112
113 // sentence makes a store error read as a sentence on a page. The store's errors
114 // are written for a terminal, so a leading lower-case letter is raised; nothing
115 // else is changed, because the message is the explanation.
116 func sentence(msg string) string {
117 msg = strings.TrimSpace(msg)
118 if msg == "" {
119 return ""
120 }
121 if first := msg[0]; first >= 'a' && first <= 'z' {
122 msg = string(first-'a'+'A') + msg[1:]
123 }
124 return msg
125 }