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.
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 AI 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 AI 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 to publish, and three more variables and one more secret for the
scheduled certificate renewal, 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. If a device cannot register, tell us rather
than working around it.
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, in pull mode. We set pull mode up with you at
onboarding: write to hello@ziffer.io. The host 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 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 lists every row by name
and says where each one goes; your tenant id is the sheet's title line. Nothing
there is guessable. The sheet's ZIFFER_CLIENT_KEY row names a key that is
yours, from section 3, and never reaches us. The tool versions are not on the
sheet: they are the release's own, read off the release you verify in section 1.2.
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.
| 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 the release announcement: the part of the tag after ziffer- |
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 AI 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 release announcement: the release's tag is ziffer-<version>. It is the CLI's crate version and the 7-character engine commit it was built from, so one string names one tree. It is not on the handover sheet, which is written before the release you install is chosen |
<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 are published, 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). They are the only three install.sh and the block below know. A
release holds only the builds it was made with and lists them in its SHA256SUMS.
If yours has no line for your target,
install.sh refuses with ArtefactNotInRelease: <name> and the block below stops at
its first download: write to us with the version.
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 the release announcement
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. It also skips every check a load makes (expiry, epoch, tenant, the registry's quorum, the two doors, the receipt identity, the limits' bounds), so a tree can lint clean here and still be refused at publish.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 published 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 AI 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 AI agent speaks MCP:
npm install @ziffer-io/mcp
# or, with no install at all:
npx @ziffer-io/mcp@ziffer-io/client, @ziffer-io/verify and @ziffer-io/mcp are at 0.3.0 and
ziffer is at 0.3.0, as each one's own manifest declares it. sdk.md is the guide: section 8 is the Python
loop, section 9 the TypeScript one, section 10 the MCP path.
The SDK reads four 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/ and change nothing in it but your policy
folder. If your bundle does not live in policy/, set POLICY_DIR to its folder and the
paths: filter beside it to the same folder; the file's header names that value, and nothing
else in it is yours to edit. 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 | the release you verified in section 1.2: the part of its tag after ziffer-, which is also in its SHA256SUMS file names |
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_VERSION | e.g. 0.1.1-f6f9197 — ziffer-renew's own version, not ZIFFER_CLI_VERSION | the same SHA256SUMS: the part of ziffer-renew's file name between ziffer-renew- and the target. It is built from a different tree than ziffer, so the two versions differ, and the release notes print it under this name |
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
# ziffer-renew's own version, for ZIFFER_RENEW_VERSION, read off its file name:
RENEW_VERSION="$(sed -n 's/^[0-9a-f]\{64\} ziffer-renew-\(.*\)-linux-x86_64$/\1/p' SHA256SUMS)"
echo "$RENEW_VERSION"
# and into ZIFFER_RENEW_SHA256, the same file's ziffer-renew line:
grep " ziffer-renew-${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. 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 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 AI agent's environment |
ZIFFER_TRUST_ANCHOR | ZIFFER_TRUST_ANCHOR — the anchor.json file | your AI agent's host |
ZIFFER_API_URL | ZIFFER_API_URL | your AI agent's environment |
ZIFFER_SUITE_FLOOR | ZIFFER_SUITE_FLOOR | your AI 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 | ZIFFER_CLIENT_KEY — the row says the key is yours, from section 3; its value never reaches us | CI secret |
ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256 | the release announcement and the SHA256SUMS you verified | CI variables |
ZIFFER_RENEW_VERSION, ZIFFER_RENEW_SHA256 | the same SHA256SUMS, ziffer-renew's row: the version in its file name, and its hash | CI variables |
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 |
ZIFFER_EXECUTOR_PEER_CA | ZIFFER_EXECUTOR_PEER_CA — our root, the same file as ZIFFER_CA | the Executor host |
ZIFFER_EXECUTOR_POLICY_ADDR, ..._NAME | the two rows of the same names, beside the pull rows: where your Executor asks for the release receipt after a held action runs. Its certificate and key are your pull pair's files | the Executor host |
ZIFFER_APPROVAL_ADDR, ZIFFER_APPROVAL_NAME | the two rows of the same names: a public host name and port where your administrator creates invitations, and the name the service's certificate is checked against (approvers.md section 2) | your administrator's invite-approver.sh --addr and --san |
| 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 with nothing changed butPOLICY_DIRand itspaths:filter, if your policy folder is notpolicy/; eight variables and five secrets set, the secrets in a gated environment. Section 5's tables are the list -- four of the variables and one of the secrets exist only for therenewjob. - Your administrator's
tenant-adminsigning request sent, its key still on their machine, andinvite-approver.shverified from the release (section 11). -
pip install zifferornpm install @ziffer-io/clienton the AI agent's host, and the four 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.
11. invite-approver.sh, on your administrator's machine
Your administrator invites each approver with one command, invite-approver.sh
(approvers.md section 2). It ships in the same release as ziffer, as its own
file, and it is in the same SHA256SUMS under the same signature, so it is
checked exactly the way section 1.2 checks the CLI: the signature first, then the
checksum, then it runs.
What the machine needs. bash, python3 (the standard library only, nothing
to install from a package index), and a curl built with HTTP/2 and TLS 1.3:
curl --version must list HTTP2 among its features. The curl that comes with
macOS (8.7.1, LibreSSL) is one, and so is the one current Linux distributions ship. It does not run on Windows
without a Unix shell (WSL, for example).
Where it connects. Over the internet, to the host name and port on the
ZIFFER_APPROVAL_ADDR row of your handover sheet (port 443, the one most
company networks let out). Nothing on your side needs to be opened for it.
set -euo pipefail
VERSION=0.1.0-0605fd0 # the same release as your ziffer CLI
BASE="https://github.com/ziffer-hq/ziffer-spec/releases/download/ziffer-${VERSION}"
curl -fsSL -O "${BASE}/invite-approver.sh"
curl -fsSL -O "${BASE}/SHA256SUMS"
curl -fsSL -O "${BASE}/SHA256SUMS.sig"
curl -fsSL -o release-key.pub \
https://raw.githubusercontent.com/ziffer-hq/ziffer-spec/main/release-key.pub
# (1) the signature over the checksum file, before anything else.
openssl pkeyutl -verify -pubin -inkey release-key.pub -rawin \
-in SHA256SUMS -sigfile SHA256SUMS.sig
# (2) the checksum of the script. Prints "invite-approver.sh: OK".
grep " invite-approver.sh$" SHA256SUMS | shasum -a 256 -c -
chmod 0755 invite-approver.sh
./invite-approver.sh --helpOn a Mac, openssl above is OpenSSL 3, for the reason section 1.2 gives.
./invite-approver.sh --selfcheck is not for a downloaded copy: there it answers
SourceTreeAbsent, by design, and the checksum above is what tells you the copy is ours.