ZIFFER home

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:

MachineWhat goes on itWhy that machine
The signing machine — one laptop, one person (checklist item 1.3)the ziffer CLI, your policy signing keythe key that signs your policy never leaves it, and no ZIFFER host ever holds one
Your CI runnerthe ziffer CLI (installed per job, pinned by checksum), ziffer-renew, the publish-ci key and certificateit publishes policy over mutual TLS; it holds a signing key only for the length of one job
Your agent's hostthe SDK (ziffer for Python, @ziffer-io/client for TypeScript)it proposes actions and verifies receipts
Your Executor host — pull mode onlyziffer-renew, the executor-pull key and certificateit 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 needs sudo for 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 ~/.ziffer in /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.

ProgramMinimumNeeded formacOS 13+Linux x86_64 (glibc or musl)
curlanyfetching the release and the verifying keypreinstalledsudo apt-get install -y curl / sudo dnf install -y curl / apk add curl
openssl3.0verifying the release signature (section 1.2) and generating the two CSRs (section 3)brew install openssl@3the preinstalled /usr/bin/openssl is LibreSSL and will not dopreinstalled on Ubuntu 22.04+, Debian 12+, Fedora 36+, Alpine 3.16+
shasum or sha256sumanychecking the binary against the signed checksumpreinstalledpreinstalled (coreutils)
gitanyyour policy repository, and the installer's check that it is not writing a private key inside onexcode-select --installsudo apt-get install -y git
python3 and pip3.10the Python SDK onlybrew install pythonsudo apt-get install -y python3 python3-pip
node and npm22the TypeScript SDK onlybrew install nodecurl -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 | sh

It 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 wantPass
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.

RefusalWhat it means, and what to do
WindowsNeedsWslthere 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
OpenSslTooOldLibreSSL, or OpenSSL 1.x. brew install openssl@3 on a Mac
PythonTooOld / NodeTooOldthe SDK floor is Python 3.10 and Node 22. Upgrade, or pick the other --lang
NotInteractiveno terminal to ask on. Pass --yes, or run it as sh -c "$(curl -fsSL <url>)"
InsecureBaseUrla download URL that is not https. There is no flag that permits one
LatestUnresolvedthe releases API did not answer, or carried no tag. Pass --version from your handover sheet
ReleaseNotFoundno release at that version, or one with no SHA256SUMS.sig. Check the version; an unsigned handout is not ours
ReleaseSignatureInvalidstop. 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
ChecksumMismatchthe binary does not hash to the value signed for it. Nothing was installed
BinaryDoesNotRunthe 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>
InstallDirNotWritablethe --prefix you named cannot be written and sudo was not used. Name one you own
KeyFileExistsa signing key is already there and this script never replaces one. Move it aside yourself if you meant to
CsrFileExistssame rule for a certificate request's private key
KeyDirInsideRepositoryyou are about to write a private key inside a git repository. Choose a path outside it
KeyFileNotPrivateziffer keygen wrote the key at a mode other than 0600. chmod 0600 it and tell us — keygen is supposed to do that itself
KeygenFailed / PubkeyFailedthe CLI itself failed. Run the command it printed by hand and send us the output
CsrFailedopenssl 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
NoPackageJsonthe 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
AnswerNotUnderstooda 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

ValueExampleWhere you get it
<version>0.1.0-0605fd0the 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_64your machine. The script below derives it
the base URLhttps://github.com/ziffer-hq/ziffer-spec/releases/download/ziffer-0.1.0-0605fd0the 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.pub

Then, 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 --help

On 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.sig

On 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>
  • --now is 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)".
  • --pubkey is your own public key file, the document ziffer pubkey writes (section 2.2). Every command that checks a signature takes it, including publish: your bundle is verified locally, under your own key, before a byte of it is sent.
  • decide --unsigned is 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. --unsigned and --pubkey are mutually exclusive. policy-ci.md section 5 is where it belongs in a workflow.
  • list needs 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 covers

1.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.json

That 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.csr

Send 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:

NameLivesRenewed byIf you let it expire
publish-ci30 daysziffer-renew, before publish, once past half-lifeyou cannot renew at all — a renewal is presented over a still-valid leaf. Send a new CSR (section 3)
executor-pull24 hoursthe Executor, by itself, at half-lifesame, 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/mcp

All 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 API

5. 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):

VariableValueFrom
ZIFFER_CLI_URLhttps://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_VERSIONe.g. 0.1.0-0605fd0your handover sheet
ZIFFER_CLI_SHA256the linux-x86_64 line of SHA256SUMSthe same SHA256SUMS you verified in section 1.2
ZIFFER_PUBLISH_URLhttps://<publisher address>handover row ZIFFER_PUBLISH_URL
ZIFFER_RENEW_SHA256the linux-x86_64 line of SHA256SUMS, naming ziffer-renew rather than zifferthe same SHA256SUMS you verified in section 1.2
ZIFFER_ISSUER_ADDRour certificate issuer's host:portyour handover sheet
ZIFFER_ISSUER_NAMEthe dNSName the issuer's certificate presentsyour 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' ' -f1

Secrets, 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:

SecretWhat
POLICY_SIGNING_KEYthe whole contents of policy-signing.key
ZIFFER_CLIENT_CERTthe publish-ci chain we signed and returned
ZIFFER_CLIENT_KEYyour own publish-ci.key.pem — generated in section 3, never sent to us
ZIFFER_CAour root, handover row ZIFFER_CA
ZIFFER_SECRETS_TOKENa 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-renew
ziffer-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.key

It 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-renew on the machine where the CI secrets are kept, then update ZIFFER_CLIENT_CERT and ZIFFER_CLIENT_KEY in the ziffer-production environment 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 setHandover sheet rowGoes where
ZIFFER_API_KEYZIFFER_API_KEY — printed once, stored nowhere on our sideyour agent's environment
ZIFFER_TRUST_ANCHORZIFFER_TRUST_ANCHOR — the anchor.json fileyour agent's host
ZIFFER_API_URLZIFFER_API_URLyour agent's environment
ZIFFER_SUITE_FLOORZIFFER_SUITE_FLOORyour agent's environment
ZIFFER_PUBLISH_URLZIFFER_PUBLISH_URL (with the SNI name beside it)CI variable
ZIFFER_CAZIFFER_CA — our rootCI secret, and the Executor's ZIFFER_EXECUTOR_PEER_CA
ZIFFER_CLIENT_CERTZIFFER_CLIENT_CERT — the chain we signed from your CSRCI secret
ZIFFER_CLIENT_KEYnot on the sheet. It is yours, from section 3CI secret
ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256the release announcement and the SHA256SUMS you verifiedCI variables
ZIFFER_RENEW_SHA256the same SHA256SUMS, ziffer-renew's rowCI variable
ZIFFER_ISSUER_ADDR, ZIFFER_ISSUER_NAMEthe two rows of the same namesCI variables
ZIFFER_SECRETS_TOKENnot on the sheet. You create it (Settings -> Developer settings -> Personal access tokens -> Fine-grained), scoped to "Secrets: read and write" on this repository aloneCI secret
ZIFFER_EXECUTOR_PULL_ADDR, ..._NAME, ..._INTERVAL_MSthe three rows of the same names — all three or nonethe Executor host (executor.md)
your tenant idthe sheet's title line, and inside every receiptmanifest.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.

ThingStagingProduction
tenant id<name>-sandbox<name>
signing keyits own policy-signing.keyits own
POLICY_SIGNING_KEY, ZIFFER_CLIENT_CERT, ZIFFER_CLIENT_KEYenvironment ziffer-stagingenvironment ziffer-production
ZIFFER_PUBLISH_URL, ZIFFER_API_URL, ZIFFER_API_KEY, ZIFFER_TRUST_ANCHORthe staging sheet's rowsthe production sheet's rows
ZIFFER_CLI_URL, ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256the same valuesthe 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 --help runs on the signing machine and names nine subcommands.
  • SHA256SUMS.sig verified against release-key.pub fetched from the repository's main branch, and the binary's line checked, before it was installed.
  • ~/.ziffer/policy-signing.key generated with ziffer keygen, mode 0600, on the signing machine, and backed up where you back up signing keys.
  • ~/.ziffer/policy-signing.pub.json committed exactly as ziffer pubkey printed it, and the same file sent to us.
  • Two CSRs sent; two .key.pem files still on the machines that made them and nowhere else.
  • publish-policy.yml copied 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 the renew job.
  • pip install ziffer or npm install @ziffer-io/client on the agent's host, and the three environment variables set outside the source tree.
  • A fortnightly calendar entry to confirm the scheduled renew job passed, with a named owner (section 6; ziffer-renew by 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.

On this page