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.

forge/instance.go +32−2

@@ -23,6 +23,7 @@ import (
23 23 "github.com/rodent-software/vole/forge/index"
24 24 "github.com/rodent-software/vole/forge/pageview"
25 25 "github.com/rodent-software/vole/forge/repo"
26 "github.com/rodent-software/vole/forge/secret"
26 27 "github.com/rodent-software/vole/forge/session"
27 28 "github.com/rodent-software/vole/internal/audit"
28 29 "github.com/rodent-software/vole/internal/secure"
@@ -41,6 +42,10 @@ type Instance struct {
41 42 LFS *lfs.Store
42 43 Logs *logs.Store
43 44 PageViews *pageview.Store
45 // Secrets holds each repository's sealed values. It is a separate database
46 // from the roster because it is the one whose contents are encrypted, and it is
47 // opened last because nothing else depends on it.
48 Secrets *secret.Store
44 49
45 50 // ImportedACLs is how many legacy refs/vole/acl rule sets were imported by
46 51 // Open, so a caller that logs startup can say so.
@@ -144,6 +149,26 @@ func Open(cfg *config.Config, binPath string) (*Instance, error) {
144 149 return nil, fmt.Errorf("open page views: %w", err)
145 150 }
146 151
152 // Secrets are opened last because nothing else depends on them, and they are
153 // the one store whose failure an operator can act on: a key file that does not
154 // match the database is a restore that cannot read what it restored.
155 secrets, err := secret.Open(cfg.SecretsDB(), cfg.SecretsKeyPath())
156 if err != nil {
157 views.Close()
158 lk.Close()
159 acls.Close()
160 rn.Close()
161 ix.Close()
162 sessions.Close()
163 ident.Close()
164 return nil, fmt.Errorf("open secrets: %w", err)
165 }
166 secrets.AuditPath = cfg.AuditLog()
167 // The repository store deletes a repository's secrets with the repository, so
168 // it needs the store it deletes from. Wiring it here is what keeps a deleted
169 // repository from leaving values behind for a name that is created again.
170 rs.Secrets = secrets
171
147 172 return &Instance{
148 173 Cfg: cfg,
149 174 Repos: rs,
@@ -156,13 +181,15 @@ func Open(cfg *config.Config, binPath string) (*Instance, error) {
156 181 LFS: lfs.New(cfg.LFSDir(), cfg.LFSObjectMaxBytes),
157 182 Logs: logs.New(cfg.LogsDir()),
158 183 PageViews: views,
184 Secrets: secrets,
159 185 ImportedACLs: imported,
160 186 }, nil
161 187 }
162 188
163 189 // TightenSecrets brings the modes of everything that holds a credential to what
164 // they must be: the account database and its write-ahead files, the SSH host key,
165 // the ACME account directory and the audit log. It runs after the stores are open
190 // they must be: the account database and its write-ahead files, the sealed secret
191 // store and its key, the SSH host key, the ACME account directory and the audit
192 // log. It runs after the stores are open
166 193 // because opening a store may create its file, and it reports what it changed so
167 194 // the caller can say so — an upgraded instance silently changing modes is better
168 195 // than one that leaves a private key readable, but it should not be silent.
@@ -179,6 +206,8 @@ func TightenSecrets(cfg *config.Config) ([]string, error) {
179 206 cfg.UsersDB(), cfg.UsersDB() + "-wal", cfg.UsersDB() + "-shm",
180 207 cfg.IndexDB(), cfg.IndexDB() + "-wal", cfg.IndexDB() + "-shm",
181 208 cfg.RunsDB(), cfg.RunsDB() + "-wal", cfg.RunsDB() + "-shm",
209 cfg.SecretsDB(), cfg.SecretsDB() + "-wal", cfg.SecretsDB() + "-shm",
210 cfg.SecretsKeyPath(),
182 211 cfg.AuditLog(),
183 212 cfg.SSHHostKey(),
184 213 cfg.ACMECachePath(),
@@ -214,6 +243,7 @@ func (i *Instance) Close() error {
214 243 }
215 244 var err error
216 245 for _, closeFn := range []func() error{
246 i.Secrets.Close,
217 247 i.PageViews.Close,
218 248 i.Locks.Close,
219 249 i.ACLs.Close,