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

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

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

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

```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**, and `SIGNATURE_POLICY` to require
  signatures. Signatures are stored, not checked.
- **Cosign bundles**, and signature paths on the six non-Maven ecosystems.
- **`VULN_SCAN` on 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.
