# Signing this repository

How to publish this directory — served as `http://rio.skylm.org/extensions` — with an
OpenSSH signature over its contents.

**Status:** rio verifies these signatures (rio's AGENTS.md **D118**, and **D119** the same
day). Before that it ignored them, and signing early cost nothing; now it is load-bearing.
A rio that trusts this key refuses the whole repository — not just the changed file — if
the signature is missing, doesn't verify, or doesn't cover a file it fetched. **Re-run
step 2 on every publish.**

**Who trusts this key, and who gets asked.** Since D119 rio never trusts a signing key
because it arrived first. A repository publishing a key rio has no decision about is
*refused* — it lists as *signing key not confirmed* and nothing from it is installed — and
the user is shown the fingerprint and asked to confirm it, the way `ssh` asks on a first
connection. **This repository is the exception:** rio ships with the key below already
trusted, so an ordinary user is never asked about it. They are asked only if they withdraw
it (*Preferences ▸ Extensions ▸ Repository signing keys…* → *Forget selected* on the
`(built in)` row) or if this key is ever rotated. Every *other* publisher's users are asked
once, on their first scan — which is why step 1 now ends in publishing the fingerprint,
and why anyone copying this document should read that part as required rather than tidy.

**What is signed:** one `SHA256SUMS` at the repository root covering every served file —
`rio-repository.conf`, `index`, every manifest and payload, and the root documents
(`README.md`, this file, and `LICENSE`).
The signature covers that one file, so it covers the whole repository transitively.
There is no per-extension signature: you sign the repository, not `emacs/`.

Signing the root documents along with everything else is deliberate. They are
served, so they are part of what a reader receives; rio itself only ever fetches
`rio-repository.conf`, `index`, and the manifests and payloads named in them, and
ignores the rest.

---

## 1. Once: make the key

```sh
ssh-keygen -t ed25519 -f ~/.ssh/rio-repo -C 'rio repository signing key (jlsksr)'
```

Keep it out of this working tree and off the web server. A passphrase is fine — add it
to `ssh-agent` (`ssh-add ~/.ssh/rio-repo`) and signing stays non-interactive.

Then publish the public half in `rio-repository.conf`, which is where rio reads it:

```
key = ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
```

Type and base64 only — drop the trailing comment:

```sh
cut -d' ' -f1,2 ~/.ssh/rio-repo.pub
```

**Then publish the fingerprint somewhere that is not this repository:**

```sh
ssh-keygen -lf ~/.ssh/rio-repo.pub
```

Since D119 this is load-bearing rather than a nicety. rio shows a user exactly this
string and asks them to confirm it, and the only way they can answer honestly is by
comparing it against something of yours that the repository itself did not serve — a
project page, a release note, the mail announcing the repository. Anyone who can rewrite
this repository in flight can also rewrite a fingerprint written down inside it, so a
fingerprint published only here proves nothing. Signing without publishing it elsewhere
leaves your users clicking yes on faith, which is the thing the question exists to
replace.

**Order matters:** adding the `key =` line changes `rio-repository.conf`, which is a
hashed file. Add it before step 2, not after.

---

## 2. Every publish: hash, sign, check

From the repository root:

```sh
cd /home/jka/Projekte/rio-extensions

find . -path ./.git -prune -o -type f \
    ! -name 'SHA256SUMS' ! -name 'SHA256SUMS.sig' -print \
    | sed 's|^\./||' | LC_ALL=C sort | xargs sha256sum > SHA256SUMS

ssh-keygen -Y sign -f ~/.ssh/rio-repo -n rio-repository SHA256SUMS
```

That writes `SHA256SUMS.sig` beside it. Two commands, no rio tooling.

Every part of that `find` is load-bearing:

- **`-path ./.git -prune`** — without it you hash several thousand git objects into
  `SHA256SUMS`, and they change on every commit.
- **The two `! -name` exclusions** — without them `sha256sum` reads `SHA256SUMS` while
  the shell is still writing to it and the command never terminates.
- **`LC_ALL=C sort`** — keeps the order stable, so a republish with nothing changed
  produces a byte-identical `SHA256SUMS` and an empty git diff.

`-n rio-repository` is the signature namespace. It is what stops a signature you made
for git (`-n git`) or for SSH authentication being replayed as a rio repository
signature — a `-n git` signature fails verification with *namespace does not match*.

On OpenBSD, `sha256 -r` replaces `sha256sum` and prints the same format.

### Check before you upload

```sh
sha256sum -c SHA256SUMS --quiet && echo 'payloads ok'

printf '%s %s\n' 'http://rio.skylm.org/extensions' \
    "$(cut -d' ' -f1,2 ~/.ssh/rio-repo.pub)" > /tmp/rio-allowed-signers
ssh-keygen -Y verify -f /tmp/rio-allowed-signers -I 'http://rio.skylm.org/extensions' \
    -n rio-repository -s SHA256SUMS.sig < SHA256SUMS
```

A good run prints `Good "rio-repository" signature for http://rio.skylm.org/extensions
with ED25519 key SHA256:…`. This is the publisher-side dry run of what rio does at scan
time; rio writes its own allowed-signers file from the key it trusts, so you never ship
one. (The identity in it is a label, not part of the verdict — the key and the namespace
are what the signature is bound to.)

The two checks catch different things, and you want both:

| what went wrong | `ssh-keygen -Y verify` | `sha256sum -c` |
|---|---|---|
| a payload changed after hashing | passes | **fails** |
| `SHA256SUMS` edited | **fails** | passes |
| signed with the wrong key | **fails** | passes |
| wrong namespace | **fails** | passes |

### Commit, then upload

`SHA256SUMS` and `SHA256SUMS.sig` are **committed**, not ignored. The private key never
leaves your machine, so the web server must receive bytes that are already signed — it
can never produce a signature itself. Committing them also means the published state is
exactly a commit, and `git log` is the publication history.

Upload payloads first and `SHA256SUMS` + `SHA256SUMS.sig` **last**, or stage into a
temporary directory and swap. A client scanning mid-upload otherwise sees the old sums
against new payloads and **refuses the repository** — every extension in it, for everyone
scanning during that window, not just the file you were replacing. Since D118 this is the
failure mode to plan uploads around.

And check the **served** bytes afterwards, not your working tree: a partial upload passes
locally every time. That is not hypothetical — on 2026-09-19 two root `.md` files were
missing from the server while `SHA256SUMS` listed them, which a local `sha256sum -c` could
never have caught.

**Do not let the web server serve `.git/`.** If you deploy by cloning or pulling on the
server, the whole history — including anything ever committed by mistake — becomes
fetchable over http. Either deploy the tree without it (`git archive`, or rsync with
`--exclude .git`) or block the path in the server config.

---

## 3. When to sign again

Every publish, whatever changed. The signature covers `SHA256SUMS`, and `SHA256SUMS`
covers every byte, so all of these invalidate it:

- a new `claude/claude.tcl`, or any payload edit;
- a bumped `version =` in any `rio-extension.conf`;
- a new extension directory plus its line in `index`;
- a typo fix in `rio-repository.conf` — including adding the `key =` line itself;
- an edit to this file or `README.md`.

It is not a per-extension step. Re-run the two commands in step 2 and re-upload. The key
stays the same across all of it.

## Rotating the key

Only deliberately. Every rio that has already trusted the old key refuses the new one as
**changed** — the repository lists as *signing key changed* and offers *Review signing
key…*, which shows both fingerprints and asks the user to confirm the new one away from
this connection before trusting it (rio's D118, the shape D111 gave a changed TLS
certificate). There is no cross-signing and no revocation. Replace the `key =` line,
re-hash, re-sign, and **publish the new fingerprint somewhere a user can check it against**
— that is the out-of-band step the dialog sends them looking for.

For *this* repository a rotation is the one routine event that puts its users in front of
that question at all, since they start with the old key already trusted. Announce it with
the new fingerprint, and expect to be asked about it.

Until they take it, they get no updates from here. So rotate only for a reason.

## What a signature does and does not say

It says: these bytes came from the holder of this key and were not altered in transit.
Over plain http, which rio treats as first-class, that is the whole point — anyone on the
path can otherwise rewrite `emacs.tcl` and rio would install it.

It does not say the code is good, reviewed, or approved by anyone. There is no store
here, no curation and no central authority: in rio, the sources list *is* the trust list.
