diff --git a/.dockerignore b/.dockerignore
index 296e95b..8c32d45 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,5 +1,6 @@
# The image builds from Go sources only; keep the build context small.
.git/
bin/
+data/
docs/
tasks/
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 1a8ba60..51dd028 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -22,6 +22,7 @@ import (
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
"github.com/atmin/gitmote/internal/scaleway"
+ "github.com/atmin/gitmote/internal/secrets"
"github.com/atmin/gitmote/internal/store"
"github.com/atmin/gitmote/internal/webui"
"github.com/atmin/s3lite"
@@ -252,6 +253,17 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
materializer := repo.New(md, objs, cacheRoot)
guard := auth.NewGuard(md)
+ // CI secrets: master keys from GITMOTE_CI_SECRET_KEY_V<n>. A malformed key is
+ // fatal (fail loud). With none set the service is disabled — no secrets UI,
+ // none injected. The service is the single hub for the UI and the dispatcher.
+ keyring, err := secrets.NewKeyringFromEnv()
+ if err != nil {
+ _ = writer.Close()
+ _ = md.Close()
+ return nil, nil, nil, noop, fmt.Errorf("ci secrets: %w", err)
+ }
+ secretsSvc := secrets.NewService(keyring, md)
+
// Select the CI trigger. Cloud and local run the *same* runner code and env
// contract; only the substrate differs — Scaleway Serverless Jobs in
// production, a local process for dev (tasks/16-ci.md, tasks/21-ci-runner.md).
@@ -288,6 +300,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
Materializer: materializer,
Trigger: trigger,
Minter: guard,
+ Secrets: secretsSvc,
BaseURL: gitmoteURL,
WorkerSecret: workerSecret,
Logger: logger,
@@ -334,7 +347,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return nil, nil, nil, noop, err
}
- ui, err := buildUI(md, materializer, guard, logger)
+ ui, err := buildUI(md, materializer, guard, secretsSvc, logger)
if err != nil {
_ = cleanup()
return nil, nil, nil, noop, err
@@ -345,13 +358,13 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
// buildUI constructs the management UI when GITMOTE_COOKIE_KEY is set; otherwise
// it returns nil (UI disabled) so a misconfigured key never yields an insecurely
// signed session.
-func buildUI(md *meta.Metadata, mz *repo.Materializer, guard *auth.Guard, logger *slog.Logger) (*webui.Handler, error) {
+func buildUI(md *meta.Metadata, mz *repo.Materializer, guard *auth.Guard, secretsSvc *secrets.Service, logger *slog.Logger) (*webui.Handler, error) {
key := os.Getenv("GITMOTE_COOKIE_KEY")
if key == "" {
logger.Warn("GITMOTE_COOKIE_KEY unset; management UI disabled")
return nil, nil
}
- return webui.New(md, mz, guard, []byte(key), logger)
+ return webui.New(md, mz, guard, secretsSvc, []byte(key), logger)
}
// hookBinaryPath resolves the pre-receive hook executable: GITMOTE_HOOK if set,
diff --git a/docs/architecture/safety.md b/docs/architecture/safety.md
index b0b3ec6..2cdc030 100644
--- a/docs/architecture/safety.md
+++ b/docs/architecture/safety.md
@@ -2,7 +2,7 @@
> Part of the [gitmote architecture](README.md).
-**"Safe" is a hard requirement.** The model rests on four points:
+**"Safe" is a hard requirement.** The model rests on these points:
1. **Single writer = single _lease-holder_, not single _user_.** s3lite requires
exactly one writer instance; that's a deployment fact, not a limit on
@@ -44,3 +44,19 @@
idempotent — this is self-healing in a way a ledger isn't, so we accept it.
(Escape hatch if ever needed: move _refs_ to plain S3 behind a synchronous
conditional-PUT CAS, keeping only softer metadata in s3lite.)
+
+5. **CI secrets are encrypted at rest, and the threat model is narrow — say so.**
+ Per-repo CI secrets are sealed with AES-256-GCM under a **server-held** master
+ key (`GITMOTE_CI_SECRET_KEY_V<n>`, a container env var alongside
+ `GITMOTE_COOKIE_KEY` and the AWS keys); a per-repo subkey is derived with
+ HKDF-SHA256, and the envelope's AAD binds `(repoID, name, version)` so a stolen
+ ciphertext can't be replayed under a different repo/name/version. Only the
+ sealed `{v, iv, ct}` lives in s3lite — never the plaintext or the key. So the
+ one property this buys is: **a compromise of the S3 replica / DB snapshot (the
+ litestream WAL, the object bucket) does not leak secret values.** It is
+ explicitly **not** a defense against a compromised *running* server, which
+ holds the master key by necessity — it must decrypt to inject secrets into the
+ runner env at trigger. There is no user-password KDF here (unlike an E2E
+ design): the server is the trust boundary. Rotation is a new key version + bump
+ (old envelopes still decrypt under their own version); values are write-only in
+ the UI (only names are ever shown) and are never logged.
diff --git a/docs/ops.md b/docs/ops.md
index 1063619..65b7997 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -93,6 +93,7 @@ Setting these on the container does nothing for CI, and vice versa.
| `SCW_SECRET_KEY` | secret — Scaleway API secret key (the UUID) used to start the CI job; also the registry/deploy key |
| `SCW_REGION` | Scaleway region for the CI job start (falls back to `AWS_REGION`) |
| `WORKER_SECRET` | secret — shared runner-auth secret; injected into the runner env and compared on its report-back |
+| `GITMOTE_CI_SECRET_KEY_V1` | secret — base64 of 32 bytes; master key for per-repo CI secrets. Add `_V2`, … to rotate (highest is current; old envelopes still decrypt). Unset → the secrets UI is disabled and none are injected |
| `AWS_REGION` | `fr-par` |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | secret — Scaleway API key pair; covers both the object store and the litestream replica |
diff --git a/internal/ci/dispatcher.go b/internal/ci/dispatcher.go
index 7e9c83b..937f3f5 100644
--- a/internal/ci/dispatcher.go
+++ b/internal/ci/dispatcher.go
@@ -62,6 +62,12 @@ type Minter interface {
MintScoped(ctx context.Context, userID int64, label string, repoScope *int64, readOnly bool, expiresAt time.Time) (string, error)
}
+// Secrets decrypts a repo's CI secrets into a name→value map to inject at
+// trigger. *secrets.Service satisfies it. Nil injects no secrets.
+type Secrets interface {
+ Secrets(ctx context.Context, repoID int64) (map[string]string, error)
+}
+
// Event is one updated ref from a completed push.
type Event struct {
RepoID int64
@@ -82,9 +88,10 @@ type Config struct {
Runs Runs
Materializer Materializer
Trigger Trigger
- Minter Minter // mints the per-run clone token; nil leaves it empty
- BaseURL string // GITMOTE_URL
- WorkerSecret string // WORKER_SECRET
+ Minter Minter // mints the per-run clone token; nil leaves it empty
+ Secrets Secrets // decrypts per-repo CI secrets to inject; nil injects none
+ BaseURL string // GITMOTE_URL
+ WorkerSecret string // WORKER_SECRET
Logger *slog.Logger
}
@@ -96,6 +103,7 @@ type Dispatcher struct {
mz Materializer
trigger Trigger
minter Minter
+ secrets Secrets
baseURL string
secret string
logger *slog.Logger
@@ -113,6 +121,7 @@ func NewDispatcher(cfg Config) *Dispatcher {
mz: cfg.Materializer,
trigger: cfg.Trigger,
minter: cfg.Minter,
+ secrets: cfg.Secrets,
baseURL: cfg.BaseURL,
secret: cfg.WorkerSecret,
logger: logger,
@@ -184,6 +193,9 @@ func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files
// every job of a run clones the same repo at the same SHA. A mint failure is
// non-fatal — the run still records, and the runner's clone will fail visibly.
cloneToken := d.mintCloneToken(ctx, ev, run.ID)
+ // Decrypt the repo's CI secrets once for the run; injected into every job's
+ // env. Non-fatal on error (fire-and-forget) — the run proceeds without them.
+ secretsEnv := d.repoSecrets(ctx, ev)
var (
parseErr error
jobs int
@@ -202,7 +214,7 @@ func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files
continue
}
jobs++
- if err := d.trigger.Trigger(ctx, d.jobEnv(ev, run.ID, job.ID, cloneToken)); err != nil {
+ if err := d.trigger.Trigger(ctx, d.jobEnv(ev, run.ID, job.ID, cloneToken, secretsEnv)); err != nil {
d.logger.Error("ci: trigger failed",
"run", run.ID, "job", job.ID, "name", f.Name, "error", err)
if err := d.runs.SetJobStatus(ctx, job.ID, meta.RunError); err != nil {
@@ -222,19 +234,46 @@ func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files
}
}
+// secretEnvPrefix namespaces an injected CI secret in the runner env, so the
+// engine can tell a secret from the runner's own coordinates and expose it to the
+// workflow (as `${{ secrets.NAME }}`). Kept in sync with the runner's ActEngine.
+const secretEnvPrefix = "GITMOTE_CI_SECRET_"
+
// jobEnv is the per-job environment injected into the triggered runner: the
-// coordinates it claims and reports against, plus the scoped clone token.
-func (d *Dispatcher) jobEnv(ev Event, runID, jobID int64, cloneToken string) map[string]string {
- return map[string]string{
- "GITMOTE_CI_RUN_ID": strconv.FormatInt(runID, 10),
- "GITMOTE_CI_JOB_ID": strconv.FormatInt(jobID, 10),
- "GITMOTE_URL": d.baseURL,
- "WORKER_SECRET": d.secret,
- "GITMOTE_REPO": ev.RepoName,
- "GITMOTE_SHA": ev.NewSHA,
- "GITMOTE_REF": ev.Ref,
- "GITMOTE_CI_CLONE_TOKEN": cloneToken,
+// repo's CI secrets (each namespaced under secretEnvPrefix so the engine can
+// forward them), plus the coordinates it claims and reports against and the
+// scoped clone token.
+func (d *Dispatcher) jobEnv(ev Event, runID, jobID int64, cloneToken string, secretsEnv map[string]string) map[string]string {
+ env := make(map[string]string, len(secretsEnv)+8)
+ for k, v := range secretsEnv {
+ env[secretEnvPrefix+k] = v
+ }
+ env["GITMOTE_CI_RUN_ID"] = strconv.FormatInt(runID, 10)
+ env["GITMOTE_CI_JOB_ID"] = strconv.FormatInt(jobID, 10)
+ env["GITMOTE_URL"] = d.baseURL
+ env["WORKER_SECRET"] = d.secret
+ env["GITMOTE_REPO"] = ev.RepoName
+ env["GITMOTE_SHA"] = ev.NewSHA
+ env["GITMOTE_REF"] = ev.Ref
+ env["GITMOTE_CI_CLONE_TOKEN"] = cloneToken
+ return env
+}
+
+// repoSecrets decrypts the repo's CI secrets for injection. It returns nil (and
+// logs) when no service is configured or decryption fails — running without
+// secrets is preferable to failing the fire-and-forget dispatch; the workflow
+// surfaces the missing value itself.
+func (d *Dispatcher) repoSecrets(ctx context.Context, ev Event) map[string]string {
+ if d.secrets == nil {
+ return nil
+ }
+ m, err := d.secrets.Secrets(ctx, ev.RepoID)
+ if err != nil {
+ d.logger.Error("ci: load secrets failed; running without them",
+ "repo", ev.RepoName, "error", err)
+ return nil
}
+ return m
}
// mintCloneToken mints the per-run read-only, repo-scoped, expiring clone token
diff --git a/internal/ci/dispatcher_test.go b/internal/ci/dispatcher_test.go
index 59d3387..3aef247 100644
--- a/internal/ci/dispatcher_test.go
+++ b/internal/ci/dispatcher_test.go
@@ -393,6 +393,69 @@ func TestDispatchMintFailureLeavesRunUsable(t *testing.T) {
}
}
+// stubSecrets returns a fixed secret map (or an error) for any repo.
+type stubSecrets struct {
+ env map[string]string
+ err error
+}
+
+func (s stubSecrets) Secrets(context.Context, int64) (map[string]string, error) {
+ return s.env, s.err
+}
+
+func TestDispatchInjectsSecrets(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ ".github/workflows/ci.yml": "name: CI\non: push\n",
+ })
+
+ tr := &stubTrigger{}
+ NewDispatcher(Config{
+ Runs: md, Materializer: mz, Trigger: tr,
+ Secrets: stubSecrets{env: map[string]string{"API_TOKEN": "s3cr3t"}},
+ BaseURL: "https://gitmote.test",
+ WorkerSecret: "worker-secret",
+ }).Dispatch(ctx, branchEvent(r, head))
+
+ if len(tr.calls) != 1 {
+ t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
+ }
+ env := tr.calls[0]
+ // Secrets are namespaced so the engine can tell them from coordinates.
+ if env["GITMOTE_CI_SECRET_API_TOKEN"] != "s3cr3t" {
+ t.Errorf("GITMOTE_CI_SECRET_API_TOKEN = %q, want the injected value", env["GITMOTE_CI_SECRET_API_TOKEN"])
+ }
+ // The namespacing keeps a secret from ever shadowing the runner's coordinates.
+ if env["GITMOTE_URL"] != "https://gitmote.test" || env["GITMOTE_REPO"] != "atmin/app" {
+ t.Errorf("coordinates altered by secret injection: %+v", env)
+ }
+}
+
+func TestDispatchSecretsFailureRunsWithout(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ ".github/workflows/ci.yml": "name: CI\non: push\n",
+ })
+
+ // A secrets error is non-fatal: the job still triggers, just without secrets.
+ tr := &stubTrigger{}
+ NewDispatcher(Config{
+ Runs: md, Materializer: mz, Trigger: tr,
+ Secrets: stubSecrets{err: errors.New("keyring down")},
+ BaseURL: "https://gitmote.test",
+ WorkerSecret: "worker-secret",
+ }).Dispatch(ctx, branchEvent(r, head))
+
+ if len(tr.calls) != 1 {
+ t.Fatalf("trigger calls = %d, want 1 (secrets failure must not abort dispatch)", len(tr.calls))
+ }
+ if tr.calls[0]["GITMOTE_REPO"] != "atmin/app" {
+ t.Errorf("job env missing fixed coords: %+v", tr.calls[0])
+ }
+}
+
// failNthTrigger fails the Nth (1-based) trigger call and succeeds the others.
type failNthTrigger struct {
failOn int
diff --git a/internal/meta/schema.sql b/internal/meta/schema.sql
index 314471d..bf83f61 100644
--- a/internal/meta/schema.sql
+++ b/internal/meta/schema.sql
@@ -77,3 +77,18 @@ CREATE TABLE IF NOT EXISTS ci_jobs (
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
+
+-- CI secrets: per-repo named values encrypted at rest with a server-held master
+-- key (never stored here). Only the sealed envelope lives in the DB — {version,
+-- iv, ciphertext} — so a replica/snapshot leak does not expose values. Values are
+-- write-only from the UI; only names are ever listed. See internal/secrets and
+-- tasks 16/22.
+CREATE TABLE IF NOT EXISTS ci_secrets (
+ repo_id INTEGER NOT NULL REFERENCES repos(id),
+ name TEXT NOT NULL, -- env var name injected at trigger
+ v INTEGER NOT NULL, -- master key version that sealed ct
+ iv BLOB NOT NULL, -- 12-byte GCM nonce
+ ct BLOB NOT NULL, -- AES-256-GCM ciphertext + tag
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (repo_id, name)
+);
diff --git a/internal/meta/secrets.go b/internal/meta/secrets.go
new file mode 100644
index 0000000..a2ff9fa
--- /dev/null
+++ b/internal/meta/secrets.go
@@ -0,0 +1,76 @@
+package meta
+
+import (
+ "context"
+)
+
+// CISecret is one repo's stored secret envelope. It never carries the plaintext
+// value — only the sealed bytes and the key version that sealed them. Decryption
+// happens in internal/secrets with the server-held master key.
+type CISecret struct {
+ Name string
+ Version int
+ IV []byte
+ CT []byte
+}
+
+// SetSecret upserts a repo's secret envelope by (repo_id, name). Written only by
+// the leader, like every other row.
+func (m *Metadata) SetSecret(ctx context.Context, repoID int64, name string, version int, iv, ct []byte) error {
+ _, err := m.db.ExecContext(ctx,
+ `INSERT INTO ci_secrets (repo_id, name, v, iv, ct, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(repo_id, name) DO UPDATE SET
+ v = excluded.v, iv = excluded.iv, ct = excluded.ct, created_at = excluded.created_at`,
+ repoID, name, version, iv, ct, now())
+ return err
+}
+
+// ListSecretNames returns a repo's secret names (never values), ordered by name
+// — the only view the UI ever gets.
+func (m *Metadata) ListSecretNames(ctx context.Context, repoID int64) ([]string, error) {
+ rows, err := m.db.QueryContext(ctx,
+ `SELECT name FROM ci_secrets WHERE repo_id = ? ORDER BY name`, repoID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var names []string
+ for rows.Next() {
+ var n string
+ if err := rows.Scan(&n); err != nil {
+ return nil, err
+ }
+ names = append(names, n)
+ }
+ return names, rows.Err()
+}
+
+// GetSecrets returns a repo's secret envelopes for decryption at trigger time.
+func (m *Metadata) GetSecrets(ctx context.Context, repoID int64) ([]CISecret, error) {
+ rows, err := m.db.QueryContext(ctx,
+ `SELECT name, v, iv, ct FROM ci_secrets WHERE repo_id = ? ORDER BY name`, repoID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var secrets []CISecret
+ for rows.Next() {
+ var s CISecret
+ if err := rows.Scan(&s.Name, &s.Version, &s.IV, &s.CT); err != nil {
+ return nil, err
+ }
+ secrets = append(secrets, s)
+ }
+ return secrets, rows.Err()
+}
+
+// DeleteSecret removes a repo's secret by name. Deleting an absent secret is a
+// no-op (idempotent), so it returns no ErrNotFound.
+func (m *Metadata) DeleteSecret(ctx context.Context, repoID int64, name string) error {
+ _, err := m.db.ExecContext(ctx,
+ `DELETE FROM ci_secrets WHERE repo_id = ? AND name = ?`, repoID, name)
+ return err
+}
diff --git a/internal/runner/act.go b/internal/runner/act.go
index 27f774d..4a869ee 100644
--- a/internal/runner/act.go
+++ b/internal/runner/act.go
@@ -5,9 +5,16 @@ import (
"context"
"errors"
"fmt"
+ "os"
"os/exec"
+ "strings"
)
+// secretEnvPrefix namespaces CI secrets the dispatcher injects into the runner
+// env (kept in sync with internal/ci's constant of the same name). The engine
+// strips the prefix and forwards each as an act secret.
+const secretEnvPrefix = "GITMOTE_CI_SECRET_"
+
// ActEngine runs .github/workflows with nektos/act — the locked engine
// (tasks/16-ci.md Stage 0 #1). act runs each job in a container via the local
// Docker/podman daemon, so one definition runs both self-hosted and on the
@@ -17,12 +24,30 @@ type ActEngine struct{}
// Run invokes act over workflowDir inside repoDir on the default (push) event,
// capturing combined stdout+stderr. act's non-zero exit means the workflow
// failed (passed=false); a failure to start act at all is an engine error.
+//
+// Injected CI secrets (GITMOTE_CI_SECRET_<NAME> in the env) are forwarded to act
+// as `-s NAME`, which act reads from its environment (so values never touch the
+// argv, and multiline values survive) and exposes to the workflow as
+// `${{ secrets.NAME }}` — exactly as on GitHub.
func (ActEngine) Run(ctx context.Context, repoDir, workflowDir string) ([]byte, bool, error) {
if _, err := exec.LookPath("act"); err != nil {
return nil, false, fmt.Errorf("act not found on PATH — install nektos/act (https://github.com/nektos/act): %w", err)
}
- cmd := exec.CommandContext(ctx, "act", "--workflows", workflowDir)
+
+ args := []string{"--workflows", workflowDir}
+ actEnv := os.Environ()
+ for _, kv := range os.Environ() {
+ name, val, _ := strings.Cut(kv, "=")
+ if secret, ok := strings.CutPrefix(name, secretEnvPrefix); ok && secret != "" {
+ // Re-export under the bare name so `act -s NAME` finds it in the env.
+ actEnv = append(actEnv, secret+"="+val)
+ args = append(args, "-s", secret)
+ }
+ }
+
+ cmd := exec.CommandContext(ctx, "act", args...)
cmd.Dir = repoDir
+ cmd.Env = actEnv
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
diff --git a/internal/secrets/keyring.go b/internal/secrets/keyring.go
new file mode 100644
index 0000000..4625d85
--- /dev/null
+++ b/internal/secrets/keyring.go
@@ -0,0 +1,156 @@
+// Package secrets encrypts per-repo CI secrets at rest with a server-held master
+// key, so a compromise of the S3 replica / DB snapshot (the litestream WAL, the
+// object bucket) does not leak secret values — the master key lives only in the
+// process env, not in the replicated DB. It is **not** a defense against a
+// compromised running server, which holds the key by necessity to inject secrets
+// into the runner (see docs/architecture/safety.md and tasks/16-ci.md §Stage 0 #3).
+//
+// keyring.go is pure crypto: no meta, no I/O beyond reading keys from the env at
+// construction — unit-testable in isolation. The meta-backed store and the
+// encrypt/decrypt service live alongside it.
+package secrets
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hkdf"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+)
+
+const (
+ // keyEnvPrefix is the env-var stem for a master key; the version number
+ // follows (GITMOTE_CI_SECRET_KEY_V1, …). The value is base64 of 32 raw bytes.
+ keyEnvPrefix = "GITMOTE_CI_SECRET_KEY_V"
+ masterKeyLen = 32 // AES-256
+ ivLen = 12 // GCM standard nonce
+ // subkeyInfo is the HKDF info stem; the repo id follows, giving a distinct
+ // per-repo subkey from one master (repo isolation for free).
+ subkeyInfo = "ci-secret:"
+)
+
+// Envelope is an encrypted secret at rest: the key version that sealed it, the
+// random IV, and the AES-GCM ciphertext (which carries the auth tag).
+type Envelope struct {
+ Version int
+ IV []byte
+ CT []byte
+}
+
+// Keyring holds the server-held master keys, one per version. The highest
+// version present is *current*: new secrets seal under it, while existing
+// envelopes decrypt under their own version — lazy, O(1) rotation with no
+// re-encryption. It is immutable after construction, so safe for concurrent use.
+type Keyring struct {
+ keys map[int][masterKeyLen]byte
+ current int
+}
+
+// NewKeyringFromEnv builds the keyring from GITMOTE_CI_SECRET_KEY_V<n> env vars
+// (base64 of 32 bytes each). With none set it returns a disabled keyring
+// (Enabled() == false) rather than an error, so an instance without CI secrets
+// runs unchanged. A present-but-malformed key is a hard error — fail loud rather
+// than silently drop a key version.
+func NewKeyringFromEnv() (*Keyring, error) {
+ kr := &Keyring{keys: map[int][masterKeyLen]byte{}}
+ for _, kv := range os.Environ() {
+ name, val, _ := strings.Cut(kv, "=")
+ if !strings.HasPrefix(name, keyEnvPrefix) {
+ continue
+ }
+ if strings.TrimSpace(val) == "" {
+ continue // an empty/cleared var is "unset", not a malformed key
+ }
+ v, err := strconv.Atoi(strings.TrimPrefix(name, keyEnvPrefix))
+ if err != nil || v < 1 {
+ return nil, fmt.Errorf("secrets: %s has a non-numeric key version", name)
+ }
+ raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(val))
+ if err != nil {
+ return nil, fmt.Errorf("secrets: %s is not valid base64: %w", name, err)
+ }
+ if len(raw) != masterKeyLen {
+ return nil, fmt.Errorf("secrets: %s must decode to %d bytes, got %d", name, masterKeyLen, len(raw))
+ }
+ var k [masterKeyLen]byte
+ copy(k[:], raw)
+ kr.keys[v] = k
+ if v > kr.current {
+ kr.current = v
+ }
+ }
+ return kr, nil
+}
+
+// Enabled reports whether any master key is configured. When false, Encrypt
+// fails and the secrets UI/injection are inert.
+func (k *Keyring) Enabled() bool { return len(k.keys) > 0 }
+
+// Encrypt seals plaintext for (repoID, name) under the current key version. The
+// IV is fresh per call, so two encryptions of the same value differ. The AAD
+// binds the envelope to (repoID, name, version): a ciphertext moved to a
+// different repo/name/version fails to open.
+func (k *Keyring) Encrypt(repoID int64, name, plaintext string) (Envelope, error) {
+ if !k.Enabled() {
+ return Envelope{}, errors.New("secrets: no master key configured (set GITMOTE_CI_SECRET_KEY_V1)")
+ }
+ gcm, err := k.gcm(k.current, repoID)
+ if err != nil {
+ return Envelope{}, err
+ }
+ iv := make([]byte, ivLen)
+ if _, err := rand.Read(iv); err != nil {
+ return Envelope{}, err
+ }
+ ct := gcm.Seal(nil, iv, []byte(plaintext), aad(repoID, name, k.current))
+ return Envelope{Version: k.current, IV: iv, CT: ct}, nil
+}
+
+// Decrypt opens an envelope for (repoID, name). It fails closed: an absent key
+// version, a bad IV, or a tag mismatch (wrong repo/name/version, or tampering)
+// all return an error — never a silent empty value or a panic. The error carries
+// the repo/name (not secret) but never the plaintext.
+func (k *Keyring) Decrypt(repoID int64, name string, e Envelope) (string, error) {
+ if len(e.IV) != ivLen {
+ return "", fmt.Errorf("secrets: bad IV length %d for repo %d secret %q", len(e.IV), repoID, name)
+ }
+ gcm, err := k.gcm(e.Version, repoID)
+ if err != nil {
+ return "", err
+ }
+ pt, err := gcm.Open(nil, e.IV, e.CT, aad(repoID, name, e.Version))
+ if err != nil {
+ return "", fmt.Errorf("secrets: decrypt failed for repo %d secret %q: %w", repoID, name, err)
+ }
+ return string(pt), nil
+}
+
+// gcm derives the per-repo subkey for the given master version via HKDF and
+// returns an AES-256-GCM AEAD over it. An unknown version fails closed.
+func (k *Keyring) gcm(version int, repoID int64) (cipher.AEAD, error) {
+ master, ok := k.keys[version]
+ if !ok {
+ return nil, fmt.Errorf("secrets: key version %d is not configured (set %s%d) — cannot process this secret", version, keyEnvPrefix, version)
+ }
+ sub, err := hkdf.Key(sha256.New, master[:], nil, subkeyInfo+strconv.FormatInt(repoID, 10), masterKeyLen)
+ if err != nil {
+ return nil, err
+ }
+ block, err := aes.NewCipher(sub)
+ if err != nil {
+ return nil, err
+ }
+ return cipher.NewGCM(block)
+}
+
+// aad binds an envelope to its (repoID, name, version). name is NUL-free (the
+// service validates it), so the concatenation is unambiguous.
+func aad(repoID int64, name string, version int) []byte {
+ return []byte(strconv.FormatInt(repoID, 10) + "\x00" + name + "\x00" + strconv.Itoa(version))
+}
diff --git a/internal/secrets/keyring_test.go b/internal/secrets/keyring_test.go
new file mode 100644
index 0000000..0ae6451
--- /dev/null
+++ b/internal/secrets/keyring_test.go
@@ -0,0 +1,132 @@
+package secrets
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+)
+
+// b64key returns a distinct base64 32-byte key seeded by n.
+func b64key(n byte) string {
+ var k [masterKeyLen]byte
+ for i := range k {
+ k[i] = n + byte(i)
+ }
+ return base64.StdEncoding.EncodeToString(k[:])
+}
+
+// keyringEnv sets GITMOTE_CI_SECRET_KEY_V<v> for each (v→key) and builds a
+// keyring from the env.
+func keyringEnv(t *testing.T, keys map[int]string) *Keyring {
+ t.Helper()
+ // Clear any inherited key vars so the test is hermetic.
+ for v := 1; v <= 9; v++ {
+ t.Setenv(keyEnvPrefix+string(rune('0'+v)), "")
+ }
+ for v, k := range keys {
+ t.Setenv(keyEnvPrefix+string(rune('0'+v)), k)
+ }
+ kr, err := NewKeyringFromEnv()
+ if err != nil {
+ t.Fatalf("NewKeyringFromEnv: %v", err)
+ }
+ return kr
+}
+
+func TestEncryptDecryptRoundTrip(t *testing.T) {
+ kr := keyringEnv(t, map[int]string{1: b64key(1)})
+ env, err := kr.Encrypt(7, "TOKEN", "s3cr3t-value")
+ if err != nil {
+ t.Fatalf("Encrypt: %v", err)
+ }
+ if env.Version != 1 || len(env.IV) != ivLen || len(env.CT) == 0 {
+ t.Fatalf("envelope = %+v, want v1 with a 12-byte IV and ciphertext", env)
+ }
+ got, err := kr.Decrypt(7, "TOKEN", env)
+ if err != nil {
+ t.Fatalf("Decrypt: %v", err)
+ }
+ if got != "s3cr3t-value" {
+ t.Errorf("decrypted = %q, want the original", got)
+ }
+}
+
+func TestWrongAADFails(t *testing.T) {
+ kr := keyringEnv(t, map[int]string{1: b64key(1)})
+ env, _ := kr.Encrypt(7, "TOKEN", "v")
+
+ if _, err := kr.Decrypt(8, "TOKEN", env); err == nil {
+ t.Error("decrypt under a different repoID succeeded, want auth-tag failure")
+ }
+ if _, err := kr.Decrypt(7, "OTHER", env); err == nil {
+ t.Error("decrypt under a different name succeeded, want auth-tag failure")
+ }
+ tampered := Envelope{Version: 1, IV: env.IV, CT: append([]byte{env.CT[0] ^ 0xff}, env.CT[1:]...)}
+ if _, err := kr.Decrypt(7, "TOKEN", tampered); err == nil {
+ t.Error("decrypt of tampered ciphertext succeeded, want auth-tag failure")
+ }
+}
+
+func TestFreshIVPerEncryption(t *testing.T) {
+ kr := keyringEnv(t, map[int]string{1: b64key(1)})
+ a, _ := kr.Encrypt(7, "TOKEN", "same")
+ b, _ := kr.Encrypt(7, "TOKEN", "same")
+ if string(a.IV) == string(b.IV) {
+ t.Error("two encryptions reused the IV")
+ }
+ if string(a.CT) == string(b.CT) {
+ t.Error("two encryptions of the same plaintext produced identical ciphertext")
+ }
+}
+
+func TestMissingVersionFailsClosed(t *testing.T) {
+ // Encrypt under V1, then present a keyring that only knows V2.
+ env, _ := keyringEnv(t, map[int]string{1: b64key(1)}).Encrypt(7, "TOKEN", "v")
+ only2 := keyringEnv(t, map[int]string{2: b64key(2)})
+ if _, err := only2.Decrypt(7, "TOKEN", env); err == nil {
+ t.Error("decrypt with the sealing key version absent succeeded, want a fail-closed error")
+ } else if !strings.Contains(err.Error(), "version 1") {
+ t.Errorf("error = %v, want it to name the missing version", err)
+ }
+}
+
+func TestRotation(t *testing.T) {
+ // A secret written under V1 must still decrypt after V2 is added and becomes
+ // current; new writes then use V2.
+ old := keyringEnv(t, map[int]string{1: b64key(1)})
+ e1, _ := old.Encrypt(7, "TOKEN", "old-value")
+
+ rotated := keyringEnv(t, map[int]string{1: b64key(1), 2: b64key(2)})
+ got, err := rotated.Decrypt(7, "TOKEN", e1)
+ if err != nil || got != "old-value" {
+ t.Fatalf("V1 secret after rotation = %q, %v; want the original", got, err)
+ }
+ e2, err := rotated.Encrypt(7, "TOKEN", "new-value")
+ if err != nil {
+ t.Fatalf("Encrypt after rotation: %v", err)
+ }
+ if e2.Version != 2 {
+ t.Errorf("new write sealed under v%d, want v2 (current)", e2.Version)
+ }
+}
+
+func TestDisabledKeyring(t *testing.T) {
+ kr := keyringEnv(t, nil)
+ if kr.Enabled() {
+ t.Error("keyring with no keys reports enabled")
+ }
+ if _, err := kr.Encrypt(7, "TOKEN", "v"); err == nil {
+ t.Error("Encrypt with no key configured succeeded, want an error")
+ }
+}
+
+func TestBadKeyEnvIsHardError(t *testing.T) {
+ t.Setenv(keyEnvPrefix+"1", "not-base64!!")
+ if _, err := NewKeyringFromEnv(); err == nil {
+ t.Error("malformed base64 key accepted, want a hard error")
+ }
+ t.Setenv(keyEnvPrefix+"1", base64.StdEncoding.EncodeToString([]byte("too-short")))
+ if _, err := NewKeyringFromEnv(); err == nil {
+ t.Error("wrong-length key accepted, want a hard error")
+ }
+}
diff --git a/internal/secrets/service.go b/internal/secrets/service.go
new file mode 100644
index 0000000..96eff9d
--- /dev/null
+++ b/internal/secrets/service.go
@@ -0,0 +1,95 @@
+package secrets
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+// Store is the meta slice the service reads and writes. *meta.Metadata satisfies
+// it. It carries only sealed envelopes — never plaintext.
+type Store interface {
+ SetSecret(ctx context.Context, repoID int64, name string, version int, iv, ct []byte) error
+ ListSecretNames(ctx context.Context, repoID int64) ([]string, error)
+ GetSecrets(ctx context.Context, repoID int64) ([]meta.CISecret, error)
+ DeleteSecret(ctx context.Context, repoID int64, name string) error
+}
+
+// Service is the CI-secrets hub: it seals on write and opens on read, over the
+// keyring and the meta store. The admin UI uses Set/List/Delete/Enabled; the CI
+// dispatcher uses Secrets to build the trigger env.
+type Service struct {
+ kr *Keyring
+ store Store
+}
+
+// NewService binds a keyring to a store.
+func NewService(kr *Keyring, store Store) *Service {
+ return &Service{kr: kr, store: store}
+}
+
+// Enabled reports whether a master key is configured (so the UI can gate the
+// set form and injection is meaningful).
+func (s *Service) Enabled() bool { return s.kr.Enabled() }
+
+// nameRe constrains a secret name to a shell/env-safe identifier so it can be
+// injected as a runner env var without quoting surprises.
+var nameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+
+// validName rejects malformed names and names that would shadow the runner's own
+// injected env (GITMOTE_*, WORKER_SECRET).
+func validName(name string) error {
+ if !nameRe.MatchString(name) {
+ return fmt.Errorf("name %q must match [A-Za-z_][A-Za-z0-9_]*", name)
+ }
+ if strings.HasPrefix(name, "GITMOTE_") || name == "WORKER_SECRET" {
+ return fmt.Errorf("name %q is reserved", name)
+ }
+ return nil
+}
+
+// SetSecret validates the name, seals the value under the current key version,
+// and upserts the envelope. The plaintext never leaves this call.
+func (s *Service) SetSecret(ctx context.Context, repoID int64, name, value string) error {
+ if err := validName(name); err != nil {
+ return err
+ }
+ env, err := s.kr.Encrypt(repoID, name, value)
+ if err != nil {
+ return err
+ }
+ return s.store.SetSecret(ctx, repoID, name, env.Version, env.IV, env.CT)
+}
+
+// ListSecretNames returns a repo's secret names (never values).
+func (s *Service) ListSecretNames(ctx context.Context, repoID int64) ([]string, error) {
+ return s.store.ListSecretNames(ctx, repoID)
+}
+
+// DeleteSecret removes a repo's secret by name (idempotent).
+func (s *Service) DeleteSecret(ctx context.Context, repoID int64, name string) error {
+ return s.store.DeleteSecret(ctx, repoID, name)
+}
+
+// Secrets decrypts a repo's secrets into a name→value map for the trigger env.
+// It fails closed: if any envelope can't be opened (missing key version,
+// tampering), it returns the error rather than a partial map, so the dispatcher
+// can decide to run without secrets rather than with a silently incomplete set.
+func (s *Service) Secrets(ctx context.Context, repoID int64) (map[string]string, error) {
+ stored, err := s.store.GetSecrets(ctx, repoID)
+ if err != nil {
+ return nil, err
+ }
+ out := make(map[string]string, len(stored))
+ for _, e := range stored {
+ v, err := s.kr.Decrypt(repoID, e.Name, Envelope{Version: e.Version, IV: e.IV, CT: e.CT})
+ if err != nil {
+ return nil, err
+ }
+ out[e.Name] = v
+ }
+ return out, nil
+}
diff --git a/internal/secrets/service_test.go b/internal/secrets/service_test.go
new file mode 100644
index 0000000..12a2bab
--- /dev/null
+++ b/internal/secrets/service_test.go
@@ -0,0 +1,118 @@
+package secrets
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "path/filepath"
+ "testing"
+
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+// newSvc builds a service over a keyring (one key) and a real temp meta DB with
+// one repo, returning the service and the repo id.
+func newSvc(t *testing.T) (*Service, int64) {
+ t.Helper()
+ t.Setenv(keyEnvPrefix+"1", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, masterKeyLen)))
+ kr, err := NewKeyringFromEnv()
+ if err != nil {
+ t.Fatalf("keyring: %v", err)
+ }
+ md, err := meta.Open(context.Background(), meta.Config{LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3")})
+ if err != nil {
+ t.Fatalf("meta.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = md.Close() })
+ r, err := md.CreateRepo(context.Background(), "atmin/app", "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ return NewService(kr, md), r.ID
+}
+
+func TestServiceSetGetRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ svc, repoID := newSvc(t)
+
+ if err := svc.SetSecret(ctx, repoID, "API_TOKEN", "s3cr3t"); err != nil {
+ t.Fatalf("SetSecret: %v", err)
+ }
+ got, err := svc.Secrets(ctx, repoID)
+ if err != nil {
+ t.Fatalf("Secrets: %v", err)
+ }
+ if got["API_TOKEN"] != "s3cr3t" {
+ t.Errorf("decrypted secret = %q, want s3cr3t", got["API_TOKEN"])
+ }
+}
+
+func TestServiceUpsertAndDelete(t *testing.T) {
+ ctx := context.Background()
+ svc, repoID := newSvc(t)
+
+ _ = svc.SetSecret(ctx, repoID, "K", "one")
+ _ = svc.SetSecret(ctx, repoID, "K", "two") // overwrite
+ got, _ := svc.Secrets(ctx, repoID)
+ if got["K"] != "two" {
+ t.Errorf("after upsert K = %q, want two", got["K"])
+ }
+
+ if err := svc.DeleteSecret(ctx, repoID, "K"); err != nil {
+ t.Fatalf("DeleteSecret: %v", err)
+ }
+ names, _ := svc.ListSecretNames(ctx, repoID)
+ if len(names) != 0 {
+ t.Errorf("names after delete = %v, want empty", names)
+ }
+ // Deleting again is a no-op, not an error.
+ if err := svc.DeleteSecret(ctx, repoID, "K"); err != nil {
+ t.Errorf("second delete = %v, want nil (idempotent)", err)
+ }
+}
+
+func TestServiceListNamesOnly(t *testing.T) {
+ ctx := context.Background()
+ svc, repoID := newSvc(t)
+ _ = svc.SetSecret(ctx, repoID, "BETA", "vb")
+ _ = svc.SetSecret(ctx, repoID, "ALPHA", "va")
+
+ names, err := svc.ListSecretNames(ctx, repoID)
+ if err != nil {
+ t.Fatalf("ListSecretNames: %v", err)
+ }
+ if len(names) != 2 || names[0] != "ALPHA" || names[1] != "BETA" {
+ t.Errorf("names = %v, want [ALPHA BETA] (sorted, names only)", names)
+ }
+}
+
+func TestServiceRejectsBadAndReservedNames(t *testing.T) {
+ ctx := context.Background()
+ svc, repoID := newSvc(t)
+ for _, name := range []string{"1leading", "has-dash", "has space", "GITMOTE_URL", "WORKER_SECRET"} {
+ if err := svc.SetSecret(ctx, repoID, name, "v"); err == nil {
+ t.Errorf("SetSecret(%q) succeeded, want rejection", name)
+ }
+ }
+}
+
+// TestSecretValueNotStoredInPlaintext is the at-rest leak invariant: the stored
+// envelope's ciphertext must not contain the plaintext bytes.
+func TestSecretValueNotStoredInPlaintext(t *testing.T) {
+ ctx := context.Background()
+ svc, repoID := newSvc(t)
+ const value = "PLAINTEXT-NEEDLE-9f3a"
+ if err := svc.SetSecret(ctx, repoID, "K", value); err != nil {
+ t.Fatalf("SetSecret: %v", err)
+ }
+ stored, err := svc.store.GetSecrets(ctx, repoID)
+ if err != nil {
+ t.Fatalf("GetSecrets: %v", err)
+ }
+ if len(stored) != 1 {
+ t.Fatalf("stored = %d envelopes, want 1", len(stored))
+ }
+ if bytes.Contains(stored[0].CT, []byte(value)) {
+ t.Error("stored ciphertext contains the plaintext value")
+ }
+}
diff --git a/internal/webui/data.go b/internal/webui/data.go
index c6a7f2f..ec9c79d 100644
--- a/internal/webui/data.go
+++ b/internal/webui/data.go
@@ -45,6 +45,14 @@ type aclsData struct {
ACLs []meta.ACL // the selected repo's grants
}
+type secretsData struct {
+ base
+ Repos []meta.Repo // for the repo picker
+ Selected string // currently viewed repo name
+ Names []string // the selected repo's secret names (never values)
+ Enabled bool // whether a master key is configured (gates the set form)
+}
+
// --- browse ---
// refChoice is one entry in the ref switcher: a display name (short, e.g.
diff --git a/internal/webui/secrets_test.go b/internal/webui/secrets_test.go
new file mode 100644
index 0000000..5b91478
--- /dev/null
+++ b/internal/webui/secrets_test.go
@@ -0,0 +1,89 @@
+package webui
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestSecretsGoldenPath(t *testing.T) {
+ x := newHarness(t)
+ ctx := context.Background()
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ repo, err := x.md.CreateRepo(ctx, "atmin/app", "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+
+ // Set a secret. The value must never appear in the response.
+ const value = "hunter2-NEEDLE"
+ rec := x.do(http.MethodPost, "/ui/secrets",
+ url.Values{"repo": {"atmin/app"}, "name": {"API_TOKEN"}, "value": {value}}, session)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "saved secret API_TOKEN") {
+ t.Fatalf("set secret: %d (%s)", rec.Code, rec.Body)
+ }
+ if strings.Contains(rec.Body.String(), value) {
+ t.Error("set-secret response echoed the value")
+ }
+
+ // It is stored as an envelope (name listable, value not in plaintext).
+ stored, _ := x.md.GetSecrets(ctx, repo.ID)
+ if len(stored) != 1 || stored[0].Name != "API_TOKEN" {
+ t.Fatalf("stored = %+v, want one API_TOKEN envelope", stored)
+ }
+ if strings.Contains(string(stored[0].CT), value) {
+ t.Error("stored ciphertext contains the plaintext value")
+ }
+
+ // The list page shows the name but never the value.
+ rec = x.do(http.MethodGet, "/ui/secrets?repo=atmin/app", nil, session)
+ if !strings.Contains(rec.Body.String(), "API_TOKEN") {
+ t.Errorf("list page missing the secret name (body: %s)", rec.Body)
+ }
+ if strings.Contains(rec.Body.String(), value) {
+ t.Error("list page leaked the secret value")
+ }
+
+ // Delete it.
+ rec = x.do(http.MethodPost, "/ui/secrets/delete",
+ url.Values{"repo": {"atmin/app"}, "name": {"API_TOKEN"}}, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("delete secret: %d (%s)", rec.Code, rec.Body)
+ }
+ if names, _ := x.md.ListSecretNames(ctx, repo.ID); len(names) != 0 {
+ t.Errorf("names after delete = %v, want empty", names)
+ }
+}
+
+func TestSecretsRejectsReservedName(t *testing.T) {
+ x := newHarness(t)
+ ctx := context.Background()
+ session := x.login(x.mintTokenFor(x.admin.ID))
+ if _, err := x.md.CreateRepo(ctx, "atmin/app", "main"); err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+
+ rec := x.do(http.MethodPost, "/ui/secrets",
+ url.Values{"repo": {"atmin/app"}, "name": {"WORKER_SECRET"}, "value": {"x"}}, session)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "reserved") {
+ t.Errorf("reserved name: %d (%s), want a rejection message", rec.Code, rec.Body)
+ }
+}
+
+func TestSecretsRequireAdmin(t *testing.T) {
+ x := newHarness(t)
+
+ // Unauthenticated GET redirects to login; POST is 401 — the panel is gated
+ // like the rest of /ui.
+ if rec := x.do(http.MethodGet, "/ui/secrets", nil, nil); rec.Code != http.StatusSeeOther {
+ t.Errorf("unauth GET /ui/secrets = %d, want 303 redirect", rec.Code)
+ }
+ rec := x.do(http.MethodPost, "/ui/secrets",
+ url.Values{"repo": {"atmin/app"}, "name": {"K"}, "value": {"v"}}, nil)
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("unauth POST /ui/secrets = %d, want 401", rec.Code)
+ }
+}
diff --git a/internal/webui/templates/layout.html b/internal/webui/templates/layout.html
index 3cd53b3..4dd7155 100644
--- a/internal/webui/templates/layout.html
+++ b/internal/webui/templates/layout.html
@@ -39,6 +39,7 @@
<a href="/ui/users">Users</a>
<a href="/ui/tokens">Tokens</a>
<a href="/ui/acls">ACLs</a>
+ <a href="/ui/secrets">Secrets</a>
<span class="spacer"></span>
<span>{{.Me}}</span>
<form class="inline" method="post" action="/logout"><button type="submit">Log out</button></form>
diff --git a/internal/webui/templates/secrets.html b/internal/webui/templates/secrets.html
new file mode 100644
index 0000000..e36f69c
--- /dev/null
+++ b/internal/webui/templates/secrets.html
@@ -0,0 +1,54 @@
+{{define "secrets.html"}}{{template "top" .}}
+<h1>CI secrets</h1>
+
+<form method="get" action="/ui/secrets">
+ <label>Repository
+ <select name="repo" onchange="this.form.submit()">
+ <option value="">— choose —</option>
+ {{range .Repos}}<option value="{{.Name}}"{{if eq .Name $.Selected}} selected{{end}}>{{.Name}}</option>{{end}}
+ </select>
+ </label>
+ <noscript><button type="submit">Show</button></noscript>
+</form>
+
+{{if not .Enabled}}
+<div class="flash err">Secrets are disabled — set <code>GITMOTE_CI_SECRET_KEY_V1</code> (base64 of 32 bytes) to enable encryption.</div>
+{{end}}
+
+{{if .Selected}}
+{{if .Names}}
+<table>
+ <thead><tr><th>Name</th><th></th></tr></thead>
+ <tbody>
+ {{range .Names}}
+ <tr>
+ <td>{{.}}</td>
+ <td>
+ <form class="inline" method="post" action="/ui/secrets/delete">
+ <input type="hidden" name="repo" value="{{$.Selected}}">
+ <input type="hidden" name="name" value="{{.}}">
+ <button type="submit">Delete</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p>No secrets on {{.Selected}}.</p>
+{{end}}
+
+{{if .Enabled}}
+<fieldset>
+ <legend>Set secret on {{.Selected}}</legend>
+ <p>Values are write-only — they are encrypted at rest and never shown again. Setting an existing name overwrites it.</p>
+ <form method="post" action="/ui/secrets" autocomplete="off">
+ <input type="hidden" name="repo" value="{{.Selected}}">
+ <label>Name <input type="text" name="name" pattern="[A-Za-z_][A-Za-z0-9_]*" required></label>
+ <label>Value <input type="password" name="value" autocomplete="new-password" required></label>
+ <button type="submit">Save</button>
+ </form>
+</fieldset>
+{{end}}
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index 06fa4eb..ca8e65c 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -38,6 +38,16 @@ type Authenticator interface {
VerifyToken(ctx context.Context, raw string) (*meta.User, error)
}
+// SecretsAdmin manages a repo's CI secrets for the admin panel. Values are
+// write-only: only names are ever read back. *secrets.Service satisfies it; nil
+// disables the secrets UI.
+type SecretsAdmin interface {
+ Enabled() bool
+ SetSecret(ctx context.Context, repoID int64, name, value string) error
+ ListSecretNames(ctx context.Context, repoID int64) ([]string, error)
+ DeleteSecret(ctx context.Context, repoID int64, name string) error
+}
+
// reservedOwners are top-level path segments the server already routes; a repo
// whose owner is one of these would shadow those routes, so creation refuses it.
var reservedOwners = map[string]bool{
@@ -49,19 +59,20 @@ var nameSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// Handler serves the management UI.
type Handler struct {
- md *meta.Metadata
- mz *repo.Materializer
- auth Authenticator
- sess *sessions
- tmpl *template.Template
- log *slog.Logger
- now func() time.Time
+ md *meta.Metadata
+ mz *repo.Materializer
+ auth Authenticator
+ secrets SecretsAdmin
+ sess *sessions
+ tmpl *template.Template
+ log *slog.Logger
+ now func() time.Time
}
// New builds the UI handler. cookieKey signs session cookies and must be
// non-empty; the auth verifier backs the login form; mz materializes repos for
-// the read-only browse pages.
-func New(md *meta.Metadata, mz *repo.Materializer, a Authenticator, cookieKey []byte, logger *slog.Logger) (*Handler, error) {
+// the read-only browse pages; secrets (may be nil) backs the CI secrets panel.
+func New(md *meta.Metadata, mz *repo.Materializer, a Authenticator, secrets SecretsAdmin, cookieKey []byte, logger *slog.Logger) (*Handler, error) {
if len(cookieKey) == 0 {
return nil, errors.New("webui: empty cookie key")
}
@@ -86,13 +97,14 @@ func New(md *meta.Metadata, mz *repo.Materializer, a Authenticator, cookieKey []
logger = slog.Default()
}
return &Handler{
- md: md,
- mz: mz,
- auth: a,
- sess: &sessions{key: cookieKey},
- tmpl: tmpl,
- log: logger,
- now: time.Now,
+ md: md,
+ mz: mz,
+ auth: a,
+ secrets: secrets,
+ sess: &sessions{key: cookieKey},
+ tmpl: tmpl,
+ log: logger,
+ now: time.Now,
}, nil
}
@@ -115,6 +127,9 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /ui/acls", h.requireAdmin(h.getACLs))
mux.HandleFunc("POST /ui/acls", h.requireAdmin(h.postACL))
mux.HandleFunc("POST /ui/acls/revoke", h.requireAdmin(h.postRevokeACL))
+ mux.HandleFunc("GET /ui/secrets", h.requireAdmin(h.getSecrets))
+ mux.HandleFunc("POST /ui/secrets", h.requireAdmin(h.postSecret))
+ mux.HandleFunc("POST /ui/secrets/delete", h.requireAdmin(h.postDeleteSecret))
// Read-only browsing. The repo name contains slashes and the action tail is
// split manually on "/-/" (see browse), so one subtree handler covers all
@@ -491,6 +506,93 @@ func (h *Handler) postRevokeACL(w http.ResponseWriter, r *http.Request) {
h.renderACLs(w, r, repoName, "revoked", "")
}
+// --- ci secrets ---
+
+func (h *Handler) getSecrets(w http.ResponseWriter, r *http.Request) {
+ h.renderSecrets(w, r, strings.TrimSpace(r.FormValue("repo")), "", "")
+}
+
+func (h *Handler) renderSecrets(w http.ResponseWriter, r *http.Request, repoName, flash, errMsg string) {
+ repos, err := h.md.ListRepos(r.Context())
+ if err != nil {
+ h.serverError(w, "list repos", err)
+ return
+ }
+ data := secretsData{
+ base: h.base(r, flash, errMsg),
+ Repos: repos,
+ Selected: repoName,
+ Enabled: h.secrets != nil && h.secrets.Enabled(),
+ }
+ if repoName != "" {
+ repo, err := h.md.GetRepo(r.Context(), repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ data.Err = "no such repo: " + repoName
+ h.render(w, "secrets.html", data)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
+ }
+ names, err := h.secrets.ListSecretNames(r.Context(), repo.ID)
+ if err != nil {
+ h.serverError(w, "list secret names", err)
+ return
+ }
+ data.Names = names
+ }
+ h.render(w, "secrets.html", data)
+}
+
+func (h *Handler) postSecret(w http.ResponseWriter, r *http.Request) {
+ repoName := strings.TrimSpace(r.FormValue("repo"))
+ name := strings.TrimSpace(r.FormValue("name"))
+ value := r.FormValue("value") // not trimmed: a secret may have meaningful edges
+ if h.secrets == nil || !h.secrets.Enabled() {
+ h.renderSecrets(w, r, repoName, "", "secrets are disabled: set GITMOTE_CI_SECRET_KEY_V1")
+ return
+ }
+ if value == "" {
+ h.renderSecrets(w, r, repoName, "", "value cannot be empty")
+ return
+ }
+ repo, err := h.md.GetRepo(r.Context(), repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ h.renderSecrets(w, r, repoName, "", "no such repo: "+repoName)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
+ }
+ if err := h.secrets.SetSecret(r.Context(), repo.ID, name, value); err != nil {
+ // The error names the (public) secret name at worst, never the value.
+ h.renderSecrets(w, r, repoName, "", "set secret: "+err.Error())
+ return
+ }
+ h.renderSecrets(w, r, repoName, "saved secret "+name, "")
+}
+
+func (h *Handler) postDeleteSecret(w http.ResponseWriter, r *http.Request) {
+ repoName := strings.TrimSpace(r.FormValue("repo"))
+ name := strings.TrimSpace(r.FormValue("name"))
+ repo, err := h.md.GetRepo(r.Context(), repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ h.renderSecrets(w, r, repoName, "", "no such repo: "+repoName)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
+ }
+ if err := h.secrets.DeleteSecret(r.Context(), repo.ID, name); err != nil {
+ h.serverError(w, "delete secret", err)
+ return
+ }
+ h.renderSecrets(w, r, repoName, "deleted secret "+name, "")
+}
+
// --- rendering ---
func (h *Handler) base(r *http.Request, flash, errMsg string) base {
diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
index 054f27a..5358c95 100644
--- a/internal/webui/webui_test.go
+++ b/internal/webui/webui_test.go
@@ -2,6 +2,7 @@ package webui
import (
"context"
+ "encoding/base64"
"io"
"net/http"
"net/http/httptest"
@@ -14,6 +15,7 @@ import (
"github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
+ "github.com/atmin/gitmote/internal/secrets"
"github.com/atmin/gitmote/internal/store"
)
@@ -39,7 +41,17 @@ func newHarness(t *testing.T) *harness {
objs := store.NewMem()
mz := repo.New(md, objs, t.TempDir())
- h, err := New(md, mz, auth.NewGuard(md), []byte("test-cookie-key"), nil)
+
+ // A keyring with one all-zero key: enough to exercise the secrets panel
+ // (Enabled() true, encrypt/decrypt round-trip) without a real key.
+ t.Setenv("GITMOTE_CI_SECRET_KEY_V1", base64.StdEncoding.EncodeToString(make([]byte, 32)))
+ kr, err := secrets.NewKeyringFromEnv()
+ if err != nil {
+ t.Fatalf("keyring: %v", err)
+ }
+ svc := secrets.NewService(kr, md)
+
+ h, err := New(md, mz, auth.NewGuard(md), svc, []byte("test-cookie-key"), nil)
if err != nil {
t.Fatalf("New: %v", err)
}
diff --git a/scripts/dev.sh b/scripts/dev.sh
index c77ee12..0886896 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -35,6 +35,10 @@ export GITMOTE_COOKIE_KEY="dev-cookie-key-not-for-production"
export GITMOTE_URL="http://localhost:8080"
export WORKER_SECRET="dev-worker-secret-not-for-production"
+# Enable per-repo CI secrets locally (Secrets panel in /ui). A fixed dev master
+# key — base64 of 32 bytes — obviously not for production. Rotate by adding _V2.
+export GITMOTE_CI_SECRET_KEY_V1="ZGV2LW9ubHktY2ktc2VjcmV0LW1hc3Rlci1rZXktMzI="
+
mkdir -p "$ROOT/data"
# Free :8080 from a stale dev server left running from a previous session.
diff --git a/tasks/22-ci-secrets.md b/tasks/22-ci-secrets.md
deleted file mode 100644
index dd6cf20..0000000
--- a/tasks/22-ci-secrets.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# CI stage 5 — encrypted per-repo secrets store
-
-Part of the CI epic ([16-ci.md](16-ci.md), decision §Stage 0 #3 — **locked**).
-Depends on the dispatcher building the trigger env (stage 3
-[19](19-ci-scaleway-trigger.md)). Adds per-repo CI secrets, encrypted at rest,
-injected into the runner env at trigger. This is the one genuinely new security
-surface — treat it with care.
-
-## Spec
-
-Per-repo named secrets that an admin can set/delete (write-only — values are
-never shown again). At trigger time the dispatcher decrypts the repo's secrets and
-adds them to the Scaleway job env. Encrypted at rest with a **server-held** master
-key so a compromise of the S3 replica / DB snapshot does not leak secrets — but
-**not** a defense against a compromised running server. State that honestly in the
-safety doc; do not oversell it.
-
-## Current
-
-- No secret store. The dispatcher (stage 3) already assembles a `map[string]string`
- env for `Trigger` — the injection point.
-- Prior-art crypto in `~/dev/atmin.net` (adapt, don't copy — its trust model is
- inverted, being end-to-end with a password-derived key):
- `web/src/lib/crypto.ts` (AES-256-GCM, 12-byte random IV; HKDF-per-purpose),
- `key-chain.ts` + ADR-0012 (versioned rotation). See epic §Stage 0 #3.
-- Container secret env is the home for the master key, alongside
- `GITMOTE_COOKIE_KEY` / AWS keys ([ops.md](../docs/ops.md)).
-
-## Change
-
-**1. `internal/secrets` package** — pure-crypto, unit-testable:
-- **Master keys from env, versioned:** `GITMOTE_CI_SECRET_KEY_V1` (base64, 32
- bytes), `…_V2`, … The process holds a `map[version][32]byte`; the highest
- present is *current*. **Because the keys are all server-held, we keep a
- version→key map rather than atmin.net's on-disk wrapped key-chain** — same lazy,
- O(1)-rotation property (each envelope decrypts with its own version's key), far
- simpler. Rotation = add a new key version + bump current; no re-encryption.
-- **Per-repo subkey:** `HKDF-SHA256(masterKey[v], info="ci-secret:"+repoID)` →
- the AES key. (Isolates repos under one master, mirrors atmin.net's
- `hkdfDerive(secret, info)`.)
-- **Encrypt/Decrypt:** AES-256-GCM (`crypto/aes`+`crypto/cipher`), 12-byte IV from
- `crypto/rand` per encryption. **AAD = repoID ‖ name ‖ version** (the improvement
- over the prior art — binds the envelope so a ciphertext can't be replayed under
- a different repo/name/version). Envelope = `{v, iv, ct}`.
-- **Fail closed:** decrypt requested but the needed key version is absent → a
- clear error; never a silent empty or a panic.
-
-**2. `meta` + schema** — `ci_secrets(repo_id, name, v, iv, ct, created_at,
-PRIMARY KEY(repo_id,name))` (idempotent `CREATE TABLE IF NOT EXISTS`). Helpers:
-`SetSecret` (upsert the envelope), `ListSecretNames(repoID)` (names only — never
-values), `GetSecrets(repoID)` (envelopes, for decryption at trigger), `DeleteSecret`.
-
-**3. Wire into the dispatcher** — at trigger, `GetSecrets(repoID)` → decrypt each
-→ add to the job env map. Never log secret names+values; scrub from any error
-strings.
-
-**4. Admin UI** ([internal/webui](../internal/webui)) — a per-repo secrets panel:
-list **names** + set (name+value form) + delete, behind `requireAdmin`. The set
-form is write-only; the value is never rendered back.
-
-## Verify
-
-- **`secrets` unit tests:** encrypt→decrypt round-trip; a decrypt with the wrong
- repoID/name/version (i.e. wrong AAD) **fails** on the auth tag; two encryptions
- of the same plaintext differ (fresh IV); a value with the needed key version
- absent → fail-closed error.
-- **rotation:** a secret written under `V1` still decrypts after `V2` is added and
- becomes current; new writes use `V2`.
-- **leak checks (safety invariant, explicit test):** a set secret's **value**
- never appears in `ListSecretNames`, in any rendered UI page, or in logs; master
- key never logged.
-- **webui route tests:** set/list/delete behind `requireAdmin`; unauth → denied;
- the value isn't echoed in the response.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; e2e green.
diff --git a/tasks/README.md b/tasks/README.md
index 7fa029b..7c6b820 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -21,8 +21,6 @@ dependency-ordered stages, each implementable from fresh context:
The runner, local trigger, scoped clone token, and reconcile ticker have landed
(CI runs locally via `make dev` + `act`); what remains is packaging the runner
for Scaleway (Dockerfile.runner + `scw jobs definition`), gated on a DinD spike.
-- [22-ci-secrets.md](22-ci-secrets.md) — encrypted per-repo secrets store
- (AES-256-GCM + HKDF + versioned keys), injected at trigger.
- [23-ci-status-ui.md](23-ci-status-ui.md) — runs list, run detail, log viewer,
commit status badge (admin-gated).
- [24-ci-self-deploy.md](24-ci-self-deploy.md) — green master run redeploys