Files
Guillaume Tardif b53f61d1c5 feat(share): sign a DSSE-wrapped in-toto statement, not just the YAML bytes
`share push --key` signed the agent YAML directly, so the signature proved
"a key holder published these bytes" and nothing else. It carried no metadata:
no indication of where the artifact was published or when, and re-tagging a
signed layer under a new reference verified happily.

The signature now covers an in-toto Statement v1 carried in a DSSE envelope,
recorded in clear in a new `io.docker.agent.attestation` annotation, with the
statement's `predicateType` advertised alongside it in `in-toto.io/predicate-type`
so a consumer can tell what an attestation is about without base64-decoding it
(the same annotation key BuildKit puts on its in-toto attestation layers). The
statement's subject names the fully qualified image and the sha256 digest of the
agent YAML as stored in the layer; the predicate carries the publication
metadata (registry, repository, tag, creation date in UTC). Because it is stored
in clear anyone can read the metadata; because it is signed only a key holder
can have produced it. `share pull --key` prints the image, digest and creation
date once verification succeeds.

Three checks have to hold: the signature is valid over the exact stored payload,
the statement is an in-toto statement we understand, and a subject digest
matches the YAML that was pulled. The digest binds statement to payload, so
neither a swapped layer nor a swapped statement verifies. The subject binds it
to a location: `Verification.CheckSubject` compares the attested reference with
the one actually requested, which is what makes a copied artifact detectable.
`share pull` and `ocisource` both call it.

The wire format is the interoperable one rather than a bespoke JSON blob: the
envelope is a plain DSSE envelope (`payload`, `payloadType`,
`signatures[].sig`), the payload type is `application/vnd.in-toto+json`, and
signatures cover the DSSE pre-authentication encoding
`PAE(payloadType, body)` — replacing the previous NUL-delimited `domainInput`
framing for signatures. The annotation value is exactly an
`application/vnd.dsse.envelope.v1+json` object (exported as
`protect.EnvelopeMediaType`), and the digests in a subject are bare hex, as
in-toto requires. A standalone Python verifier using nothing but `cryptography`
validates the artifacts this produces, and the format is what cosign and
in-toto tooling consume.

Strict where it binds, lenient where it describes:

  - The statement is strict. `_type`, `subject` (each entry needs a name and a
    well-formed sha256 digest) and `predicateType` are what tie an artifact to
    its bytes and its location, so unknown fields there are refused rather than
    ignored.
  - The predicate is lenient, and this is deliberate. Unknown fields — and
    known fields carrying an unexpected JSON type — are surfaced as opaque
    key/values (`Predicate.Unknown`) instead of failing the artifact, and are
    re-emitted verbatim on marshal so a round-trip never drops them. An unknown
    `predicateType` yields "signature valid, predicate not understood", an
    honest outcome rather than a hard failure. So publication metadata can be
    added later without breaking already-deployed verifiers; a test asserts
    exactly that, and it was also checked end to end against Docker Hub with a
    publisher emitting extra predicate fields.

The `predicateType` URI is the version — there is no separate version integer to
keep in sync. A breaking change to the predicate shape gets a new URI, and old
verifiers keep checking the signature and the subject binding while reporting
the predicate as not understood.

Notes on the implementation:

  - No new dependencies. PAE is five lines and the two structs are plain JSON;
    pulling in `github.com/in-toto/attestation` (protobuf) plus a DSSE library
    would add real dependency weight for types this small. Conformance is
    pinned by tests instead: the spec's own PAE vector, the envelope shape, and
    a verification done by hand from the raw primitive.
  - The payload is verified before it is parsed, and the signature covers the
    bytes as stored, never a re-marshaled copy: DSSE explicitly forbids
    re-reading the payload out of the envelope after verification, and
    canonicalization must not depend on this process's JSON encoder.
  - `in-toto.io/predicate-type` is a filter hint, not evidence: it sits outside
    the signature, so verification reports the `predicateType` from the signed
    statement and ignores the annotation entirely. Forging or stripping it
    changes no outcome, which is asserted by tests and was checked against a
    live registry by rewriting the annotation on a pushed manifest.
  - `keyid` is recorded as a hint but never consulted: every signature in the
    envelope is tried, so a wrong or missing keyid neither rejects a valid
    signature nor lends weight to an invalid one. Both base64 alphabets
    (standard and URL-safe, padded or not) are accepted on the way in.
  - The subject digest covers the YAML layer, not the manifest. Annotations are
    part of the manifest, so a manifest digest could not be recorded inside one
    without a circular dependency; attesting the manifest instead would mean
    publishing the envelope as a referring artifact via the OCI Referrers API,
    which is a larger change and is left out. The consequence is documented
    rather than glossed over: only the agent YAML and the statement's own
    contents are authenticated, so the surrounding annotations (author,
    licenses, revision, tags, the advertised creation date) can be rewritten by
    anyone who can push to the repository while the signature still verifies.
    The docs list exactly which annotations are covered and which are not, and
    say not to build policy on the latter; the package doc carries the same
    caveat for embedders.
  - The creation date in the predicate is the same value as
    `org.opencontainers.image.created`, so the advertised and signed dates
    cannot drift.
  - Signatures are the only thing that moved to PAE framing; the AEAD path is
    untouched, its algorithm-binding header is now `encryptAAD` (same bytes as
    the old `domainInput("encrypt", alg, nil)`).
  - Encrypt mode with a symmetric secret still records only the AEAD copy — it
    is proof by itself — so such artifacts carry no signature and no
    attestation, and `CheckSubject` is a no-op for them.

Tampering with the layer now surfaces as `ErrStatementMismatch` rather than
`ErrInvalidSignature`: the signature over the statement is still valid, it is
the digest that no longer matches. Tests assert the new error accordingly.

This changes the signature format: `io.docker.agent.signature` is gone, so
artifacts signed by an earlier version carry no attestation and are rejected
with "artifact is neither signed nor encrypted" instead of silently verifying,
and older clients reject artifacts produced here the same way. Both directions
fail closed. Re-push signed artifacts. `--key` and `--encrypt` are unchanged.
2026-09-11 16:33:59 +02:00
..