Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion cmd/root/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ package root
import (
"fmt"
"log/slog"
"maps"
"os"
"slices"
"strings"

"github.com/spf13/cobra"

"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/content"
"github.com/docker/docker-agent/pkg/protect"
"github.com/docker/docker-agent/pkg/remote"
"github.com/docker/docker-agent/pkg/telemetry"
)
Expand Down Expand Up @@ -89,7 +92,14 @@ func (f *pullFlags) runPullCommand(cmd *cobra.Command, args []string) (commandEr
if err != nil {
return fmt.Errorf("verifying %s: %w", registryRef, err)
}
out.Printf("Verified %s\n", verified)
// The signature proves who published the YAML, not where it was read
// from: check the attested subject against the reference we asked for,
// so a signed artifact copied elsewhere is rejected.
if err := verified.CheckSubject(registryRef); err != nil {
return fmt.Errorf("verifying %s: %w", registryRef, err)
}
out.Printf("Verified %s\n", verified.SignatureAlgorithmSummary())
printAttestation(out, verified.Statement)
}

agentName := strings.ReplaceAll(registryRef, "/", "_")
Expand All @@ -103,3 +113,24 @@ func (f *pullFlags) runPullCommand(cmd *cobra.Command, args []string) (commandEr

return nil
}

// printAttestation reports the authenticated metadata of a verified artifact.
// A predicate this version does not know is reported as such rather than
// hidden: the signature is still valid, only the metadata is opaque.
func printAttestation(out *cli.Printer, stmt protect.Statement) {
if stmt.SubjectName() == "" {
return
}
out.Printf(" image: %s\n", stmt.SubjectName())
out.Printf(" digest: %s\n", stmt.Digest())
if !stmt.PredicateUnderstood {
out.Printf(" note: predicate %s not understood by this version\n", stmt.PredicateType)
return
}
if created := stmt.Predicate.Created; created != "" {
out.Printf(" created: %s\n", created)
}
for _, field := range slices.Sorted(maps.Keys(stmt.Predicate.Unknown)) {
out.Printf(" %s: %s\n", field, stmt.Predicate.Unknown[field])
}
}
57 changes: 57 additions & 0 deletions cmd/root/pull_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package root

import (
"bytes"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/protect"
)

// printAttestation reports the metadata a pull verified, and stays honest about
// a predicate it cannot read: the signature is valid either way.
func TestPrintAttestation(t *testing.T) {
t.Parallel()

data := []byte("version: \"2\"\n")
stmt, err := protect.NewStatement("gtardif/myagent:v1", data, time.Date(2026, 9, 11, 8, 30, 0, 0, time.UTC))
require.NoError(t, err)

var buf bytes.Buffer
printAttestation(cli.NewPrinter(&buf), stmt)
out := buf.String()
assert.Contains(t, out, "image: index.docker.io/gtardif/myagent:v1")
assert.Contains(t, out, "digest: sha256:"+protect.SubjectDigest(data)["sha256"])
assert.Contains(t, out, "created: 2026-09-11T08:30:00Z")

// Nothing signed (symmetric encrypt-only artifact): nothing to print.
buf.Reset()
printAttestation(cli.NewPrinter(&buf), protect.Statement{})
assert.Empty(t, buf.String())

// An unknown predicate type is surfaced, not hidden behind blank metadata.
unknown := stmt
unknown.PredicateType = "https://docker.com/docker-agent/share/publication/v99"
unknown.PredicateUnderstood = false
buf.Reset()
printAttestation(cli.NewPrinter(&buf), unknown)
out = buf.String()
assert.Contains(t, out, "image: index.docker.io/gtardif/myagent:v1")
assert.Contains(t, out, "not understood")
assert.NotContains(t, out, "created:")

// Unknown predicate fields of a known predicate type are shown, sorted, so
// metadata added by a newer publisher is not silently swallowed.
extended := stmt
extended.Predicate.Unknown = map[string]string{"zeta": `"z"`, "builder": `"docker-agent/9.9.9"`}
buf.Reset()
printAttestation(cli.NewPrinter(&buf), extended)
out = buf.String()
assert.Less(t, strings.Index(out, "builder"), strings.Index(out, "zeta"))
assert.Contains(t, out, `builder: "docker-agent/9.9.9"`)
}
93 changes: 89 additions & 4 deletions docs/concepts/distribution/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,103 @@ Passphrase-protected keys are not supported. Anything containing a PEM boundary

### Modes

- **Sign** (default): records a signature (private key) or an HMAC (secret) of the YAML. Anyone with the public key or secret can verify integrity and provenance.
- **Sign** (default): records a signature (private key) or an HMAC (secret) over an in-toto statement describing the artifact. Anyone with the public key or secret can verify integrity, provenance, and that the artifact is served from the reference it was published as.
- **Encrypt** (`--encrypt`): additionally records an authenticated encrypted copy of the whole YAML. Holders of the secret or private key can recover the YAML from the annotation alone, without the layer. With an asymmetric key this requires the private key and a signature is still recorded — a copy encrypted to a public key could have been produced by anyone, so it proves nothing on its own.

The pull side never needs to choose: the annotations describe what was recorded, and verification checks whatever is present. With an asymmetric key the artifact must carry a signature, which also prevents downgrading a signed artifact to an encrypted-only one.

### Signed metadata (DSSE + in-toto)

A signature does not cover the YAML directly: it covers an [in-toto Statement v1](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md) carried in a [DSSE envelope](https://github.com/secure-systems-lab/dsse) (`application/vnd.dsse.envelope.v1+json`), recorded in clear in the `io.docker.agent.attestation` annotation. The format is the one cosign and in-toto tooling use, so the attestation can be read and verified without Docker Agent.

The manifest carries two annotations for it:

| Annotation | Contents |
| ------------------------------ | --------------------------------------------------------------------- |
| `io.docker.agent.attestation` | base64 of the DSSE envelope (`application/vnd.dsse.envelope.v1+json`) |
| `in-toto.io/predicate-type` | the statement's `predicateType`, so consumers can filter without decoding |

`in-toto.io/predicate-type` is the same key BuildKit puts on its in-toto attestation layers. It is a convenience hint outside the signature: verification always uses the `predicateType` inside the signed statement and ignores the annotation.

```json
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "index.docker.io/myorg/agent:v1",
"digest": { "sha256": "889871ef7773a7f535b04b7c79c456ce7f0f70983a3f073d33b2cee07f9939dc" }
}
],
"predicateType": "https://docker.com/docker-agent/share/publication/v1",
"predicate": {
"registry": "index.docker.io",
"repository": "myorg/agent",
"tag": "v1",
"created": "2026-09-11T08:30:00Z"
}
}
```

The `subject` is the security-critical part: `name` is the fully qualified reference the artifact was published as, and `digest` (bare hex, as in-toto requires) covers the agent YAML as stored in the layer. The digest binds the statement to the YAML, so a valid statement paired with a different layer is rejected. The name binds it to a location, which is what makes a signed artifact copied to another repository or tag detectable. The `predicate` carries the publication metadata.

Because the envelope is stored in clear, anyone can read the metadata; because it is signed, only a key holder can have produced it. The signature covers the DSSE pre-authentication encoding `PAE("application/vnd.in-toto+json", <statement bytes>)`, and verification always uses the bytes exactly as received — never a re-serialized copy. The `keyid` in the envelope is an unauthenticated hint and never drives a verification decision.

On pull the metadata is printed once verification succeeds:

```console
$ docker agent share pull myorg/agent:v1 --key file://~/.ssh/id_ed25519.pub
Pulling agent myorg/agent:v1
Verified signature (ed25519)
image: index.docker.io/myorg/agent:v1
digest: sha256:889871ef7773a7f535b04b7c79c456ce7f0f70983a3f073d33b2cee07f9939dc
created: 2026-09-11T08:30:00Z
Agent saved to myorg_agent:v1.yaml
```

The metadata is readable without a key too, but only a signature check makes it trustworthy:

```bash
$ docker buildx imagetools inspect docker.io/myorg/agent:v1 --raw \
| jq -r '.annotations["io.docker.agent.attestation"]' | base64 -d \
| jq -r .payload | base64 -d | jq .
```

Encrypt mode with a symmetric secret records the encrypted copy only — it is proof by itself — so such an artifact carries no signature and no attestation (and no predicate-type annotation).

#### Adding metadata later

The two halves of the statement are treated differently on purpose:

- The **statement** is strict. `_type`, `subject` (with its digest set) and `predicateType` are what bind an artifact to its bytes and its location, so a verifier rejects unknown fields there.
- The **predicate** is lenient. Fields a future version adds are surfaced as opaque values rather than rejected, and an unknown `predicateType` yields "signature valid, predicate not understood" instead of a failure.

So new publication metadata can be added without breaking already-deployed verifiers. The `predicateType` URI is the version: a breaking change to the predicate shape gets a new URI, and old verifiers report the predicate as not understood while still checking the signature and the subject binding.

### Verifying when running

Programs embedding Docker Agent can pass `ocisource.WithVerificationKey(key)` to `sources.Resolve` or `ocisource.New` so an OCI-sourced agent is verified on every read. Import `pkg/config/sources` and `pkg/config/ocisource` from `github.com/docker/docker-agent`.
Programs embedding Docker Agent can pass `ocisource.WithVerificationKey(key)` to `sources.Resolve` or `ocisource.New` so an OCI-sourced agent is verified on every read, including that the attestation names the reference being read. Import `pkg/config/sources` and `pkg/config/ocisource` from `github.com/docker/docker-agent`.

### What the signature does and does not cover

The attestation authenticates the agent YAML and where it was published — nothing else in the manifest.

| Authenticated (in the signed statement) | Not authenticated |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The agent YAML bytes, via the subject `digest` | `org.opencontainers.image.authors`, `.licenses`, `.revision`, `.description` |
| The published reference, via the subject `name` | `io.docker.agent.tags`, `io.docker.agent.version`, `io.docker.cagent.version` |
| `predicateType`, and the predicate (registry, repository, tag, creation date) | `in-toto.io/predicate-type` (a hint; the signed value is inside the envelope) and the `org.opencontainers.image.created` annotation — the signed copy is in the predicate |

The subject digest covers the agent 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.

The practical consequence is that anyone who can push to the repository can rewrite the unauthenticated annotations of a signed artifact and the signature still verifies — the agent YAML they describe cannot be changed, but the metadata around it can. **Do not build policy on those annotations.** Use the values the pull side prints after verification, which come from the signed statement, and treat everything else in the manifest as advisory. Note that `created` appears in both places: `share pull --key` reports the signed one, so a rewritten annotation does not change what a verifying client sees.

Authenticating the whole manifest would mean publishing the envelope as a referring artifact (OCI Referrers API) so the subject can be the manifest digest, which also covers every annotation. That is also what would make the attestation discoverable by `cosign verify-attestation`: the envelope here is a conformant DSSE/in-toto object, but tools that expect attestations as referring artifacts will not find it in an annotation.

### Other limitations

### Limitations
The metadata in the predicate is what the publisher declared, not a verified identity: a valid signature proves a key holder published an artifact claiming that metadata, not that the claim is true.

Signatures cover the YAML bytes only. Re-tagging a signed artifact, or serving an older signed version under the same tag, is not detected — pin digests (`myorg/agent@sha256:…`) when that matters.
Serving an older signed version under the same tag is not detected: the attestation records the tag, not which version is current. Pin digests (`myorg/agent@sha256:…`) when rollback protection matters.

## Running from a Registry

Expand Down
10 changes: 10 additions & 0 deletions docs/features/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,16 @@ $ docker agent share pull docker.io/username/my-agent:latest --key file://~/.ssh
| `--key` | both | Key (inline, or `file://<path>`) used to sign/encrypt the agent on push, or verify it on pull |
| `--encrypt` | `push` | Also embed an encrypted copy of the agent in the manifest annotations (needs `--key`) |

Signing records a DSSE envelope over an in-toto Statement v1 in the `io.docker.agent.attestation` annotation, so the attestation interoperates with cosign and in-toto tooling. On pull, the attested reference is checked against the one requested — a signed artifact copied to another repository or tag is rejected — and the verified metadata is printed:

```console
$ docker agent share pull docker.io/username/my-agent:latest --key file://~/.ssh/id_ed25519.pub
Verified signature (ed25519)
image: index.docker.io/username/my-agent:latest
digest: sha256:889871ef…
created: 2026-09-11T08:30:00Z
```

See [Signing and encrypting agents](../../concepts/distribution/index.md#signing-and-encrypting-agents) for key formats and the security model.

See [Agent Distribution](../../concepts/distribution/index.md) for full registry workflow details.
Expand Down
9 changes: 8 additions & 1 deletion pkg/config/ocisource/ocisource.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,14 @@ func (a ociSource) loadArtifact(store *content.Store, storeKey string) ([]byte,
if err != nil {
return nil, err
}
if _, err := a.verifyKey.VerifyAnnotations(meta.Annotations, data); err != nil {
verified, err := a.verifyKey.VerifyAnnotations(meta.Annotations, data)
if err != nil {
return nil, fmt.Errorf("verifying %s: %w", a.reference, err)
}
// Reject an artifact signed for another location even though its signature
// is valid: an embedder asking for this reference must not be served a copy
// published elsewhere.
if err := verified.CheckSubject(a.reference); err != nil {
return nil, fmt.Errorf("verifying %s: %w", a.reference, err)
}
return data, nil
Expand Down
42 changes: 39 additions & 3 deletions pkg/config/ocisource/ocisource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,12 +302,24 @@ func TestOCISource_Read_DoesNotCacheDegradedFallback(t *testing.T) {
}

// storeProtectedTestArtifact is like storeTestArtifact but adds protection
// annotations produced by key in the given mode.
// annotations produced by key in the given mode, attesting the artifact at ref.
func storeProtectedTestArtifact(t *testing.T, ref string, data []byte, key *protect.Key, mode protect.Mode) {
t.Helper()

storeSignedForSubject(t, ref, ref, data, key, mode)
}

// storeSignedForSubject stores the artifact at ref but attests subjectRef, so
// tests can build an artifact that is validly signed for another location.
func storeSignedForSubject(t *testing.T, ref, subjectRef string, data []byte, key *protect.Key, mode protect.Mode) {
t.Helper()

subject, err := remote.FullyQualifiedReference(subjectRef)
require.NoError(t, err)
stmt, err := protect.NewStatement(subject, data, time.Now())
require.NoError(t, err)
annotations := map[string]string{}
require.NoError(t, key.Protect(annotations, data, mode))
require.NoError(t, key.Protect(annotations, data, stmt, mode))
storeTestArtifactWithAnnotations(t, ref, data, annotations)
}

Expand Down Expand Up @@ -380,6 +392,28 @@ func TestOCISource_Read_VerifiesProtection(t *testing.T) {
require.ErrorIs(t, err, protect.ErrNotProtected)
}

// A validly signed artifact copied to another reference must not load: the
// signature is genuine but the attested subject names a different location.
//
// Not parallel: stubs the package-level pullOCIArtifact and re-homes the
// default content store via t.Setenv.
func TestOCISource_Read_RejectsCopiedArtifact(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
resetOCIMemoizer(t)
stubOCIPull(t, func(context.Context, string, bool) (string, error) { return "", nil })

key, err := protect.ParseKey([]byte("a shared secret long enough"))
require.NoError(t, err)

testData := []byte("version: v1\nname: copied-agent")
storeSignedForSubject(t, "test-copy/agent:latest", "test-origin/agent:latest", testData, key, protect.ModeSign)

_, err = New("test-copy/agent:latest", WithVerificationKey(key)).Read(t.Context())
require.ErrorIs(t, err, protect.ErrSubjectMismatch)
}

// Not parallel: stubs the package-level pullOCIArtifact and re-homes the
// default content store via t.Setenv.
func TestOCISource_Read_CacheDistinguishesPrivateAndPublicKey(t *testing.T) {
Expand All @@ -405,7 +439,9 @@ func TestOCISource_Read_CacheDistinguishesPrivateAndPublicKey(t *testing.T) {
// signature and accepts it; the private key decrypts and must reject it.
testData := []byte("version: v1\nname: swapped-copy")
annotations := map[string]string{}
require.NoError(t, priv.Protect(annotations, testData, protect.ModeEncrypt))
stmt, stmtErr := protect.NewStatement("index.docker.io/test-halves/agent:latest", testData, time.Now())
require.NoError(t, stmtErr)
require.NoError(t, priv.Protect(annotations, testData, stmt, protect.ModeEncrypt))
forged, err := pub.Encrypt([]byte("something else"))
require.NoError(t, err)
annotations[protect.AnnotationEncrypted] = base64.StdEncoding.EncodeToString(forged)
Expand Down
16 changes: 13 additions & 3 deletions pkg/oci/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,13 @@ func PackageFileAsOCIToStore(ctx context.Context, agentSource config.Source, art
}
}

// Prepare OCI annotations
// Prepare OCI annotations. createdAt is shared with the signed statement
// below so the advertised creation date and the attested one cannot drift.
createdAt := time.Now()
annotations := map[string]string{
"io.docker.cagent.version": version.Version,
"io.docker.agent.version": version.Version,
"org.opencontainers.image.created": time.Now().Format(time.RFC3339),
"org.opencontainers.image.created": createdAt.Format(time.RFC3339),
"org.opencontainers.image.description": "OCI artifact containing " + filepath.Base(agentSource.Name()),
}
if author := cfg.Metadata.Author; author != "" {
Expand All @@ -103,7 +105,15 @@ func PackageFileAsOCIToStore(ctx context.Context, agentSource config.Source, art
annotations["io.docker.agent.tags"] = strings.Join(cfg.Metadata.Tags, ",")
}
if o.key != nil {
if err := o.key.Protect(annotations, data, o.mode); err != nil {
// The in-toto statement is the metadata the signature covers: the
// reference this artifact is published as and the digest of the YAML,
// so a verifier can detect both a swapped layer and a copy served
// under another reference.
stmt, err := protect.NewStatement(artifactRef, data, createdAt)
if err != nil {
return "", fmt.Errorf("building attestation: %w", err)
}
if err := o.key.Protect(annotations, data, stmt, o.mode); err != nil {
return "", fmt.Errorf("protecting config: %w", err)
}
}
Expand Down
Loading
Loading