Skip to content

What a self-update trusts, and why

A tool that replaces its own binary is asking a user to run code that arrived over the network, unattended. This page explains what rtb-update checks before it does that, and why each check is shaped the way it is.

The trust anchor is compiled in

ToolMetadata::update_public_keys holds base64 minisign public keys, set at build time. Nothing at run time can add to that set — not a config file, not an environment variable, not a flag. An empty set means updates are refused outright rather than performed unverified.

That is the whole trust model in one sentence: the binary you already have decides what the next binary must be signed by. An attacker who can serve you a release cannot also tell you which key to trust, because the answer was baked in before the download existed.

Multiple keys are accepted, and any one verifying is enough. That is what makes key rotation possible without a dual-signing window: ship a release trusting {old, new}, rotate, and every already-deployed binary spans the change.

Why only prehashed ED signatures

minisign has two signature formats: legacy pure Ed (ed25519 over the file) and prehashed ED (ed25519(BLAKE2b-512(file))). rtb-update accepts only the prehashed one, and refuses a legacy signature even when the trusted key could verify it.

Two reasons, and neither is cryptographic strength.

One published signature has to serve two consumers. The same release artefacts are installed by cargo-binstall, which requires the prehashed variant and rejects legacy outright. Accepting only ED means what rtb-update trusts is exactly what cargo-binstall trusts. Accepting both would create a class of release that one installer verifies and the other refuses — discovered by a user, at install time.

Prehashing is what lets an HSM sign. The signer only ever sees a 64-byte digest, which fits comfortably inside limits like the AWS KMS 4096-byte message cap. A pure signature over a 40 MB archive does not.

Why verification is delegated, not implemented here

Parsing and checking are done by minisign-verify — the same crate cargo-binstall uses.

Two independent implementations of one check is how two consumers of the same release silently drift apart: a signature one accepts and the other does not, with neither side wrong on its own terms. Sharing the implementation makes that impossible. The crate vendors its own BLAKE2b and has no dependencies, so this costs nothing in tree weight.

It also checks more than the artefact signature: the algorithm tag, the key id, the signature itself, and the global signature over signature ‖ trusted_comment. That last one matters because producers record the signing project in the trusted comment; without it, the comment could be rewritten undetected.

The test suite signs its fixtures with jedisct1's own minisign crate and verifies them with minisign-verify, so the fixtures are real minisign output rather than this project's idea of the format.

Why a checksum as well, and why it is optional

ToolMetadata::update_checksums_asset names a sha256sum-format asset. When set, the archive's hash is cross-checked against it after signature verification. When unset, the signature is the only integrity gate.

The signature already covers the bytes, so the checksum is not a second security boundary — it is a compatibility one. Publishing a .sum file is a widespread release convention, and cross-checking the file you already publish catches a pipeline that signed one artefact and uploaded another. Making it optional is honest about that: it is a build-pipeline check, not a defence against an attacker who can already produce a valid signature.

Why the swap is last, and why there is a self-test

The flow orders every fallible step before the only irreversible one:

resolve → select asset → download → verify signature → verify checksum → extract → self-test → mark executable → swap.

A failure anywhere leaves the installed binary untouched, because the installed binary is not touched until self-replace runs. There is no partial state to recover from — the disk holds either the old version or the fully verified new one.

The self-test is the step that catches what cryptography cannot. A correctly signed binary can still be built for the wrong architecture, linked against a library the host lacks, or be the wrong release entirely. Running <staged> --version and requiring the release tag in its output is a cheap end-to-end check that the thing about to replace your tool actually runs and is what it claims to be.

It is also the step most likely to surprise you, because rtb-cli does not give a tool a root --version flag — see the limitation.

Why automatic updates default to off

UpdatePolicy::Disabled is the default, and a user cannot override whatever the tool author compiled in.

An automatic check is a network call on every invocation, on a throttle. An automatic update under UpdatePolicy::Enabled is worse: the user asked to run one command and got a binary replacement first, and any failure in that replacement fails the command they actually wanted. Prompt exists so the common case — tell me, let me decide — does not require choosing between silence and surprise.

Since users have no override, Enabled is a decision made on their behalf every time they run the tool. Default off is the only defensible starting point.