Security notes for a consumer
What the integration has to get right, and the three things this design deliberately does not defend. Everything here is about the code that calls ciphr — the operator's side is in the threat model and the operational procedures.
Status: against v0.14.0. Where a claim was measured,
the date and the thing measured are named; where it was not, that is said instead of implied.
The token is the whole credential
A consumer holds exactly one secret, and everything it may do follows from it.
- From a file, never from an argument. No client here has a flag that takes a
token value, and adding one would be a regression: an argument is readable in
/proc/<pid>/cmdlineby every other process while the command runs, and it lands in the log of any runner that echoes command lines. - Not world-readable, and not world-writable. Group bits are allowed — a root-owned file read by a service group is a legitimate arrangement. World bits stop the process. Writable counts for a reason worth stating: whoever can replace the file does not have to learn the token in it, they can substitute one of their own and have the fetch run under an identity they control.
- The directory matters as much as the file. The file is opened once and both
its permissions and its content come from that one descriptor, so a file swapped in afterwards is
not the file that was read. What no check can settle is who could have written it before:
whoever can create entries in that directory can put their own token there at mode 0600 and pass
every rule. Mount it from a directory the consumer cannot write — a secrets mount, a named volume,
$RUNNER_TEMPon a runner — and never from a working directory shared with the application. - One identity per consumer. One per repository for CI (
ci-<repo>), one per host for a deployment. That is the granularity at which the trail is worth reading, and the granularity at which a leak is bounded. A host-wide token covering several services means one compromised service reads the others'. - Shorter lifetimes for CI than for a host. Those tokens are spread across more systems.
Plan the revocation before you need it
Revoking a token through the CLI needs the service stopped. There is a route that does it without an outage — but it is an optional surface entry, and turning it on requires the revoking identity to hold a credential that was issued before the incident. Turning on outage-free revocation costs that one outage; paying it during the incident is the expensive order.
Source: wrapper.md, ci.md, ADR-24
Ask for the least the consumer can work with
read serves a value. list is what a prefix fetch needs in addition, and
it is the one to leave out where the set of secrets is known when the deployment is written: naming
paths removes both of the prefix form's failure modes at once.
Policies are deny-by-default and live in version control rather than behind a write API, so a permission change is a commit with an author, a diff and a reviewer. Two things about writing them that a consumer's author should know:
- A rule granting everything means secrets, and only secrets.
path = "**"withreadused to include the audit trail, the identity inventory and the policy structure. Reading those is its own capability now (inspect), and a rule that still asks for a secret capability under the reserved prefix is refused when the policy file loads, naming the replacement. - An empty capability list is an explicit denial and beats any less specific permission. That is how a consumer is kept out of a neighbouring prefix.
Source: docs/authorization.md, ADR-23
Masking: what is measured, and where it stops
No forge masks a value fetched at runtime — only its own native secrets. That is
why masking is part of the product: the CI-side render emits ::add-mask:: for every value
before it emits anything else, one mask per line, because runners match literal strings and a value
containing a newline is never matched as a whole.
Measured on 2026-08-18, on a real Forgejo runner in the same execution mode a job uses, against values differing in a single character: the same step, across steps through the environment file, multi-line values, a value inside a composed URL, and a value in the stderr of a failing command. The multi-line round trip was verified by comparing digests rather than by printing anything.
It does not hold under set -x
A runner matches a mask as a literal substring, and bash re-quotes an argument before xtrace prints it. A value containing a single quote renders with bytes inserted in the middle; one containing a tab renders as an escape sequence. Both reach the log in clear text. Everything else survives — a space, a dollar sign, a backtick, a double quote, a backslash.
So the rule for a job that holds fetched values is set -x off, not
"the mask will catch it". A generated password from a full punctuation alphabet contains a single
quote roughly every third time at usual lengths.
act_runner is not claimed. "Both are act derivatives" is precisely the assumption this project refused to make about the Forgejo runner before measuring it, and it stays refused: measuring needs a Gitea runner to measure on, and where there is none the only alternative to measuring is assuming.
The transport
- The trust anchor is this deployment's own CA, and no client here can be built
to trust the public CA set.
-kand--insecureappear in no example in this project, not even for testing. - HTTP/1.1 only. The listener used to negotiate HTTP/2 through a transitive
dependency feature rather than through any decision; the protocol list is deliberate now. Clients
fall back on their own —
curl --http2-prior-knowledgedoes not, and will fail. - Every response carries
Cache-Control: no-store, including errors. A cached response to a secret read is a secret without an expiry date. - Redirects are not followed by the SDK. This API has no redirect contract, so a
3xxis a transport or configuration failure rather than a hop to take.
Source: ADR-8, ADR-9, openapi.yaml
What the trail records, and what a 503 means
Every read of a value is recorded before the value leaves the process, under the identity of the token that asked — which is the argument for a service fetching its own secrets rather than being handed them: the entry then names the service instead of the runner that deployed it. Bulk reads write one entry per secret served, never one per call.
503 is not a rate limit
It means the audit trail could not be written, and therefore no secret was served and nothing was changed. Fail-closed is the design: a full audit volume is an outage rather than a logging gap. A client may retry it without wondering whether its write half-happened, but the thing that fixes it is on the deployment's side.
Source: audit-trail.md, ADR-22
Rotation is not a property of the tool
A secret carries a class saying how safe it is to rotate, and a secret nobody classified says
unclassified rather than claiming to be safe. That default is deliberate: it warns
instead of reassuring. Before a consumer or a job rotates anything, the classes that mean "do not"
— a value whose rotation destroys data, or invalidates every session — are the ones to read, because
the tool will happily write a new version either way.
Source: rotating-secrets.md
Three things this does not defend
- Root on the host reads the master key and process memory. A deliberate consequence of unattended startup — an availability decision, not a cryptographic one. Moving that boundary needs split-key unsealing or an HSM, both retrofittable without a data format change.
- A value in a process environment is readable by that process's neighbourhood.
Route B puts the values in
/proc/<pid>/environof the service, which is where that image wanted them; a CI job puts them in the environment of every step that follows. Only an application that fetches its own secrets and reads them from a mapping avoids this, and that is the reason route C exists. - A secret that has left ciphr is the pipeline's problem. The trail records that a consumer read a value, never what it did with it afterwards. A runner, in particular, is a machine somebody administers, and that somebody is not necessarily whoever owns the secrets the job fetched.
What ciphr adds is that a value has an owner, an expiry, a policy and a trail entry naming who read it — not that it stops being a secret once a process holds it. The full list of adversaries, defended and undefended boundaries, and the availability trade is in the threat model.
Source: docs/threat-model.md, docs/security-review.md
Reporting something
Two channels, and neither is preferred: GitHub's private vulnerability reporting on the
repository, or plain mail to security@nuetzliche.it. The process and what is in scope
are in SECURITY.md.
What has and has not been reviewed
The cryptographic and authorization crates were read at v0.3.0 by an AI
model commissioned by the maintainer — not a human practitioner — which produced six
findings and a fitness statement whose conditions were then met. The repository was made
public on 2026-08-24 without a human review, and the project's own working paper names
publication as one of the two things that raise the bar back to one. That is a decision against a
recorded condition, not a condition that was met.
Unreviewed by anyone: the audit crate, most of the store, the server's configuration and TLS code, and the viewer. Newer than the acceptance: the honeypot surface, and the CI-side binary described under Integrate. Nothing here is an independent security assessment. What was read, what was not, and what would close it is in docs/security-review.md.