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/ssh/secret.go +154

@@ -0,0 +1,154 @@
1 package ssh
2
3 // A repository's secrets over SSH.
4 //
5 // vole-secret list <repository>
6 // vole-secret get <repository> <name>
7 // vole-secret set <repository> <name> (the value is the whole of stdin)
8 // vole-secret rm <repository> <name>
9 //
10 // The value travels on stdin and never in the command line: an exec line is
11 // visible to every process on both hosts, and the instance records the command it
12 // ran. What comes back is the value and nothing else, so `vole secret get` can be
13 // redirected without stripping a banner first.
14 //
15 // The capability is the maintainer one: the accounts that can approve work,
16 // manage releases and allow CI are the accounts that own the credentials the
17 // repository's jobs use.
18
19 import (
20 "errors"
21 "fmt"
22 "io"
23 "strings"
24 "time"
25
26 xssh "golang.org/x/crypto/ssh"
27
28 "github.com/rodent-software/vole/forge/secret"
29 )
30
31 // maxSecretInput bounds what `set` will read from the client. It is one
32 // NAME=value entry plus room for a name, which is the most a value can be; a
33 // larger body is refused before it reaches the store.
34 const maxSecretInput = secret.MaxEntryBytes + 64
35
36 func (s *Server) serveSecret(ch xssh.Channel, argv []string, user string) int {
37 if len(argv) < 3 {
38 fmt.Fprint(ch, "vole: usage: vole-secret list <repository>\n"+
39 "vole: vole-secret get|set|rm <repository> <name>\n")
40 return 129
41 }
42 op := argv[1]
43 name, ok := repoName(argv[2])
44 if !ok {
45 fmt.Fprint(ch, "vole: invalid repository name\n")
46 return 129
47 }
48 if s.cfg.Instance == nil || s.cfg.Instance.Secrets == nil {
49 fmt.Fprint(ch, "vole: this instance has no secret store\n")
50 return 1
51 }
52 m, err := s.cfg.Repos.Open(name)
53 if err != nil {
54 fmt.Fprintf(ch, "vole: %v\n", err)
55 return 1
56 }
57 acl, err := m.ACL()
58 if err != nil || acl == nil {
59 fmt.Fprintf(ch, "vole: cannot read access rules for %s\n", name)
60 return 1
61 }
62 if !acl.CanTriage(user) {
63 fmt.Fprintf(ch, "vole: %s may not manage secrets in %s\n", user, name)
64 return 1
65 }
66 store := s.cfg.Instance.Secrets
67
68 if op == "list" {
69 infos, err := store.List(m.ID)
70 if err != nil {
71 fmt.Fprintf(ch, "vole: %v\n", err)
72 return 1
73 }
74 if len(infos) == 0 {
75 fmt.Fprintf(ch, "no secrets are stored for %s\n", name)
76 return 0
77 }
78 for _, info := range infos {
79 fmt.Fprintf(ch, "%-32s v%-3d %s\n", info.Name, info.Version, secretDetail(info))
80 }
81 return 0
82 }
83
84 if len(argv) < 4 {
85 fmt.Fprintf(ch, "vole: usage: vole-secret %s <repository> <name>\n", op)
86 return 129
87 }
88 secretName := strings.TrimSpace(argv[3])
89 if err := secret.ValidateName(secretName); err != nil {
90 fmt.Fprintf(ch, "vole: %v\n", err)
91 return 1
92 }
93
94 switch op {
95 case "get":
96 value, _, err := store.Read(m.ID, secretName, user, "cli")
97 if err != nil {
98 fmt.Fprintf(ch, "vole: %v\n", secretProblem(err, secretName, name))
99 return 1
100 }
101 // The value is the whole answer, with nothing appended: a caller that
102 // redirects it to a file must get the bytes that were stored.
103 fmt.Fprint(ch, value)
104 return 0
105
106 case "set":
107 body, err := io.ReadAll(io.LimitReader(ch, maxSecretInput+1))
108 if err != nil {
109 fmt.Fprintf(ch, "vole: %v\n", err)
110 return 1
111 }
112 if len(body) > maxSecretInput {
113 fmt.Fprintf(ch, "vole: %s is larger than one environment variable can hold (%d bytes)\n",
114 secretName, secret.MaxEntryBytes)
115 return 1
116 }
117 version, err := store.Set(m.ID, secretName, string(body), user)
118 if err != nil {
119 fmt.Fprintf(ch, "vole: %v\n", err)
120 return 1
121 }
122 fmt.Fprintf(ch, "saved %s v%d for %s\n", secretName, version, name)
123 return 0
124
125 case "rm":
126 if err := store.Delete(m.ID, secretName, user); err != nil {
127 fmt.Fprintf(ch, "vole: %v\n", secretProblem(err, secretName, name))
128 return 1
129 }
130 fmt.Fprintf(ch, "deleted %s from %s\n", secretName, name)
131 return 0
132 }
133 fmt.Fprintf(ch, "vole: %s is not a secret command\n", op)
134 return 129
135 }
136
137 // secretDetail says what a list line is about: which version this is, who wrote
138 // it, and whether anybody has read it.
139 func secretDetail(info secret.Info) string {
140 read := "never read"
141 if !info.LastRead.IsZero() {
142 read = "last read " + info.LastRead.Format(time.RFC3339)
143 }
144 return fmt.Sprintf("updated %s by %s, %s", info.Updated.Format("2006-01-02 15:04"), info.UpdatedBy, read)
145 }
146
147 // secretProblem turns the store's two refusals into the sentence a person needed:
148 // a missing name is about this repository, and everything else is passed through.
149 func secretProblem(err error, secretName, repoName string) string {
150 if errors.Is(err, secret.ErrNotFound) {
151 return fmt.Sprintf("%s has no secret %s", repoName, secretName)
152 }
153 return err.Error()
154 }