Supply chain: signatures, provenance, SBOMs
Three separate things, often confused, answering three different questions:
| Question it answers | Who asserts it | |
|---|---|---|
| Signature | Did the person holding this key vouch for these exact bytes? | the publisher |
| Provenance | What did the registry observe when this was published? | the registry |
| SBOM | What is inside this artifact? | the publisher |
They are useful in that order. A signature is worth something on its own. Provenance without signatures still tells you who uploaded what and when. An SBOM tells you nothing about authenticity at all — it is an inventory, and an inventory can be wrong or absent without anything else being amiss.
Signatures
Detached PGP signatures, on Maven's own conventional path:
PUT /maven/{group}/{artifact}/{version}/{artifact}-{version}.jar.asc
GET /maven/{group}/{artifact}/{version}/{artifact}-{version}.jar.asc
.pom.asc and .module.asc work the same way.
If you already publish to Maven Central you are already doing this: Central
requires a signature on every release, maven-gpg-plugin produces one, and
mvn deploy uploads it beside the artifact. Nothing about your build changes.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals><goal>sign</goal></goals>
</execution>
</executions>
</plugin>
What the registry does and does not claim
It stores and serves the signature. It verifies it against an operator-supplied trust root, when one is configured.
Verification against a trust root is a policy decision an operator has to
make, and the registry has no basis for making it on the operator's behalf:
without a trust root the registry's verify call cannot say yes, but it can
still point at the signature bytes so the consumer can run their own
verification:
curl -sO https://api.packr.blueforge.studio/maven/com/acme/lib/1.0.0/lib-1.0.0.jar
curl -sO https://api.packr.blueforge.studio/maven/com/acme/lib/1.0.0/lib-1.0.0.jar.asc
gpg --verify lib-1.0.0.jar.asc lib-1.0.0.jar
When the operator sets TRUST_ROOT=… (a JSON file mapping coordinate
patterns to acceptable signing fingerprints), the registry records a
separate verdict per attestation: registry_valid=true when the signing
fingerprint matches an entry whose pattern covers the package coordinate.
The package page surfaces a Verified card only when at least one
attestation has registry_valid=true. The card's copy is deliberately
"verified by registry trust root" — not "verified by us" — because the
trust root is the operator's, and the registry is reporting what the
operator said, not making its own claim.
The two verdicts are independent signals: a publisher's keys may be
removed from the trust root (rotated out via POST /api/v1/admin/trust/rotate,
emitting trust.root.rotated in the audit log) without the envelope
itself becoming structurally invalid. An attestation that was once
verified stays structurally valid; the trust-root verdict is what flips.
Guards
- Publishing a signature requires the same authorization as publishing the artifact.
- The body must be an armoured
-----BEGIN PGP SIGNATURE-----block, so the route cannot be used to park arbitrary bytes beside a package. MAX_SIGNATURE_SIZEbounds the upload; the default is 64 KB against a real signature of roughly one.- A signature for a version that has not been published is a 404 rather than something that conjures the version.
- An unsigned artifact returns 404 for its
.asc, never an empty 200. "No signature" and "signed with nothing" must not look alike.
Supported on npm: cosign bundles (.packr/signature.cosign.json), with the
stored Rekor inclusion proof at .packr/signature.cosign.rekor.json (served at GET /api/v1/signature/rekor) for
offline verification when packr-cli verify cannot reach Rekor.
Maven is the only ecosystem with a dedicated *.asc route. npm, cargo, PyPI,
NuGet and the generic surface have nowhere for a detached signature to go — the
code says so at internal/npm/signature.go:20-43 — so they use the
.packr/signature.asc sidecar via POST /api/v1/admin/packages/signature. An
earlier revision of this line claimed the other six ecosystems "use the
conventional *.asc PGP path", which inverted the actual design.
Provenance
Written by the registry on every publish. No client action required.
GET /api/v1/provenance?ecosystem=cargo&name=widget&version=1.0.0
{
"schema_version": 1,
"ecosystem": "cargo",
"name": "widget",
"version": "1.0.0",
"sha256": "…",
"published_at": "2026-09-07T18:22:04Z",
"attested": {
"publisher": "alice",
"org_id": "default",
"token_id": 42,
"token_name": "ci-publish"
},
"claimed": {
"commit": "deadbeef",
"repository": "git@example.com:acme/widget.git",
"build_url": "https://ci.example.com/run/1",
"note": "supplied by the publishing client and not verified by the registry"
},
"registry": { "version": "v0.30.0", "commit": "abc1234" }
}
Read the two halves differently
attested is what the registry observed for itself: which credential
authenticated, which user owns it, when the request arrived, and the digest the
registry computed over the bytes it stored. None of it can be forged without
forging the credential.
claimed is what your client said about its build. It arrives in request
headers, and anyone holding a publish token can set them to anything:
X-Packr-Source-Commit: deadbeef
X-Packr-Source-Ref: refs/tags/v1.0.0
X-Packr-Source-Repo: git@example.com:acme/widget.git
X-Packr-Build-Url: https://ci.example.com/run/1
The section is absent when you send none of them.
Keeping these apart is the point. A document whose fields all look equally trustworthy invites you to trust the half that is not, which is the exact failure provenance exists to prevent.
On SLSA
The attested half is the registry's own observation and needs no trust root. Reaching SLSA build level 2 requires the build platform to attest, which is a different claim from a different party. This deliberately does not pretend to provide it.
unattributed
If attested.publisher reads "unattributed", a publish reached storage
without an identity. That is a bug worth reporting, not an expected state. It
is recorded explicitly rather than left blank so it cannot be mistaken for "not
recorded yet".
SBOMs
CycloneDX or SPDX, attached to a published version.
curl -X POST https://api.packr.blueforge.studio/api/v1/admin/packages/sbom \
-H "Authorization: Bearer $PACKR_TOKEN" \
-F ecosystem=cargo -F name=widget -F version=1.0.0 \
-F sbom=@bom.json
GET /api/v1/sbom?ecosystem=cargo&name=widget&version=1.0.0
The format is read from the document, not from the filename or a declared
content type: CycloneDX JSON by bomFormat, SPDX by spdxVersion, CycloneDX
XML by its root element. A document matching none of those is refused. A
mislabelled SBOM is worse than a rejected one, because whatever consumes it
will try to parse it as what it claims to be.
Stored and served verbatim. The registry does not re-derive an SBOM from the artifact: the document is your assertion about your own build, and a registry-derived substitute would be a different, weaker claim wearing the same name.
Reads are public but gated by the package's own visibility. An SBOM lists a package's dependencies, so it is exactly as sensitive as the package.
What is not here
Verification against a trust root— now implemented.TRUST_ROOT(internal/config/config.go:467, read once at boot inpackages/packr/cmd/main.go:216-244) producesregistry_validverdicts in provenance (internal/core/provenance.go:296-339).— now implemented. It defaults toSIGNATURE_POLICYto require signaturesoffbut is honoured when set:CheckSignatureGate(internal/core/service.go:354-370) runs at resolve time, which is why a published-but-ungated signature can still be made to block installs. (The section above describingTRUST_ROOTwas always right; this list contradicted it and the code.)Cosign bundles— now supported.CosignKind(internal/core/signature_store.go:74) with the sidecar.packr/signature.cosign.rekor.json, and the bundle is served atGET /api/v1/signature/rekor.VULN_SCANon cargo and PyPI. The dependency gate reads npm, Go, Maven and NuGet manifests —CoveredEcosystems()(internal/vulns/ecosystems.go:242-246) is exactly those four. An earlier revision of this list said "npm, Go, Maven/JVM and NuGet", which implies JVM coverage; it has none. JVM module metadata is{"module": ...}, not the{"pom": ...}the reader needs. A crate's dependency list is in the publish payload this registry does not retain, and a wheel's requirements are inside the uploaded archive rather than in anything twine sends — checking either means work at publish time that has not been done yet. The credential scan (SECRET_SCAN) covers every ecosystem that has a publish path. Note that composer is a mirror only —internal/composer/composer.go:96-98is GET-only — so there is nothing for it to scan, and the registry has sixteen ecosystem adapters, not seven. Every adapter that publishes throughcore.StoreArtifactis covered by construction rather than by remembering to call the scanner — OCI, generic, Lua and Conan included, each with a test that fires the gate on a real upload (TestCredentialScanAppliesToLayers,TestCredentialScanAppliesToRawArtifacts,TestCredentialScanAppliesToRocks,TestCredentialScanAppliesToRecipeUploads) — and on Conan the later files of a version ridecore.PutVersionFile, which runs the same scan, so a recipe's second file is not a side door around it. Each of those tests builds its service withSecretScan: "block"explicitly, because aconfig.Configliteral in a test leaves the field""— which is neither"off"nor"block", so the scan runs and a finding only warns. A scan test on such a service passes a leaking upload straight through and looks like the scanner is blind.