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 does not verify it.
Verification requires a trust root: a statement of which keys are allowed to sign for which coordinates. That is a policy decision an operator has to make, and the registry has no basis for making it on your behalf. Serving the signature is useful without one, because you verify with the key you already trust:
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
A registry that answered "signature valid" without a configured trust root would be making a claim it cannot back. It does not.
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.
Not yet supported: cosign bundles, and the conventional signature path on the other six ecosystems.
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, and
SIGNATURE_POLICYto require signatures. Signatures are stored, not checked. - Cosign bundles, and signature paths on the six non-Maven ecosystems.
VULN_SCANon cargo and PyPI. The dependency gate now reads npm, Go, Maven/JVM and NuGet manifests, because those are stored in a parseable form. 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 all seven.