chore(npm): parse and shape-check an attestation bundle (S11 Provenance Attestations plan: 3/20)
Summary
S11's Step 4a refuses a provenance bundle on its shape, and refuses a keyed one outright, before anything reaches storage. That verdict is a pure function over the bundle's bytes, and nothing in this package parses a sigstore bundle today. This MR is that function and its two refusal sentinels. It is Step 3 of docs/plans/2026-09-11-npm-provenance-attestations.md, one of that plan's three roots. The plan itself landed as docs(plans): add the S11 npm provenance attesta... (!2648 - merged) • Hayley Swimelar • 19.4, over the S11 amendment docs(specs): amend S11 with npm provenance atte... (!2469 - merged) • Hayley Swimelar • 19.4.
The field set matters more than the function. Step 7 compares the subject this parser exposes against the streamed tarball, and Steps 9, 12, and 13 store and serve the predicate type it reads out, so each is written against these three fields. Nothing calls the parser until Step 17 wires it, the accepted smell the plan records for this step.
The calls the diff does not explain:
- The bytes answer
utf8.Validbefore anything decodes them. A decoder that repairs an ill-formed byte to U+FFFD admits a bundle whose recorded subject is not the one a consumer reads, and leaves a later check nothing to see, so the check is explicit rather than a property of whichever decoder this file uses. The decoder's own strictness sits behind it: an escaped\ud800is well-formed UTF-8 on the wire and the decode is what refuses it. - The three decodes go through
encoding/json/v2.encoding/jsonv1 matches member names case-insensitively and takes duplicates last-wins, so a decoy"PAYLOAD"beside the conforming"payload"decided this parse while every sigstore consumer read the other one. Both fixes are v2 defaults, invisible at the call site, so raw-literal rows pin them by the outcome a consumer sees: optingMatchCaseInsensitiveNames(true)orAllowDuplicateNames(true)back in reddens the suite, and reverting to v1 does not compile. signaturesandsubjectcarry at most 8 entries,predicateTypeat most 256 bytes. The numbers are S11's, ruled on 2026-09-21, and thedocs(specs)MR that states them is in flight, so this step implements them ahead of its merge on the spec author's word. The two array ceilings refuse mid-decode through ajsontext.Decoder, so the array past them is never built: a 128 KiB bundle carrying 43,506 signature entries costs 0.02 MB and 76 µs, against 6.59 MB and 7.2 ms for the same bytes decoded into a plain slice. The checked-in fixture carries one signature, one subject, and a 30-byte predicate type.- The keyed refusal reads every signature entry, not
signatures[0]alone. The same ruling. A certificate-backed first entry followed by a keyed second one was stored, which hands consumers thenpm audit signaturesfailure the refusal exists to prevent. - The refusals are otherwise the spec's enumeration and nothing more. A
verificationMaterialcarrying anx509CertificateChainwhosecertificatesarray is empty is admitted, because the spec enumerates "carrying neither" and an emptiness check here would invent a refusal. Whether a certificate verifies is npm hosted: verify the signature and certificat... (#1276) • Unassigned. A test pins the admission so the reading is deliberate rather than accidental. mediaTypeand the statement's_typeare both read for nothing.@sigstore/bundledeclares four spellings ofmediaType, and real statements carry bothin-toto.io/Statement/v0.1and/v1, so a check keyed on either spelling silently refuses the other half of what producers emit. The spec says so formediaType, and_typeis the same trap.- The shape checks run before the keyed check, so a bundle that is both
malformed and keyed answers
attestation_invalid. The spec pins no order, and no case it enumerates observes one, so a three-arm test pins it here rather than leaving it to the next edit. npm's own publish attestation carriespublicKeymaterial, so a real keyed bundle fails the shape check first and never reaches the keyed refusal. - The subject digest is read as
jsontext.Value, notmap[string]any. The obvious map decodes every sibling digest algorithm a publisher chooses to send, costing 4.20 ms/op and 2,006 KB of churn against 0.36 ms and 105 KB for the raw form on the same input. It also moved one verdict: the map forced every digest value throughfloat64, so it refused a digest number past that range, which the raw form admits. The spec enumerates no digest typing, so admitting is the correct behavior, and a test pins it. fuzz:npm's timeout moves 10m to 15m, because this MR adds the job's 10th fuzz target. On the pipelines of chore(npm): register the attestation codes, lab... (!2773 - merged) • Hayley Swimelar • 19.5 that job ran 347 s, 358 s, and 526 s against a 10m cap, a 179 s spread driven by build-cache state, since.go-cachecachesGOCACHEtoo. The new target adds 30 s of fuzzing plus the first compile of the npm handler test binary, 24 s locally against an empty cache and more on a small runner. Against the slowest run already observed that leaves under 20 s of headroom, on a job whose own run-to-run spread is nine times that.fuzz:mavenalready runs 5 targets at 15m.
attestationBundle carries one subject rather than a slice, since the shape
check already refuses a subject whose length is not one.
Governing ADRs
scripts/adr-freshness.sh reports the mirror one commit behind upstream, on
012_usage_data_collection.md alone, which adds a namespace-created event to
that ADR's catalog. This step emits no usage data and no event, so the ADRs
below read current.
- ADR 023
locks
internal/format/<name>/and enforces cross-format isolation and raw-SQL blocking through depguard. The new files sit insideinternal/format/npm/and import the standard library alone, so no sibling format and nodatabase/sql. That ADR's own "provenance" passages are its demo-era cross-cutting benchmark, unrelated to npm provenance. - ADR 009 enumerates the npm routes and the attestations route is absent. This MR mounts no route, so the gap stands where the spec records it, as a Step 18 precondition.
- ADR 004
enumerates the application limits. It carries the container analogue of the
three ceilings this step adds, a 250 KB manifest payload and a 200-reference
count, and no attestation bundle row. The three here are S11's, refused at
parse as
attestation_invalidrather than configured, which is the shapemaxDeprecateVersionTargetsalready takes in this package without an ADR row. The configurable one,npm.max_attestation_bundle_size, still arrives in Step 15.
Testing
mise exec -- go test ./internal/format/npm/... passes. The table covers the
five shape refusals and the keyed refusal against their sentinels, the four
mediaType spellings and an absent mediaType as admitted, both statement-type
spellings, and the predicate type on provenance/v0.2 and provenance/v1.
Degenerate input is its own case: nil, empty bytes, {}, [], null, a bare
number, and non-JSON all refuse rather than panic.
Four tables carry the shapes the option builder cannot mint, as raw JSON literals: the member spellings a decode must not accept, each ceiling at its boundary and one entry past it, bytes that are not UTF-8, and a key id of the wrong JSON type. Deleting any one of the refusals they cover reddens a row, the outer decode check included, which is what the builder-minted rows could not reach.
FuzzParseAttestationBundle asserts no input panics and that every refusal
wraps one of the two sentinels. That second property is the load-bearing one: an
unclassified error escaping would be mapped to a 500 by a later step where the
spec says 422. Its seed corpus reaches every branch in the parser, which is all
a run without -fuzz executes, and the registered CI target is what gives it a
coverage-guided run.
One real GitHub-published bundle under internal/format/npm/testdata/attestation/
parses and yields its predicate type, so the shape rules are checked against a
producer's bytes and not only against minted ones.
No e2e scenario is affected. The provenance rows in
docs/testing/e2e/npm.md
flip in Steps 15, 17, and 19, and this step adds no runtime surface for one to
exercise. No docs/user/ page is affected for the same reason: nothing here is
reachable by a customer until the publish path calls it.
Context for LLM agents
Spec coverage
Spec: docs/specs/S11-npm-hosted.md, Step 4a. Plan:
docs/plans/2026-09-11-npm-provenance-attestations.md, Step 3.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-1 | A bundle carrying a non-empty keyid on any dsseEnvelope.signatures entry is refused, distinctly from a malformed one |
TestParseAttestationBundle_RefusesKeyed |
| AC-2 | Shape validation fails for each of a missing dsseEnvelope, an empty signatures[0].sig, a verificationMaterial carrying neither certificate member, a payload that does not base64-decode, and a subject array whose length is not one |
TestParseAttestationBundle_RefusesInvalidShape, TestParseAttestationBundle_RefusesDegenerateInput, FuzzParseAttestationBundle |
| AC-2a | Bytes that are not UTF-8, a member spelled otherwise than the wire spells it, and a duplicated member are each refused | TestParseAttestationBundle_RefusesIllFormedText, TestParseAttestationBundle_ReadsTheExactMemberSpelling |
| AC-2b | signatures past 8 entries, subject past 8 entries, and a predicateType past 256 bytes are refused, and each ceiling admits its boundary |
TestParseAttestationBundle_RefusesPastItsCeilings, TestAttestationCeilings |
| AC-3 | Any of the four mediaType spellings, and no mediaType at all, are admitted, because the member is read for nothing |
TestParseAttestationBundle_Admits |
| AC-4 | The predicate type comes from the statement rather than from a value the handler chose | TestParseAttestationBundle_Admits, TestParseAttestationBundle_RealFixture, TestNewAttestationBundle_RoundTrip |
| AC-5 | The subject digest and purl are compared against the streamed tarball | Fixture support only. The comparison is not on this branch, and the builder mints both mismatch shapes. |
| AC-6 | A .sigstore data string over npm.max_attestation_bundle_size is refused |
Walker-owned, upstream of this parser. Not tested here. |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | Shape validation fails, so attestation_invalid, here the errAttestationInvalid sentinel |
TestParseAttestationBundle_RefusesInvalidShape, TestParseAttestationBundle_RefusesDegenerateInput, FuzzParseAttestationBundle |
| E-2 | A non-empty keyid, so attestation_unverifiable, here the errAttestationKeyed sentinel |
TestParseAttestationBundle_RefusesKeyed, TestParseAttestationBundle_ShapeChecksPrecedeTheKeyedCheck |
| E-3 | The in-toto subject digest or purl does not match the streamed tarball, so attestation_subject_mismatch |
Not on this branch. The parse carries both subject values verbatim for that comparison to read. |
| E-4 | A data string over the cap, so attestation_bundle_too_large |
Walker-owned. Not tested here. |
| E-5 | The attestations endpoint reached for a version holding no bundle, so attestation_not_found |
Route-owned. Not tested here. |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Verification is shape only and runs no cryptography, so a well-formed but cryptographically invalid bundle is admitted | TestParseAttestationBundle_Admits (a placeholder signature and certificate parse), TestParseAttestationBundle_RealFixture (a producer's bytes parse with no trust root) |
| S-2 | Bundle bytes are untrusted external input, so no input may panic and every refusal must classify | FuzzParseAttestationBundle, TestParseAttestationBundle_RefusesDegenerateInput |
| S-3 | Attestation bundle memory budget (npm.max_attestation_bundle_size, the jsontext string-materialization cost) |
The size cap stays walker-owned and arrives in Step 15. Within the buffer it is handed, this parser now caps each array at 8 entries mid-decode and the predicate type at 256 bytes, so the widest bundle a 128 KiB buffer can hold no longer amplifies. TestParseAttestationBundle_RefusesPastItsCeilings. |
Rationale
Spec ambiguities resolved here, so a later step does not re-litigate them:
- Shape-check order: all five shape checks run before the keyed check, so a bundle that is both malformed and keyed reports the shape refusal. Refusing keyed first is unobservable in every case the spec enumerates, which is why a test pins the order.
- A
verificationMaterialabsent entirely takes the shape refusal, on the reading that an absent member carries neither certificate member. - A
payloadthat base64-decodes to bytes that are not JSON takes the shape refusal namingpayload, on the reading that such bytes fail "base64-decodes to an in-toto statement". Namingsubjectwould satisfy the sentinel equally, and only the message assertion distinguishes them. - An
x509CertificateChainwhosecertificatesarray is empty, and a bundle carryingcertificatein place of the chain, are both admitted, because the requirement is member presence and not content. - An absent, empty, or non-hex
subject[0].digest.sha512is admitted and carried verbatim. The spec's shape enumeration lists no digest form, so the refusal belongs to the subject comparison. - Every refusal returns the zero
attestationBundle, keyed refusals included, so no caller can read values off a bundle that was rejected.
Rejected alternatives:
internal/jsonsafefor the decode. The decode isencoding/json/v2into typed structs, and the two arrays stream through ajsontext.Decoderof their own to refuse width.jsonsafe's bounds are depth and object keys, neither of which counts an array's elements, and adopting it across the formats is the platform work gitlab-org/ops/artifact-registry#90+s tracks.encoding/jsonv1, which this step first shipped. It matches member names case-insensitively, takes duplicates last-wins, and repairs ill-formed UTF-8, so three classes of bundle parsed here as something other than what a sigstore consumer reads.map[string]anyfor the subject digest, the obvious decode. Rejected on the 20.5x input amplification measured above.- Leaving
predicateTypeunbounded. The stored value reaches every packument, so an unbounded one amplifies. The spec author ruled 256 bytes on 2026-09-21, against a checked-in fixture whose predicate type is 30 bytes. - Reading
dsseEnvelope.signatures[0].keyidalone, which the spec's three statements of the refusal each name. A publisher who put a certificate-backed signature first and a keyed one second got the bundle stored, which hands consumers thenpm audit signaturesfailure the refusal exists to prevent. The spec author ruled on 2026-09-21 that any entry's key id refuses the bundle, and thedocs(specs)MR restating those three passages is in flight. - Deferring the entry-count bound to gitlab-org/ops/artifact-registry#90+s. That issue's criteria are nesting depth and object keys, and the shape costs neither: 43,506 entries at one depth, in objects of two keys. Delivered in full it would have left the shape admitted while the record said otherwise. The bound is here instead, and it refuses mid-decode rather than after the array exists, which is the difference between 0.02 MB and 76 µs for a 128 KiB wide bundle and 6.59 MB and 7.2 ms for the same bytes.
LOC split, 1,448 insertions: source 277 (attestation_verify.go), test 1,040
(attestation_verify_test.go 623, attestation_fixture_test.go 405,
export_test.go 12), docs and fixture 121 (testdata/attestation/README.md 50,
testdata/attestation/provenance_bundle.json 66, testdata/README.md 5), CI 10.
The plan forecast this step at about 150 source and 300 test, and its Over-ceiling steps section names only Steps 11 and 16, so the forecast was wrong for this step. Splitting still would not help: the reviewable source is 277 lines of one pure function and its decoder, the fixture builder is infrastructure Steps 7, 16, 17, and 19 consume and has to land with the parser Step 7 stacks on, the suite spends one row per case the spec enumerates plus the pins three review passes added, and the fixture is a verbatim producer artifact.
The plan's Step 3 text says the parser is encoding/json into typed structs and
that it caps nothing, which the review round overtook. Correcting it is a plan
MR's work, not a step MR's.
Non-goals
- Cryptographic verification of the signature or certificate chain. Shape and subject only, per the spec's Out of Scope, tracked in npm hosted: verify the signature and certificat... (#1276) • Unassigned.
- Bounding the bundle's size in bytes. The walker cuts the buffer at
npm.max_attestation_bundle_sizebefore this parser sees it, and wiring that cap is Step 15. - Reading
mediaType,payloadType, or the statement's_typefor anything. Each has multiple live spellings, so a check on one refuses the others. - Mounting the attestations route or calling the parser from the publish path. Steps 16 through 19 own those, and the parser is deliberately unreferenced here.
- Adopting
internal/jsonsafeacross the npm decoders, per gitlab-org/ops/artifact-registry#90+s.
Related to #1273