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 | } | ||