Rio
adr/0124-signed-repositories.mdRaw Markdown · GitHub ↗

ADR-0124: A repository can be signed, and rio checks it

  • Status: Accepted; amended by ADR-0125; trust on first use superseded by ADR-0126
  • Date: 2026-09-16
  • Deciders: jka
  • Decision: D118

Context

ADR-0039 keeps plain http first-class, and that is why this decision exists: anyone on the path between a user and a repository can rewrite an extension's Tcl in flight, and rio would install it and run it inside the editor. ADR-0109 and ADR-0111 hardened the transport, but https is an option, not a requirement, and a publisher who serves http has no way to vouch for what a user receives.

The trust model must not change to fix that. There is no registry, no operator and no account; the sources list is the trust list, and a repository is three text files on any web server. A signature over the repository's contents fits that model, because it needs nothing but more files in the same directory.

Decision

A publisher signs one root SHA256SUMS with an SSH key, and rio checks it by running ssh-keygen. The sums file is in sha256sum's own format and covers everything the repository serves: the marker, the index, every manifest and every payload. It is signed as a detached OpenSSH signature in the namespace rio-repository, the way git signs commits. rio verifies it with ssh-keygen -Y verify against an allowed-signers file it writes itself, containing one line for the one key it trusts. File hashes stay in Tcl (tcllib's sha256); only the public-key work is the tool's.

The binding is the key and the namespace. The signature identity only selects a line in a file rio generated, so it contributes nothing to the verdict; rio uses the source URL for it as a label, not as a claim. The namespace is what stops a signature made for git or for ssh authentication being replayed as a repository signature.

Trust on first use, with rio's own key seeded. A repository publishes its public key in rio-repository.conf. The first scan whose signature verifies against that key records it, and from then on only that key speaks for that source. The trust store is repository-keys.conf, beside sources.list in the GUI's configuration, in the conf format (ADR-0021), commented and hand-editable: deleting a section is how a user takes a trust decision back. Entries are keyed without the scheme, like the rest of the source matching, so moving a repository from http to https (ADR-0109) is a change of route, not of publisher. The default source ships with the project's own key already trusted, so a fresh install is not asked a question it has no way to answer.

A key that later differs is refused as changed, with a review dialog in the shape ADR-0111 gave a changed certificate: both fingerprints side by side in a bordered, selectable box (ADR-0068), an explicit instruction to confirm the new one away from this connection, Go Back as the default button, and trust granted to exactly the key the dialog showed.

What first use cannot do is recorded rather than glossed. An attacker present on the very first scan serves a marker, sums and signature of their own, and rio has nothing to compare them against. What is defended is every scan and install after that one, which is an extension's whole lifetime.

No downgrade, and no partial trust. Once a key is trusted for a source, a key that disappears from the marker, a missing or unfetchable signature, a signature that does not verify, and a file whose hash does not match are all refusals of the whole source. "Most of it verified" is not a state a user can act on. Every file rio fetches from a signed source must appear in SHA256SUMS with that hash: a publisher's sums cover everything served, so a file the sums do not mention is not an omission but a file from somewhere else. The web server's own directory listing is exempt, because it is not a file of the repository; every directory it names must still produce a manifest the sums vouch for.

The one way out is for the missing tool, not for the missing signature. Where ssh-keygen is absent or older than OpenSSH 8.0, nothing can be checked. A source nobody has trusted yet simply lists as unsigned; a source whose key is trusted is refused, which is the fail-closed shape ADR-0120 gave https on a tcltls that cannot check host names. Preferences ▸ Extensions ▸ "Use repositories rio can't check", off by default, lets those through marked unverified — never signed — in the list and in the install consent. It excuses nothing else: a bad signature, a changed key or a mismatched hash is refused with the switch on. A tool too old to verify is reported as the version problem it is, never as a bad signature.

The core hashes; the GUI decides. Verification is a core operation (sig.verify, sig.fingerprint) because ssh-keygen has to be on the host that fetches — the same split ADR-0109 made for tcltls, and the reason a GUI-only box stays Tk and nothing else. The policy above is the GUI's, because the trust store belongs with the sources list. repo.fetch gained an opt-in hash of the body as it arrived; opt-in because tcllib's sha256 is pure Tcl and an unsigned repository must not pay for it.

Asking for the bytes forced an honest change in the fetch itself: it is now binary, and rio's own decoder owns the charset the server declared. What it deliberately loses is http's \r\n → \n translation, which alone would make a repository published from Windows unverifiable. Because the client then holds the signed file as text and hands it back over a JSON wire, sig.verify takes the hash the core reported, re-encodes the text and checks that it lands on the same bytes before asking ssh-keygen anything. A round trip that lost something says so in those words, instead of arriving as a forged signature — the one message a user must never see for a repository that was fine.

What the user sees. Every variant line ends in signed, unsigned or unverified, including the boring case, because "signed" means nothing to a user who has never seen rio say "unsigned". The install consent names the fingerprint that vouched for the files, or says plainly that nothing does. A refused source gets its own phrase in the list rather than reading as unreachable. The provenance ledger records who vouched for an installed extension's bytes.

Alternatives considered

Owning Ed25519 and SHA-512 in Tcl, so rio depends on no external program. Rejected on review as the heavier and less POSIX road: it is signature verification written from scratch, and OpenSSH is present on every host rio targets. Running the same tool the publisher used also keeps the two halves from drifting apart.

Declaring the namespace in the allowed-signers file as well as on the command line. Written first, then removed: fault injection could not construct a test that fails when it is absent, because the command-line namespace already enforces the match. An option no test can break is decoration, not defence.

Refusing outright where ssh-keygen is missing, which is what the design originally said. The maintainer pushed back, pointing at ADR-0120: fail closed by default, but give one explicit, off-by-default way through, and never let it hide which way was taken.

Putting that switch in the core, as ADR-0120's is. Rejected because ADR-0120's switch also governs the agent's transport, while this one governs nothing but the Extensions window, and the trust store it belongs with is the GUI's.

Hashing the decoded text rather than the bytes as received. Rejected: the hash has to be the one the publisher's sha256sum saw. Anything else silently excludes every repository whose files were authored on a system with different line endings.

A "Repository signing keys…" management dialog mirroring ADR-0111's accepted certificates. Deferred by the maintainer rather than grown onto an already large change: a rotation already has its own dialog, and forgetting a key is a section deleted from a commented file the dialogs name.

Consequences

  • Tampering in flight fails on every scan and install after the first, over plain http, with no registry, no account and nothing to shut down. ADR-0039's trust model is unchanged; signing sits on top of it.
  • A publisher takes on an obligation: re-sign after every upload. A publish that uploads payloads without refreshing the sums, or the sums without the signature, refuses the whole source until it is fixed.
  • The core's host needs OpenSSH 8.0 or newer for a signed repository to be usable. Hosts without it either see unsigned repositories only, or turn the preference on and accept unverified ones.
  • There is no freshness check. A replayed older SHA256SUMS cannot introduce anything, but it can freeze a repository at an old version; ADR-0107 never downgrades an installation.
  • There is no revocation and no cross-signing. A rotation is the "changed" step, answered by the user out of band.
  • The trust store is the GUI's, so a second frontend on the same core keeps its own — the same limit ADR-0039 has for the provenance ledger.
  • Every caller of repo.fetch now receives the body without line-ending translation. Repository files are line-oriented text, so this affects parsing nowhere, but it is a change in what the operation returns.
  • Still open: a list of trusted signing keys with a Forget action, in the shape of ADR-0111's accepted certificates.