attestation: emit in-toto v1 statements directly

Use the existing in-toto-golang JSON statement types for attestation export
and set the statement type URI to in-toto v1. Remove the local protojson
compatibility wrapper so this patch only changes the emitted statement type.

Update in-toto-golang to v0.11.0 and refresh vendored module metadata.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2026-06-02 22:58:03 -07:00
parent bb7ed5c529
commit f21f35fad0
7 changed files with 30 additions and 327 deletions

View File

@@ -1,147 +0,0 @@
package intoto
import (
"bytes"
"encoding/json"
"maps"
attestationv1 "github.com/in-toto/attestation/go/v1"
legacyintoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/pkg/errors"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
)
var (
marshalOptions = protojson.MarshalOptions{}
unmarshalOptions = protojson.UnmarshalOptions{}
)
// Subject is the subset of the in-toto v1 resource descriptor.
type Subject struct {
Name string `json:"name,omitempty"`
Digest map[string]string `json:"digest,omitempty"`
}
// Statement is a local compatibility wrapper around the in-toto v1 protobuf
// statement. It keeps protojson isolated at the boundary while still accepting
// legacy v0.1 JSON on decode.
type Statement struct {
Type string
Subject []Subject
PredicateType string
Predicate json.RawMessage
}
func ToResourceDescriptors(subjects []Subject) []*attestationv1.ResourceDescriptor {
out := make([]*attestationv1.ResourceDescriptor, 0, len(subjects))
for _, subject := range subjects {
out = append(out, &attestationv1.ResourceDescriptor{
Name: subject.Name,
Digest: maps.Clone(subject.Digest),
})
}
return out
}
func FromResourceDescriptors(subjects []*attestationv1.ResourceDescriptor) []Subject {
out := make([]Subject, 0, len(subjects))
for _, subject := range subjects {
if subject == nil {
continue
}
out = append(out, Subject{
Name: subject.GetName(),
Digest: maps.Clone(subject.GetDigest()),
})
}
return out
}
func (s Statement) MarshalJSON() ([]byte, error) {
stmt, err := s.toProtobuf()
if err != nil {
return nil, err
}
return marshalOptions.Marshal(stmt)
}
func (s *Statement) UnmarshalJSON(data []byte) error {
var stmt attestationv1.Statement
if err := unmarshalOptions.Unmarshal(data, &stmt); err != nil {
return errors.Wrap(err, "cannot decode in-toto statement")
}
if !validStatementType(stmt.GetType()) {
return errors.Errorf("unsupported in-toto statement type %q", stmt.GetType())
}
return s.fromProtobuf(&stmt)
}
func (s Statement) toProtobuf() (*attestationv1.Statement, error) {
predicate, err := toPredicateStruct(s.Predicate)
if err != nil {
return nil, err
}
return &attestationv1.Statement{
Type: attestationv1.StatementTypeUri,
Subject: ToResourceDescriptors(s.Subject),
PredicateType: s.PredicateType,
Predicate: predicate,
}, nil
}
func (s *Statement) fromProtobuf(stmt *attestationv1.Statement) error {
predicate, err := fromPredicateStruct(stmt.GetPredicate())
if err != nil {
return err
}
s.Type = stmt.GetType()
s.Subject = FromResourceDescriptors(stmt.GetSubject())
s.PredicateType = stmt.GetPredicateType()
s.Predicate = predicate
return nil
}
func toPredicateStruct(raw json.RawMessage) (*structpb.Struct, error) {
raw = normalizePredicate(raw)
if len(raw) == 0 {
return nil, nil
}
var predicate map[string]any
if err := json.Unmarshal(raw, &predicate); err != nil {
return nil, errors.Wrap(err, "cannot decode in-toto predicate as object")
}
st, err := structpb.NewStruct(predicate)
if err != nil {
return nil, errors.Wrap(err, "cannot convert in-toto predicate to protobuf")
}
return st, nil
}
func fromPredicateStruct(predicate *structpb.Struct) (json.RawMessage, error) {
if predicate == nil {
return nil, nil
}
dt, err := marshalOptions.Marshal(predicate)
if err != nil {
return nil, errors.Wrap(err, "cannot encode in-toto predicate")
}
return normalizePredicate(dt), nil
}
func normalizePredicate(raw json.RawMessage) json.RawMessage {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
return bytes.Clone(raw)
}
func validStatementType(t string) bool {
switch t {
case legacyintoto.StatementInTotoV01, attestationv1.StatementTypeUri:
return true
default:
return false
}
}

View File

@@ -1,156 +0,0 @@
package intoto
import (
"encoding/json"
"strings"
"testing"
attestationv1 "github.com/in-toto/attestation/go/v1"
legacyintoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/stretchr/testify/require"
)
func TestStatementMarshalJSON(t *testing.T) {
t.Parallel()
stmt := Statement{
Type: legacyintoto.StatementInTotoV01,
Subject: []Subject{{
Name: "pkg:docker/example@sha256:abc",
Digest: map[string]string{
"sha256": strings.Repeat("a", 64),
},
}},
PredicateType: "https://example.com/attestations/v1.0",
Predicate: json.RawMessage(`{"success":true}`),
}
expected := `{
"_type":"https://in-toto.io/Statement/v1",
"subject":[{"name":"pkg:docker/example@sha256:abc","digest":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}],
"predicateType":"https://example.com/attestations/v1.0",
"predicate":{"success":true}
}`
for _, tc := range []struct {
name string
v any
}{
{"value", stmt},
{"pointer", &stmt},
} {
t.Run(tc.name, func(t *testing.T) {
dt, err := json.Marshal(tc.v)
require.NoError(t, err)
require.JSONEq(t, expected, string(dt))
})
}
}
func TestStatementMarshalJSONOmitsEmptyPredicate(t *testing.T) {
t.Parallel()
stmt := Statement{
Subject: []Subject{{
Name: "artifact",
Digest: map[string]string{
"sha256": strings.Repeat("b", 64),
},
}},
PredicateType: "https://example.com/attestations/v1.0",
}
dt, err := json.Marshal(&stmt)
require.NoError(t, err)
require.JSONEq(t, `{
"_type":"https://in-toto.io/Statement/v1",
"subject":[{"name":"artifact","digest":{"sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}],
"predicateType":"https://example.com/attestations/v1.0"
}`, string(dt))
}
func TestStatementUnmarshalJSONLegacyV01(t *testing.T) {
t.Parallel()
var stmt Statement
err := json.Unmarshal([]byte(`{
"_type":"https://in-toto.io/Statement/v0.1",
"subject":[{"name":"artifact","digest":{"sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}}],
"predicateType":"https://example.com/attestations/v1.0",
"predicate":{"success":true}
}`), &stmt)
require.NoError(t, err)
require.Equal(t, legacyintoto.StatementInTotoV01, stmt.Type)
require.Equal(t, []Subject{{
Name: "artifact",
Digest: map[string]string{
"sha256": strings.Repeat("c", 64),
},
}}, stmt.Subject)
require.Equal(t, "https://example.com/attestations/v1.0", stmt.PredicateType)
require.JSONEq(t, `{"success":true}`, string(stmt.Predicate))
}
func TestStatementUnmarshalJSONV1(t *testing.T) {
t.Parallel()
var stmt Statement
err := json.Unmarshal([]byte(`{
"_type":"https://in-toto.io/Statement/v1",
"subject":[{"name":"artifact","digest":{"sha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}}],
"predicateType":"https://example.com/attestations/v1.0",
"predicate":{"success":true}
}`), &stmt)
require.NoError(t, err)
require.Equal(t, attestationv1.StatementTypeUri, stmt.Type)
require.Equal(t, []Subject{{
Name: "artifact",
Digest: map[string]string{
"sha256": strings.Repeat("d", 64),
},
}}, stmt.Subject)
require.Equal(t, "https://example.com/attestations/v1.0", stmt.PredicateType)
require.JSONEq(t, `{"success":true}`, string(stmt.Predicate))
}
func TestStatementUnmarshalJSONNullPredicate(t *testing.T) {
t.Parallel()
var stmt Statement
err := json.Unmarshal([]byte(`{
"_type":"https://in-toto.io/Statement/v0.1",
"subject":[{"name":"artifact","digest":{"sha256":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}}],
"predicateType":"https://example.com/attestations/v1.0",
"predicate":null
}`), &stmt)
require.NoError(t, err)
require.Nil(t, stmt.Predicate)
}
func TestStatementUnmarshalJSONOmittedPredicate(t *testing.T) {
t.Parallel()
var stmt Statement
err := json.Unmarshal([]byte(`{
"_type":"https://in-toto.io/Statement/v1",
"subject":[{"name":"artifact","digest":{"sha256":"abababababababababababababababababababababababababababababababab"}}],
"predicateType":"https://example.com/attestations/v1.0"
}`), &stmt)
require.NoError(t, err)
require.Nil(t, stmt.Predicate)
}
func TestStatementUnmarshalJSONRejectsUnknownType(t *testing.T) {
t.Parallel()
var stmt Statement
err := json.Unmarshal([]byte(`{
"_type":"https://in-toto.io/Statement/v9",
"subject":[{"name":"artifact","digest":{"sha256":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}}],
"predicateType":"https://example.com/attestations/v1.0",
"predicate":{"success":true}
}`), &stmt)
require.Error(t, err)
}

View File

@@ -6,8 +6,8 @@ import (
"os"
"github.com/containerd/continuity/fs"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/moby/buildkit/exporter"
intotojson "github.com/moby/buildkit/exporter/attestation/intoto"
gatewaypb "github.com/moby/buildkit/frontend/gateway/pb"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/snapshot"
@@ -56,9 +56,9 @@ func ReadAll(ctx context.Context, s session.Group, att exporter.Attestation) ([]
// MakeInTotoStatements iterates over all provided result attestations and
// generates intoto attestation statements.
func MakeInTotoStatements(ctx context.Context, s session.Group, attestations []exporter.Attestation, defaultSubjects []intotojson.Subject) ([]*intotojson.Statement, error) {
func MakeInTotoStatements(ctx context.Context, s session.Group, attestations []exporter.Attestation, defaultSubjects []intoto.Subject) ([]intoto.Statement, error) {
eg, ctx := errgroup.WithContext(ctx)
statements := make([]*intotojson.Statement, len(attestations))
statements := make([]intoto.Statement, len(attestations))
for i, att := range attestations {
eg.Go(func() error {
@@ -73,7 +73,7 @@ func MakeInTotoStatements(ctx context.Context, s session.Group, attestations []e
if err != nil {
return err
}
statements[i] = stmt
statements[i] = *stmt
case gatewaypb.AttestationKind_Bundle:
return errors.New("bundle attestation kind must be un-bundled first")
}
@@ -86,13 +86,13 @@ func MakeInTotoStatements(ctx context.Context, s session.Group, attestations []e
return statements, nil
}
func makeInTotoStatement(content []byte, attestation exporter.Attestation, defaultSubjects []intotojson.Subject) (*intotojson.Statement, error) {
func makeInTotoStatement(content []byte, attestation exporter.Attestation, defaultSubjects []intoto.Subject) (*intoto.Statement, error) {
if len(attestation.InToto.Subjects) == 0 {
attestation.InToto.Subjects = []result.InTotoSubject{{
Kind: gatewaypb.InTotoSubjectKind_Self,
}}
}
var subjects []intotojson.Subject
subjects := []intoto.Subject{}
for _, subject := range attestation.InToto.Subjects {
subjectName := "_"
if subject.Name != "" {
@@ -109,14 +109,14 @@ func makeInTotoStatement(content []byte, attestation exporter.Attestation, defau
}
for _, name := range subjectNames {
subjects = append(subjects, intotojson.Subject{
subjects = append(subjects, intoto.Subject{
Name: name,
Digest: defaultSubject.Digest,
})
}
}
case gatewaypb.InTotoSubjectKind_Raw:
subjects = append(subjects, intotojson.Subject{
subjects = append(subjects, intoto.Subject{
Name: subjectName,
Digest: result.ToDigestMap(subject.Digest...),
})
@@ -124,10 +124,13 @@ func makeInTotoStatement(content []byte, attestation exporter.Attestation, defau
return nil, errors.Errorf("unknown attestation subject type %T", subject)
}
}
stmt := intotojson.Statement{
Subject: subjects,
PredicateType: attestation.InToto.PredicateType,
Predicate: json.RawMessage(content),
stmt := intoto.Statement{
StatementHeader: intoto.StatementHeader{
Type: intoto.StatementInTotoV1,
PredicateType: attestation.InToto.PredicateType,
Subject: subjects,
},
Predicate: json.RawMessage(content),
}
return &stmt, nil
}

View File

@@ -1,7 +1,6 @@
package attestation
import (
"bytes"
"context"
"encoding/json"
"io"
@@ -10,8 +9,8 @@ import (
"strings"
"github.com/containerd/continuity/fs"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/moby/buildkit/exporter"
intotojson "github.com/moby/buildkit/exporter/attestation/intoto"
gatewaypb "github.com/moby/buildkit/frontend/gateway/pb"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/snapshot"
@@ -138,7 +137,7 @@ func unbundle(root string, bundle exporter.Attestation) ([]exporter.Attestation,
return nil, err
}
dec := json.NewDecoder(f)
var stmt intotojson.Statement
var stmt intoto.Statement
if err := dec.Decode(&stmt); err != nil {
return nil, errors.Wrap(err, "cannot decode in-toto statement")
}
@@ -149,6 +148,11 @@ func unbundle(root string, bundle exporter.Attestation) ([]exporter.Attestation,
return nil, errors.Errorf("bundle entry %s does not match required predicate type %s", stmt.PredicateType, bundle.InToto.PredicateType)
}
predicate, err := json.Marshal(stmt.Predicate)
if err != nil {
return nil, err
}
subjects := make([]result.InTotoSubject, len(stmt.Subject))
for i, subject := range stmt.Subject {
subjects[i] = result.InTotoSubject{
@@ -161,7 +165,7 @@ func unbundle(root string, bundle exporter.Attestation) ([]exporter.Attestation,
Kind: gatewaypb.AttestationKind_InToto,
Metadata: bundle.Metadata,
Path: path.Join(bundle.Path, entry.Name()),
ContentFunc: func(context.Context) ([]byte, error) { return bytes.Clone(stmt.Predicate), nil },
ContentFunc: func(context.Context) ([]byte, error) { return predicate, nil },
InToto: result.InTotoAttestation{
PredicateType: stmt.PredicateType,
Subjects: subjects,

View File

@@ -20,7 +20,6 @@ import (
cacheconfig "github.com/moby/buildkit/cache/config"
"github.com/moby/buildkit/exporter"
"github.com/moby/buildkit/exporter/attestation"
intotojson "github.com/moby/buildkit/exporter/attestation/intoto"
"github.com/moby/buildkit/exporter/containerimage/exptypes"
"github.com/moby/buildkit/exporter/util/epoch"
"github.com/moby/buildkit/session"
@@ -321,7 +320,7 @@ func (ic *ImageWriter) Commit(ctx context.Context, inp *exporter.Source, session
return nil, err
}
var defaultSubjects []intotojson.Subject
var defaultSubjects []intoto.Subject
for name := range strings.SplitSeq(opts.ImageName, ",") {
if name == "" {
continue
@@ -330,7 +329,7 @@ func (ic *ImageWriter) Commit(ctx context.Context, inp *exporter.Source, session
if err != nil {
return nil, err
}
defaultSubjects = append(defaultSubjects, intotojson.Subject{
defaultSubjects = append(defaultSubjects, intoto.Subject{
Name: pl,
Digest: result.ToDigestMap(desc.Digest),
})
@@ -580,7 +579,7 @@ func (ic *ImageWriter) commitDistributionManifest(ctx context.Context, opts *Ima
}, &configDesc, nil
}
func (ic *ImageWriter) commitAttestationsManifest(ctx context.Context, opts *ImageCommitOpts, target ocispecs.Descriptor, statements []*intotojson.Statement, ociArtifact bool) (*ocispecs.Descriptor, error) {
func (ic *ImageWriter) commitAttestationsManifest(ctx context.Context, opts *ImageCommitOpts, target ocispecs.Descriptor, statements []intoto.Statement, ociArtifact bool) (*ocispecs.Descriptor, error) {
var (
manifestType = ocispecs.MediaTypeImageManifest
configType = ocispecs.MediaTypeImageConfig

View File

@@ -12,10 +12,10 @@ import (
"strings"
"time"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/moby/buildkit/cache"
"github.com/moby/buildkit/exporter"
"github.com/moby/buildkit/exporter/attestation"
intotojson "github.com/moby/buildkit/exporter/attestation/intoto"
"github.com/moby/buildkit/exporter/util/epoch"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/snapshot"
@@ -148,7 +148,7 @@ func CreateFS(ctx context.Context, sessionID string, k string, ref cache.Immutab
return nil, nil, err
}
if len(attestations) > 0 {
subjects := []intotojson.Subject{}
subjects := []intoto.Subject{}
err = outputFS.Walk(ctx, "", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
@@ -165,7 +165,7 @@ func CreateFS(ctx context.Context, sessionID string, k string, ref cache.Immutab
if _, err := io.Copy(d.Hash(), f); err != nil {
return err
}
subjects = append(subjects, intotojson.Subject{
subjects = append(subjects, intoto.Subject{
Name: path,
Digest: result.ToDigestMap(d.Digest()),
})

2
go.mod
View File

@@ -46,7 +46,6 @@ require (
github.com/hashicorp/go-immutable-radix/v2 v2.1.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/hiddeco/sshsig v0.2.0
github.com/in-toto/attestation v1.1.2
github.com/in-toto/in-toto-golang v0.11.0
github.com/klauspost/compress v1.18.6
github.com/mitchellh/hashstructure/v2 v2.0.2
@@ -194,6 +193,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hanwen/go-fuse/v2 v2.9.0 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/in-toto/attestation v1.1.2 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
github.com/moby/sys/mount v0.3.4 // indirect