Documentation

Supply chain

Signatures, provenance and SBOMs: what each answers, and what the registry does not claim.

Source: api.packr.blueforge.studio/-/docs/supply-chain

Supply chain: signatures, provenance, SBOMs

Three separate things, often confused, answering three different questions:

Question it answersWho asserts it
SignatureDid the person holding this key vouch for these exact bytes?the publisher
ProvenanceWhat did the registry observe when this was published?the registry
SBOMWhat 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_SIZE bounds 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 in packages/packr/cmd/main.go:216-244) produces registry_valid verdicts in provenance (internal/core/provenance.go:296-339).
  • SIGNATURE_POLICY to require signatures — now implemented. It defaults to off but 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 describing TRUST_ROOT was 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 at GET /api/v1/signature/rekor.
  • VULN_SCAN on 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-98 is GET-only — so there is nothing for it to scan, and the registry has sixteen ecosystem adapters, not seven. Every adapter that publishes through core.StoreArtifact is 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 ride core.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 with SecretScan: "block" explicitly, because a config.Config literal 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.