TacidOS
Developers
Documentation

Decision 0068

0068. A rollback check that compares strings must check they are comparable

Architecture decision record 0068: 0068. A rollback check that compares strings must check they are comparable

Status
accepted
Date
2026-09-07

Status: accepted. Concerns the update system ADR-0013 describes and ADR-0038 proves on a booted machine.

Context

SignedManifest::verify names three attacks and refuses each: rollback, freeze, cross-channel. The rollback refusal is one line —

if manifest.revision.as_str() <= installed { … }

— and it works because a revision reads YYYY.MM.DD-HHMM, a format chosen precisely so that string order is time order.

Nothing on the reading side checked that either string had that shape.

The publisher checks its own: channel::check_revision refuses to build a manifest from a malformed revision, so a Publication cannot be signed with one. But that is the producer enforcing a property the consumer depends on, which is the arrangement this module exists not to have. Its own doc comment says the signature covers the canonical bytes rather than the parsed structure, “so what was verified is exactly what was read” — the same care, one level down, says that what is compared must be comparable.

2026.9.7-0800 is what a signing pipeline emits the day somebody formats a date without padding. It sorts above 2026.10.01-0800. A genuinely older manifest carrying it passes a check whose entire purpose is to refuse a genuinely older manifest — and the failure is silent, and lands on exactly the manifest an attacker would choose.

Decision

Both revisions are checked for shape before either is compared, in verify.

The shape itself — is_revision — moves to manifest.rs, next to the field it describes and next to the comparison that depends on it, and channel::check_revision calls it. One definition, checked on the way out and again on the way in.

The manifest’s revision comes off the wire. Malformed, it is refused: an unverifiable claim about time is not one to reason from.

The installed revision comes off this machine, so a malformed one is not an attack — it is a machine whose state is not what it thinks. It is refused too. The machine cannot establish that what it is being offered is newer, and installing anyway would be guessing about the single question the rollback check exists to answer. Refusing is recoverable by the owner; a silent downgrade is not.

ManifestError::Revision carries whose revision it was, because “the manifest’s” and “this machine’s” send whoever reads the message to two entirely different places.

Consequences

  • A first install is unaffected: installed is None, there is nothing to compare, and a test says so — the two new checks must not have made the fresh-install path harder.
  • A machine already following a channel with a malformed revision in its genome stops updating and says why, rather than updating on a comparison that means nothing. No such revision can have been published by this code; the case exists because the check does not depend on that.
  • One definition of the format, in the module that owns the field. The publisher’s error type is unchanged.

Alternatives rejected

Parse both into a date and compare that. Stronger in principle, and it invites a second question — what is a valid month, does the 30th of February sort before the 1st of March — that the format was chosen to avoid. The shape check plus lexical order is exact for well-formed input, and the point is to be sure the input is well-formed.

Check only the manifest’s revision, since only that comes from an attacker. It would leave the comparison depending on a value this machine has never checked, and the failure mode is the same silent wrong answer. Where the two sides differ is in what refusing means, and that difference belongs in the message, not in whether the check happens.

Leave it to the publisher. It is already there, and it is not enough: it protects a manifest this project’s own tool built. A verifier’s job is to be right about a document whoever it came from, which is the reason the signature is checked over bytes rather than over a parse.