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