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.

#154

P2: Repository secrets: an encrypted store, job-level delivery, and log masking

closed · opened by keenan 7d ago

keenan opened this 7d ago

Why

Part 4 of #138 names secrets as the biggest blocker for deploy and publish jobs, and today the only secret Vole stores is the SSH host key. A job cannot be given a registry token, a deploy key, or a staging password without that value becoming workflow content, a shell argument, or a file on a runner that nobody rotates and nobody can audit.

This is the design for a small encrypted store that a workflow declares and a runner receives, together with the two ways people use it: vole secret get for a developer who needs the staging key, and a write-only form on the repository’s settings page.

The store

data/secrets.db (SQLite, 0600) beside data/users.db, plus data/secrets.key (32 random bytes, 0600, created on first use, path overridable with secrets_key in vole.toml).

secrets(repo, name, version, sealed, created_by, created, updated_by, updated, last_read)
  PRIMARY KEY (repo, name)

sealed is AES-256-GCM with a random nonce and AAD repo \0 name \0 version, so a row cannot be moved to another name or replayed as an older version. A format byte in front of the blob leaves room for key rotation without a migration. set replaces the value and increments version; old values are not kept.

Repository scope only. A value a team shares across repositories is set in one repository and read by that repository’s maintainers. Instance-wide secrets would need a second access model for one store and are not worth it yet.

Say plainly what the encryption buys: the file mode is the real boundary, and the key mainly protects copies of data/ that leave the host — a backup, an rsync, a stray tarball. A key stored beside the database in the default configuration is not a defence against someone who has the directory.

The workflow field

Job scope, because a job is one container: a step-scoped value would keep the name out of a step, not the value.

jobs:
  deploy:
    image: alpine:3.22
    secrets: [DEPLOY_KEY, SENTRY_DSN]
    steps:
      - run: ./deploy.sh     # reads ${DEPLOY_KEY:?} and ${SENTRY_DSN:-none}

Declaring a name is what makes it present. A job that does not list a secret does not receive it, so a workflow a maintainer approved is also the list of values its steps can see.

There is no interpolation and no ${{ }}: a secret arrives as an environment variable and nothing else.

Delivery

  1. When the run is queued, the row pins the declared names, beside Env and Artifacts. No value is stored in runs.db.
  2. When the job is leased, the server resolves the names it has and sends {name, version, value} in the lease reply — the same authenticated SSH channel that already carries the steps and the image definition.
  3. The runner writes export NAME='…' lines, POSIX single-quoted so a ' becomes '\'' and newlines survive exactly, into a 0600 file in the run’s directory and mounts that directory at /run/vole, where event payloads already live.
  4. Every step exec is prefixed with . /run/vole/secrets.env, so the values are exported into the step’s shell and reach the processes it starts.
  5. The finish message reports {name, version} for what was delivered, so the attempt record says which value ran without holding it.

--env-file was the obvious choice and does not work: the format is one NAME=VALUE per line, so a PEM key would be truncated silently. --env NAME=value carries newlines but puts the value in the runtime’s argv, where ps on the runner host shows it. Sourcing a file avoids both, keeps every value out of every argv, and needs nothing runtime-specific.

A side effect worth knowing: only the step shells and their children see the values, not PID 1 and not a background process an earlier step left running. That is slightly tighter than container-wide environment.

When a secret is missing

There is no separate “required” flag. A name that does not resolve is simply left out, the variable is unset rather than empty, and the runner writes one line to the attempt log before the first step:

vole: DEPLOY_KEY is not set for demo, so this job runs without it.

An optional secret stays optional, and a required one is made required by the step — ${DEPLOY_KEY:?DEPLOY_KEY is not set}, or set -u — which puts the message in the hands of whoever wrote the step. vole workflow run prints the same line.

Limits

The values end up as environment variables of the step’s processes, so the limits are the kernel’s, enforced early enough that the message is ours:

Limit Value Enforced at
One secret len(name) + 1 + len(value) ≤ 128 KiB set / save
A value’s bytes valid UTF-8, no NUL set / save
All secrets in one job Σ len(name) + 1 + len(value) ≤ 2 MiB lease

128 KiB is MAX_ARG_STRLEN, the kernel’s ceiling for one NAME=value string in envp, so a longer value could never reach a process. 2 MiB is the default ARG_MAX, RLIMIT_STACK / 4. A value over the first limit is refused with that reason; values that do not fit in the second are withheld in declaration order and get the warning above, because a step that quietly ran without its token is worse than one that was told.

Invalid UTF-8 is refused because the lease channel is JSON and Go’s encoder replaces an invalid byte with U+FFFD, which would corrupt a secret silently; a NUL cannot be passed to a process at all. There is no limit on how many secrets a repository has, how many a job declares, or how much secrets.db holds.

Masking

The runner masks every delivered value in the log before a chunk is uploaded, so data/logs/ never holds one. The streamer keeps a carry of the longest value minus one byte between chunks, so a secret split across a chunk boundary is still covered, and flushes at the end of the step. Runner-generated error text and vole workflow run’s output go through the same pass.

Documented as best effort: a value that is base64-encoded, hex-encoded, or embedded in a URL will not be masked. Masking guards against accidents; the approval gate is the control.

Audit and interface

Every delivery writes one line: secret.read, actor is the runner’s account, subject is the repository, detail names the run, ref, and versions. Every human read and every change is recorded the same way, and the settings page shows names, version, who set it, when it was last read — never a value.

vole secret list <repo>
vole secret set <repo> <NAME> [--from-file <path>]   # value on stdin otherwise
vole secret get <repo> <NAME>                        # value on stdout, audited
vole secret rm <repo> <NAME>

set never takes the value as an argument: argv is visible to every process on the host and lands in shell history. list and get need maintain or above; the web form saves and deletes only, and never renders a value.

What this promises, in plain words

Two statements belong in the docs, because both are true and a reader should not have to infer them:

  • Approving a contributor’s branch tip grants that tip the repository’s secrets, along with the runner, the network and the release path it already grants.
  • Whoever runs a runner for a repository can read every secret that repository’s jobs declare, because the values arrive on that machine. The runner allowlist is the control, not the container.

The other channels stay as they are and are worth repeating on the page: /cache persists between jobs and is not a secret store, artifacts and site deploys are published to the server, and an image-file build receives no secrets.

What this does not do

  • No audience, protected-ref rule, or per-secret allowlist. Approving a tip is the trust boundary; a second one would be a rule to keep in sync with the first.
  • No step-scoped secrets, expressions for building a secret name, required-secret declarations, rotation history, or instance-wide scope.
  • No secret for an image build, an environment protection rule, or a service container. Those are #138 items that need their own design.
  • No file or certificate delivery. A large value is a path the step fetches, not something the store carries.

Scope

Slice 1 — the store and the people side:

  • forge/secret: schema, seal and open, version, last_read, repository scope.
  • secrets_key in the configuration, the key file created at 0600, both paths added to TightenSecrets in forge/instance.go and to the backup list in docs/operations.md.
  • vole secret list, set, get, rm, with set reading from stdin or a file and get requiring maintain or above.
  • A secrets section on /r/{name}/settings: names, version, last read, and set/replace/delete. No value is ever rendered.
  • Audit records for read, write, and delete.
  • docs/architecture.md data table, and the “keep the host key as the only secret” line in Why these choices, which stops being true.

Slice 2 — CI:

  • secrets: in ci/workflow.go: name shape, duplicates rejected, pinned on the run row beside Env, and checkRecord covering what was delivered.
  • Lease-time resolution in server/ssh/runner.go, values in the reply and out of the queue.
  • The runner writes secrets.env into the run directory and prefixes each step exec with . /run/vole/secrets.env; vole workflow run does the same.
  • The absent-and-oversized warning line.
  • docs/workflows.md and docs/reference/workflow-reference.md: the field, the unset-not-empty rule, and the two limits.

Slice 3 — the safety net:

  • Masking in ci/agent/logstream.go with the carry across chunks, and the same pass over runner error text and local run output.
  • The two trust statements in docs/accounts-and-access.md and docs/runner.md, and the /run/vole paragraph there extended to cover the secrets file.

Acceptance criteria

  • A job can read a declared secret as an environment variable, and the value appears in no argv, no runs.db row, and no stored log.
  • A job whose declared secret does not exist runs with the variable unset and one warning line in its log; it does not fail.
  • A value longer than one environment string can carry is refused when it is stored, with a message that says why.
  • A maintainer can set, read, and delete a value from the CLI and from the settings page; nobody can read one from a page.
  • Every delivery and every human read appears in data/audit.log with the account, the repository, the name, and the version.
  • An attempt record names the secret versions it used.
  • docs/architecture.md, docs/operations.md, docs/runner.md, docs/workflows.md, docs/reference/workflow-reference.md, docs/accounts-and-access.md, and docs/cli.md describe what was built.

Related

  • #138 01M69AZZ9MHCY6K8BPYP5BDEDG — the parent: this closes part 4 and depends on the approval flow that #138 already records as the trust boundary.
  • #11 — the runner trust boundary this must not weaken.
  • #17, #18 — the run record and log pages this extends.
  • #64 — combined-result runs for change requests; a run queued by one is a run like any other, so it may read the secrets its workflow declares.

Comments (3)

keenan 7d ago

Slice 1 is up for review as #155 (01M6EPW9GMEXNH8F4BWDRXBTBH): the encrypted store, vole secret list|set|get|rm, the Settings tab section, and the audit trail. It covers the store and people-side checkboxes in ## Scope, plus the docs/architecture.md, docs/operations.md, docs/configuration.md, docs/cli.md and docs/accounts-and-access.md edits.

Two notes for review:

  • The page is reachable by a maintainer now, not only an admin. The admin fields stay hidden, and the two tests that asserted 404 for a maintainer were changed to assert that the page opens and the admin controls are absent.
  • get writes the value with no trailing newline so a redirect is exact; a piped set drops one newline, because otherwise echo token | vole secret set … would store the line ending.

Slice 2 is next: the secrets: field, lease delivery, and the warning line. It is not in this change, so the CLI help and the settings copy describe only what exists today.

keenan 7d ago

Slice 2 is up for review as #156 (01M6ERWF81FG1WF4GE2T0HZ12C): the secrets: field, delivery at lease time, the missing-name warning, {name, version} in the attempt record, and local-run parity.

It covers these checkboxes in ## Scope:

  • secrets: with name validation, pinned on the run row beside Env.
  • Lease-time resolution in server/ssh/runner.go, values in the reply and out of the queue.
  • /run/vole/secrets.env written by the runner and sourced before each step; vole workflow run does the same.
  • The absent-and-unreadable warning line.
  • docs/workflows.md, docs/reference/workflow-reference.md, and the /run/vole paragraph in docs/runner.md.

Two things moved earlier than the slice plan had them, because they became true with this change: the two trust statements (a runner can read what it is given; approving a tip grants its workflow the repository’s secrets) are now in docs/runner.md and docs/accounts-and-access.md. Masking stays in slice 3, and the reference page says plainly that a step which prints a secret writes it to the log — that sentence is what slice 3 replaces.

A value over 128 KiB is refused when it is stored rather than at lease: the name rule and the limit live in ci now, so the store and the workflow cannot disagree about either.

keenan 7d ago

Slice 3 is up for review as #157 (01M6ET925J0MRT1QPZNSPJC2BK): values are masked in the attempt log as it is written, so the file the runner streams and the instance keeps never holds one.

That closes the remaining checkboxes in ## Scope: masking in ci/agent/logstream.go’s path (the spool, so the streamer cannot miss a value), the same pass over runner error text and local run output. The {name, version} record field and the two trust statements went in with #156, because they became true when delivery landed.

Two decisions worth recording on the issue:

  • A 4-byte floor. A value shorter than four bytes is not masked, because replacing every occurrence of 1 or yes would rewrite the log and protect nothing. That is stated on the reference page next to the other two limits: a re-encoded value is not caught, and an artifact or site file is published as the step wrote it.
  • The mask lives below the streamer. It sits in the spool rather than in the log stream, so the value never reaches a file at all, and the runner flushes at each step boundary to keep byte ranges exact.

All three slices of #154 are now in review or landed: #155 merged, #156 merged, #157 open.