Resolve layers by channel and verify the signed pointer #34
No reviewers
Labels
No labels
accessibility
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Keel-Linux/keel!34
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/channel-layout"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #33.
The client half of the mirror layout the maintainer decided on 2026-09-28. It
lands before the publisher writes the new layout (Keel-Linux/apt#14), and
that order is not incidental: a client that could not read the flat layout,
shipped after the mirror is converted, would strand every appliance already in
the field. Both layouts are read here and the flat one is untouched; removing
it is a separate, later change.
What it reads
keel pull --channel stablefetches the pointer, verifies it, refuses it whenit has expired, and resolves the revision it names. The digest chain is then
complete: the signature covers the sha256 of every manifest, and each manifest
covers the sha256 of its blob. A layer the pointer does not name is a mismatch,
because nothing signed says which manifest it should have.
--release YYYY-MM-DD --rev Nnames a revision directly and follows nopointer. That is rollback, and it always works, because a blob is never
deleted.
An expired pointer is an error
Exit 19,
CHANNEL_EXPIRED, never a warning. Without it a mirror that is stale,broken or hostile holds an appliance on an old and vulnerable release simply by
not updating, and the client cannot tell that apart from "nothing new". A
pointer that goes backwards is exit 20,
CHANNEL_ROLLBACK: a mirror does notmove an appliance backwards, an operator does.
Note what the expiry does and does not cover. The channel key is online, so it
protects against a stale or hostile mirror, which is a different machine
from the one holding the key. It does not protect against a compromised build
host, and nothing signed on the build host could.
What the review found, and what changed
Two of the claims below were false when this was first pushed, and both are
fixed with tests that fail without the fix.
A revoked or expired signing key verified. gpgv exits 0 for both and
still prints
VALIDSIG, withGood signature fromon stderr;GOODSIGisthe only line it withholds, substituting
REVKEYSIG/EXPKEYSIG. A pointersigned by a key revoked in the very keyring passed to
--channel-keyring,with
--channel-signerpinned to its fingerprint, fetched the layer at exit0. Since the channel key is the one unattended online key in this design and
revocation is the only answer to its theft, that made the answer do nothing.
GOODSIGis now required and the retirement lines refused by name. Provenend to end against really revoked and really expired keys, made in the test
helper.
--weak-digest SHA1too, because a SHA-1 pointer got its ownGOODSIG.--release/--revverified nothing at all. No pointer was fetched and nomanifest digest was checked, so a mirror serving no
layers/stableand arewritten release manifest put attacker bytes in the cache at exit 0 — on
the documented rollback path, which is used precisely when the mirror is
least trusted. Each revision now keeps the pointer that was signed for it at
layers/<release>/<rev>/revision;--release/--revverifies it by the samechain and requires it to name the revision it is filed under. Only the
expiry is not enforced, because an earlier revision is old on purpose, and
the output says so.
Also: an expiry now has a 30 day ceiling and a pointer signed in the
future is refused (unbounded, one signature is a permanent freeze and the
rollback refusal never fires); an expired pointer's message prints this
machine's clock and names a wrong local clock as one of the three
explanations; a flat pull says it read no pointer instead of printing what a
verified pull prints; a
.hashfile is bounded and dropped when itdisagrees with the manifest, so the mirror cannot choose
keel verify's exitcode; and the two strings saying no trusted key is configured, which this
change makes false, are gone.
On the tests, which mattered more than the number. The suite now fails
rather than skips when no verifier is installed — with
gpgandgpgvoffPATHthe gate passed at 97 percent withsignature.pyat 15, and a gatethat passes over unrun verification code is the shape this project keeps
fixing.
--channel-signerhas end-to-end tests at both call sites and inits one reader; all three mutants that previously survived now die. The
blob-sharing test names the other release instead of resolving the same one
twice, and the
parent_sha256test asserts the substring.1010 tests, 100 percent line and branch, and CI shows
signature.pyat 100percent, which is how you can tell the verifier actually ran.
Two properties of gpgv, measured rather than assumed
gpgvis the verifier because it refuses to do anything but verify: it cannotbe talked into importing a key, consulting a trust database or asking an agent.
It is not assumed to be installed. The first draft here said it is on every
appliance because apt verifies
InReleasewith it. That is no longer true — onDebian 13 apt verifies with
sqvandgpgvis a package of its own, whichKeel-Linux/apt's own README already records for
bin/verify-repo. The packagenow depends on
gpgv, because the one refusal a client must never make quietlyis "I could not check this".
gpgv --outputproduced the body of a tampered pointer and of one signed bya key outside the keyring, in both cases beside a non zero exit. The output
is therefore read only after the exit status and a
VALIDSIGline have beenaccepted, and a refusal raises instead of returning text.
this project publishes keys in, gives
NO_PUBKEYand exit 2. An armoredkeyring is accepted and dearmored in memory.
Both have a test that fails if the behaviour is ever taken on trust.
keel inspect
The channel and revision are recorded in
/var/lib/keel/channeland reportedas
layers.channelandlayers.revision.--check-channelasks the mirrorwhat that channel now holds and says whether the instance is behind; it is the
only network request
inspectmakes, so it is off by default, and itsrefusals win over
INSPECT_INCOMPLETE.Tests
1010 tests, 100 percent line and branch coverage (
coverage report, gate is95). The adversarial cases each have a test: an expired pointer, a pointer
signed by the wrong key, a pointer signed by a key that is in the keyring but
not allowed to move a channel, a pointer altered after signing, an unsigned
pointer, a pointer for another channel, a manifest whose bytes do not hash to
what the pointer signed, a manifest the pointer does not name, a blob that is
not what the manifest records, a rollback, and one blob serving two releases
and fetched once.
gnupgandgpgvwere added to the workflow's apt packages: without themthose tests skip, and a skipped suite would pass the coverage gate on code
nothing ran.
Related
Reviewed as security code. Everything marked measured was run here on Debian 13.7 / gpgv 2.4.7 against this branch; everything marked read is from the diff.
CRITICAL — a revoked or expired signing key verifies
keel/layers/signature.py:141-153treats any zero exit fromgpgvas success, and:156-166accepts anyVALIDSIGline. Neither is sufficient. Measured, gpgv 2.4.7:GOODSIG,VALIDSIGREVKEYSIG,VALIDSIGREVKEYSIG,VALIDSIGKEYEXPIRED,EXPKEYSIG,VALIDSIGstderr says
Good signature fromin all four.End to end against this branch, not at the gpgv level — pointer signed by a key that is revoked in the very keyring passed to
--channel-keyring, with--channel-signerpinned to that fingerprint:Same result for an expired signing key (
EXIT=0, layer fetched,/var/lib/keel/channelwritten).Note the subkey row.
VALIDSIG's last field is the primary fingerprint, and_check_signers(signature.py:169-180) intersects against both, so pinning--channel-signer <primary>matches a signature made by a revoked subkey of that primary. That is the exact shape ofapt/keys/keel-archive-keyring.asc, which already carriessub D276B62C2BD16F4E ... r— a signing subkey this project has already had to revoke once.Where it bites. The channel key is, by design, the unattended key on the build host — the most likely key in the project to be stolen. The one response to that is to revoke it and ship the revocation in
keel-channel-keyring. With this code that does nothing: every appliance keeps accepting the thief's pointers. Worse, it keeps accepting them until a new keyring reaches every appliance, and preventing a new keyring from reaching appliances is exactly what the stolen key can now do — pin them to a stale revision with a long expiry (see the MEDIUM on TTL below).Fix, one line each: require a
GOODSIGline and not onlyVALIDSIG— gpgv emitsGOODSIGonly when the key is neither revoked nor expired, and replaces it withREVKEYSIG/EXPKEYSIGotherwise — or refuse outright whenREVKEYSIG|EXPKEYSIG|KEYREVOKED|KEYEXPIREDappears on the status fd. The same applies toapt/lib/channel.sh:170.Related, same call site:
VERIFIER = ("gpgv",)atconstants.py:79passes no--weak-digest SHA1. A SHA-1 clearsigned pointer verifies today (measured: exit 0,GOODSIG,VALIDSIG).HIGH —
--release/--revverifies no signature at all, and the output does not say sokeel/layers/pull.py:471-476. The by-release branch buildsPlaces(release, rev)withdigests=None, soPlaces.digest_problem(:198-201) returnsNonefor every layer, and no pointer is fetched. The chain the whole PR is about — signature → manifest digest → blob digest — is simply absent on this path.Measured against a hostile mirror that serves no
layers/stableat all and has had2026-09-28/1/core.manifestrewritten to point at an attacker blob:docs/layers.mdcalls this "how a rollback is asked for". Rollback is the operation an operator performs after something has gone wrong, against the mirror they have the least reason to trust, and it is the path with no verification on it.keel/layers/pull.py:176-177is honest about it in a docstring; nothing the operator sees is. Passing--channel stablealongside--release/--revdoes not help —by_releasewins at:100-110and the pointer is never fetched.The publisher signs a release
MANIFESTwith the release key and now recordsrevisioninside it (Keel-Linux/apt#14). The client never fetches it. Either resolve--release/--revthrough that signed MANIFEST, or at minimum makeresolution_line()say the revision was not verified.HIGH — nothing ships the default keyring, so the feature is dead on a stock appliance
constants.py:24defaults to/usr/share/keyrings/keel-channel-keyring.gpg.debian/keel.installshipsexamples/instance.yamland the firstboot hook and nothing else; no repository in the project produces that path (keel-transitionshipskeel-archive-keyring.gpg,apt/keys/publisheskeel-staging-keyring.asc), anddebian/controlgainsgpgvbut no keyring dependency.keel pull --channel stableon an installed appliance exits 18 on a missing file.It fails closed, which is right. But the feature ships unusable, and the one refusal an operator will actually see looks identical to an attack.
HIGH — the coverage gate is green when no verifier is present
.github/workflows/tests.yml:20-23reasons correctly that a skipped suite would pass the gate on code nothing ran, and then relies entirely on theapt-packages:line to prevent it. Measured: re-running the exact CI command withgpgandgpgvoffPATHgives897 passed, 74 skipped,TOTAL … 97%, andcoverage report --fail-under=95exits 0.keel/layers/signature.pyis at 15% on that run — the entire verifier unrun — andpull.pyat 76%.The project already has the right guard one file away:
tests/test_hook_bats.py:32-36fails rather than skips whenCIis set.tests/channel_helpers.py:44-49has no equivalent.HIGH —
--channel-signerhas no end-to-end testgrep -rn "channel.signer\|channel_signers" tests/returns nothing. Mutation run: replacing the signers argument with()atkeel/commands.py:383(pull) andkeel/commands.py:259(inspect --check-channel) leaves all 971 tests passing at 100% line and branch. The flag works today (measured: exit 18, "which is not the key that may move a channel"); the net under it does not exist. It is also the clearest demonstration that 100/100 is not the property being claimed for this suite.(Credit where due: 17 of 19 other mutants died. Removing the signer allowlist, the expiry refusal, the manifest-digest check, the whole signature step, the rollback check, the blob digest check, the channel-name binding, the no-keyring refusal,
expired()'s>=, and theSIGNATURE_PRESENTwording each turned tests red. The adversarial cases assert behaviour: the two keys really are two distinct keys, the tampering is inside an intact clearsign envelope so verification and not parsing is what refuses, and the unsigned case uses a body that would otherwise validate. This is a good suite.)MEDIUM — no ceiling on
expires_at, in either directionchannel.py:206-220requires onlyexpires_at > signed_at. Measured, both accepted, exit 0:expires_at 2126-01-01T00:00:00Zsigned_at 2076-01-01T00:00:00Z(fifty years in the future)This is the attack on decision 0016's argument. The note says the online key "can only say which of those is current, and until when. That is all." Unbounded, "until when" is a permanent freeze primitive: one signature pins an appliance to a known-vulnerable already-staged revision indefinitely, and
is_rollbacknever fires because the revision does not go backwards. The attacker does not even need to keep re-signing, which is the weaker version the note concedes. The publisher takesKEEL_CHANNEL_TTL_DAYSunvalidated (apt/lib/channel.sh:64,198), so nothing bounds it at either end — and "lengthening the expiry until it means nothing" is the exact failure mode the note says the whole design exists to prevent.A client-side maximum (
expires_at - signed_at <= N days, andsigned_at <= now + skew) turns "forever" into "N days" and costs two comparisons.MEDIUM — clock skew is invisible and misdiagnosed
channel.py:85-97. Clock ahead: every pointer is expired, exit 19, and the message is "the mirror is not being updated, or is holding this instance back" — it never printsnowand never mentions the local clock. A fleet-wide NTP failure sends the operator to the mirror. Stalled rather than bricked, since the flat layout and--release/--revstill work, but the diagnosis is actively wrong.Clock behind: dead pointers still look fresh, and the freeze attack the expiry exists to stop succeeds. An LXC container restored from a snapshot, or one on a host with a bad RTC, is exactly that case. Print
nowinstaleness(), and refuse asigned_atin the future.MEDIUM — a flat pull is indistinguishable from a verified one in the output
PullReport.resolution_line()(pull.py:155-166) returnsNonefor the flat layout, so a pull that verified nothing prints the same layer lines and the same summary as one that checked a signature.--channelhaschoices=, so a typo is caught — but forgetting it entirely is not. One line saying the flat layout follows no pointer would close it.MEDIUM — an unauthenticated
.hashfile decideskeel verify's exit codepull.py:348-366writes whatever the mirror serves at<tarball>.hashinto the cache with no validation;verify.py:196-226then reads it. Measured, four mirrors differing only in that file, byte-perfect tarball and a validly signed pointer in every case: no.hash→ exit 9 andok; truthful unsigned → exit 8;.hashlying about the sha256 → exit 7LAYER_MISMATCHon a layer that matches its signed manifest exactly; two PGP marker strings and no signature → exit 8 "signature present".It can only downgrade a verdict, never upgrade one —
check_tarballhashes the real bytes independently — so this is not a trust hole. But a mirror picks the exit code out of {9, 8, 7} at will, and exit 7's documented meaning (exits.py:39) is a false statement about the layer.MEDIUM —
fetch_hash_filereads without a boundpull.py:359-360,fob.read()overurllibwith no size cap. The tarball path is careful about exactly this (:326-331streams inREAD_CHUNKand breaks pastlayer.size); the.hashpath is not.MEDIUM — with no
--channel-signer, any key in the keyring may move a channelsignature.py:172-173. Documented atdocs/layers.md:286, and measured: keyring holding both keys, no--channel-signer, pointer signed by the second key → accepted, exit 0. Decision 0016's whole argument for putting an online key in this role is that it is a strictly weaker role than the release key. That separation is opt-in, and nothing in this repo, indebian/, or in any hook opts in.On
keel verifyIt does not make an unearned claim. The status word is
unverified, the strings are "signature present, not verified" and "hash file present, not signed", the exit constant isSIGNATURE_UNVERIFIED, andtests/test_layers_verify.py:153pins the wording (rewording it to "signature verified" turns two tests red). Shipping channel verification alongside it is fine.What is not fine to leave is the reason it gives.
verify.py:46-48says "(no trusted key configured)" andhashfile.py:7-8says "checking the signature needs a trusted key, which the project does not have yet". This commit configures a trusted key. Both sentences become false in the same change that makes them false, and an appliance can now print "no trusted key configured" seconds after verifying a signature with one. Fix the two strings here.The deeper version is a follow-up, not a blocker, but worth naming:
add_channel_optionswas factored out atcli.py:270-271as "what a channel pointer is verified against, wherever one is read", and then given topullandinspectand not toverify(cli.py:164-170). The one command whose job is to say whether what is installed can be trusted is the one command with no keyring.LOW
signature.py:55-84dearmor. On the question of whether a crafted armored blob can produce a trusted keyring: no, and the reason is reachability rather than the parser — the input is only ever--channel-keyring/$KEEL_CHANNEL_KEYRING, a local path, never anything fetched. Worth saying so in the docstring. Two parser edges while here:data.split(ARMOR_START)[1:]silently discards everything before the first armor header, so a keyring holding binary keys followed by an armored block loses the binary ones; and a file armored under any other header is passed through raw and yieldsNO_PUBKEY, the same message as a wrong key — which is the second gpgv trap recurring in the code written to avoid it.verify.py:213returnsSIGNATURE_ABSENT("hash file present, not signed") when the hash file exists but could not be read, asserting a signature state for bytes nobody saw. Masked today becauseproblemsis non-empty and the note is discarded; a latent false claim.tests/test_layers_channel_pull.py:110,test_one_blob_serves_two_releases_and_is_fetched_once: both pulls resolve the same directory2026-09-29/1, so it restates the test at line 130 and thelen(blobs)==2assertion is a property of the fixture. The property does hold (checked by pulling the other release:['cached','cached'], 0 bytes). One-word fix: make the second pull name2026-09-28.tests/test_layers_channel_pull.py:294survives deletion of theparent_sha256check — the flat tarball fallback makes the download digest check produce the same exit 7. The check is covered bytest_layers_pull.py; assert theparent_sha256substring here.Verified as claimed
Miss=0,BrPart=0, not a blended number).--outputfor a signature it refused — reproduced for a tampered body (exit 1,BADSIG, full plaintext written) and for an untrusted key (exit 2,NO_PUBKEY, full plaintext written). The module reads the output only after the status is accepted, and raises rather than returning text. Correct, and the right call.NO_PUBKEYand exit 2, with a stderr last line identical to the wrong-key case.aptDepends: sqvand not gpgv, andgpgvisPriority: optionalin the trixiePackagesindex.keel-wordpress/COVERAGE.md:158-161records the project measuring the same thing. The other half also holds:gpgvis in trixie main, so the dependency installs. One thing to check before merge that I could not: the same COVERAGE note records a CI runner with no IPv4 route reaching only the Keel archive and nothing of Debian's, which is where a new Debian dependency onkeelwould first bite.channel.py:223-232, exit 17,"the pointer must name at least one layer". Measured.refreshcannot change the revision — verified by readingapt/lib/channel.sh:227-249: release, rev and the layer lines all come out ofchannel_verified's output, never out of the staged tree. Onlysetmoves a channel. Good.local a="$1" b="$dir/$a"shape — swept every shell file in every Keel repository and worktree with a detector validated against the known-bad shapes: zero remaining occurrences. Clean.make_test_keyshape —apt/tests/helpers.bash:55-78now looks both keys up by user id. Correct. Small hardening:gpg --list-keys <uid>matches substrings, so"=Keel Test Key <test@example.invalid>"is the exact-match form if a third key is ever added. No other position-selected-where-identity-matters helper found in the repositories.Block. The CRITICAL is one added status-line check away from being closed, and the HIGH on
--release/--revis the same digest plumbing that already exists on the channel path. Everything else here is good work — the digest chain, the refusal ordering infetch_channel, the reading of--outputrather than the file, the exit-code taxonomy, and a mutation-resistant test suite — which is why the two holes are worth closing before this becomes the thing every appliance trusts.Verified every finding of the review as fixed on
94d5792(mutating the GOODSIG check turns 9 tests red), and checked end to end against apt#14's publisher.28c674bonly aligns the rollback docs with it: the revision pointer is the first one signed, written once, so--release/--revworks for a revision a channel has named.