Install — every tool, on every machine that needs one
For the customer: every line here is a command you can paste.
For the customer: every line here is a command you can paste. Nothing is described in prose that you are then expected to reconstruct. Updated 2026-09-15.
You install three kinds of thing, on up to three kinds of machine, and no machine needs all of them:
| Machine | What goes on it | Why that machine |
|---|---|---|
| The signing machine — one laptop, one person (checklist item 1.3) | the ziffer CLI, your policy signing key | the key that signs your policy never leaves it, and no ZIFFER host ever holds one |
| Your CI runner | the ziffer CLI (installed per job, pinned by checksum), ziffer-renew, the publish-ci key and certificate | it publishes policy over mutual TLS; it holds a signing key only for the length of one job |
| Your agent's host | the SDK (ziffer for Python, @ziffer-io/client for TypeScript) | it proposes actions and verifies receipts |
| Your Executor host — pull mode only | ziffer-renew, the executor-pull key and certificate | it holds the one credential that is itself an authorisation, so its leaf lives 24 hours |
Section 0 is the prerequisites and the one command that does sections 1–4 for you. Sections 1–3 are the signing machine. Section 4 is your agent. Section 5 is CI. Section 6 is the renewal task somebody has to own. Section 7 says where every value you are asked for comes from, by the name it has on the sheet we hand you.
Windows is not supported, and WSL is the path. There is no Windows build of
ziffer and none is planned. Install WSL 2 with any Ubuntu image and follow the
Linux column everywhere below — inside WSL, uname -sm prints Linux x86_64,
which is the target the commands select on their own. The one thing to keep on the
Windows side is nothing: put the policy repository, the key and the CLI inside the
WSL filesystem (~/…, not /mnt/c/…), because file modes on /mnt/c do not hold
and a signing key at mode 0600 is a signing key at mode 0600.
0. Prerequisites
Nothing in this guide installs its own prerequisites. This section is what has to be on each machine before section 1, who should be sitting at it, and what you will already have been handed.
Who runs this, and where. The signing machine is one laptop belonging to the one person named in checklist item 1.3 — the person who will hold your policy signing key. Sections 1, 2 and 3 happen there and nowhere else, because the key generated in section 2 never leaves the machine that generated it.
- Not a shared CI runner. A runner is ephemeral, its filesystem is readable by every job the repository can start, and a key that has been on one is a key to rotate. CI gets the key as a secret (section 5), which is a copy you place deliberately — not a file it found lying there.
- Not as root, and not with
sudo sh. The installer needssudofor exactly one thing, writing one file into/usr/local/bin, and it asks for it at that moment. Running the whole thing as root makes your key root-owned and puts~/.zifferin/root. - Not on a machine somebody else administers. Whoever can read the disk can read the key.
- One person, not a team account. Item 1.3 asks for a name and an email because losing this key means re-enrolling, and "we thought someone else had a copy" is how that happens.
Programs, per operating system. The minimum version is a minimum, not a
recommendation: below it the command does not work at all, and in the openssl
row it fails in a way that reads like a corrupt file.
| Program | Minimum | Needed for | macOS 13+ | Linux x86_64 (glibc or musl) |
|---|---|---|---|---|
curl | any | fetching the release and the verifying key | preinstalled | sudo apt-get install -y curl / sudo dnf install -y curl / apk add curl |
openssl | 3.0 | verifying the release signature (section 1.2) and generating the two CSRs (section 3) | brew install openssl@3 — the preinstalled /usr/bin/openssl is LibreSSL and will not do | preinstalled on Ubuntu 22.04+, Debian 12+, Fedora 36+, Alpine 3.16+ |
shasum or sha256sum | any | checking the binary against the signed checksum | preinstalled | preinstalled (coreutils) |
git | any | your policy repository, and the installer's check that it is not writing a private key inside one | xcode-select --install | sudo apt-get install -y git |
python3 and pip | 3.10 | the Python SDK only | brew install python | sudo apt-get install -y python3 python3-pip |
node and npm | 22 | the TypeScript SDK only | brew install node | curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs |
The last two rows are per language. A Python shop needs no Node and a TypeScript shop needs no Python; the installer asks which, and defaults to whichever the machine already has.
Windows: WSL 2, and nothing else. There is no Windows build of ziffer and
none is planned. Install WSL 2 with any Ubuntu image, then follow the Linux
column everywhere — inside WSL, uname -sm prints Linux x86_64, which is the
target every command below selects on its own. Keep the policy repository, the
key and the CLI inside the WSL filesystem (~/…, never /mnt/c/…): file
modes do not hold on /mnt/c, and a signing key at mode 0600 is a signing key at
mode 0600. Anything that refuses WindowsNeedsWsl is telling you this.
What your CI runner needs. GitHub Actions, on a ubuntu-latest runner, with
no self-hosted requirement. The workflow installs ziffer per job, so the runner
needs nothing preinstalled. What it needs is four repository variables and four
secrets, all listed in section 5 — the variables are ZIFFER_CLI_URL,
ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256 and ZIFFER_PUBLISH_URL; the secrets
are POLICY_SIGNING_KEY, ZIFFER_CLIENT_CERT, ZIFFER_CLIENT_KEY and
ZIFFER_CA, and they belong in a gated environment, not repository-wide.
What an approver's device needs. A passkey, on the device that person will
actually approve with — a current iPhone, Android phone, Mac or Windows machine,
in a current Safari, Chrome, Firefox or Edge. That is assurance AS1. For
AS2 it is a FIDO2 hardware key (a YubiKey 5 or equivalent, USB or NFC)
instead, and checklist item 1.4 is where you tell us which each approver will
use. Nothing is installed: registration is one browser page, once, on the device
that will approve (approvers.md section 2). There is no app, and there is no
approval by replying to an email. We have not certified a browser version
list, so if a device cannot register, that is the thing to tell us rather than a
thing to work around.
The rule that follows from "only that device can approve" is in checklist item 1.4 and is worth repeating here: enrol at least one more approver per role than you require. There is no delegation and no out-of-office.
What your Executor host needs. A container runtime, and somewhere to put
state that outlives the container. You run our image, in your account, and it
is pulled from the private repository we publish to for you — publishing to a
public registry is blocked until the agreement, so there is no docker pull from
a public name today. executor.md section 8 is the AWS deployment with its
parameters, its IAM line by line, and its health check; section 4 is the
configuration surface if you run it on something else. In pull mode the host
also needs ziffer-renew (section 6) and outbound TLS to the addresses on your
handover sheet — but no calendar entry, because the Executor renews its own
24-hour certificate itself.
The eight values that come from us, by name. They are on the handover sheet a
person hands you out of band at onboarding, and section 7 says where each one
goes. Nothing here is guessable: ZIFFER_API_KEY, ZIFFER_TRUST_ANCHOR,
ZIFFER_API_URL, ZIFFER_SUITE_FLOOR, ZIFFER_PUBLISH_URL, ZIFFER_CA,
ZIFFER_CLIENT_CERT, and your tenant id. In pull mode three more join them —
ZIFFER_EXECUTOR_PULL_ADDR, ..._NAME and ..._INTERVAL_MS, all three or none.
ZIFFER_CLIENT_KEY is deliberately not on the sheet: it is yours, from section
3, and it never reaches us. If a row is missing from your sheet, ask — do not
infer it.
0.1 One command
Everything in sections 1 to 4 is one script. It asks one question per step — four steps, plus where the key goes, which SDK, and (for TypeScript) which project — and shows the default for each, so pressing Enter at every prompt is a complete install:
curl -fsSL https://github.com/ziffer-hq/ziffer-spec/releases/latest/download/install.sh | shIt does, in this order: checks the prerequisites above and refuses by name if
one is missing; downloads SHA256SUMS, its signature and the verifying key, and
verifies the signature before it downloads any binary at all; checks the
binary's checksum; installs it; generates your policy signing key with ziffer keygen and derives the public half; installs the SDK for the language you pick;
writes the two certificate signing requests; and prints three blocks — SEND
US, KEEP and MAINTENANCE — naming the exact files.
| You want | Pass |
|---|---|
| no questions at all, for a scripted install | --yes |
| one step only | --only cli, --only key, --only sdk, --only csr (repeatable) |
| a language chosen rather than detected | --lang python / --lang typescript / --lang both |
somewhere other than /usr/local/bin | --prefix "$HOME/.local/bin" |
| a pinned release rather than the latest | --version 0.1.0-0605fd0 |
| to see every command without running one | --dry-run |
Arguments go after -- when the script is piped:
curl -fsSL <url> | sh -s -- --yes --lang python. One environment variable is
read in normal use: ZIFFER_OPENSSL=/path/to/openssl names an OpenSSL 3 to use
instead of the search described in the openssl row above.
Three things it never does: it sends nothing anywhere (every call is a GET), it
uses sudo only for the one install into the prefix and only if you say yes,
and it never overwrites a private key — a key file that already exists is refused
as KeyFileExists and nothing is touched.
It writes the same files to the same places as the by-hand path below:
~/.ziffer/policy-signing.key and ~/.ziffer/policy-signing.pub.json, and the
two certificate requests in ~/.ziffer/csr/.
What it refuses, by name. It stops at the first one and does nothing after it.
Quote the name back to us if the line below does not resolve it; every one of
these is checked by the script's own --selfcheck, which the gate runs.
| Refusal | What it means, and what to do |
|---|---|
WindowsNeedsWsl | there is no Windows build. Install WSL 2 and run it inside |
UnsupportedPlatform: <os> <arch> | the three targets are darwin-arm64, darwin-x86_64, linux-x86_64. Section 1.5 |
PrerequisiteMissing: <tool> | the table above, for that row. The refusal prints the one install line for this machine |
OpenSslTooOld | LibreSSL, or OpenSSL 1.x. brew install openssl@3 on a Mac |
PythonTooOld / NodeTooOld | the SDK floor is Python 3.10 and Node 22. Upgrade, or pick the other --lang |
NotInteractive | no terminal to ask on. Pass --yes, or run it as sh -c "$(curl -fsSL <url>)" |
InsecureBaseUrl | a download URL that is not https. There is no flag that permits one |
LatestUnresolved | the releases API did not answer, or carried no tag. Pass --version from your handover sheet |
ReleaseNotFound | no release at that version, or one with no SHA256SUMS.sig. Check the version; an unsigned handout is not ours |
ReleaseSignatureInvalid | stop. The signature over SHA256SUMS does not verify under the key on the repository's main branch. Nothing was downloaded past this point. Write to us with the version you tried |
ArtefactNotInRelease: <name> | that release has no binary for this machine's target |
ChecksumMismatch | the binary does not hash to the value signed for it. Nothing was installed |
BinaryDoesNotRun | the downloaded binary does not answer --help here. Nothing was installed; send us uname -a |
BinaryDoesNotMatchRelease | (--check) the binary on disk is not the published one. install.sh --upgrade --version <v> |
InstallDirNotWritable | the --prefix you named cannot be written and sudo was not used. Name one you own |
KeyFileExists | a signing key is already there and this script never replaces one. Move it aside yourself if you meant to |
CsrFileExists | same rule for a certificate request's private key |
KeyDirInsideRepository | you are about to write a private key inside a git repository. Choose a path outside it |
KeyFileNotPrivate | ziffer keygen wrote the key at a mode other than 0600. chmod 0600 it and tell us — keygen is supposed to do that itself |
KeygenFailed / PubkeyFailed | the CLI itself failed. Run the command it printed by hand and send us the output |
CsrFailed | openssl req failed. Run the printed command by hand to see why |
SdkInstallFailed: <lang> | pip or npm failed; the last lines of its output are in the message |
SdkDoesNotLoad: <lang> | the package installed and the interpreter cannot load it |
NoPackageJson | the TypeScript SDK belongs in your agent's project. npm init -y there first |
InstallRecordAbsent / BinaryAbsent | (--check, --upgrade) nothing on this machine says what is installed. Pass --version, or install with this script |
UnknownStep: <x> / UnknownLanguage: <x> | a --only or --lang value that is not one of the listed ones |
AnswerNotUnderstood | a question was asked three times and never answered. Pass --yes |
Afterwards, install.sh --check re-verifies at any time that the binary on this
machine is the one we published, and install.sh --upgrade replaces it when a new
release is announced. Both are in after-install.md.
Or do it by hand. Sections 1 to 9 below are the same steps as individual commands, and if your organisation does not pipe scripts into a shell, that is the path — it is not a lesser one. The installer's whole job is to run those commands in that order without skipping the two checks.
1. The ziffer CLI
ziffer is the offline policy authoring and signing tool. It runs on your machines
and your CI runners, and it deliberately does not run on ours: the whole
guarantee is that no running ZIFFER component holds a key that could sign your
policy.
It ships as a bare binary per platform — no archive, nothing to unpack —
beside a SHA256SUMS file and an Ed25519 signature over it.
1.1 The three values, and where they come from
| Value | Example | Where you get it |
|---|---|---|
<version> | 0.1.0-0605fd0 | the handover sheet, and every release announcement. It is the CLI's crate version and the 7-character engine commit it was built from, so one string names one tree |
<target> | linux-x86_64 | your machine. The script below derives it |
| the base URL | https://github.com/ziffer-hq/ziffer-spec/releases/download/ziffer-0.1.0-0605fd0 | the tag is ziffer-<version>, so the base is that URL with your version in it |
Three targets exist and no others: darwin-arm64 (Apple Silicon), darwin-x86_64
(Intel Mac), linux-x86_64 (static, so it runs on any glibc or musl distribution).
1.2 Install it
Paste this whole block. It picks your target, downloads the binary, the checksum file and the signature, verifies the signature before it trusts the checksum, and verifies the checksum before the binary is made executable — a signing tool you did not verify is a supply chain you did not choose.
set -euo pipefail
VERSION=0.1.0-0605fd0 # from your handover sheet
BASE="https://github.com/ziffer-hq/ziffer-spec/releases/download/ziffer-${VERSION}"
case "$(uname -sm)" in
"Darwin arm64") TARGET=darwin-arm64 ;;
"Darwin x86_64") TARGET=darwin-x86_64 ;;
"Linux x86_64") TARGET=linux-x86_64 ;;
*) echo "no ziffer build for $(uname -sm) -- see section 1.5" >&2; exit 1 ;;
esac
WORK="$(mktemp -d)"
cd "$WORK"
curl -fsSL -O "${BASE}/ziffer-${VERSION}-${TARGET}"
curl -fsSL -O "${BASE}/SHA256SUMS"
curl -fsSL -O "${BASE}/SHA256SUMS.sig"
# The public key comes from the repository's main branch, NEVER from the release
# it verifies: a key fetched from the thing it checks is that thing asserting its
# own identity. Fetch it once, keep it, and compare it the next time.
curl -fsSL -o release-key.pub \
https://raw.githubusercontent.com/ziffer-hq/ziffer-spec/main/release-key.pubThen, in that same directory, the two checks — in this order:
# (1) the signature over the checksum file. Prints "Signature Verified
# Successfully" and exits 0; anything else means STOP.
openssl pkeyutl -verify -pubin -inkey release-key.pub -rawin \
-in SHA256SUMS -sigfile SHA256SUMS.sig
# (2) the checksum of the one binary you downloaded. Prints "<name>: OK".
grep " ziffer-${VERSION}-${TARGET}$" SHA256SUMS | shasum -a 256 -c -Only now install it:
sudo install -m 0755 "ziffer-${VERSION}-${TARGET}" /usr/local/bin/ziffer
ziffer --helpOn a Mac, openssl above must be OpenSSL 3, not the one in /usr/bin. macOS
ships LibreSSL 3.3, which cannot load an Ed25519 public key at all: it answers
unable to load Public Key / unsupported algorithm, which reads like a corrupt
key file and is not one. Install and use OpenSSL 3 explicitly:
brew install openssl@3
"$(brew --prefix openssl@3)/bin/openssl" pkeyutl -verify -pubin -inkey release-key.pub \
-rawin -in SHA256SUMS -sigfile SHA256SUMS.sigOn Ubuntu, Debian, Fedora and Alpine the packaged openssl is OpenSSL 3 and the
first form works as written. On Linux, shasum -a 256 -c - is equivalent to
sha256sum -c -; either is fine.
1.3 What the CLI does — the nine subcommands, in full
Ten lines, nine commands: decide has two mutually exclusive forms.
ziffer list <bundle-dir> [--suite <name>]
ziffer sign <bundle-dir> --key <file> [--suite <name>] [--tier T0|T1] [--env development|production]
ziffer verify <bundle-dir> --pubkey <file> --now <RFC3339> [--floor <name>] [--grace <seconds>] [--high-water <n>]
ziffer limits <bundle-dir> --pubkey <file> --now <RFC3339> [--floor <name>] [--grace <seconds>] [--high-water <n>]
ziffer decide <bundle-dir> --pubkey <file> --now <RFC3339> --proposal <file>
ziffer decide <bundle-dir> --unsigned --now <RFC3339> --proposal <file>
ziffer publish <bundle-dir> --pubkey <file> --now <RFC3339> --url <URL> --cert <pem> --key <pem> --cacert <pem>
ziffer epoch --url <URL> --cert <pem> --key <pem> --cacert <pem> --now <RFC3339>
ziffer pubkey --key <file> [--out <file>]
ziffer keygen --out <file>--nowis required wherever it appears and has no default. The tool never reads the host clock, so an expiry check is reproducible rather than dependent on which runner picked up the job. In a script that is always--now "$(date -u +%Y-%m-%dT%H:%M:%SZ)".--pubkeyis your own public key file, the documentziffer pubkeywrites (section 2.2). Every command that checks a signature takes it, includingpublish: your bundle is verified locally, under your own key, before a byte of it is sent.decide --unsignedis the one command that checks no signature, and it says so on its own first line:UNSIGNED: the signature was not checked; this is a lint of the tree, not a verdict on a bundle. Same grader, same clause names, same exit codes, and a malformed member is still refused; what it skips is the signature, because the pull request that runs it has edited members the signature does not cover yet.--unsignedand--pubkeyare mutually exclusive.policy-ci.mdsection 5 is where it belongs in a workflow.listneeds no key at all, so anyone on your team can always see what the next signature would cover.
1.4 Check the install
ziffer --help # names the nine subcommands above
ziffer list ./policy # your bundle's members and the tree hash a signature covers1.5 Anything that is not one of the three targets
There is no build, and there will not be one on request. A 32-bit machine, an ARM
Linux box, a BSD: run ziffer on a machine that is one of the three and move the
signed directory, or run it in a container on the linux-x86_64 image. The bundle
is bytes; the machine that signed it is not part of what we verify.
2. Your policy signing key
2.1 Generate it
umask 077
ziffer keygen --out ~/.ziffer/policy-signing.key(If you used the installer in section 0.1, this file is already there and this step is done.)
keygen reads the OS random source, writes the file at mode 0600, refuses to
overwrite a file that exists, and never prints a secret. What it prints is the
public document — the same one section 2.2 writes to a file.
Keep this file where you keep signing keys and nowhere else. It is not in your repository, not in a CI secret on the signing machine, not in a note. Every bundle this deployment ever activates for you is signed with it, and we never see it: a key that has left your machine is a key to rotate.
There is deliberately no --seed flag. Seeds are test key material, and a
signing tool that offered derived keys would be endorsing them for production.
2.2 Derive the public half, and commit it
ziffer pubkey --key ~/.ziffer/policy-signing.key \
--out ~/.ziffer/policy-signing.pub.jsonThat file has four fields — the two public halves in hex, a fingerprint over
both, and an identity object carrying the same two halves in the base64 encoding
an attester registry uses. Commit it exactly as printed: its top level is
precisely what --pubkey reads, so the file you commit is the file every command
above takes. The output is byte-stable, so two runs diff clean and a file that
changed means a key that changed.
Send us that same file — it is the enrolment document of
policy-ci.md section 2. It is public; there is nothing in it to protect.
3. The two certificate signing requests
You hold two client identities that reach us over mutual TLS: publish-ci, which
may replace policy, and executor-pull, which fetches your Executor's pending
work. We sign both certificates; you generate and keep both private keys, and
neither ever reaches us.
Run each command on the machine that will use the key — the CI runner for the
first, the Executor host for the second — and send us back only the .csr.
# on the CI runner
umask 077
mkdir -p ~/.ziffer/csr
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout ~/.ziffer/csr/publish-ci.key.pem -subj "/CN=publish-ci" \
-out ~/.ziffer/csr/publish-ci.csr# on the Executor host (pull mode only)
umask 077
mkdir -p ~/.ziffer/csr
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout ~/.ziffer/csr/executor-pull.key.pem -subj "/CN=executor-pull" \
-out ~/.ziffer/csr/executor-pull.csrSend publish-ci.csr and executor-pull.csr. Send nothing ending in .key.pem.
The installer in section 0.1 writes exactly these two pairs, with these names, in this directory, at mode 0600.
Two things about what comes back, so neither is a surprise:
- The subject you typed is discarded. We set the name our deployment pins —
publish-ci.<your tenant>.ziffer.internal— because the endpoint admits exactly one name and a certificate carrying yours would never complete a handshake. What the CSR actually carries across is your public key, and that is the whole point of it. - What you get back is the chain, your leaf followed by every issuer below the root, as one file. Store it as one file; it keeps working the day an intermediate signs.
The lifetimes are short and they are not negotiable per customer:
| Name | Lives | Renewed by | If you let it expire |
|---|---|---|---|
publish-ci | 30 days | ziffer-renew, before publish, once past half-life | you cannot renew at all — a renewal is presented over a still-valid leaf. Send a new CSR (section 3) |
executor-pull | 24 hours | the Executor, by itself, at half-life | same, and it is the credential that reads your pending work, so it is the short one on purpose |
4. The SDK, on your agent's host
Python:
pip install ziffer
python3 -c 'from ziffer import Client, verify; print("ok")'TypeScript:
npm install @ziffer-io/client@ziffer-io/client re-exports the verifier, so there is one verifyReceipt in
your dependency tree rather than two. If you verify receipts in a process that
never proposes anything, install @ziffer-io/verify alone.
The MCP server, if your agent speaks MCP:
npm install @ziffer-io/mcp
# or, with no install at all:
npx @ziffer-io/mcpAll four packages are at 0.1.0. sdk.md is the guide: section 7 is the Python
loop, section 8 the TypeScript one, section 9 the MCP path.
The SDK reads three environment variables and none of them belongs in your source tree — section 7 below says which handover row each comes from:
export ZIFFER_API_KEY=zfr_... # the key is your tenant
export ZIFFER_TRUST_ANCHOR=/path/to/anchor.json # never fetched over the API it verifies
export ZIFFER_SUITE_FLOOR=hybrid-ed25519-mldsa65
export ZIFFER_API_URL=https://... # the decision API5. CI
Copy publish-policy.yml into .github/workflows/ unchanged. It installs
ziffer per job at a pinned version and checksum, checks that your committed
public key is your signing key's, signs, verifies what it signed, and publishes
over mutual TLS. policy-ci.md section 5 explains every step; the file is the
thing you run.
What you set once, in Settings → Secrets and variables → Actions:
Variables (not secrets — none of these is one):
| Variable | Value | From |
|---|---|---|
ZIFFER_CLI_URL | https://github.com/ziffer-hq/ziffer-spec/releases/download/ziffer-<version> | section 1.1 — it is the base URL, with the tag in it |
ZIFFER_CLI_VERSION | e.g. 0.1.0-0605fd0 | your handover sheet |
ZIFFER_CLI_SHA256 | the linux-x86_64 line of SHA256SUMS | the same SHA256SUMS you verified in section 1.2 |
ZIFFER_PUBLISH_URL | https://<publisher address> | handover row ZIFFER_PUBLISH_URL |
ZIFFER_RENEW_SHA256 | the linux-x86_64 line of SHA256SUMS, naming ziffer-renew rather than ziffer | the same SHA256SUMS you verified in section 1.2 |
ZIFFER_ISSUER_ADDR | our certificate issuer's host:port | your handover sheet |
ZIFFER_ISSUER_NAME | the dNSName the issuer's certificate presents | your handover sheet |
# the checksum to paste into ZIFFER_CLI_SHA256, from the SHA256SUMS you verified:
grep " ziffer-${VERSION}-linux-x86_64$" SHA256SUMS | cut -d' ' -f1
# and into ZIFFER_RENEW_SHA256, the same file's ziffer-renew line:
grep " ziffer-renew-${VERSION}-linux-x86_64$" SHA256SUMS | cut -d' ' -f1Secrets, in a gated environment (ziffer-production) rather than
repository-wide — a repository secret is readable from every workflow in the
repository, including one added by a pull request nobody has read yet:
| Secret | What |
|---|---|
POLICY_SIGNING_KEY | the whole contents of policy-signing.key |
ZIFFER_CLIENT_CERT | the publish-ci chain we signed and returned |
ZIFFER_CLIENT_KEY | your own publish-ci.key.pem — generated in section 3, never sent to us |
ZIFFER_CA | our root, handover row ZIFFER_CA |
ZIFFER_SECRETS_TOKEN | a fine-grained personal access token, scoped to "Secrets: read and write" on this one repository and nothing else |
6. The renewal task somebody has to own
The scheduled renew job in publish-policy.yml does this for you now. Section 5's copy
of the workflow carries a second job that runs on a fortnightly schedule (and by hand, from the
Actions tab), renews the publish-ci leaf while it is still valid, and writes the renewed pair
back to ZIFFER_CLIENT_CERT / ZIFFER_CLIENT_KEY itself -- under ZIFFER_SECRETS_TOKEN
(section 5's secrets table), a token scoped to Secrets alone, because the default
GITHUB_TOKEN cannot write a repository secret at any permissions: setting. The task you
own is no longer running the renewal: it is a fortnightly calendar entry to confirm the
scheduled renew run passed -- the Actions tab, or a failure notification if you have one
wired up.
By hand, as the fallback -- for a repository that has not adopted the scheduled job, or for
a renewal you want to run yourself. ziffer-renew holds no key of any kind: given your current
certificate and key and the issuer's address, it generates a fresh key, asks for a certificate
over the still-valid leaf, proves the answer goes with the key it made, and writes the new
pair to paths you name -- never over the inputs, so a failed run leaves you exactly what you
had.
It ships in the same release as ziffer, under the same tag and the same
SHA256SUMS, with its own version string (its crate version plus our
repository's commit, so it is named after the tree it was built from):
curl -fsSL -O "${BASE}/ziffer-renew-${RENEW_VERSION}-${TARGET}"
grep " ziffer-renew-${RENEW_VERSION}-${TARGET}$" SHA256SUMS | shasum -a 256 -c -
sudo install -m 0755 "ziffer-renew-${RENEW_VERSION}-${TARGET}" /usr/local/bin/ziffer-renewziffer-renew --addr <issuer host:port> --name <issuer dNSName> --ca ziffer-ca.pem \
--cert publish-ci.crt.pem --key publish-ci.key.pem \
--out-cert publish-ci.new.pem --out-key publish-ci.new.keyIt renews only a leaf past its half-life and refuses an expired one by name, so running it more often than needed is safe and cheap.
If you are running it by hand, put this in a calendar, every two weeks, owned by a named person:
ZIFFER: renew the publish-ci leaf. Run
ziffer-renewon the machine where the CI secrets are kept, then updateZIFFER_CLIENT_CERTandZIFFER_CLIENT_KEYin theziffer-productionenvironment from its two output files.
Fortnightly, against a 30-day leaf, means a missed cycle still leaves you two weeks -- true of the scheduled job's cadence as well as the by-hand one.
The failure this prevents is silent. A repository whose leaf has gone unrenewed for 30
days holds an expired one, and the next publish fails at the handshake with no answer from us
to read. Since the CLI refuses it as ClientCertExpired with the renewal command in the
message, you at least get told which of the two it is -- but by then you cannot renew, and the
fix is a new CSR (section 3).
The Executor's leaf needs no calendar entry: the Executor renews it itself, at half-life, because it is always connected.
7. Where every value comes from
Every value you are asked to set appears by this name on the handover sheet a person hands you out of band at onboarding. Nothing here is guessable and nothing should be typed from memory.
| You set | Handover sheet row | Goes where |
|---|---|---|
ZIFFER_API_KEY | ZIFFER_API_KEY — printed once, stored nowhere on our side | your agent's environment |
ZIFFER_TRUST_ANCHOR | ZIFFER_TRUST_ANCHOR — the anchor.json file | your agent's host |
ZIFFER_API_URL | ZIFFER_API_URL | your agent's environment |
ZIFFER_SUITE_FLOOR | ZIFFER_SUITE_FLOOR | your agent's environment |
ZIFFER_PUBLISH_URL | ZIFFER_PUBLISH_URL (with the SNI name beside it) | CI variable |
ZIFFER_CA | ZIFFER_CA — our root | CI secret, and the Executor's ZIFFER_EXECUTOR_PEER_CA |
ZIFFER_CLIENT_CERT | ZIFFER_CLIENT_CERT — the chain we signed from your CSR | CI secret |
ZIFFER_CLIENT_KEY | not on the sheet. It is yours, from section 3 | CI secret |
ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256 | the release announcement and the SHA256SUMS you verified | CI variables |
ZIFFER_RENEW_SHA256 | the same SHA256SUMS, ziffer-renew's row | CI variable |
ZIFFER_ISSUER_ADDR, ZIFFER_ISSUER_NAME | the two rows of the same names | CI variables |
ZIFFER_SECRETS_TOKEN | not on the sheet. You create it (Settings -> Developer settings -> Personal access tokens -> Fine-grained), scoped to "Secrets: read and write" on this repository alone | CI secret |
ZIFFER_EXECUTOR_PULL_ADDR, ..._NAME, ..._INTERVAL_MS | the three rows of the same names — all three or none | the Executor host (executor.md) |
| your tenant id | the sheet's title line, and inside every receipt | manifest.json's tenant_id |
If a row is missing from your sheet, ask — do not infer it. Every value on that sheet was read out of the deployment that will answer for it; a plausible value reads exactly like a checked one, and the failures it causes appear at a handshake or as a signature that does not verify.
8. Staging and production are two tenants, and two sets of values
A staging tenant is a separate enrolment: its own tenant id, its own signing
key, its own publish-ci leaf, its own API key and its own trust anchor. That is
not an inconvenience we have not got round to removing — your CI leaf is
publish-ci.<tenant>.ziffer.internal and the publisher takes the tenant from that
name, so one leaf publishes for exactly one tenant and a bundle whose manifest
names another is refused TENANT_MISMATCH. One key that could publish to both is
the thing that lets a staging mistake change production policy.
What that means concretely: everything in sections 2, 3, 5 and 7 exists twice.
ziffer itself does not — one binary serves both, because it holds nothing.
| Thing | Staging | Production |
|---|---|---|
| tenant id | <name>-sandbox | <name> |
| signing key | its own policy-signing.key | its own |
POLICY_SIGNING_KEY, ZIFFER_CLIENT_CERT, ZIFFER_CLIENT_KEY | environment ziffer-staging | environment ziffer-production |
ZIFFER_PUBLISH_URL, ZIFFER_API_URL, ZIFFER_API_KEY, ZIFFER_TRUST_ANCHOR | the staging sheet's rows | the production sheet's rows |
ZIFFER_CLI_URL, ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256 | the same values | the same values |
The workflow reads its secrets from the environment: it names, so the way you
point a workflow at staging rather than production is the environment: line and
the on: branch filter — not a flag and not a variable. Two workflow files, two
environments, two sets of secrets, one binary.
A sandbox tenant's receipts must never verify in production. A sandbox tenant
approves with a k=1 robot approver, which means no human ever saw the action;
sandbox.md says what else differs. It is for integrating before any person is
enrolled, and it is clearly labelled everywhere it appears.
9. Checklist
-
ziffer --helpruns on the signing machine and names nine subcommands. -
SHA256SUMS.sigverified againstrelease-key.pubfetched from the repository's main branch, and the binary's line checked, before it was installed. -
~/.ziffer/policy-signing.keygenerated withziffer keygen, mode 0600, on the signing machine, and backed up where you back up signing keys. -
~/.ziffer/policy-signing.pub.jsoncommitted exactly asziffer pubkeyprinted it, and the same file sent to us. - Two CSRs sent; two
.key.pemfiles still on the machines that made them and nowhere else. -
publish-policy.ymlcopied in unchanged; seven variables and five secrets set, the secrets in a gated environment. Section 5's tables are the list -- three of the variables and one of the secrets exist only for therenewjob. -
pip install zifferornpm install @ziffer-io/clienton the agent's host, and the three environment variables set outside the source tree. - A fortnightly calendar entry to confirm the scheduled
renewjob passed, with a named owner (section 6;ziffer-renewby hand is the fallback). - If you have a staging tenant: everything above, twice, with different values
and a different
environment:.
10. What happens after this
after-install.md is the next chapter: upgrading the CLI and moving the pin in
CI, upgrading the SDKs, the fortnightly certificate renewal, rotating your policy
signing key and your API key, what to do when we publish a release or a security
notice, where the three secrets are backed up, uninstalling — and a section on the
security of the install itself, including how to re-verify at any time that the
binary on your machine is the one we published.
Synced from ziffer docs/onboarding/install.md at 32bc4db; edit the source, never this page.