An offline anchor
for online updates.
Jenni signs every release with Ed25519. The key that signs the release lives on the server. The key that authorises which signing keys are valid never does — it sits on the operator's laptop, passphrase-encrypted, on two physically separated backups. Clients pin one 32-byte public key, fetch a signed trust list, and verify every manifest against whichever signing key that list currently endorses. A server breach can lie, but it cannot impersonate.
The scenario we built for.
Picture this: your app polls a download server every morning to see whether a new version is available. The server hands back a JSON blob with a version number, a SHA-256, a URL. The app downloads, verifies the hash, installs. Standard auto-update flow, in production at countless places.
Now picture the server is compromised. The attacker has a shell, can edit files, can swap binaries. What stops them from serving a malicious release that your app gladly installs tomorrow morning?
- Not TLS. The attacker controls the server; the certificate is theirs to use.
- Not the SHA-256. They computed it over the file they crafted. Hash and file agree.
- Not the ClamAV scan. They can edit the scan status field. The JSON says
cleanbecause they wroteclean.
The only thing that stops a compromised server from owning every client is a signature the server cannot forge — one whose verification key was decided long before the breach. That is the trust layer.
One key the client pins. One key the server uses. One manifest per release.
| Layer | What it is | Where it lives | Rotation cadence |
|---|---|---|---|
| Root key | The trust anchor. Signs the list of currently valid signing keys. | Operator laptop, Argon2id-passphrase-encrypted. Never on the server. Two physically separated backups. | Years. Decade-scale. Rotating it requires re-pinning every client out-of-band. |
| Signing key | Signs each individual release manifest as the server activates it. | Server, encrypted at rest with the master password (Fernet over Argon2id-stretched bytes). | Months, or whenever the operator wants. Rotation does not require a client update. |
| Release manifest | JSON document for one release: version, SHA-256, monotonic counter, signed timestamp, signing key ID. | Generated and signed automatically when a release is activated. Served via /api/projects/<slug>/manifest. |
One per activated public release. |
The client pins one thing: the root public key. Everything else — which signing keys are valid this month, which versions are current — is delivered over the wire and verified on the way in.
The shape of the signed trust list:
{
"trust": {
"trust_version": 1,
"expires_at": "2028-05-12T20:29:04Z",
"signed_at": "2026-05-13T20:29:04Z",
"valid_keys": [
{ "key_id": "5af2613e6e92c56d",
"pubkey_b64": "mg6FbywzvR…",
"valid_from": "2026-05-13T20:29:04Z" }
],
"revoked_keys": []
},
"signature": "BASE64 — Ed25519 over canonical(trust) with root privkey"
}
And the shape of a release manifest:
{
"manifest": {
"project": "breznflow",
"version": "1.2.3",
"sha256": "abc123…",
"size_bytes": 184320,
"counter": 7,
"signed_at": "2026-05-14T09:21:33Z",
"key_id": "5af2613e6e92c56d",
"url": "/breznflow/v/1.2.3"
},
"signature": "BASE64 — Ed25519 over canonical(manifest) with signing privkey"
}
Four attacks, four signed fields.
| Attack | What the attacker does | How the manifest stops it |
|---|---|---|
| Forgery | Serve a malicious ZIP and claim it is the new release. | Signature over the manifest does not verify against any key in the current trust list. Client rejects. |
| MITM tampering | Intercept the ZIP in transit and substitute another binary with the same filename. | sha256 is signed. Client computes the hash after download and compares to the signed value. |
| Rollback | Serve an older, still-validly-signed release that has a known vulnerability. | counter is monotonic per project and signed. Client stores max_counter seen so far; any manifest with counter ≤ max is refused. |
| Freeze / replay | Keep serving a stale-but-valid manifest indefinitely to prevent security updates from reaching clients. | signed_at is signed. Client enforces its own freshness policy — e.g. warn if the latest manifest is older than 30 days, hard-fail at 90. |
None of these depend on TLS. The trust layer assumes the transport can be observed, modified, and impersonated. The signature is what carries trust, not the channel.
Rotate the signing key without shipping a client update.
Long-lived secrets are bad operational hygiene. The signing key sits on the server, so a server breach exposes it. The point of the trust layer is that this exposure does not become a client-side disaster.
- Operator generates a new signing keypair on the server through the admin UI at
/admin/signing. - Server starts signing new manifests with the new key. The old key is marked inactive but its
key_idstays in the trust list — older manifests already signed with it remain verifiable. - Operator opens the trust-tool on the laptop, drafts a new
trust.json: bumpstrust_version, adds the new signing pubkey tovalid_keys, optionally moves the old one torevoked_keysif it is to be invalidated. - Operator signs the new trust list with the root privkey, uploads it to the server.
- Clients pick up the new trust list on the next refresh (cache TTL: 5 minutes). From that point, signatures by the new key verify; signatures by revoked keys do not.
No client code change. No app re-release. The pinning never moved — only the list of who is currently allowed to sign on the root's behalf.
What happens after a server breach.
Assume the worst: an attacker pulled the signing privkey out of server memory before you noticed. They can now sign forged manifests at will, as long as the trust list still lists their stolen key as valid. Recovery is a coordinated revocation, done from the laptop.
- Generate a fresh signing keypair on the server (after the server itself is rebuilt clean — that is its own playbook).
- Draft a new trust list in the trust-tool: bump
trust_version, move the compromisedkey_idtorevoked_keys, add the new pubkey tovalid_keys. - Sign it with the root privkey on the offline laptop. The root privkey was never on the breached server, so it remains untainted.
- Upload the new trust list. Clients pick it up within 5 minutes. From that moment, any manifest signed by the stolen key is refused — even if the attacker still holds it.
Replay protection comes for free: trust_version is monotonic, so a client that already saw version 5 will refuse to downgrade to version 4 even if the attacker can resurface an older, signed-by-root copy.
The expires_at field is the dead-man switch. If the operator vanishes and never re-signs, clients freeze rather than keep accepting forever. Default validity: two years. The operator must re-sign before that — a small chore that proves the root privkey is still under control.
A small Python CLI that lives next to the laptop, not the server.
The trust-tool is a single-file CLI in the repo at tools/trust-tool/. It runs on Linux, macOS, and Windows, depends only on standard cryptography primitives, and is what the operator actually touches when rotation, revocation, or initial setup is needed.
| Command | What it does |
|---|---|
trust-tool keygen | Generate a fresh Ed25519 keypair. Encrypts the privkey with an Argon2id-stretched passphrase and a Fernet envelope. Prints the pubkey to stdout for pinning. |
trust-tool sign | Read a draft trust list, sign it with the offline root privkey, optionally upload it to the server's admin endpoint in one shot. |
trust-tool inspect | Pretty-print a signed trust list or release manifest. Useful for audit logs and incident response notes. |
trust-tool extract-pubkey | Recover the public key from an encrypted privkey file (you forgot to write down the pinned value, it happens). |
The privkey file format is documented in the same directory's README.md. You can decrypt it with any Python script that knows cryptography — the format is not magic, just disciplined.
What the auto-updater actually checks.
The repo ships a reference verifier at tests/integration/test_trust_e2e.py that walks every step of the protocol against the live server using only the raw cryptography library — no Jenni-specific code on the client side. Re-implement it in your language of choice; the protocol is small.
- On install, pin the root pubkey. Ship it as a 32-byte constant in your app. Never accept it over the network.
- Daily (or whenever you check for updates), fetch
/api/signing/trust. Verify the signature against the pinned root pubkey. Comparetrust_versionto the largest version you have already accepted; refuse if smaller. - Check
expires_at. If the trust list has expired, refuse all manifests until a fresh trust list arrives. - Fetch the project manifest. Look up the
key_idinvalid_keys(notrevoked_keys). Verify the manifest signature with the pubkey from the trust list. - Compare
counterto the largest counter you have already accepted for this project. Refuse if smaller-or-equal. - Download the ZIP from the signed
url. Compute SHA-256. Compare to the signedsha256. Refuse on mismatch. - Persist
max trust_versionandmax counterper project. These are the entire client-side state for rollback protection.
That is the whole verification surface. Three signatures, two monotonic counters, one expiration check, one hash comparison. Easy to audit, easy to port, easy to test offline against fixture files.
Things the trust layer doesn't try to be.
| Feature | Why it's out |
|---|---|
| Online signing of the root key | A root privkey on the server defeats the entire model. Convenience-UX here would equal trust-layer-compromise the next time the server is breached. The operator must use the offline laptop tool. |
| Hardware-token / HSM / YubiKey for the root key | Out of scope for the current phase. Passphrase-encrypted file is enough for a single-admin self-hosted install. HSM is a natural follow-up if the threat model demands it. |
| Multi-signature (k-of-n) trust lists | Single-admin tool. A k-of-n model presumes a team, which Jenni explicitly does not have. Would be a different product. |
| Trust-on-first-use (TOFU) pinning over the wire | Bootstrapping pinning over the same network the attacker controls is a hole large enough to drive a truck through. Pin the root pubkey in-app, ship it with the binary, decide it before the network exists. |
| Automatic revocation on suspicious traffic patterns | The server has no business making revocation decisions. Revocation is a human-with-laptop decision, always. |
API endpoints, response shapes, cache rules, and the rest of the platform are documented on the specs page. The dated record of every security choice that built up to this trust layer — Phase 1's Argon2id, Phase 4's ClamAV strict mode, Phase 7's signed manifests, Phase 8's offline root — lives on the security changelog. The live reference instance at jenni.noschmarrn.dev runs the full Phase 8 build — every endpoint mentioned here is reachable there for inspection.
If you are integrating a Jenni-backed auto-updater into your own app and want a sanity check on the verification flow, the reference verifier is the canonical answer — but I am also happy to look at your implementation.
info@noschmarrn.dev