ciphr / secrets for machines

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.

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:

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.

Source: ci.md, cli.md

The transport

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

  1. 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.
  2. A value in a process environment is readable by that process's neighbourhood. Route B puts the values in /proc/<pid>/environ of 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.
  3. 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.