ZIFFER home

After install: maintenance, upgrades, security

For the customer, and for the one person who owns the signing machine.

For the customer, and for the one person who owns the signing machine. Chapter 3 of the handbook, after customer-checklist.md and install.md. 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.

Installing is a day. This is the rest of it. Six of the seven things below are calendar items, and the seventh — reading a release notice — is the one that tells you when the others come early.

The one-line version:

WhenDo this
every 14 daysthe scheduled renew job in CI does it; confirm it passed (fallback: ziffer-renew by hand, section 3)
before day 90rotate your API key: successor first, revoke second (section 5)
on every release noticeinstall.sh --upgrade, then move the CI pin (section 1)
whenever you wantinstall.sh --check — is the binary on this machine the one we published (section 9)
never on a schedulerotate the policy signing key. It is event-driven (section 4)

1. Upgrading the ziffer CLI

On your signing machine, one command. It resolves the latest release, verifies its signature before downloading anything, checks the checksum, replaces the binary in place and prints the old and new versions:

curl -fsSL https://github.com/ziffer-hq/ziffer-spec/releases/latest/download/install.sh | sh -s -- --upgrade

Or, if you kept the script:

sh install.sh --upgrade              # the latest release
sh install.sh --upgrade --version 0.1.0-0605fd0   # a named one

It refuses rather than half-upgrades. ReleaseSignatureInvalid and ChecksumMismatch both stop before anything on your PATH is touched, and --upgrade on a machine that is already current prints already at <version> and downloads nothing at all.

Then move the pin in CI, deliberately, as a separate act. Your workflow installs ziffer per job at a pinned version and a pinned checksum (install.md section 5), which is the point: a runner that fetched "whatever is newest" would be a runner whose signing tool changed without a commit. The two values come from the release you just installed:

VERSION=<the version --upgrade printed>
BASE="https://github.com/ziffer-hq/ziffer-spec/releases/download/ziffer-${VERSION}"
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
openssl pkeyutl -verify -pubin -inkey release-key.pub -rawin -in SHA256SUMS -sigfile SHA256SUMS.sig
grep " ziffer-${VERSION}-linux-x86_64$" SHA256SUMS | cut -d' ' -f1    # -> ZIFFER_CLI_SHA256

Set ZIFFER_CLI_VERSION, ZIFFER_CLI_SHA256 and (if the tag base changed) ZIFFER_CLI_URL in Settings → Secrets and variables → Actions → Variables.

When to move it. Not on a schedule, and not the hour a release lands. Move it when a release notice says the CLI's behaviour changed in a way you want — a new subcommand, a refusal you have been hitting, a fix named in the notes — or when a security notice says to (section 6). A pinned older CLI signs perfectly valid bundles; what it cannot do is produce a signature over a bundle shape it does not know about, and the release notice says when that is the case.

Your signing machine and your CI runner may sit on different versions, and that is fine. They are checked against each other every run: the workflow re-derives your public key from the signing key in POLICY_SIGNING_KEY and diffs it against the file committed in your repository. What is not fine is two people on your team signing with two different keys — the diff catches that too, and it is the only step whose purpose is to notice a rotated key.

2. Upgrading the SDKs

Python:

pip install -U ziffer
python3 -c 'import ziffer; print(ziffer.__version__)'

TypeScript, in your agent's project:

npm update @ziffer-io/client        # and @ziffer-io/mcp, if you use it
npm ls @ziffer-io/client

@ziffer-io/client re-exports the verifier, so there is one verifyReceipt in your dependency tree rather than two. If you have @ziffer-io/verify pinned separately, update it in the same commit — two verifiers at two versions is the one dependency shape that can make a receipt verify in one code path and not the other.

Read the release notes before you upgrade a verifier, not after. A verifier that gets stricter refuses receipts it used to accept, which is the correct behaviour and is still a change to your runtime. The notes for every release are on the release page in ziffer-hq/ziffer-spec, and every entry that changes a refusal says so in its first line.

Pin the SDK the way you pin everything else. A lockfile, committed. pip install -U on a deploy is the same defect as an unpinned CLI in CI.

3. The fortnightly certificate renewal

This is the item that bites, because its failure is silent and its fix after the fact is a new certificate signing request. Your publish-ci leaf lives 30 days. ziffer-renew replaces it, but only while it is still valid: a renewal is presented over the leaf it replaces, so an expired one cannot renew itself.

If your policy repository uses the publish-policy.yml we hand out, this is now automatic. A second job in that file, renew, does exactly what follows on a fortnightly schedule and writes the renewed pair back itself; install.md section 6 is what changes for you. Everything below is the fallback -- for a repository that has not adopted the scheduled job, or for a renewal you want to run yourself.

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.

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 generates a fresh key, asks for a certificate over the still-valid leaf, proves the answer goes with the key it made, and writes to new paths — never over its inputs, so a failed run leaves you exactly what you had. It renews only a leaf past its half-life and refuses an expired one by name, so running it more often than necessary is safe and cheap.

Then paste the two new files into the environment's secrets and delete the old pair from the machine.

Fortnightly against a 30-day leaf means a missed cycle still leaves you two weeks. That margin is the whole reason for the cadence.

If you let it expire: the next publish fails at the TLS handshake. The CLI refuses it as ClientCertExpired and puts the renewal command in the message, so you at least learn which of the two problems it is — but by then you cannot renew, and the fix is a new CSR (install.md section 3, or install.sh --only csr) and a new certificate from us.

Your Executor's leaf needs no calendar entry. It lives 24 hours and the Executor renews it itself at half-life, because it is always connected. That is the short one on purpose: it is the credential that reads your pending work.

4. Your policy signing key

There is no rotation schedule, and that is deliberate. This key signs your policy bundle and nothing else; it is offline, it is on one machine, it never touches the network, and a calendar-driven rotation of it would buy you nothing while giving you a recurring opportunity to get it wrong. Rotate it on an event, not on a date:

  • the person who held it leaves, or changes machine;
  • the machine is lost, stolen, sold, or repaired by somebody else;
  • the file was copied anywhere it should not have been — a repository, a chat, a backup you do not control, a screenshot;
  • we tell you to, in a security notice.

Rotating it, in order. Nothing you have already published stops working: the bundle serving you is signed and verified and keeps serving, every bundle you published stays valid, and the epoch does not reset. What you cannot do between step 1 and step 4 is publish.

# 1. a NEW key, beside the old one -- never over it
umask 077
ziffer keygen --out ~/.ziffer/policy-signing.new.key
ziffer pubkey --key ~/.ziffer/policy-signing.new.key --out policy-signing.pub.json

# 2. send us policy-signing.pub.json, out of band. It is public
# 3. raise bundle_epoch in policy/manifest.json above the one we hold for you --
#    `ziffer epoch` prints that integer -- and re-sign. Your policy does not
#    change; only the epoch and the signature over it do
ziffer epoch --url "$ZIFFER_PUBLISH_URL" --cert publish-ci.crt.pem \
             --key publish-ci.key.pem --cacert ziffer-ca.pem \
             --now "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
ziffer sign ./policy --key ~/.ziffer/policy-signing.new.key
  1. Send us the signed directory. We re-place your subtree under the new public key — the composer verifies your bundle under that key before a byte of it is copied, so a mismatch is refused and nothing is laid out.
  2. Only then: replace POLICY_SIGNING_KEY in your CI environment, commit the new policy-signing.pub.json, and destroy the old key file.

If you have LOST it (no copy anywhere), the procedure is the same from step 1: generate a new one and tell us. Write to hello@ziffer.io with your tenant id and say the key is lost. Four things are true and worth hearing straight away: nothing breaks right now; everything you already published stays valid; you cannot publish until the new key is placed; and if it was stolen rather than lost, say so — a thief with your signing key and your CI leaf could publish policy in your name, and that conversation is faster and starts with revoking the leaf.

There is no automated re-enrolment for this yet. It is an operator running a documented procedure on our side, and we would rather say so than let you discover it at the worst moment.

5. Your API key

Your key (zfr_…) is your tenant and it expires — 90 days by default. Every API call tells you the expiry date, so the deadline is never a surprise. Rotate successor first, revocation second, and never the other way round:

  1. Ask us for the successor (hello@ziffer.io, or your usual channel). It is issued out of band, once, and is not stored on our side.
  2. Deploy it: set ZIFFER_API_KEY in your agent's environment and roll your processes. Both keys are live at this point, which is the whole point of the order.
  3. Confirm traffic is being decided under the new one.
  4. Then ask us to revoke the old one.

A stolen API key can propose actions as you and read your decisions. It cannot approve, release, change policy, or act on your systems — but rotate it immediately anyway, and tell us, because the proposals it can make are yours.

6. When we publish a release, or a security notice

A release notice names the version, what changed, and whether any refusal moved. Do this, in order:

sh install.sh --upgrade        # or the curl form in section 1
sh install.sh --check          # confirm the machine now holds the published bytes

Then move the CI pin (section 1) when the notes give you a reason to, and update the SDKs (section 2) in a commit of their own.

A security notice is different and says so in its first line. It names what to do and by when, and it is the one case where "upgrade now, read later" is the right order. If it concerns the release signing key, it will tell you to re-fetch release-key.pub from the main branch of ziffer-hq/ziffer-spec and compare it against the copy you kept, which is why section 9 tells you to keep one.

We do not push anything to your machines and never will. There is no auto-update, no agent phoning home, no background service. Every upgrade above is something a person on your side ran.

7. Backups: three secrets, and where they must never go

Exactly three files never leave your machines, and all three are at mode 0600:

FileLives onIf you lose it
~/.ziffer/policy-signing.keythe signing machinesection 4 — re-enrol the key, nothing published breaks
~/.ziffer/csr/publish-ci.key.pemthe machine holding your CI secretsnew CSR, new certificate from us
~/.ziffer/csr/executor-pull.key.pemthe Executor hostnew CSR, new certificate from us
ls -l ~/.ziffer/policy-signing.key ~/.ziffer/csr/*.key.pem   # all three must read -rw-------
chmod 0600 ~/.ziffer/policy-signing.key ~/.ziffer/csr/*.key.pem

Back them up where you back up signing keys — an offline password manager, an encrypted volume, a sealed envelope in a safe. Test the restore once; a backup nobody has restored is a belief, not a backup.

Never put any of the three in: a git repository (even a private one), a CI log or build artefact, a chat message, a support ticket, a screenshot, a synced cloud folder, or a shared drive. The public halves — policy-signing.pub.json, the two .csr files — are the opposite: commit them, send them, there is nothing in them to protect.

CI is the exception that proves the rule. POLICY_SIGNING_KEY and ZIFFER_CLIENT_KEY are in your CI secrets, because CI signs and publishes. Put them in a gated environment (ziffer-production), never repository-wide: a repository secret is readable from every workflow in the repository, including one added by a pull request nobody has read yet. And never echo one in a step — GitHub masks a registered secret in logs, but it cannot mask it after your script has base64'd it.

8. Uninstalling

sh install.sh --uninstall

It removes the binary and the install record, then shows you what is left in ~/.ziffer and asks — once, explicitly — whether to delete it. The default is no, and --uninstall --yes therefore keeps it, because --yes means "take every default" and the default for a key that cannot be recovered is to keep it.

Deleting ~/.ziffer deletes your policy signing key. Everything you ever signed verifies under that key and under nothing else; we do not hold a copy and cannot recover it. If you mean to, do it deliberately:

rm -rf ~/.ziffer          # after you have read section 4

Uninstalling changes nothing on our side. Your tenant, your policy and your API key are unaffected; tell us if you want them retired.

9. Security of the install itself

curl … | sh is acceptable here for exactly one reason, and it is worth being able to state it. The script does not ask you to trust it because it came from us; it verifies the release with a key that is not in the release:

  1. It downloads SHA256SUMS, SHA256SUMS.sig, and release-key.pub — the last from https://raw.githubusercontent.com/ziffer-hq/ziffer-spec/main/release-key.pub, the main branch, never the release.
  2. It runs exactly this, and stops if it does not print Signature Verified Successfully:
    openssl pkeyutl -verify -pubin -inkey release-key.pub -rawin \
      -in SHA256SUMS -sigfile SHA256SUMS.sig
    That is an Ed25519 signature made with our offline release key — a key that is not on any machine that serves you, and never has been.
  3. Only then does it download a binary, and it checks that binary's SHA-256 against the line in the file it just verified. A mismatch is ChecksumMismatch and nothing is installed.

A key fetched from the release it verifies would prove nothing: whoever could replace one asset could replace both. Keep your copy of release-key.pub and compare it the next time — that one diff is what turns a signature check into a statement about us rather than about whatever answered the request.

If your organisation does not pipe scripts into a shell, that is a reasonable policy and the by-hand path is not a lesser one. It is three commands after the downloads in install.md section 1.2:

openssl pkeyutl -verify -pubin -inkey release-key.pub -rawin -in SHA256SUMS -sigfile SHA256SUMS.sig
grep " ziffer-${VERSION}-${TARGET}$" SHA256SUMS | shasum -a 256 -c -
sudo install -m 0755 "ziffer-${VERSION}-${TARGET}" /usr/local/bin/ziffer

You can also read the script first — it is a release asset like any other and its own line is in the SHA256SUMS you just verified:

curl -fsSL -O "${BASE}/install.sh"
grep " install.sh$" SHA256SUMS | shasum -a 256 -c -
less install.sh
sh install.sh --dry-run          # prints every command it would run, runs none

No root, unless the prefix needs it. The installer uses sudo for one thing — install -m 0755 into /usr/local/bin — and asks first. Decline it and it installs into ~/.local/bin instead and tells you which directory it used. Do not run the whole script as root: it would leave your signing key root-owned and ~/.ziffer under /root.

Nothing is sent anywhere. Every network call the installer makes is a GET: the release, the verifying key, and the GitHub releases API for "what is the latest". It has no telemetry, no analytics and no callback. Your key is generated by ziffer keygen on your machine, and the only thing that ever crosses to us is what the SEND US block names: the public key document and the two .csr files.

Re-verify at any time, on any machine, without reinstalling:

sh install.sh --check

It re-fetches the release's SHA256SUMS, verifies the signature again, recomputes the SHA-256 of the binary sitting on your disk, and compares. It believes nothing it wrote down earlier — the install record holds a version, a target, a checksum, a path and a date, and --check recomputes rather than re-reads. A binary that has changed is refused as BinaryDoesNotMatchRelease with both hashes printed, and it also tells you whether a newer release exists.

What this does not cover. --check tells you the bytes on disk are the bytes we published; it cannot tell you our build was honest, and nothing in a checksum can. That is what the signing key's being offline is for, and it is a claim about our process that you are entitled to ask us to evidence rather than assert.

Synced from ziffer docs/onboarding/after-install.md at 32bc4db; edit the source, never this page.

On this page