From b53f61d1c5d42242d7a9dc8f5504f8cec3b29044 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Fri, 11 Sep 2026 12:12:06 +0200 Subject: [PATCH] feat(share): sign a DSSE-wrapped in-toto statement, not just the YAML bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- cmd/root/pull.go | 33 +- cmd/root/pull_test.go | 57 +++ docs/concepts/distribution/index.md | 93 ++++- docs/features/cli/index.md | 10 + pkg/config/ocisource/ocisource.go | 9 +- pkg/config/ocisource/ocisource_test.go | 42 +- pkg/oci/package.go | 16 +- pkg/oci/package_test.go | 57 ++- pkg/protect/annotations.go | 144 +++++-- pkg/protect/attestation.go | 423 ++++++++++++++++++++ pkg/protect/attestation_test.go | 525 +++++++++++++++++++++++++ pkg/protect/encrypt.go | 4 +- pkg/protect/key.go | 33 +- pkg/protect/protect_test.go | 117 ++++-- pkg/protect/sign.go | 36 +- 15 files changed, 1518 insertions(+), 81 deletions(-) create mode 100644 cmd/root/pull_test.go create mode 100644 pkg/protect/attestation.go create mode 100644 pkg/protect/attestation_test.go diff --git a/cmd/root/pull.go b/cmd/root/pull.go index 75b18b8eb5..2a72cf2e68 100644 --- a/cmd/root/pull.go +++ b/cmd/root/pull.go @@ -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" ) @@ -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, "/", "_") @@ -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]) + } +} diff --git a/cmd/root/pull_test.go b/cmd/root/pull_test.go new file mode 100644 index 0000000000..9d00bf36c9 --- /dev/null +++ b/cmd/root/pull_test.go @@ -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"`) +} diff --git a/docs/concepts/distribution/index.md b/docs/concepts/distribution/index.md index a3fdb8ba64..00d0f049b9 100644 --- a/docs/concepts/distribution/index.md +++ b/docs/concepts/distribution/index.md @@ -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", )`, 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 diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index ed7f92c43e..eb20ebe65c 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -462,6 +462,16 @@ $ docker agent share pull docker.io/username/my-agent:latest --key file://~/.ssh | `--key` | both | Key (inline, or `file://`) 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. diff --git a/pkg/config/ocisource/ocisource.go b/pkg/config/ocisource/ocisource.go index 198afd70e3..4bc979c37a 100644 --- a/pkg/config/ocisource/ocisource.go +++ b/pkg/config/ocisource/ocisource.go @@ -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 diff --git a/pkg/config/ocisource/ocisource_test.go b/pkg/config/ocisource/ocisource_test.go index 518caec2ce..1bc6200e96 100644 --- a/pkg/config/ocisource/ocisource_test.go +++ b/pkg/config/ocisource/ocisource_test.go @@ -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) } @@ -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) { @@ -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) diff --git a/pkg/oci/package.go b/pkg/oci/package.go index 1533f6e9c3..cff833805a 100644 --- a/pkg/oci/package.go +++ b/pkg/oci/package.go @@ -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 != "" { @@ -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) } } diff --git a/pkg/oci/package_test.go b/pkg/oci/package_test.go index 67e25d41dc..411a749c04 100644 --- a/pkg/oci/package_test.go +++ b/pkg/oci/package_test.go @@ -2,10 +2,12 @@ package oci import ( "bytes" + "encoding/base64" "io" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -305,11 +307,64 @@ agents: // The protection covers the exact bytes stored in the layer. yamlData, err := store.GetArtifact(tag) require.NoError(t, err) - _, err = key.VerifyAnnotations(metadata.Annotations, []byte(yamlData)) + verified, err := key.VerifyAnnotations(metadata.Annotations, []byte(yamlData)) require.NoError(t, err) _, err = other.VerifyAnnotations(metadata.Annotations, []byte(yamlData)) require.Error(t, err) + // A symmetric secret in encrypt mode records the AEAD copy only (it + // is proof by itself), so there is no signature and no attestation. + if verified.SignatureAlgorithm == "" { + assert.Empty(t, metadata.Annotations[protect.AnnotationAttestation]) + assert.NotContains(t, metadata.Annotations, protect.AnnotationPredicateType) + assert.Empty(t, verified.Statement.SubjectName()) + require.NoError(t, verified.CheckSubject("anything/at:all")) + return + } + + // The in-toto statement attests the artifact: the reference it was + // published as and the digest of the YAML as stored. + stmt := verified.Statement + assert.Equal(t, protect.StatementType, stmt.Type) + assert.Equal(t, protect.PredicateTypePublication, stmt.PredicateType) + // Advertised in clear alongside the envelope, matching the signed value. + assert.Equal(t, protect.PredicateTypePublication, metadata.Annotations[protect.AnnotationPredicateType]) + assert.True(t, stmt.PredicateUnderstood) + assert.Equal(t, "index.docker.io/library/test-protected:"+string(mode), stmt.SubjectName()) + assert.Equal(t, "index.docker.io", stmt.Predicate.Registry) + assert.Equal(t, "library/test-protected", stmt.Predicate.Repository) + assert.Equal(t, string(mode), stmt.Predicate.Tag) + assert.Equal(t, "sha256:"+protect.SubjectDigest([]byte(yamlData))["sha256"], stmt.Digest()) + + // The attested creation date is the one advertised in the standard + // OCI annotation, so the two cannot disagree. + advertised, err := time.Parse(time.RFC3339, metadata.Annotations["org.opencontainers.image.created"]) + require.NoError(t, err) + signedAt, err := time.Parse(time.RFC3339, stmt.Predicate.Created) + require.NoError(t, err) + assert.True(t, advertised.Equal(signedAt)) + + // The annotation is a plain DSSE envelope: any in-toto tool can read + // the metadata out of it without a key. + raw, err := base64.StdEncoding.DecodeString(metadata.Annotations[protect.AnnotationAttestation]) + require.NoError(t, err) + env, err := protect.ParseEnvelope(raw) + require.NoError(t, err) + assert.Equal(t, protect.PayloadType, env.PayloadType) + body, err := base64.StdEncoding.DecodeString(env.Payload) + require.NoError(t, err) + fromAnnotation, err := protect.ParseStatement(body) + require.NoError(t, err) + assert.Equal(t, stmt, fromAnnotation) + + // A swapped layer is detected via the attested subject digest. + _, err = key.VerifyAnnotations(metadata.Annotations, []byte("version: \"2\"\n")) + require.ErrorIs(t, err, protect.ErrStatementMismatch) + + // The subject is checked against the reference actually read. + require.NoError(t, verified.CheckSubject(tag)) + require.ErrorIs(t, verified.CheckSubject("other/test-protected:"+string(mode)), protect.ErrSubjectMismatch) + if mode == protect.ModeEncrypt { // The clear YAML is recoverable, byte-for-byte, from the annotations alone. recovered, err := key.Recover(metadata.Annotations) diff --git a/pkg/protect/annotations.go b/pkg/protect/annotations.go index 5762ca186a..0c90a326af 100644 --- a/pkg/protect/annotations.go +++ b/pkg/protect/annotations.go @@ -3,15 +3,29 @@ package protect import ( "crypto/subtle" "encoding/base64" + "encoding/json" "errors" "fmt" "strings" ) const ( - // AnnotationSignature holds the base64 signature (or MAC) of the YAML layer. - AnnotationSignature = "io.docker.agent.signature" - // AnnotationSignatureAlgorithm names the algorithm behind AnnotationSignature. + // AnnotationAttestation holds the base64 DSSE [Envelope] whose payload is + // the in-toto [Statement] describing this artifact: the reference it was + // published as, the digest of the agent YAML, and the publication + // metadata. Stored in clear so anyone (and any DSSE/in-toto tool) can read + // it, and authenticated by the signature it carries so only a key holder + // can have produced it. + AnnotationAttestation = "io.docker.agent.attestation" + // AnnotationPredicateType advertises the `predicateType` of the statement + // inside AnnotationAttestation, so a consumer can tell what an attestation + // is about without base64-decoding it. Same key BuildKit puts on its + // in-toto attestation layers. Purely informational: it is not signed, so + // verification always uses the value inside the envelope (see + // [Statement.PredicateType]) and never this annotation. + AnnotationPredicateType = "in-toto.io/predicate-type" + // AnnotationSignatureAlgorithm names the algorithm of the signature inside + // AnnotationAttestation. AnnotationSignatureAlgorithm = "io.docker.agent.signature.algorithm" // AnnotationEncrypted holds a base64 authenticated-encrypted copy of the // whole YAML layer. @@ -20,6 +34,11 @@ const ( AnnotationEncryptedAlgorithm = "io.docker.agent.encrypted.algorithm" ) +// EnvelopeMediaType is the media type of the value held in +// [AnnotationAttestation], for consumers that want to know what they are +// looking at. The annotation stores the base64 of exactly this object. +const EnvelopeMediaType = "application/vnd.dsse.envelope.v1+json" + var ( ErrNotProtected = errors.New("artifact is neither signed nor encrypted") ErrNotEncrypted = errors.New("artifact has no encrypted copy") @@ -65,12 +84,17 @@ func (k *Key) Supports(mode Mode) error { } // Protect records the protection for data in annotations according to mode. -func (k *Key) Protect(annotations map[string]string, data []byte, mode Mode) error { +// The statement is the in-toto attestation the signature covers; it is stored +// in clear inside a DSSE envelope. +func (k *Key) Protect(annotations map[string]string, data []byte, stmt Statement, mode Mode) error { if err := k.Supports(mode); err != nil { return err } + if !stmt.describes(data) { + return fmt.Errorf("%w: no subject digest matches the agent YAML", ErrStatementMismatch) + } if mode == ModeSign || !k.Symmetric() { - if err := k.sign(annotations, data); err != nil { + if err := k.sign(annotations, stmt); err != nil { return err } } @@ -85,19 +109,27 @@ func (k *Key) Protect(annotations map[string]string, data []byte, mode Mode) err return nil } -func (k *Key) sign(annotations map[string]string, data []byte) error { - sig, err := k.Sign(data) +// sign records a DSSE envelope over the statement. +func (k *Key) sign(annotations map[string]string, stmt Statement) error { + env, err := k.SignStatement(stmt) if err != nil { return err } - annotations[AnnotationSignature] = base64.StdEncoding.EncodeToString(sig) + raw, err := json.Marshal(env) + if err != nil { + return fmt.Errorf("marshaling attestation: %w", err) + } + annotations[AnnotationAttestation] = base64.StdEncoding.EncodeToString(raw) + // A hint for consumers filtering attestations; the signed copy inside the + // envelope is the only one verification trusts. + annotations[AnnotationPredicateType] = stmt.PredicateType annotations[AnnotationSignatureAlgorithm] = k.SignAlgorithm() return nil } -// IsProtected reports whether annotations carry a signature or encrypted copy. +// IsProtected reports whether annotations carry an attestation or encrypted copy. func IsProtected(annotations map[string]string) bool { - return annotations[AnnotationSignature] != "" || annotations[AnnotationEncrypted] != "" + return annotations[AnnotationAttestation] != "" || annotations[AnnotationEncrypted] != "" } // Verification reports which protections VerifyAnnotations actually checked. @@ -108,9 +140,23 @@ type Verification struct { // matched the content. It stays empty for a public key, which can only // check the copy's algorithm label. EncryptedAlgorithm string + // Statement is the authenticated in-toto statement the publisher signed. + // Set whenever SignatureAlgorithm is. + Statement Statement } +// String reports what was checked, including the attested metadata. func (v Verification) String() string { + out := v.SignatureAlgorithmSummary() + if v.Statement.SubjectName() != "" { + out += " for " + v.Statement.String() + } + return out +} + +// SignatureAlgorithmSummary names the protections that were checked, without +// the attested metadata. +func (v Verification) SignatureAlgorithmSummary() string { var parts []string if v.SignatureAlgorithm != "" { parts = append(parts, "signature ("+v.SignatureAlgorithm+")") @@ -124,15 +170,20 @@ func (v Verification) String() string { // VerifyAnnotations checks that data is what a holder of this key published, // using the protection annotations carry, and reports what was checked. // -// A signature, when present, is always verified. An encrypted copy is -// decrypted and compared to data when the key can decrypt; a public key only -// checks its algorithm label and relies on the signature. With an asymmetric -// key a signature is mandatory, since anyone holding the public key could -// have produced the encrypted copy. ErrNotProtected is returned when the -// artifact carries no protection at all. +// A DSSE attestation, when present, is always verified: the signature covers +// the in-toto statement, whose subject digest must in turn match data — so a +// valid statement paired with a different layer is rejected. The authenticated +// statement is returned in the report, and callers that know which reference +// they requested should also call [Verification.CheckSubject]. +// +// An encrypted copy is decrypted and compared to data when the key can +// decrypt; a public key only checks its algorithm label and relies on the +// signature. With an asymmetric key a signature is mandatory, since anyone +// holding the public key could have produced the encrypted copy. +// ErrNotProtected is returned when the artifact carries no protection at all. func (k *Key) VerifyAnnotations(annotations map[string]string, data []byte) (Verification, error) { var v Verification - signed := annotations[AnnotationSignature] != "" + signed := annotations[AnnotationAttestation] != "" encrypted := annotations[AnnotationEncrypted] != "" if !signed && !encrypted { return v, ErrNotProtected @@ -142,10 +193,12 @@ func (k *Key) VerifyAnnotations(annotations map[string]string, data []byte) (Ver } if signed { - if err := k.verifySignature(annotations, data); err != nil { - return v, err + stmt, err := k.verifyAttestation(annotations, data) + if err != nil { + return Verification{}, err } v.SignatureAlgorithm = k.SignAlgorithm() + v.Statement = stmt } if encrypted { if err := k.checkEncryptedAlgorithm(annotations); err != nil { @@ -166,18 +219,59 @@ func (k *Key) VerifyAnnotations(annotations map[string]string, data []byte) (Ver return v, nil } -func (k *Key) verifySignature(annotations map[string]string, data []byte) error { +// verifyAttestation checks the DSSE envelope against this key and that the +// in-toto statement it carries attests data. It returns the authenticated +// statement. +func (k *Key) verifyAttestation(annotations map[string]string, data []byte) (Statement, error) { if !k.CanVerify() { - return fmt.Errorf("%w (%s)", ErrCannotVerify, k.Describe()) + return Statement{}, fmt.Errorf("%w (%s)", ErrCannotVerify, k.Describe()) } if alg := annotations[AnnotationSignatureAlgorithm]; alg != k.SignAlgorithm() { - return fmt.Errorf("%w: artifact signed with %q but key supports %q", ErrAlgorithmMism, alg, k.SignAlgorithm()) + return Statement{}, fmt.Errorf("%w: artifact signed with %q but key supports %q", ErrAlgorithmMism, alg, k.SignAlgorithm()) + } + raw, err := base64.StdEncoding.DecodeString(annotations[AnnotationAttestation]) + if err != nil { + return Statement{}, fmt.Errorf("%w: %w", ErrMalformedAttestation, err) } - sig, err := base64.StdEncoding.DecodeString(annotations[AnnotationSignature]) + env, err := ParseEnvelope(raw) if err != nil { - return fmt.Errorf("%w: malformed signature annotation: %w", ErrInvalidSignature, err) + return Statement{}, err } - return k.Verify(data, sig) + // Verify first, then parse the exact bytes the signature covered: DSSE + // forbids re-reading the payload out of the envelope after verification, + // and canonicalization must not depend on this process's JSON encoder. + body, err := k.VerifyEnvelope(env) + if err != nil { + return Statement{}, err + } + stmt, err := ParseStatement(body) + if err != nil { + return Statement{}, err + } + if !stmt.describes(data) { + return Statement{}, fmt.Errorf("%w: attested digest is %s but the agent YAML is %s", ErrStatementMismatch, stmt.Digest(), digestAlgorithm+":"+sha256Hex(data)) + } + return stmt, nil +} + +// CheckSubject reports whether the attestation names ref as a subject. +// Callers that know which reference they requested should use this to detect a +// signed artifact copied to another location, which the signature alone cannot +// catch. It is a no-op when the artifact carried no attestation (nothing was +// signed) or when ref cannot be parsed. +func (v Verification) CheckSubject(ref string) error { + if v.Statement.SubjectName() == "" { + return nil + } + normalized, err := fullyQualified(ref) + if err != nil { + // An unparseable reference is the caller's problem, not a mismatch. + return nil //nolint:nilerr // best-effort check; the signature already verified + } + if !v.Statement.names(normalized) { + return fmt.Errorf("%w: signed as %s but read from %s", ErrSubjectMismatch, v.Statement.SubjectName(), normalized) + } + return nil } func (k *Key) checkEncryptedAlgorithm(annotations map[string]string) error { diff --git a/pkg/protect/attestation.go b/pkg/protect/attestation.go new file mode 100644 index 0000000000..201b04cca0 --- /dev/null +++ b/pkg/protect/attestation.go @@ -0,0 +1,423 @@ +package protect + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/go-containerregistry/pkg/name" +) + +const ( + // PayloadType is the DSSE payloadType of the signed body: an in-toto + // Statement. It is part of the pre-authentication encoding (see + // paeEncode), so it cannot be swapped without invalidating the signature. + PayloadType = "application/vnd.in-toto+json" + // StatementType is the in-toto Statement v1 `_type`. + StatementType = "https://in-toto.io/Statement/v1" + // PredicateTypePublication identifies the predicate holding the + // publication metadata of a shared agent. The URI is the version: a + // breaking change to the predicate shape gets a new URI, and verifiers + // that do not know a URI report the predicate as not understood instead of + // rejecting the artifact. + PredicateTypePublication = "https://docker.com/docker-agent/share/publication/v1" + // digestAlgorithm is the only digest algorithm we produce in a subject's + // digest set. Values are bare hex, as in-toto requires. + digestAlgorithm = "sha256" +) + +var ( + ErrMalformedAttestation = errors.New("malformed attestation annotation") + ErrUnknownStatementType = errors.New("unsupported in-toto statement type") + ErrStatementMismatch = errors.New("attestation does not describe this artifact") + ErrSubjectMismatch = errors.New("attested subject does not match the reference being read") +) + +// Envelope is a DSSE envelope (Payload holds the base64 in-toto Statement). +// It is stored verbatim in [AnnotationAttestation]. +type Envelope struct { + // Payload is the base64 SERIALIZED_BODY. Verification covers the exact + // bytes it decodes to, never a re-serialized copy. + Payload string `json:"payload"` + // PayloadType must be [PayloadType]; it is authenticated through the PAE. + PayloadType string `json:"payloadType"` + // Signatures holds one entry per signer. At least one must verify. + Signatures []Signature `json:"signatures"` +} + +// Signature is one DSSE signature over PAE(payloadType, body). +type Signature struct { + // KeyID is an unauthenticated hint identifying the signing key. It must + // never drive a security decision: verification tries every signature. + KeyID string `json:"keyid,omitempty"` + // Sig is the base64 raw signature (or MAC). + Sig string `json:"sig"` +} + +// Subject is one in-toto subject: a name and a digest set of bare-hex digests. +type Subject struct { + Name string `json:"name"` + Digest map[string]string `json:"digest"` +} + +// Statement is an in-toto Statement v1: the signed body of the envelope. +// +// The security-critical part is parsed strictly: `_type`, `subject` (each with +// its digest set) and `predicateType` bind the artifact to a location and to +// its bytes, so unknown fields there are refused rather than ignored. The +// predicate is parsed leniently — see [Predicate]. +type Statement struct { + // Type is always [StatementType]. + Type string + // Subject lists the artifacts the predicate is about. A statement attests + // all of its subjects equally: a match against any entry is a match. + Subject []Subject + // PredicateType names the predicate shape, and is its version. + PredicateType string + // Predicate is the publication metadata, populated only when + // PredicateUnderstood is set. + Predicate Predicate + // PredicateRaw is the predicate exactly as received (nil when absent). It + // is what Marshal re-emits, so unknown fields survive a round-trip. + PredicateRaw json.RawMessage + // PredicateUnderstood reports whether PredicateType is a shape this + // version knows how to read. A false value is a valid outcome: the + // signature is still verified, the metadata is simply opaque. + PredicateUnderstood bool +} + +// Predicate is the publication metadata of a shared agent: where the artifact +// was published and when. +// +// It is deliberately lenient. Fields a future publisher adds must not break a +// deployed verifier, so anything this version does not know is surfaced in +// Unknown rather than rejected, and a known field carrying an unexpected JSON +// type is treated the same way instead of failing the artifact. Everything +// security-critical lives in the statement instead (see [Statement]). +type Predicate struct { + // Registry is the registry host, e.g. "index.docker.io". + Registry string `json:"registry"` + // Repository is the namespace and repository, e.g. "gtardif/myagent". + Repository string `json:"repository"` + // Tag is the tag, e.g. "v1". Empty for a digest reference. + Tag string `json:"tag,omitempty"` + // Created is the publication time, RFC 3339, UTC. + Created string `json:"created"` + // Unknown holds the fields this version does not understand, as the + // compact JSON text they were received as. Never written. + Unknown map[string]string `json:"-"` +} + +// statementWire is the strict on-the-wire shape of a statement. The predicate +// stays raw so the strict decoder (DisallowUnknownFields, which recurses into +// nested structs) never touches it. +type statementWire struct { + Type string `json:"_type"` + Subject []Subject `json:"subject"` + PredicateType string `json:"predicateType"` + Predicate json.RawMessage `json:"predicate,omitempty"` +} + +// NewStatement describes data published at ref at time created. +// +// The subject digest covers the agent YAML, not the manifest: annotations are +// part of the manifest, so a manifest digest could never be recorded inside +// one without a circular dependency. Attesting the manifest instead would mean +// storing the envelope as a referring artifact (OCI Referrers API). +func NewStatement(ref string, data []byte, created time.Time) (Statement, error) { + parsed, err := name.ParseReference(ref) + if err != nil { + return Statement{}, fmt.Errorf("parsing reference %s: %w", ref, err) + } + tag := "" + if t, ok := parsed.(name.Tag); ok { + tag = t.TagStr() + } + predicate := Predicate{ + Registry: parsed.Context().RegistryStr(), + Repository: parsed.Context().RepositoryStr(), + Tag: tag, + Created: created.UTC().Format(time.RFC3339), + } + raw, err := json.Marshal(predicate) + if err != nil { + return Statement{}, fmt.Errorf("marshaling predicate: %w", err) + } + return Statement{ + Type: StatementType, + Subject: []Subject{{Name: parsed.Name(), Digest: SubjectDigest(data)}}, + PredicateType: PredicateTypePublication, + Predicate: predicate, + PredicateRaw: raw, + PredicateUnderstood: true, + }, nil +} + +// SubjectDigest returns the in-toto digest set of data: bare hex, no +// "sha256:" prefix. +func SubjectDigest(data []byte) map[string]string { + return map[string]string{digestAlgorithm: sha256Hex(data)} +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// Marshal returns the statement JSON to sign and store. The predicate is +// re-emitted byte-for-byte when it came from the wire, so unknown fields are +// never dropped. +func (s Statement) Marshal() ([]byte, error) { + predicate := s.PredicateRaw + if predicate == nil { + raw, err := json.Marshal(s.Predicate) + if err != nil { + return nil, fmt.Errorf("marshaling predicate: %w", err) + } + predicate = raw + } + return json.Marshal(statementWire{ + Type: s.Type, + Subject: s.Subject, + PredicateType: s.PredicateType, + Predicate: predicate, + }) +} + +// ParseStatement decodes a statement from the exact bytes the signature +// covered. Callers must verify those bytes first: nothing here is trustworthy +// before that, and re-serializing to verify would make verification depend on +// this process's JSON encoder. +// +// The statement envelope is strict — unknown fields, a foreign `_type` or a +// subject without a usable digest are refused. The predicate is not: an +// unknown predicateType or unknown predicate fields yield a statement whose +// metadata is (partly) opaque, not an error. +func ParseStatement(raw []byte) (Statement, error) { + var wire statementWire + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&wire); err != nil { + return Statement{}, fmt.Errorf("%w: %w", ErrMalformedAttestation, err) + } + if wire.Type != StatementType { + return Statement{}, fmt.Errorf("%w: got %q, want %q", ErrUnknownStatementType, wire.Type, StatementType) + } + if len(wire.Subject) == 0 { + return Statement{}, fmt.Errorf("%w: statement has no subject", ErrMalformedAttestation) + } + for _, subject := range wire.Subject { + if err := subject.validate(); err != nil { + return Statement{}, err + } + } + stmt := Statement{ + Type: wire.Type, + Subject: wire.Subject, + PredicateType: wire.PredicateType, + PredicateRaw: wire.Predicate, + } + if wire.PredicateType == PredicateTypePublication { + stmt.Predicate, stmt.PredicateUnderstood = parsePredicate(wire.Predicate) + } + return stmt, nil +} + +// validate checks the part of a subject verification relies on: a name to +// compare against the reference read, and a digest to bind the payload. +func (s Subject) validate() error { + if s.Name == "" { + return fmt.Errorf("%w: subject has no name", ErrMalformedAttestation) + } + digest, ok := s.Digest[digestAlgorithm] + if !ok { + return fmt.Errorf("%w: subject %s has no %s digest", ErrMalformedAttestation, s.Name, digestAlgorithm) + } + if decoded, err := hex.DecodeString(digest); err != nil || len(decoded) != sha256.Size { + return fmt.Errorf("%w: subject %s has a malformed %s digest %q", ErrMalformedAttestation, s.Name, digestAlgorithm, digest) + } + return nil +} + +// parsePredicate reads the known fields of a publication predicate and keeps +// everything else opaque. It reports whether the predicate was an object at +// all; anything else is metadata this version cannot read. +func parsePredicate(raw json.RawMessage) (Predicate, bool) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { + return Predicate{}, false + } + var p Predicate + known := map[string]*string{ + "registry": &p.Registry, + "repository": &p.Repository, + "tag": &p.Tag, + "created": &p.Created, + } + for field, value := range fields { + if target, ok := known[field]; ok { + var s string + if err := json.Unmarshal(value, &s); err == nil { + *target = s + continue + } + } + if p.Unknown == nil { + p.Unknown = map[string]string{} + } + p.Unknown[field] = string(value) + } + return p, true +} + +// describes reports whether the statement attests data, i.e. whether any +// subject's digest matches it. +func (s Statement) describes(data []byte) bool { + want := sha256Hex(data) + for _, subject := range s.Subject { + if subject.Digest[digestAlgorithm] == want { + return true + } + } + return false +} + +// names reports whether any subject is the fully qualified reference ref. +func (s Statement) names(ref string) bool { + for _, subject := range s.Subject { + if subject.Name == ref { + return true + } + } + return false +} + +// SubjectName returns the first subject's name, for display. +func (s Statement) SubjectName() string { + if len(s.Subject) == 0 { + return "" + } + return s.Subject[0].Name +} + +// Digest returns the first subject's digest in the usual "sha256:" form, +// for display. In the statement itself digests are bare hex. +func (s Statement) Digest() string { + if len(s.Subject) == 0 { + return "" + } + digest, ok := s.Subject[0].Digest[digestAlgorithm] + if !ok { + return "" + } + return digestAlgorithm + ":" + digest +} + +// String renders the statement for humans, in a stable field order. +func (s Statement) String() string { + out := s.SubjectName() + if digest := s.Digest(); digest != "" { + out += " " + digest + } + if !s.PredicateUnderstood { + return out + " (predicate " + s.PredicateType + " not understood)" + } + if s.Predicate.Created != "" { + out += " created " + s.Predicate.Created + } + return out +} + +// SignStatement returns a DSSE envelope over stmt, signed with this key. +func (k *Key) SignStatement(stmt Statement) (Envelope, error) { + body, err := stmt.Marshal() + if err != nil { + return Envelope{}, err + } + sig, err := k.Sign(body) + if err != nil { + return Envelope{}, err + } + return Envelope{ + Payload: base64.StdEncoding.EncodeToString(body), + PayloadType: PayloadType, + // A hint for key selection only; verification never consults it. + Signatures: []Signature{{KeyID: k.Fingerprint(), Sig: base64.StdEncoding.EncodeToString(sig)}}, + }, nil +} + +// ParseEnvelope decodes a DSSE envelope. Nothing in it is authenticated yet. +func ParseEnvelope(raw []byte) (Envelope, error) { + var env Envelope + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&env); err != nil { + return Envelope{}, fmt.Errorf("%w: %w", ErrMalformedAttestation, err) + } + if env.PayloadType != PayloadType { + return Envelope{}, fmt.Errorf("%w: payloadType is %q, want %q", ErrMalformedAttestation, env.PayloadType, PayloadType) + } + if len(env.Signatures) == 0 { + return Envelope{}, fmt.Errorf("%w: envelope carries no signature", ErrMalformedAttestation) + } + return env, nil +} + +// VerifyEnvelope checks env against this key and returns the payload bytes it +// authenticated — the exact SERIALIZED_BODY, which is what callers must parse. +// Re-encoding the returned bytes, or reading the payload out of the envelope +// again afterwards, would break that guarantee. +// +// Every signature is tried: keyid is an unauthenticated hint and must not +// select which one counts. +func (k *Key) VerifyEnvelope(env Envelope) ([]byte, error) { + if !k.CanVerify() { + return nil, fmt.Errorf("%w (%s)", ErrCannotVerify, k.Describe()) + } + body, err := decodeBase64(env.Payload) + if err != nil { + return nil, fmt.Errorf("%w: malformed payload: %w", ErrMalformedAttestation, err) + } + for _, signature := range env.Signatures { + sig, err := decodeBase64(signature.Sig) + if err != nil { + continue + } + if k.Verify(body, sig) == nil { + return body, nil + } + } + return nil, ErrInvalidSignature +} + +// decodeBase64 accepts the four base64 alphabets DSSE producers use in +// practice: standard or URL-safe, padded or not. +func decodeBase64(s string) ([]byte, error) { + encodings := []*base64.Encoding{ + base64.StdEncoding, base64.RawStdEncoding, + base64.URLEncoding, base64.RawURLEncoding, + } + for _, encoding := range encodings { + if decoded, err := encoding.DecodeString(s); err == nil { + return decoded, nil + } + } + return nil, errors.New("not valid base64") +} + +// fullyQualified normalizes an OCI reference to its registry-qualified form, +// so that equivalent shorthands ("repo:tag", "docker.io/repo:tag") compare +// equal. It mirrors remote.FullyQualifiedReference, duplicated here to keep +// this package free of the OCI client stack. +func fullyQualified(ref string) (string, error) { + parsed, err := name.ParseReference(ref) + if err != nil { + return "", fmt.Errorf("parsing reference %s: %w", ref, err) + } + return parsed.Name(), nil +} diff --git a/pkg/protect/attestation_test.go b/pkg/protect/attestation_test.go new file mode 100644 index 0000000000..5f5ec66175 --- /dev/null +++ b/pkg/protect/attestation_test.go @@ -0,0 +1,525 @@ +package protect + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "maps" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The DSSE pre-authentication encoding must match the spec byte for byte: +// interoperability with cosign/in-toto verifiers depends on it. +func TestPAEEncoding(t *testing.T) { + t.Parallel() + + // The DSSE spec's own test vector. + assert.Equal(t, "DSSEv1 29 http://example.com/HelloWorld 11 hello world", + string(paeEncode("http://example.com/HelloWorld", []byte("hello world")))) + assert.Equal(t, "DSSEv1 28 application/vnd.in-toto+json 0 ", string(paeEncode(PayloadType, nil))) + + // Length-prefixing must make the encoding unambiguous: two different + // (type, body) pairs can never produce the same bytes. + assert.NotEqual(t, string(paeEncode("ab", []byte("cd"))), string(paeEncode("ab c", []byte("d")))) +} + +// The stored annotation must be a DSSE envelope a third-party verifier can +// consume: a JSON object with payload/payloadType/signatures, whose payload is +// an in-toto Statement v1 with bare-hex digests. +func TestAttestation_WireFormat(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ed25519/pkcs8+pkix"] + priv := mustParse(t, kp.priv) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + + env := envelopeIn(t, annotations) + assert.Equal(t, PayloadType, env.PayloadType) + require.Len(t, env.Signatures, 1) + assert.Equal(t, priv.Fingerprint(), env.Signatures[0].KeyID) + + body, err := base64.StdEncoding.DecodeString(env.Payload) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(body, &raw)) + assert.Equal(t, StatementType, raw["_type"]) + assert.Equal(t, PredicateTypePublication, raw["predicateType"]) + + subjects, ok := raw["subject"].([]any) + require.True(t, ok) + require.Len(t, subjects, 1) + subject := subjects[0].(map[string]any) + assert.Equal(t, testRef, subject["name"]) + digest := subject["digest"].(map[string]any)["sha256"].(string) + // in-toto digests are bare hex, never "sha256:"-prefixed. + assert.NotContains(t, digest, ":") + sum := sha256.Sum256([]byte(payload)) + assert.Equal(t, hex.EncodeToString(sum[:]), digest) + + predicate := raw["predicate"].(map[string]any) + assert.Equal(t, "index.docker.io", predicate["registry"]) + assert.Equal(t, "library/agent", predicate["repository"]) + assert.Equal(t, "v1", predicate["tag"]) + + // The predicate type is advertised in clear so a consumer can filter + // attestations without decoding the envelope. + assert.Equal(t, PredicateTypePublication, annotations[AnnotationPredicateType]) + assert.Equal(t, raw["predicateType"], annotations[AnnotationPredicateType]) +} + +// The predicate-type annotation is a convenience hint, not evidence: it is +// outside the signature, so a verifier must ignore it and report the signed +// value instead. +func TestPredicateTypeAnnotation_IsNotTrusted(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ecdsa/pkcs8+pkix"] + priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + + // Lying about the predicate type must not change the outcome, and must not + // make the predicate look unreadable: the signed statement still says what + // it says. + lying := maps.Clone(annotations) + lying[AnnotationPredicateType] = "https://example.com/something-else/v1" + v, err := pub.VerifyAnnotations(lying, []byte(payload)) + require.NoError(t, err) + assert.Equal(t, PredicateTypePublication, v.Statement.PredicateType) + assert.True(t, v.Statement.PredicateUnderstood) + + // Removing it entirely is equally harmless. + stripped := maps.Clone(annotations) + delete(stripped, AnnotationPredicateType) + v, err = pub.VerifyAnnotations(stripped, []byte(payload)) + require.NoError(t, err) + assert.Equal(t, PredicateTypePublication, v.Statement.PredicateType) +} + +// An independent DSSE verifier must accept our signature: recompute the PAE by +// hand and check it with the raw crypto primitive, the way cosign would — no +// code from this package involved in the verification itself. +func TestAttestation_VerifiableWithoutThisPackage(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ed25519/pkcs8+pkix"] + priv := mustParse(t, kp.priv) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + + env := envelopeIn(t, annotations) + body, err := base64.StdEncoding.DecodeString(env.Payload) + require.NoError(t, err) + sig, err := base64.StdEncoding.DecodeString(env.Signatures[0].Sig) + require.NoError(t, err) + + pae := fmt.Sprintf("DSSEv1 %d %s %d %s", len(env.PayloadType), env.PayloadType, len(body), body) + assert.True(t, ed25519.Verify(priv.pub.(ed25519.PublicKey), []byte(pae), sig)) + // And the signature is over the PAE, not over the bare body. + assert.False(t, ed25519.Verify(priv.pub.(ed25519.PublicKey), body, sig)) +} + +// Both base64 alphabets are accepted on the way in, since DSSE producers differ. +func TestEnvelope_AcceptsURLSafeBase64(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ecdsa/pkcs8+pkix"] + priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + + env := envelopeIn(t, annotations) + body, err := base64.StdEncoding.DecodeString(env.Payload) + require.NoError(t, err) + sig, err := base64.StdEncoding.DecodeString(env.Signatures[0].Sig) + require.NoError(t, err) + + for name, encoding := range map[string]*base64.Encoding{ + "raw std": base64.RawStdEncoding, + "url": base64.URLEncoding, + "raw url": base64.RawURLEncoding, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + reencoded := Envelope{ + Payload: encoding.EncodeToString(body), + PayloadType: PayloadType, + Signatures: []Signature{{Sig: encoding.EncodeToString(sig)}}, + } + verified, err := pub.VerifyEnvelope(reencoded) + require.NoError(t, err) + assert.Equal(t, body, verified) + }) + } +} + +// keyid is an unauthenticated hint: it must never decide whether a signature +// counts, and a wrong or missing one must not affect the outcome. +func TestEnvelope_KeyIDIsNotTrusted(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ecdsa/pkcs8+pkix"] + priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + env := envelopeIn(t, annotations) + + lying := env + lying.Signatures = []Signature{{KeyID: "not-our-key-id", Sig: env.Signatures[0].Sig}} + _, err := pub.VerifyEnvelope(lying) + require.NoError(t, err, "a wrong keyid must not reject a valid signature") + + // Conversely, a matching keyid over a bogus signature proves nothing. + forged := env + forged.Signatures = []Signature{{KeyID: priv.Fingerprint(), Sig: base64.StdEncoding.EncodeToString([]byte("garbage"))}} + _, err = pub.VerifyEnvelope(forged) + require.ErrorIs(t, err, ErrInvalidSignature) + + // Every signature is tried, so a valid one among junk still verifies. + mixed := env + mixed.Signatures = []Signature{ + {Sig: "not base64!"}, + {KeyID: "someone-else", Sig: base64.StdEncoding.EncodeToString([]byte("garbage"))}, + env.Signatures[0], + } + _, err = pub.VerifyEnvelope(mixed) + require.NoError(t, err) +} + +// The attested metadata must be authenticated: editing any field invalidates +// the signature, and a statement lifted from another artifact does not attest +// this one. +func TestAttestation_IsAuthenticated(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ecdsa/pkcs8+pkix"] + priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + + // Tamper with the subject while keeping the signature. + forged := maps.Clone(annotations) + stmt := stmtFor(t, []byte(payload)) + stmt.Subject = []Subject{{Name: "index.docker.io/attacker/agent:v1", Digest: SubjectDigest([]byte(payload))}} + body, err := stmt.Marshal() + require.NoError(t, err) + env := envelopeIn(t, annotations) + env.Payload = base64.StdEncoding.EncodeToString(body) + rawEnv, err := json.Marshal(env) + require.NoError(t, err) + forged[AnnotationAttestation] = base64.StdEncoding.EncodeToString(rawEnv) + require.ErrorIs(t, verifyErr(pub, forged, []byte(payload)), ErrInvalidSignature) + + // Strip the attestation: an encrypted-only artifact from an asymmetric key + // is not proof, and a signature cannot be recovered from nothing. + stripped := maps.Clone(annotations) + delete(stripped, AnnotationAttestation) + require.ErrorIs(t, verifyErr(pub, stripped, []byte(payload)), ErrNotProtected) + + // An attestation validly signed for other content does not attest this one. + other := []byte("agents:\n root:\n model: other\n") + otherAnnotations := map[string]string{} + require.NoError(t, protect(t, priv, otherAnnotations, other, ModeSign)) + require.ErrorIs(t, verifyErr(pub, otherAnnotations, []byte(payload)), ErrStatementMismatch) + require.NoError(t, verifyErr(pub, otherAnnotations, other)) + + // A foreign statement type is refused rather than guessed at, even when + // validly signed: `_type` is what makes the subject binding meaningful. + alien := maps.Clone(annotations) + body, err = json.Marshal(map[string]any{ + "_type": "https://example.com/NotAStatement/v1", + "subject": []Subject{{Name: testRef, Digest: SubjectDigest([]byte(payload))}}, + "predicateType": PredicateTypePublication, + "predicate": map[string]string{}, + }) + require.NoError(t, err) + reseal(t, priv, alien, body) + require.ErrorIs(t, verifyErr(pub, alien, []byte(payload)), ErrUnknownStatementType) +} + +// Forward compatibility, the whole point of the lenient predicate: an +// artifact published by a future version that adds predicate fields must still +// verify here, and the unknown metadata must be surfaced rather than dropped. +func TestPredicate_UnknownFieldsStillVerify(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ecdsa/pkcs8+pkix"] + priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + + // What a future publisher would emit: the same predicateType with extra + // fields this version has never heard of. + body, err := json.Marshal(map[string]any{ + "_type": StatementType, + "subject": []Subject{{Name: testRef, Digest: SubjectDigest([]byte(payload))}}, + "predicateType": PredicateTypePublication, + "predicate": map[string]any{ + "registry": "index.docker.io", + "repository": "library/agent", + "tag": "v1", + "created": "2026-09-11T08:30:00Z", + "builder": "docker-agent/9.9.9", + "provenance": map[string]any{"workflow": "release.yml"}, + }, + }) + require.NoError(t, err) + reseal(t, priv, annotations, body) + + v, err := pub.VerifyAnnotations(annotations, []byte(payload)) + require.NoError(t, err) + assert.True(t, v.Statement.PredicateUnderstood) + assert.Equal(t, "2026-09-11T08:30:00Z", v.Statement.Predicate.Created) + assert.Equal(t, "library/agent", v.Statement.Predicate.Repository) + // Unknown fields are kept as opaque JSON, not silently dropped. + assert.Equal(t, `"docker-agent/9.9.9"`, v.Statement.Predicate.Unknown["builder"]) + assert.JSONEq(t, `{"workflow":"release.yml"}`, v.Statement.Predicate.Unknown["provenance"]) + require.NoError(t, v.CheckSubject(testRef)) + + // A known field carrying an unexpected type is opaque too, never fatal. + body, err = json.Marshal(map[string]any{ + "_type": StatementType, + "subject": []Subject{{Name: testRef, Digest: SubjectDigest([]byte(payload))}}, + "predicateType": PredicateTypePublication, + "predicate": map[string]any{"registry": "index.docker.io", "tag": 3}, + }) + require.NoError(t, err) + reseal(t, priv, annotations, body) + v, err = pub.VerifyAnnotations(annotations, []byte(payload)) + require.NoError(t, err) + assert.Equal(t, "index.docker.io", v.Statement.Predicate.Registry) + assert.Empty(t, v.Statement.Predicate.Tag) + assert.Equal(t, "3", v.Statement.Predicate.Unknown["tag"]) +} + +// An unknown predicateType is "signature valid, predicate not understood" — +// an honest outcome, not a failure. The subject binding still holds. +func TestPredicate_UnknownTypeIsNotFatal(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ecdsa/pkcs8+pkix"] + priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + + body, err := json.Marshal(map[string]any{ + "_type": StatementType, + "subject": []Subject{{Name: testRef, Digest: SubjectDigest([]byte(payload))}}, + "predicateType": "https://docker.com/docker-agent/share/publication/v2", + "predicate": map[string]any{"shape": "we cannot know"}, + }) + require.NoError(t, err) + reseal(t, priv, annotations, body) + + v, err := pub.VerifyAnnotations(annotations, []byte(payload)) + require.NoError(t, err) + assert.False(t, v.Statement.PredicateUnderstood) + assert.Empty(t, v.Statement.Predicate.Created) + assert.Contains(t, v.String(), "not understood") + // Everything security-critical still applies. + require.NoError(t, v.CheckSubject(testRef)) + require.ErrorIs(t, v.CheckSubject("attacker/agent:v1"), ErrSubjectMismatch) + require.ErrorIs(t, verifyErr(pub, annotations, []byte("other")), ErrStatementMismatch) +} + +// The predicate is re-emitted verbatim, so a verifier that re-signs or stores a +// parsed statement cannot silently drop a field it did not understand. +func TestStatement_RoundTripPreservesUnknownPredicateFields(t *testing.T) { + t.Parallel() + + original := []byte(`{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"index.docker.io/library/agent:v1","digest":{"sha256":"` + + hexDigest([]byte(payload)) + `"}}],"predicateType":"https://docker.com/docker-agent/share/publication/v1","predicate":{"registry":"index.docker.io","future":"kept"}}`) + + stmt, err := ParseStatement(original) + require.NoError(t, err) + assert.Equal(t, `"kept"`, stmt.Predicate.Unknown["future"]) + + remarshaled, err := stmt.Marshal() + require.NoError(t, err) + assert.Contains(t, string(remarshaled), `"future":"kept"`) + + again, err := ParseStatement(remarshaled) + require.NoError(t, err) + assert.Equal(t, stmt, again) +} + +func hexDigest(data []byte) string { + return SubjectDigest(data)["sha256"] +} + +// The attested subject is what makes a copied artifact detectable. +func TestAttestation_CheckSubject(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ecdsa/pkcs8+pkix"] + priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + + v, err := pub.VerifyAnnotations(annotations, []byte(payload)) + require.NoError(t, err) + + // Equivalent shorthands for the same reference all match. + for _, ref := range []string{testRef, "library/agent:v1", "docker.io/library/agent:v1"} { + require.NoError(t, v.CheckSubject(ref), ref) + } + require.ErrorIs(t, v.CheckSubject("library/agent:v2"), ErrSubjectMismatch) + require.ErrorIs(t, v.CheckSubject("attacker/agent:v1"), ErrSubjectMismatch) + require.ErrorIs(t, v.CheckSubject("ghcr.io/library/agent:v1"), ErrSubjectMismatch) + // An unparseable reference is not reported as a mismatch. + require.NoError(t, v.CheckSubject("not a reference")) + + // Nothing signed, nothing to check: a symmetric encrypt-only artifact + // carries no attestation, so CheckSubject must stay silent. + key := mustParse(t, []byte(secret)) + encrypted := map[string]string{} + require.NoError(t, protect(t, key, encrypted, []byte(payload), ModeEncrypt)) + ev, err := key.VerifyAnnotations(encrypted, []byte(payload)) + require.NoError(t, err) + assert.Empty(t, ev.Statement.SubjectName()) + require.NoError(t, ev.CheckSubject("anything/at:all")) +} + +// A statement may attest several subjects; a match against any of them is a +// match, and the digest binding works the same way. +func TestStatement_MultipleSubjects(t *testing.T) { + t.Parallel() + + kp := keyPairs(t)["ed25519/pkcs8+pkix"] + priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) + annotations := map[string]string{} + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) + + body, err := json.Marshal(map[string]any{ + "_type": StatementType, + "subject": []Subject{ + {Name: "index.docker.io/library/agent:v1", Digest: SubjectDigest([]byte(payload))}, + {Name: "ghcr.io/library/agent:v1", Digest: SubjectDigest([]byte(payload))}, + }, + "predicateType": PredicateTypePublication, + "predicate": map[string]string{"registry": "index.docker.io"}, + }) + require.NoError(t, err) + reseal(t, priv, annotations, body) + + v, err := pub.VerifyAnnotations(annotations, []byte(payload)) + require.NoError(t, err) + require.NoError(t, v.CheckSubject("library/agent:v1")) + require.NoError(t, v.CheckSubject("ghcr.io/library/agent:v1")) + require.ErrorIs(t, v.CheckSubject("quay.io/library/agent:v1"), ErrSubjectMismatch) +} + +// The statement records the parsed components of the reference so consumers can +// display them without re-parsing. +func TestStatement_Fields(t *testing.T) { + t.Parallel() + + data := []byte(payload) + stmt, err := NewStatement("gtardif/myagent:v3", data, time.Date(2026, 9, 11, 8, 30, 0, 0, time.UTC)) + require.NoError(t, err) + assert.Equal(t, StatementType, stmt.Type) + assert.Equal(t, PredicateTypePublication, stmt.PredicateType) + assert.Equal(t, "index.docker.io/gtardif/myagent:v3", stmt.SubjectName()) + assert.Equal(t, "index.docker.io", stmt.Predicate.Registry) + assert.Equal(t, "gtardif/myagent", stmt.Predicate.Repository) + assert.Equal(t, "v3", stmt.Predicate.Tag) + assert.Equal(t, "sha256:"+hexDigest(data), stmt.Digest()) + assert.Equal(t, "2026-09-11T08:30:00Z", stmt.Predicate.Created) + + // Creation dates are normalized to UTC so the attested value is unambiguous. + east := time.FixedZone("UTC+5", 5*60*60) + stmt, err = NewStatement("gtardif/myagent:v3", data, time.Date(2026, 9, 11, 13, 30, 0, 0, east)) + require.NoError(t, err) + assert.Equal(t, "2026-09-11T08:30:00Z", stmt.Predicate.Created) + + // A digest reference has no tag. + stmt, err = NewStatement("gtardif/myagent@sha256:"+hexDigest(data), data, time.Now()) + require.NoError(t, err) + assert.Empty(t, stmt.Predicate.Tag) + assert.Equal(t, "gtardif/myagent", stmt.Predicate.Repository) + + _, err = NewStatement("not a reference", data, time.Now()) + require.Error(t, err) +} + +// Protect must refuse to sign a statement that does not attest the data. +func TestStatement_MustDescribeData(t *testing.T) { + t.Parallel() + + key := mustParse(t, []byte(secret)) + mismatched := stmtFor(t, []byte("some other content")) + err := key.Protect(map[string]string{}, []byte(payload), mismatched, ModeSign) + require.ErrorIs(t, err, ErrStatementMismatch) +} + +// The security-critical half of the statement is strict: anything that would +// weaken the subject↔digest binding is refused. +func TestParseStatement_StrictEnvelope(t *testing.T) { + t.Parallel() + + digest := hexDigest([]byte(payload)) + valid := `{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"a:b","digest":{"sha256":"` + digest + `"}}],"predicateType":"x","predicate":{}}` + _, err := ParseStatement([]byte(valid)) + require.NoError(t, err) + + for name, raw := range map[string]string{ + "empty": "", + "truncated": "{", + "array": "[]", + "unknown top field": strings.Replace(valid, `"predicate":{}`, `"predicate":{},"extra":true`, 1), + "unknown subj field": strings.Replace(valid, `"name":"a:b"`, `"name":"a:b","extra":1`, 1), + "no subject": strings.Replace(valid, `[{"name":"a:b","digest":{"sha256":"`+digest+`"}}]`, `[]`, 1), + "no subject name": strings.Replace(valid, `"name":"a:b",`, ``, 1), + "no digest": strings.Replace(valid, `"digest":{"sha256":"`+digest+`"}`, `"digest":{}`, 1), + "foreign digest only": strings.Replace(valid, `"sha256":"`+digest+`"`, `"sha512":"`+digest+`"`, 1), + "short digest": strings.Replace(valid, digest, "abcd", 1), + "non-hex digest": strings.Replace(valid, digest, strings.Repeat("z", 64), 1), + "wrong type": strings.Replace(valid, StatementType, "https://example.com/v1", 1), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := ParseStatement([]byte(raw)) + require.Error(t, err, raw) + }) + } +} + +// An envelope must be a well-formed DSSE envelope of the expected payload type +// before anything in it is looked at. +func TestParseEnvelope_Malformed(t *testing.T) { + t.Parallel() + + for name, raw := range map[string]string{ + "empty": "", + "truncated": "{", + "unknown field": `{"payload":"e30=","payloadType":"` + PayloadType + `","signatures":[{"sig":"AA"}],"extra":1}`, + "wrong type": `{"payload":"e30=","payloadType":"application/json","signatures":[{"sig":"AA"}]}`, + "no payload type": `{"payload":"e30=","signatures":[{"sig":"AA"}]}`, + "no signatures": `{"payload":"e30=","payloadType":"` + PayloadType + `","signatures":[]}`, + "null signatures": `{"payload":"e30=","payloadType":"` + PayloadType + `"}`, + "signature is str": `{"payload":"e30=","payloadType":"` + PayloadType + `","signatures":"AA"}`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := ParseEnvelope([]byte(raw)) + require.ErrorIs(t, err, ErrMalformedAttestation) + }) + } + + // A malformed payload is caught at verification, not at parse time. + key := mustParse(t, []byte(secret)) + env, err := ParseEnvelope([]byte(`{"payload":"not base64!","payloadType":"` + PayloadType + `","signatures":[{"sig":"AA"}]}`)) + require.NoError(t, err) + _, err = key.VerifyEnvelope(env) + require.ErrorIs(t, err, ErrMalformedAttestation) +} diff --git a/pkg/protect/encrypt.go b/pkg/protect/encrypt.go index 29d0012736..f47e4f7a4d 100644 --- a/pkg/protect/encrypt.go +++ b/pkg/protect/encrypt.go @@ -69,7 +69,7 @@ func (k *Key) Encrypt(data []byte) ([]byte, error) { if !k.CanEncrypt() { return nil, ErrCannotEncrypt } - aad := domainInput("encrypt", k.EncryptAlgorithm(), nil) + aad := encryptAAD(k.EncryptAlgorithm()) switch { case k.Symmetric(): key, err := deriveKey(k.secret, "docker-agent/"+AlgAESGCM) @@ -93,7 +93,7 @@ func (k *Key) Decrypt(blob []byte) ([]byte, error) { if !k.CanDecrypt() { return nil, ErrCannotDecrypt } - aad := domainInput("encrypt", k.EncryptAlgorithm(), nil) + aad := encryptAAD(k.EncryptAlgorithm()) switch p := k.priv.(type) { case nil: key, err := deriveKey(k.secret, "docker-agent/"+AlgAESGCM) diff --git a/pkg/protect/key.go b/pkg/protect/key.go index acacc4f54b..ede27a3902 100644 --- a/pkg/protect/key.go +++ b/pkg/protect/key.go @@ -10,6 +10,23 @@ // (MAC or digital signature) and optionally an authenticated encrypted copy // of the whole YAML that key holders can decrypt. // +// # Attestation format +// +// Signatures are carried in a DSSE envelope ([Envelope]) whose payload is an +// in-toto Statement v1 ([Statement]): the artifact's subject (the reference it +// was published as, plus the sha256 digest of the agent YAML) and a predicate +// holding the publication metadata ([Predicate]). The envelope is stored in +// clear in [AnnotationAttestation], so any DSSE/in-toto consumer can read and +// check it, and only a key holder can have produced it. +// +// The statement half is parsed strictly: `_type`, the subjects and their +// digests, and `predicateType` are what bind an artifact to its bytes and its +// location, so unknown fields there are refused. The predicate half is +// deliberately lenient — unknown fields are surfaced as opaque values +// ([Predicate].Unknown) and an unknown predicateType yields "signature valid, +// predicate not understood" rather than a failure. Adding metadata must never +// break a deployed verifier; predicateType URIs are the version. +// // # Security model // // Verification answers "was this produced by a holder of the key?". For a @@ -21,9 +38,21 @@ // verification with an asymmetric key always requires a signature. This also // rules out downgrading a signed artifact to an encrypted-only one. // -// Signatures cover the layer bytes only. Re-tagging a signed artifact or -// serving an older signed version under a tag is not detected; pin digests +// Signatures cover the DSSE pre-authentication encoding of the statement, and +// the statement carries the digest of the YAML, so the two are bound together: +// neither a swapped layer nor a swapped statement verifies. Because the subject +// records the publication reference, a signed artifact copied to another +// repository or tag is detected via [Verification.CheckSubject]. Serving an +// older signed version under the same tag is still not detected; pin digests // when that matters. +// +// Only the agent YAML and the statement's own contents are authenticated. The +// subject digest covers the YAML layer, not the manifest, so the other manifest +// annotations (author, licenses, revision, tags, the advertised creation date) +// are unauthenticated: a party who can push to the repository can rewrite them +// while the signature still verifies. Callers must not base decisions on them; +// use [Verification.Statement] instead. Authenticating the whole manifest would +// require publishing the envelope as a referring artifact (OCI Referrers API). package protect import ( diff --git a/pkg/protect/protect_test.go b/pkg/protect/protect_test.go index b50e355be8..07e18a523c 100644 --- a/pkg/protect/protect_test.go +++ b/pkg/protect/protect_test.go @@ -8,12 +8,14 @@ import ( "crypto/rsa" "crypto/x509" "encoding/base64" + "encoding/json" "encoding/pem" "maps" "os" "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -50,6 +52,48 @@ func verifyErr(k *Key, annotations map[string]string, data []byte) error { return err } +// testRef is the reference test statements are published as. +const testRef = "index.docker.io/library/agent:v1" + +// stmtFor builds the statement a publisher would sign for data at testRef. +func stmtFor(t *testing.T, data []byte) Statement { + t.Helper() + stmt, err := NewStatement(testRef, data, time.Now()) + require.NoError(t, err) + return stmt +} + +// protect records protection for data with a statement attesting it. +func protect(t *testing.T, k *Key, annotations map[string]string, data []byte, mode Mode) error { + t.Helper() + return k.Protect(annotations, data, stmtFor(t, data), mode) +} + +// envelopeIn decodes the DSSE envelope stored in annotations. +func envelopeIn(t *testing.T, annotations map[string]string) Envelope { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(annotations[AnnotationAttestation]) + require.NoError(t, err) + env, err := ParseEnvelope(raw) + require.NoError(t, err) + return env +} + +// reseal replaces the envelope in annotations with a fresh one over body, the +// way a publisher running a newer (or a malicious) version would. +func reseal(t *testing.T, k *Key, annotations map[string]string, body []byte) { + t.Helper() + sig, err := k.Sign(body) + require.NoError(t, err) + raw, err := json.Marshal(Envelope{ + Payload: base64.StdEncoding.EncodeToString(body), + PayloadType: PayloadType, + Signatures: []Signature{{KeyID: k.Fingerprint(), Sig: base64.StdEncoding.EncodeToString(sig)}}, + }) + require.NoError(t, err) + annotations[AnnotationAttestation] = base64.StdEncoding.EncodeToString(raw) +} + func mustParse(t *testing.T, data []byte) *Key { t.Helper() key, err := ParseKey(data) @@ -294,16 +338,28 @@ func TestAsymmetric_SignMode(t *testing.T) { priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) annotations := map[string]string{} - require.ErrorIs(t, pub.Protect(annotations, []byte(payload), ModeSign), ErrCannotSign) - require.NoError(t, priv.Protect(annotations, []byte(payload), ModeSign)) + require.ErrorIs(t, protect(t, pub, annotations, []byte(payload), ModeSign), ErrCannotSign) + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) assert.Equal(t, kp.signAlg, annotations[AnnotationSignatureAlgorithm]) assert.NotContains(t, annotations, AnnotationEncrypted) require.NoError(t, verifyErr(pub, annotations, []byte(payload))) require.NoError(t, verifyErr(priv, annotations, []byte(payload))) - require.ErrorIs(t, verifyErr(pub, annotations, []byte(payload+"#\n")), ErrInvalidSignature) + // The signature covers the statement, so a swapped layer is caught + // by the subject digest the statement carries. + require.ErrorIs(t, verifyErr(pub, annotations, []byte(payload+"#\n")), ErrStatementMismatch) - _, err := priv.Recover(annotations) + // Verification hands back the authenticated statement. + v, err := pub.VerifyAnnotations(annotations, []byte(payload)) + require.NoError(t, err) + assert.Equal(t, StatementType, v.Statement.Type) + assert.Equal(t, testRef, v.Statement.SubjectName()) + assert.Equal(t, PredicateTypePublication, v.Statement.PredicateType) + assert.True(t, v.Statement.PredicateUnderstood) + require.NoError(t, v.CheckSubject("library/agent:v1")) + require.ErrorIs(t, v.CheckSubject("library/agent:v2"), ErrSubjectMismatch) + + _, err = priv.Recover(annotations) require.ErrorIs(t, err, ErrNotEncrypted) }) } @@ -319,11 +375,11 @@ func TestAsymmetric_EncryptMode(t *testing.T) { annotations := map[string]string{} if !kp.canEncrypt { - require.ErrorIs(t, priv.Protect(annotations, []byte(payload), ModeEncrypt), ErrCannotEncrypt) + require.ErrorIs(t, protect(t, priv, annotations, []byte(payload), ModeEncrypt), ErrCannotEncrypt) // This key type never produces an encrypted copy, so a signed // artifact carrying one is inconsistent whatever its label. - require.NoError(t, priv.Protect(annotations, []byte(payload), ModeSign)) + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) annotations[AnnotationEncrypted] = "AAAA" require.ErrorIs(t, verifyErr(pub, annotations, []byte(payload)), ErrAlgorithmMism) require.ErrorIs(t, verifyErr(priv, annotations, []byte(payload)), ErrAlgorithmMism) @@ -331,28 +387,30 @@ func TestAsymmetric_EncryptMode(t *testing.T) { } // Encrypting with only the public key would leave the artifact unauthenticated. - require.ErrorIs(t, pub.Protect(annotations, []byte(payload), ModeEncrypt), ErrEncryptNeedsPriv) + require.ErrorIs(t, protect(t, pub, annotations, []byte(payload), ModeEncrypt), ErrEncryptNeedsPriv) assert.Empty(t, annotations) - require.NoError(t, priv.Protect(annotations, []byte(payload), ModeEncrypt)) + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeEncrypt)) assert.Equal(t, kp.encAlg, annotations[AnnotationEncryptedAlgorithm]) assert.Equal(t, kp.signAlg, annotations[AnnotationSignatureAlgorithm], "encrypt mode must also sign") // Private key: signature + decrypt-and-compare. Public key: signature only. + want := stmtFor(t, []byte(payload)) v, err := priv.VerifyAnnotations(annotations, []byte(payload)) require.NoError(t, err) - assert.Equal(t, Verification{SignatureAlgorithm: kp.signAlg, EncryptedAlgorithm: kp.encAlg}, v) + assert.Equal(t, Verification{SignatureAlgorithm: kp.signAlg, EncryptedAlgorithm: kp.encAlg, Statement: want}, v) v, err = pub.VerifyAnnotations(annotations, []byte(payload)) require.NoError(t, err) - assert.Equal(t, Verification{SignatureAlgorithm: kp.signAlg}, v) - assert.Equal(t, "signature ("+kp.signAlg+")", v.String()) + assert.Equal(t, Verification{SignatureAlgorithm: kp.signAlg, Statement: want}, v) + assert.Equal(t, "signature ("+kp.signAlg+")", v.SignatureAlgorithmSummary()) + assert.Contains(t, v.String(), testRef) // Even a public key must notice an encrypted copy that this key could not have produced. relabeled := maps.Clone(annotations) relabeled[AnnotationEncryptedAlgorithm] = "something-else" require.ErrorIs(t, verifyErr(pub, relabeled, []byte(payload)), ErrAlgorithmMism) - require.ErrorIs(t, verifyErr(priv, annotations, []byte("tampered")), ErrInvalidSignature) - require.ErrorIs(t, verifyErr(pub, annotations, []byte("tampered")), ErrInvalidSignature) + require.ErrorIs(t, verifyErr(priv, annotations, []byte("tampered")), ErrStatementMismatch) + require.ErrorIs(t, verifyErr(pub, annotations, []byte("tampered")), ErrStatementMismatch) plain, err := priv.Recover(annotations) require.NoError(t, err) @@ -383,14 +441,14 @@ func TestAsymmetric_EncryptedCopyAloneIsNotProof(t *testing.T) { // Legitimate signed artifact. annotations := map[string]string{} - require.NoError(t, priv.Protect(annotations, []byte(payload), ModeSign)) + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeSign)) // Attacker with the public key swaps the layer and downgrades the // signature to an encrypted copy of the malicious content. malicious := []byte("agents:\n root:\n instruction: exfiltrate\n") forged, err := pub.Encrypt(malicious) require.NoError(t, err) - delete(annotations, AnnotationSignature) + delete(annotations, AnnotationAttestation) delete(annotations, AnnotationSignatureAlgorithm) annotations[AnnotationEncrypted] = base64.StdEncoding.EncodeToString(forged) annotations[AnnotationEncryptedAlgorithm] = pub.EncryptAlgorithm() @@ -415,15 +473,16 @@ func TestAsymmetric_SwappedEncryptedCopyIsDetected(t *testing.T) { priv, pub := mustParse(t, kp.priv), mustParse(t, kp.pub) annotations := map[string]string{} - require.NoError(t, priv.Protect(annotations, []byte(payload), ModeEncrypt)) + require.NoError(t, protect(t, priv, annotations, []byte(payload), ModeEncrypt)) malicious := []byte("malicious") forged, err := pub.Encrypt(malicious) require.NoError(t, err) annotations[AnnotationEncrypted] = base64.StdEncoding.EncodeToString(forged) - // Layer swapped too: signature fails. Layer intact: copy mismatch. - require.ErrorIs(t, verifyErr(priv, annotations, malicious), ErrInvalidSignature) + // Layer swapped too: the attested digest no longer matches it. Layer + // intact: the encrypted copy no longer matches. + require.ErrorIs(t, verifyErr(priv, annotations, malicious), ErrStatementMismatch) require.ErrorIs(t, verifyErr(priv, annotations, []byte(payload)), ErrTampered) } @@ -447,16 +506,16 @@ func TestSymmetric_BothModes(t *testing.T) { other := mustParse(t, []byte("a completely different secret")) signed := map[string]string{} - require.NoError(t, key.Protect(signed, []byte(payload), ModeSign)) + require.NoError(t, protect(t, key, signed, []byte(payload), ModeSign)) assert.NotContains(t, signed, AnnotationEncrypted) require.NoError(t, verifyErr(key, signed, []byte(payload))) - require.ErrorIs(t, verifyErr(key, signed, []byte("changed")), ErrInvalidSignature) + require.ErrorIs(t, verifyErr(key, signed, []byte("changed")), ErrStatementMismatch) require.ErrorIs(t, verifyErr(other, signed, []byte(payload)), ErrInvalidSignature) // With a secret, the AEAD copy alone is proof: producing it needs the secret. encrypted := map[string]string{} - require.NoError(t, key.Protect(encrypted, []byte(payload), ModeEncrypt)) - assert.NotContains(t, encrypted, AnnotationSignature) + require.NoError(t, protect(t, key, encrypted, []byte(payload), ModeEncrypt)) + assert.NotContains(t, encrypted, AnnotationAttestation) require.NoError(t, verifyErr(key, encrypted, []byte(payload))) require.ErrorIs(t, verifyErr(key, encrypted, []byte("changed")), ErrTampered) require.ErrorIs(t, verifyErr(other, encrypted, []byte(payload)), ErrDecryption) @@ -467,7 +526,7 @@ func TestSymmetric_BothModes(t *testing.T) { // Each encryption uses a fresh nonce. again := map[string]string{} - require.NoError(t, key.Protect(again, []byte(payload), ModeEncrypt)) + require.NoError(t, protect(t, key, again, []byte(payload), ModeEncrypt)) assert.NotEqual(t, encrypted[AnnotationEncrypted], again[AnnotationEncrypted]) } @@ -483,14 +542,14 @@ func TestDomainSeparation(t *testing.T) { key := mustParse(t, []byte(secret)) annotations := map[string]string{} - require.NoError(t, key.Protect(annotations, []byte(payload), ModeEncrypt)) + require.NoError(t, protect(t, key, annotations, []byte(payload), ModeEncrypt)) // Relabeling the algorithm changes the AAD and must break decryption, not // just the label check. blob, err := base64.StdEncoding.DecodeString(annotations[AnnotationEncrypted]) require.NoError(t, err) derived, err := deriveKey(key.secret, "docker-agent/"+AlgAESGCM) require.NoError(t, err) - _, err = aeadOpen(derived, blob, domainInput("encrypt", "other-alg", nil)) + _, err = aeadOpen(derived, blob, encryptAAD("other-alg")) require.ErrorIs(t, err, ErrDecryption) } @@ -502,16 +561,16 @@ func TestVerifyAnnotations_Errors(t *testing.T) { require.ErrorIs(t, verifyErr(key, map[string]string{}, []byte(payload)), ErrNotProtected) err := verifyErr(key, map[string]string{ - AnnotationSignature: "AAAA", + AnnotationAttestation: "AAAA", AnnotationSignatureAlgorithm: AlgEd25519, }, []byte(payload)) require.ErrorIs(t, err, ErrAlgorithmMism) err = verifyErr(key, map[string]string{ - AnnotationSignature: "not base64!", + AnnotationAttestation: "not base64!", AnnotationSignatureAlgorithm: AlgHMACSHA256, }, []byte(payload)) - require.ErrorIs(t, err, ErrInvalidSignature) + require.ErrorIs(t, err, ErrMalformedAttestation) err = verifyErr(key, map[string]string{ AnnotationEncrypted: "AAAA", @@ -531,7 +590,7 @@ func TestVerifyAnnotations_Errors(t *testing.T) { } } - require.ErrorContains(t, key.Protect(map[string]string{}, nil, Mode("bogus")), "unknown protection mode") + require.ErrorContains(t, key.Protect(map[string]string{}, []byte(payload), stmtFor(t, []byte(payload)), Mode("bogus")), "unknown protection mode") } func TestLoadKey(t *testing.T) { diff --git a/pkg/protect/sign.go b/pkg/protect/sign.go index 70ee85a70c..fe55783bf3 100644 --- a/pkg/protect/sign.go +++ b/pkg/protect/sign.go @@ -9,6 +9,7 @@ import ( "crypto/rsa" "crypto/sha256" "errors" + "fmt" ) const ( @@ -47,14 +48,15 @@ func (k *Key) CanSign() bool { return k.SignAlgorithm() != "" && k.Private() } // CanVerify reports whether the key can verify signatures. func (k *Key) CanVerify() bool { return k.SignAlgorithm() != "" } -// Sign returns the raw signature (or MAC) of data. The signed message is -// domain-separated (see domainInput) so a signature cannot be reused in -// another protocol or under another algorithm label. +// Sign returns the raw signature (or MAC) of data. data is a DSSE +// SERIALIZED_BODY: what is actually signed is its pre-authentication encoding +// (see paeEncode), which binds the payload type and so keeps a signature from +// being reused over a body of another type. func (k *Key) Sign(data []byte) ([]byte, error) { if !k.CanSign() { return nil, ErrCannotSign } - msg := domainInput("sign", k.SignAlgorithm(), data) + msg := paeEncode(PayloadType, data) switch p := k.priv.(type) { case nil: mac := hmac.New(sha256.New, k.secret) @@ -73,12 +75,13 @@ func (k *Key) Sign(data []byte) ([]byte, error) { } } -// Verify checks that sig is a valid signature of data for this key. +// Verify checks that sig is a valid signature over the DSSE +// pre-authentication encoding of data for this key. func (k *Key) Verify(data, sig []byte) error { if !k.CanVerify() { return ErrCannotVerify } - msg := domainInput("sign", k.SignAlgorithm(), data) + msg := paeEncode(PayloadType, data) var ok bool switch p := k.pub.(type) { case nil: @@ -102,11 +105,24 @@ func (k *Key) Verify(data, sig []byte) error { return nil } -// domainInput prefixes data with a protocol/purpose/algorithm header. NUL +// paeEncode is the DSSE pre-authentication encoding: +// +// "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body +// +// Length-prefixing every field makes the encoding unambiguous, so no body can +// be read as a different (type, body) pair. Verifiers outside this repo +// (cosign, in-toto tooling) compute the same bytes. +func paeEncode(payloadType string, body []byte) []byte { + header := fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(body)) + return append([]byte(header), body...) +} + +// encryptAAD is the AEAD additional data binding a ciphertext to this +// protocol and to its algorithm label, so a relabeled blob fails to open. NUL // separators keep the header unambiguous since none of the fields contain NUL. -func domainInput(purpose, alg string, data []byte) []byte { - header := "docker-agent/agent-yaml/v1\x00" + purpose + "\x00" + alg + "\x00" - return append([]byte(header), data...) +// Signatures use the DSSE PAE instead (see paeEncode). +func encryptAAD(alg string) []byte { + return []byte("docker-agent/agent-yaml/v1\x00encrypt\x00" + alg + "\x00") } func rsaPSSOptions() *rsa.PSSOptions {