# 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.

```xml
<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:

```sh
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
```

```json
{
  "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.

```sh
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.
