ciphr / secrets for machines

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:

WhatWhere it comes fromSecret?
--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