Skip to content

ingest: the pgp-sums rung, a PGP-signed digest list as proof of origin, bound to its release (REQ-SIGNEDSUMS-001) - #344

Open
avrabe wants to merge 24 commits into
mainfrom
feat/pgp-sums-rung
Open

avrabe wants to merge 24 commits into
mainfrom
feat/pgp-sums-rung

Conversation

@avrabe

@avrabe avrabe commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

This PR adds the pgp-sums ingestion rung, discharging REQ-SIGNEDSUMS-001 for v0.47.0. A publisher's PGP-signed digest list now counts as proof of origin, and is never confused with an unsigned list. It also carries the REQ-VERIFYPAR-001 rewording, a rivet-only commit.

What it does

Input. The producer's committed tool entry gains four fields:

Field Meaning
pgp-sums The digest list's asset name or template
pgp-sums-signature The detached signature; defaults to the list name plus .asc
pgp-sums-format sha256sums (default) or rust-channel
pgp-key-file The pinned OpenPGP certificate, relative to the input file

The key lives in producer input, not in the realm. The maintainer decided this on 2026-11-08 (factory-notice 20261107T163508-5f01e8): a realms-file field would make every released varve refuse the realms file, and consumers never verify the upstream signature themselves. Clauses 2 and 3 are reworded to match, and DD-034 notes the change. The key is never fetched, and never read from beside the artifact.

Ladder position. cosign-sums, then build-provenance, then pgp-sums, then upstream-sums. A failure of any kind is fatal and never falls through to a weaker rung. That covers a bad signature, another key, a revoked certificate, a text-type signature, a weak digest, a missing signature, a fetch error, an unparseable list, an absent asset, a digest mismatch, and the wrong release.

Clause 5. Digests are reachable only through a VerifiedList, whose fields are private. The ladder's PgpSumsProbe::Verified can be built only from one. A compile_fail doctest holds this.

What the signed manifest records:

  • proof = pgp-sums.
  • proof-signer = openpgp:<primary fingerprint> (<user ID>). The identity is the fingerprint; the user ID is only a label.
  • A separate proof-signing-key field, holding the fingerprint of the key that actually signed.
  • proof-asserts, which names the release the list was bound to.

An older varve reads an unknown proof string as UnknownProof and prints it. It does not refuse the layer.

OCI encoding: it refuses to encode any pgp-sums field rather than silently drop the mechanism.

Which key may vouch (clauses 1 and 3)

A subkey vouches only if all of these hold:

  • Its newest binding from the primary states SIGN. A binding with no flags refuses, and in a same-second tie the binding without SIGN wins.
  • It carries the 0x19 back-signature. rPGP enforces this.
  • No entry with its fingerprint carries a SubkeyRevocation.
  • Its binding and back-signature meet the 256-bit digest floor.

The primary key:

  • It refuses if the newest direct-key signature, or the newest certification on a non-revoked user ID, states flags without SIGN.
  • It refuses if no eligible self-signature remains.
  • If both are silent, it refuses whenever any self-signature anywhere stated flags. A certificate that states no flags at all, like Rust's, is unrestricted.
  • Deliberate exception: the primary's own self-certification is not held to the 256-bit floor, because Rust's release key certifies itself with SHA-1 and must keep verifying. A test pins this. That signature only states flags and grants the pinned key nothing.

The data signature:

  • It must be binary type, with a digest of at least 256 bits.
  • It needs a hashed creation time.
  • Critical hashed subpackets other than creation time and issuer are refused.
  • Signature, binding and key expiry are recorded in clause 6 as not checked, for want of a trusted clock.

Release binding (clause 7, new)

A signature says who published the digests, not which release they belong to.

sha256sums format:

  • %V or %R must expand to a token that names exactly one release: MAJOR.MINOR[.PATCH] with an optional suffix, or a full date.
  • The token is refused if it contains wildcards, or moving words such as latest, lts or stable.
  • It is also refused if its last segment is a cycle label (nightly, snapshot, dev and similar) not followed by a number.
  • /, % and whitespace are refused.
  • Both the list name and every asset template must carry the version.
  • At ingestion, every expanded asset name must contain the concrete version on a token boundary.

rust-channel format:

  • A stable channel binds by version.
  • A prerelease (-nightly, -beta) binds by the manifest's date.

Plans that disagree. Plans of one (repo, release) group that disagree on pgp-sums or upstream-sums are refused before anything is fetched.

Limits stated in the docs:

  • This is a check on names, not a cryptographic binding.
  • A hub release whose payloads carry different versions cannot use pgp-sums. It fails closed.

Reviews

Clean-room review of 1bfdc9d. Of nine claims, four were confirmed (R2, R3, R5, R8), four partial and one confirmed with one test that cannot fail.

  • Medium-high: a revoked signing subkey was accepted.
  • Medium: nightly replay; moving tags; the probe type could be built without verifying.
  • Low: the subkey was not a separate field; the documentation had drifted.

All fixed, each with a test that fails before the fix.

Adversarial review of 866f161, focused on key usage and release binding.

  • No forgery found: no signature that should not count got through without the upstream's private key or an altered pinned certificate.
  • Medium: channel-wide prerelease labels passed as one release.
  • Low:
    • a substring version re-check;
    • weak-hash bindings;
    • critical notation and a missing creation time were accepted;
    • per-entry revocation;
    • same-second ties decided by packet order.

All fixed, each with a test that fails before the fix.

Automated security reviews raised:

  • the group-disagreement fail-open;
  • the replay of an older signed list;
  • moving tags;
  • the newest-self-signature and restriction-stated-anywhere rules;
  • a loose names_a_release.

All fixed, each with a test.

Not in this PR. The same replay gap in the existing rungs (cosign-sums, build-provenance, upstream-sums) is tracked privately as a draft security advisory and will be fixed in a following v0.47.0 PR.

Evidence

Negative controls, each restored after:

Change to production code Red tests
Ignore a failed pgp rung 10
Disable the plan-time version check 3
Make channel_is_for always accept 2
Drop the subkey revocation check 1
Raw member paths, from the earlier review fixes 1

Mutants on the final tree, restricted to the changed files:

Shard Tested Caught Unviable Missed
pgp (pgpsums.rs) 85 70 15 0
varve-producer (plan.rs) 87 79 8 0
forge (orchestrate.rs) 79 70 9 0
delta (delta.rs and ingest.rs), earlier round 48 45 3 0

Rust channel end to end: the real channel-rust-stable.toml and its .asc verify against Rust's real key and yield 1213 (url, digest) pairs. An asset gets its digest from the verified list, and the proof signer starts with Rust's fingerprint.

Local gates, on fa8f1ec

  • cargo fmt --check: OK.
  • clippy with -D warnings: clean.
  • Workspace tests: 2105 passed, 0 failed.
  • tools unittest: OK.
  • rivet validate: exits 0.
  • rivet check verification-evidence, trace-gate, claim-check, tracepack-check and review-check all exit 0.
  • tools/systest/install-shadow.sh: exits 0.
  • shellcheck 0.11.0, -S warning: clean.
  • cargo deny check: exits 0.

🤖 Generated with Claude Code

https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu

avrabe and others added 24 commits November 8, 2026 09:33
…input

A [[tool]] entry can declare pgp-sums (the digest list), optionally
pgp-sums-signature and pgp-sums-format, and pgp-key-file (an OpenPGP
certificate, relative to the input file). The rung sits below cosign-sums and
build-provenance and above upstream-sums. A signature that does not verify
aborts the run and never falls to a weaker rung; the signer is recorded by
fingerprint. The list parsers are private to pgpsums.rs, so entries are
reachable only together with who vouched. REQ-SIGNEDSUMS-001 clause 3 now
records the maintainer's 2026-11-08 decision that the key is producer input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
With pgp-sums-format = "rust-channel" the real channel-rust-stable manifest,
its detached signature and Rust's release key verify through the ladder; an
asset named by one of the manifest's urls takes its digest from the verified
list and the signer leads with Rust's key fingerprint. Tampered, mis-formatted
and unnamed cases are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
A signature says who published a list, not which release it is for. A
sha256sums entry's asset names must now carry %V or %R (refused at plan time
otherwise), a Rust channel's own version must match the declared version or
release, and the recorded assertion names the release. Plans of one release
that declare different pgp-sums or upstream-sums are refused before anything
is fetched, instead of verifying with only the first plan's declaration.
REQ-SIGNEDSUMS-001 clause 2 and DD-034 now say the key is pinned in the
operator's committed producer input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
…o v0.48.0

The maintainer chose this on 2026-11-08 (factory-notice
20261107T193300-80b3fc), after clause 1's cold measurement
(VER-VERIFYPAR-001, runs 42402881731 and 42438484840).

The measured layer carries one large payload, so digesting payloads
concurrently gains almost nothing on any disk. On the cold runner,
reading was 72% of the wall time; on local NVMe, hashing would
dominate. Overlapping the read with the hash inside a payload helps in
both cases. Clause 2 now asks for that, with memory still bounded per
payload, and says why the first wording was set aside. Clauses 1 and 3-5
are unchanged. The requirement stays proposed and moves to v0.48.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
…elease

The rung already enforces this: d024695 added the versioned-name
refusal, the rust-channel version check, the recorded binding and the
refusal of split declarations. Until now no clause stated it, so the
tests that verify it pointed at text that did not say it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
A subkey the primary has revoked, or whose latest binding lacks the SIGN
flag, still produced signatures that verified, and verify_detached_signature
tried every subkey as a candidate. Keys now carry a standing: a revoked or
encryption-only subkey, or a primary whose own self-signatures state flags
without SIGN, is tried only so its refusal can be named (with the key's
fingerprint) and never vouches. A primary that states no flags (Rust's) may
still sign.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Every nightly of a cycle carries the same 1.99.0-nightly version, so a
version match let an older, validly signed nightly manifest stand in for a
newer one. When the manifest's rust version has a prerelease part (any '-'),
only its date binds; a stable version still binds by version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
The template check looked at text, so a version of `latest` still expanded to
the same names for every release and an older signed list covered it. Each
placeholder a sha256sums entry's templates use must now expand from a value
that names one release (digit-led, no moving-tag word), the list or its
signature must carry the release, and ingestion re-checks that every asset
name contains the concrete version or release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
The primary's flag check accepted the key if ANY self-signature had ever
granted SIGN, so a stale one outvoted a newer one that removed it. The newest
certification (on a non-revoked user ID, preferring the primary user ID's) or
direct-key signature now decides, as the latest binding already did for a
subkey. A deciding signature with no key flags states no restriction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Starting with a digit still admitted moving tags such as 1, 3.x, 2-lts and a
bare year. A version or release must now be MAJOR.MINOR[.PATCH] with an
optional prerelease or build suffix, or a full date; wildcards, fused words
(3lts) and moving-word suffixes are refused. Documented as a name heuristic,
not a cryptographic binding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
PgpSumsProbe::Verified had public fields, so any code could hold digests that
never came out of a verification. It now wraps a private-field struct built
only by PgpSumsProbe::from_verified(VerifiedList, ..), with a compile_fail
doctest to hold it there, and verify_release overwrites whatever Source::probe
reported with NotDeclared before deciding the entry's own state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
…atures

The primary's flag check now decides from the newest direct-key signature and
the newest certification on the primary (else any live) user ID, refuses if
either states flags without SIGN, refuses when nothing eligible remains, and
treats a restriction stated anywhere in the certificate, revoked user IDs
included, as binding when the eligible signatures are merely silent. A
certificate that states no flags anywhere (Rust's) is unrestricted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Clause 1 says the key that actually signed is recorded separately from the
identity, but a subkey fingerprint appeared only inside the free-text
assertion. pgp-sums now also writes eu.pulseengine.source.proof-signing-key
(the primary's fingerprint when the primary signed), carried through the
producer spec and the deposit, refused when blank or without a mechanism, and
shown by varve inspect --format json as proof_signing_key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
pgpsums.rs described pgp 0.20, cited a source line number, said the advisory
gate FAILS (deny.toml now records the ignores and cargo deny passes), and said
key flags were not consulted. The concept doc gains the concrete-version rule,
the nightly date rule, the signing-key annotation and the hub limit, and
REQ-SIGNEDSUMS-001 clauses 1 and 7 state the key-standing, concrete-version
and prerelease rules, including that the name check is a heuristic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
1.2.3-nightly, -nightly2, -SNAPSHOT, -dev, -unstable, -tip, -trunk, -develop,
-daily, -weekly and -latestbuild passed names_a_release although every build
of such a cycle expands to the same names. A cycle label now passes only when
a numeric segment follows it (1.2.3-nightly.20261108), and a token containing
/, % or whitespace is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
The ingestion re-check was a substring test, so declared 1.2 passed
tool-v1.2.3, tool-11.2 and tool-1.2.30-old. The version must now not be
preceded by a digit or '.', nor followed by a digit or '.' and a digit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
The 256-bit floor applied only to the signature over the list. A subkey whose
deciding binding, or the back-signature it carries, is made with a weaker hash
is now refused with CertificateSignatureWeak. The floor is deliberately NOT
applied to the primary's own certification: it grants the pinned key nothing,
and Rust's own release key certifies itself with SHA-1 and must keep
verifying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
A detached signature must state its creation time in the hashed area (RFC
9580 5.2.3.11), and any critical hashed subpacket other than creation time
and the issuer is refused: a critical notation, regular expression or expiry
says the signature means something this module cannot act on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
…side

A subkey is now revoked if ANY entry with its fingerprint carries a valid
SubkeyRevocation, so a duplicated entry without one no longer slips through,
and when two bindings share a creation second the one without SIGN wins.
REQ-SIGNEDSUMS-001 clause 6 records signature and binding expiry as not
checked, beside key expiry, for the same clock reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Whether a build number follows a cycle label is now a flag carried along a
reversed walk, which has the same meaning without the index arithmetic whose
mutation was equivalent and so could not be killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
… cap

On this PR's first run, cargo mutants (varve-producer) was cancelled at
90:17 and (varve-producer (advisories)) at 90:20, before either printed a
summary. Neither had a surviving mutant; both were too slow.

- varve-producer: plan.rs grew to 87 mutants, taking the shard to 455.
- advisories: unchanged at 404, but the larger producer lib suite made each
  mutant slower. It took 57-74 minutes before.

The split, per-file counts from `cargo mutants --list`:
- varve-producer (plan): 275.
- varve-producer: the remaining 180.
- varve-producer (advisories): advisory + cvss, 199.
- varve-producer (advisory watch): advisorywatch + statusdraft, 205.

The required check stays `cargo mutants (trust-critical gate)`, which
needs the whole matrix, so the new shards are gated without a ruleset
change. mutation_scope_is_decided_not_drifted stays green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant