ADR 007 Transport security and mirror trust - nekrozis/qtogo GitHub Wiki

ADR-007: Transport security and mirror trust

Status: Accepted (2026-10-09).

Context

  • Downloading is the one step where the tool talks to a system it does not control, and it is where an archive's authenticity is decided. The reference implementation (aqt/helper.py) sets that policy loosely: _check_mirror accepts http and ftp, a fallback mirror is picked at random (secrets.choice), redirect handling differs between the single-hop and multi-hop paths, and --pw is passed on the command line. A tool whose premise is a stricter posture than the reference cannot inherit that.
  • The plan already states the intended policy (ยง4, ยง9 D6, ยง10 QTOGO-010). This record is where it becomes the decision the code answers to, and the thing a later change has to argue with.
  • What the repository declares about an archive is weaker than ADR-001 assumed. Updates.xml carries SHA-1 only โ€” <SHA1> per package and for the metadata โ€” and the collected corpus holds no SHA-256 anywhere. The stronger declaration is a separate <archive>.sha256 file the official endpoint publishes beside an archive, and that is the one worth verifying against. model.Archive.SHA256 (ADR-001 ยง4) names an algorithm the metadata never carries.
  • A mirror is a transport convenience, not a source of truth. Whatever serves the bytes, the answer to "what must these bytes hash to" has to come from somewhere else.

What the official repositories publish

Measured against download.qt.io on 2026-10-09, and against the captured Updates.xml samples:

Where What Algorithm Note
Updates.xml <Checksum> โ€” a boolean ("this repository provides checksums"), not a digest
Updates.xml, per package <SHA1> SHA-1 the declared digest of the package
Updates.xml, root <SHA1>, beside <MetadataName> SHA-1 the declared digest of the metadata archive
beside any object <object>.sha1 SHA-1 the file IFW itself requires
beside any object <object>.sha256 SHA-256 what this decision verifies against by default
beside any object <object>.md5 MD5 present, unused
redirect recovery <object>.meta4 โ€” Metalink v4; not a digest

The sidecar body is <hex> <file name> โ€” except an archive's .sha1, which is bare hex with no file name (40 characters; measured on two archives), while Updates.xml's .sha1 is the named form. A reader takes the first whitespace-delimited token, so it accepts both; the default path (.sha256) is always the named form.

Nothing here is version-dependent. The .sha1, .sha256 and .md5 sidecars exist for Updates.xml in every sampled release from 5.9 to 6.10.1, and beside an archive at 6.10.1, while Updates.xml declares SHA-1 throughout โ€” no <SHA256> element exists in the collected or the live samples. What changes by version is which algorithm a client picks and how the directories nest, not the checksums the repository publishes. This replaces an earlier research note that dated the .sha256 sidecar to 6.8.1: there is no such boundary. Only windows_x86/desktop was sampled; the other hosts and the extensions repository are spot-checked when the transport lands.

The endpoint also redirects the archive itself, and only the archive. Measured with redirects off: Updates.xml and both checksum sidecars answer 200 directly, while a .7z returns 302 to a nearby official mirror โ€” ftp.jaist.ac.jp from here and qt.mirror.constant.com from GitHub's runners, which is why the community Qt actions appear to "default" to that mirror. So the metadata and the digests are served by download.qt.io itself, and the heavy bytes are served by a mirror it names. That split is the shape the decisions below lean on.

Decision

Trust is placed in the content of an archive โ€” verified against a digest the official endpoint served directly โ€” and not in the host that served it. Everything below follows from that, and it is why no allowlist of mirrors is kept.

  1. HTTPS only. ftp is refused outright โ€” not merely off by default. http is refused unless --allow-insecure-http is given, which warns on every run it affects. The default source is download.qt.io.

  2. Mirrors are named on the command line, in order, and are never chosen at random. The order is the order they are written and sets the priority; the tool never reorders, measures, or picks, so the same command line reaches the same host on every run. The default is the official endpoint alone, so an invocation has no fallback until one is named. There is no configuration file and no built-in mirror list: the official endpoint already redirects archive bytes to a nearby mirror, so a mirror here is for overriding that choice, and a curated list would be one more set of hosts to keep current and to trust. This replaces the reference tool's secrets.choice. Every mirror must be https unless the insecure switch is set.

    The flag is --base, and it is repeatable; the reference tool's is single-valued, so accepting it more than once is a superset of its behaviour. --base names the whole root โ€” listings, Updates.xml and archives all come from it โ€” while the digest still comes from the official endpoint (decision 3): one root for the data, one root for trust.

  3. The expected digest comes from the official endpoint, not from whatever served the archive. It is fetched over HTTPS from download.qt.io, keyed by the archive's repository path, whichever mirror delivered the bytes. The digest request may not itself be redirected to another host: the root of trust stays on the official endpoint, so a redirect there is a failure rather than a mirror handing back the expected value. A private --base still prefers the official digest; where the official endpoint cannot answer for a private path, the digest the base itself publishes is used only under --trust-base-checksum. The same rule covers that fallback: once a base's own digest is trusted it is the root of trust, so its digest request answers to the identical no-cross-host rule.

  4. The algorithm is SHA-256; SHA-1 is the exception, not the fallback. The expected value is the official <archive>.sha256. An object for which the official endpoint publishes only SHA-1 (or MD5) is verified only under --allow-weak-checksum, which warns. A missing expected digest is a failure, not a pass: no archive is installed unverified. .meta4 is not used in the first version. --trust-base-checksum and --allow-weak-checksum are independent โ€” the first decides whose digest is trusted, the second which algorithm is accepted โ€” and a private base that publishes only SHA-1 needs both.

  5. The bytes stream to a .part file and are renamed only when whole. The digest is computed as the bytes arrive; on any failure the .part file is removed. The cache (D5) keys on the archive URL together with this verified digest, so a cache hit re-verifies nothing it has not already proven.

  6. Redirects are bounded and may not weaken the scheme; a host is not a security property. At most 10 hops; an https URL may not redirect to http. No host allowlist is kept: the official endpoint redirects archives to mirrors it names, and the bytes are verified against the digest that endpoint served directly (decision 3), so the host that finally serves them needs no prior approval. A downgrade or a runaway chain is refused; where the bytes came from does not have to be.

  7. The repository model carries the digest the metadata declares, named for its algorithm. ADR-001 ยง4's Archive.SHA256 becomes Archive.SHA1 (json sha256 โ†’ sha1). The SHA-256 the tool verifies against is the transport layer's, obtained from the sidecar as in decision 3 โ€” not a property of the repository metadata. This amends ADR-001 ยง4. No producer emits the field yet, so the rename costs nothing now and would be a contract change once --json output exists.

  8. No new exit codes, and the two scheme refusals split by where they come from. A scheme refused before any request โ€” an http/ftp --base given without the switch โ€” is ADR-002's 2 (usage): it is the command line's mistake, it is deterministic, and fixing it means changing the command. A downgrade discovered in transit (an https URL redirecting to http), a missing or mismatched digest, a digest request redirected to another host, and a weak digest used without the switch are 5 (integrity or security). An unreachable host, a timeout, a TLS failure, an HTTP error status, and a redirect loop are 4 (network or mirror). A TLS failure is 4, not 5: this model does not rest on TLS, it rests on the digest the official endpoint served (decision 3), and TLS is only how that trust root is reached โ€” failing to reach it is an arrival failure, not a verdict on the bytes.

Consequences

  • A mirror cannot change what the bytes must hash to (3), cannot win by being chosen at random (2), and cannot be reached over a weaker scheme than the source it replaces (1, 6). Its host is deliberately not a security property, which is what removes the allowlist a stricter reading would have needed.
  • The official endpoint has to be reachable even when a mirror is configured. That is the cost of separating the digest source from the download source, and it is why --trust-base-checksum exists for a private base the official endpoint does not know.
  • model.Archive names SHA-1, because SHA-1 is what the metadata declares. The stronger digest is a transport fact rather than a repository fact โ€” the honest split.
  • A listing or an Updates.xml is not checksum-verified: the digest proves an archive's bytes, not the metadata's. A mirror can therefore serve misleading metadata โ€” a hidden version, a wrong package list โ€” but it cannot make unverified bytes land, because every archive is checked against the digest the official endpoint published. Metadata integrity is outside this decision.
  • A Qt release that publishes only SHA-1 forces --allow-weak-checksum; the warning says which object and why.
  • --base being repeatable, where the reference tool's is single-valued, is a strict superset of its behaviour and gets its own docs/compatibility.md row when the flag lands.
  • These are deliberate departures from the reference tool (schemes, mirror choice, digest source), so each gets a row in docs/compatibility.md when the code lands.

Explicitly not covered

The cache's key, location and prune semantics (D5); official-installer support (D7); a configuration-file layer for mirrors (deferred until a feature such as commercial licensing needs one โ€” here mirrors are command-line only); the CLI spelling of any flag beyond the names used here; _meta.7z package metadata, which follows the same rule when it lands; and progress events.

Alternatives considered

  • Accept http/ftp as the reference tool does. Rejected: it is the clearest security regression the plan set out to fix, and a downloader's whole job is to be trusted about bytes.
  • Pick a fallback mirror at random. Rejected: it makes a run unreproducible for no benefit, and a fixed order is no worse at spreading load for a single-user CLI.
  • Ship a curated mirror list, as the reference tool does. Rejected: the official endpoint already redirects archives to a nearby mirror, so a shipped list buys little while becoming a set of hosts to keep current and to trust. A mirror is named on the command line when one is wanted.
  • Keep a host allowlist and refuse every other host. Rejected: the official endpoint redirects archives to mirrors it names, so the allowlist would have to be exactly that mirror set. Content verification (3) makes the host unnecessary, so the list is not kept.
  • Trust whichever host served the archive to also state its digest. Rejected: it makes the mirror the authority on the bytes it sent, which is the threat decision 3 removes.
  • Verify the Updates.xml SHA-1 by default. Rejected: it is the weaker declaration, and a default that reached for it would erase the difference between the metadata and the sidecar. It stays behind the switch that says it is weaker.
  • Keep Archive.SHA256 and add SHA1. Rejected: it leaves a field naming an algorithm the repository never publishes and that no code would ever fill.
  • A general-purpose digest type ({Algo, Value}). Rejected for now: one algorithm is the whole of the repository's vocabulary, and a type that can hold many when one is used is structure without a reason.
  • Put every refusal on one exit code. Rejected: ADR-002 already separates "could not reach it" (4) from "arrived but untrustworthy" (5), and a caller retries one and not the other.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ