Integrating a consumer
Four routes, and which one applies is decided by what the consumer is, not by preference. Every example below is the code that is in the documentation it links to.
Status: all four routes are in v0.14.0; route A's
binary and its action arrived in v0.11.0, covered by tests that run the real program
against the real service. Route A's example pins v0.12.0 deliberately and does not
follow every release: its tag and both checksums are real and that release's assets are still
there, which is all a workflow needs. Copy the block as it stands.
What every consumer needs
Three things, and none of them is optional or has a default worth guessing:
| What | Where it comes from | Secret? |
|---|---|---|
--url |
The instance, as an https URL. TLS terminates at the service; there is no
plaintext listener to fall back to. |
No |
--ca |
The internal certificate authority, as PEM. Required by design: no
client here can be built to trust the public CA set, so -k appears in no example
on this site — not even for testing. |
No — distribute it as an ordinary variable |
--token-file |
One credential per identity, from a file. There is deliberately no flag that
takes a token value: an argument is in /proc/<pid>/cmdline while
the process runs and in the log of any runner that echoes command lines. |
Yes, and it is the one secret a consumer holds |
And one rule that decides what the values are called: the variable name is the last path
segment. infra/service-a/DB_PASSWORD becomes DB_PASSWORD, in every
route, from one implementation. A set in which two paths want the same name is refused whole rather
than delivered with one of them silently winning.
Source: ADR-18, ADR-17, openapi.yaml
Route A — a CI job
A build or deploy job that needs values at runtime. The thing that makes this its own program
rather than a documented curl line is masking: no forge masks a value fetched at
runtime, only its own native secrets, so a job that reads a value with curl | jq
has it in the log the moment anybody adds set -x.
- name: Fetch secrets
uses: nuetzliches/ciphr@v0.12.0
with:
url: ${{ vars.CIPHR_URL }}
ca: ${{ vars.CIPHR_CA }} # not a secret: the internal CA, as PEM
token: ${{ secrets.CIPHR_TOKEN }} # the one forge secret that stays
paths: ci/widget/DB_PASSWORD ci/widget/API_KEY
version: v0.12.0
sha256-amd64: d11c662f9e5ee7d790eb03512ff298e6e785772915b75b24c9df69d3eb44100f
sha256-arm64: e6b91c4f1ec66bfaf694adca0abaf63d9f4860b5b0392677e919ecadbf643591
- name: Deploy
run: ./deploy.sh # DB_PASSWORD and API_KEY are in the environment
The action is a wrapper and carries no masking logic of its own: it downloads the asset, verifies
the published checksum before making the file executable, writes the token to a mode-0600 file in
$RUNNER_TEMP, and calls the binary. The architecture is the runner's, not a
choice — it reads uname -m, takes the matching asset and checks it against the
checksum for that architecture, which is why there are two inputs rather than one that can only be
right about one of them. On a runner without the GitHub CLI — which is most non-GitHub runners — pass
binary: pointing at a ciphr-ci that is already there, and nothing is
downloaded.
Without the action, on anything that can run a shell:
install -m 0600 /dev/null "$RUNNER_TEMP/ciphr-token"
printf %s "$CIPHR_TOKEN" > "$RUNNER_TEMP/ciphr-token"
ciphr-ci --url "$CIPHR_URL" \
--token-file "$RUNNER_TEMP/ciphr-token" \
--ca /etc/ciphr/ca.crt \
--path ci/widget/DB_PASSWORD \
--format actions-env --github-env
What that writes: ::add-mask:: for every value on standard output before
anything else, one mask per line for a multi-line value, then the assignments into the file
the runner reads back — a multi-line value with a heredoc whose delimiter is 128 random bits checked
against the value, so a value cannot close its own assignment and define variables for later steps.
The identity this runs as, in the policy file. One per repository, because that is the granularity at which the trail is worth reading:
[[identity]]
name = "ci-widget"
kind = "machine"
policies = ["ci-widget"]
[[policy]]
name = "ci-widget"
[[policy.rule]]
path = "ci/widget/**"
capabilities = ["read"]
Source: docs/operations/ci.md, ADR-25
Route B — a container that only understands environment variables
An image this project does not own, and no derived Dockerfile: mount one file, override the
entrypoint, leave the image alone. The wrapper fetches, then execs the real entrypoint
with the values in its environment.
services:
app:
image: someone-elses/app@sha256:<digest> # unchanged, and no derived Dockerfile
entrypoint:
- /ciphr-run
- --url
- https://ciphr.internal:4400
- --token-file
- /run/secrets/ciphr-token
- --ca
- /etc/ciphr/ca.crt
- --path
- infra/host/app/DB_PASSWORD
- --path
- infra/host/app/API_KEY
- --report
- --
# Everything after `--` is what the image's own entrypoint was.
- /original/entrypoint
- --config
- /etc/app.conf
volumes:
- ./ciphr-run:/ciphr-run:ro
- ciphr-token:/run/secrets:ro # not a directory this service can write
- ./ca.crt:/etc/ciphr/ca.crt:ro
The list form of entrypoint: is required — the string form goes through a shell,
which puts the flags back into something that re-splits them. Overriding an entrypoint means owning
it, so --report prints the delivered variable names and the program about to be
executed to standard error, never a value: a base image that moves its entrypoint then shows up in
the container log rather than only in the outage.
Exit codes are part of the contract
125 means the wrapper failed and no child was started: an unreadable token file, an unreachable service, a certificate the CA does not sign, an empty listing, a path that cannot become a variable name. 126 is a command that exists and cannot be executed, 127 one that was not found, and anything else is the child's own code. A restart policy can therefore tell "my service crashed" from "it never started".
Source: docs/operations/wrapper.md, ADR-14
Route C — an application that fetches its own secrets
The route with the smallest footprint: no plaintext on disk, none in the container configuration, and the audit entry names the service rather than the deploy runner that started it.
use ciphr_sdk::{Client, SecretPath};
// All three are required, and the certificate authority is required by design:
// there is no way to build a client that trusts the public CA set.
let client = Client::builder(
"https://ciphr.internal:4400",
&std::fs::read_to_string("/run/secrets/ciphr-token")?,
&std::fs::read("/etc/ciphr/ca.crt")?,
)
.build()?;
let environment = client.environment(&SecretPath::parse("infra/service-a")?)?;
// Names are safe to log; values are not, and the type system enforces the
// difference rather than a review catching it.
let password = environment.get("DB_PASSWORD").expect("under the prefix");
Three properties hold by construction rather than by discipline. The client cannot trust
the public CA set — the transport is compiled without web root certificates and the trust
anchor is a constructor argument. It cannot log a secret — values live in a type
implementing neither Debug, Display nor Serialize. And it
cannot set an environment variable — that is unsafe in this edition and
the crate forbids unsafe code, so what it hands back is a mapping you read from, which keeps the
value out of /proc/<pid>/environ entirely.
Where the consumer is a child process that only reads environment variables, hand them over without setting any of your own:
let mut command = Command::new("/usr/local/bin/migrate");
for (name, value) in environment.into_entries() {
command.env(name.as_str(), std::str::from_utf8(value.expose())?);
}
let status = command.status()?;
There is deliberately no retry loop in the crate and no Client::from_env(): how long
a service waits for its secrets is the service's policy, and where its credential comes from is a
deployment decision. SdkError::is_retryable says which failures could change on their
own — the transport, and an audit trail that could not be written — and the crate documentation
carries the loop to copy.
Source: crates/ciphr-sdk, ADR-19
Route D — plain curl
The API is HTTPS plus a bearer token, so this has always been a complete client. Use it where a binary cannot be placed on the machine — and read what it leaves you to do before you do.
set -eu # and not `set -x`
# One secret, by path. The value is JSON-encoded, so `jq -r` is what unescapes it.
value=$(curl --fail --silent --show-error \
--cacert "$CIPHR_CA" \
-H "Authorization: Bearer $CIPHR_TOKEN" \
"$CIPHR_URL/v1/secrets/ci/widget/DB_PASSWORD" | jq -er '.value')
# A whole prefix is two requests, because there is no "export a prefix" operation:
# list authorizes every path it returns, then each path is read.
curl --fail --silent --show-error \
--cacert "$CIPHR_CA" \
-H "Authorization: Bearer $CIPHR_TOKEN" \
"$CIPHR_URL/v1/list/ci/widget" | jq -r '.paths[]'
What this route leaves to you
The masking. Nothing above emits ::add-mask::. Doing it correctly
is more than one printf: masks before anything else, one per line for a multi-line
value, and a heredoc delimiter the value cannot reproduce — a guessable one lets a value close its
own assignment and define variables for later workflow steps, which was a real finding against this
project rather than a hypothetical.
The status codes. --fail loses which one it was.
401 is the token, 403 the policy, 404 the path, and
503 means the audit trail could not be written, so nothing was served and nothing
changed — a deployment outage rather than something to retry past.
The empty listing. A token without list gets
{"paths":[]} and a 200, exactly as an empty prefix does. A job that reads
that as "nothing to fetch" starts with no secrets and fails later, somewhere else.
Source: openapi.yaml, docs/operations/ci.md
Two things that apply to all four
Name the paths rather than the prefix, wherever the set is known.
--path needs only read; a prefix needs list as well and takes
whatever exists under it at that moment. Two failure modes belong to the prefix form alone: a listing
that loses a path does so silently, because every returned path is authorized individually — and
somebody else's new secret under the same prefix can collide with a name you depend on and refuse the
whole fetch.
You do not have to turn on an optional route to fetch. The bulk read
POST /v1/export is a surface entry and is off unless a deployment names it. Consumers
read through it where it exists and one path per request where it does not, so a deployment that made
no decision still serves every route above. The audit trail is identical either way — the bulk route
writes one entry per secret served, never one per call.
Source: ADR-20, docs/authorization.md