diff --git a/app/cli/pkg/action/attestation_init.go b/app/cli/pkg/action/attestation_init.go index f75c5bc82..e0ecf2c04 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.go @@ -29,6 +29,7 @@ import ( "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" clientAPI "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" "github.com/chainloop-dev/chainloop/pkg/casclient" "github.com/chainloop-dev/chainloop/pkg/policies" "github.com/rs/zerolog" @@ -236,6 +237,12 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun uiDashboardURL string ) + // The identity token of a Chainloop sandbox goes to the control plane, which verifies it + var sandboxIdentityToken string + if r, ok := discoveredRunner.(crafter.IdentityRunner); ok { + sandboxIdentityToken = r.IdentityToken() + } + // Init in the control plane if needed including the runner context if !action.dryRun { runResp, err := client.Init( @@ -251,6 +258,7 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun UseLatestVersion: opts.UseLatestVersion, RequireExistingVersion: opts.RequireExistingVersion, MarkAsLatest: opts.MarkAsLatest, + SandboxIdentity: sandboxIdentityToken, }, ) if err != nil { @@ -277,6 +285,10 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun action.Logger.Debug().Str("workflow-run-id", workflowRun.GetId()).Msg("attestation initialized in the control plane") attestationID = workflowRun.GetId() + + if sandboxIdentityToken != "" { + action.applySandboxIdentity(ctx, discoveredRunner, sandboxIdentityToken, result.GetSandboxIdentity(), workflowRun.GetWorkflow()) + } } // Get CAS credentials for PR metadata upload @@ -504,6 +516,53 @@ func groupMaterialToCraftingSchemaMaterial(gm *v1.PolicyGroup_Material, group *v }, nil } +// applySandboxIdentity authenticates the runner when its identity token verified in the control +// plane, and verifies against the trusted root of the control plane (R-004, R-005). A token that +// does not verify is the same as no token, so the attestation goes on with a runner that is not +// authenticated and has no platform data. The CLI logs the reason (D-011). +func (action *AttestationInit) applySandboxIdentity(ctx context.Context, runner crafter.SupportedRunner, raw string, cpResult *pb.AttestationServiceInitResponse_SandboxIdentityResult, wf *pb.WorkflowItem) { + identityRunner, ok := runner.(crafter.IdentityRunner) + if !ok { + return + } + + // A control plane that does not know the identity returns no result + if !cpResult.GetVerified() { + reason := cpResult.GetReason() + if reason == "" { + reason = "the control plane did not verify it" + } + + action.Logger.Warn().Str("reason", reason).Msg("the sandbox identity did not verify, the runner is not authenticated") + return + } + + id, err := action.verifySandboxIdentity(ctx, raw, wf) + if err != nil { + action.Logger.Warn().Err(err).Msg("the sandbox identity did not verify, the runner is not authenticated") + return + } + + identityRunner.SetVerifiedIdentity(id) + action.Logger.Debug().Str("sandbox-run", id.Claims.SandboxRun).Msg("sandbox identity verified") +} + +// verifySandboxIdentity verifies the identity token against the trusted root of the control plane, +// for the workflow of the attestation. +func (action *AttestationInit) verifySandboxIdentity(ctx context.Context, raw string, wf *pb.WorkflowItem) (*sandboxidentity.Identity, error) { + trResp, err := pb.NewSigningServiceClient(action.CPConnection).GetTrustedRoot(ctx, &pb.GetTrustedRootRequest{}) + if err != nil { + return nil, fmt.Errorf("getting the trusted root: %w", err) + } + + tr, err := trustedRootPbToVerifier(trResp) + if err != nil { + return nil, fmt.Errorf("loading the trusted root: %w", err) + } + + return sandboxidentity.Verify(raw, tr, &sandboxidentity.Expected{ProjectID: wf.GetProjectId(), WorkflowID: wf.GetId()}) +} + func extractAuthInfo(authToken string) (*clientAPI.Attestation_Auth, error) { if authToken == "" { return nil, errors.New("empty token") diff --git a/app/cli/pkg/action/attestation_init_sandboxidentity_test.go b/app/cli/pkg/action/attestation_init_sandboxidentity_test.go new file mode 100644 index 000000000..ea577fdf5 --- /dev/null +++ b/app/cli/pkg/action/attestation_init_sandboxidentity_test.go @@ -0,0 +1,162 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "math/big" + "net" + "testing" + "time" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +func newIdentityTestCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test root"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, SubjectKeyId: []byte("root"), + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + return cert, key +} + +// trustedRootConn serves the CA as the trusted root of a fake control plane. +func trustedRootConn(t *testing.T, root *x509.Certificate) *grpc.ClientConn { + t.Helper() + + rootPEM, err := cryptoutils.MarshalCertificateToPEM(root) + require.NoError(t, err) + keyID := sha256.Sum256(root.SubjectKeyId) + + svc := &fakeSigningService{trustedRoot: &pb.GetTrustedRootResponse{ + Keys: map[string]*pb.CertificateChain{hex.EncodeToString(keyID[:]): {Certificates: []string{string(rootPEM)}}}, + }} + + l := bufconn.Listen(1 << 20) + srv := grpc.NewServer() + pb.RegisterSigningServiceServer(srv, svc) + go func() { _ = srv.Serve(l) }() + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return l.DialContext(ctx) }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + t.Cleanup(func() { + conn.Close() + srv.Stop() + }) + + return conn +} + +func TestApplySandboxIdentity(t *testing.T) { + const commit = "ea3a974660a030f0314aa1505a64792bf2633b33" + wf := &pb.WorkflowItem{Id: uuid.NewString(), ProjectId: uuid.NewString()} + root, rootKey := newIdentityTestCA(t) + + // A certificate with the mark, as the control plane CA gives it to the platform + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + leafDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{Organization: []string{"org-id"}, OrganizationalUnit: []string{sandboxidentity.CertificateMark}}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(10 * time.Minute), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + }, root, &key.PublicKey, rootKey) + require.NoError(t, err) + leaf, err := x509.ParseCertificate(leafDER) + require.NoError(t, err) + + sign := func(t *testing.T, workflowID string) string { + t.Helper() + raw, err := sandboxidentity.Sign(key, []*x509.Certificate{leaf}, &sandboxidentity.Claims{ + RegisteredClaims: jwt.RegisteredClaims{IssuedAt: jwt.NewNumericDate(time.Now())}, + OrganizationID: "org-id", ProjectID: wf.GetProjectId(), WorkflowID: workflowID, ChainloopTokenID: uuid.NewString(), + Data: &sandboxidentity.Data{CommitVerification: &sandboxidentity.CommitVerification{ + Commit: commit, + Verification: &commitverification.CommitVerification{Attempted: true, Status: commitverification.VerificationStatusVerified}, + }}, + }) + require.NoError(t, err) + return raw + } + + verified := &pb.AttestationServiceInitResponse_SandboxIdentityResult{Verified: true} + + testCases := []struct { + name string + raw string + cpResult *pb.AttestationServiceInitResponse_SandboxIdentityResult + conn *grpc.ClientConn + wantAuth bool + }{ + {name: "verified by the control plane and the CLI", raw: sign(t, wf.GetId()), cpResult: verified, conn: trustedRootConn(t, root), wantAuth: true}, + {name: "a control plane that does not know the identity", raw: sign(t, wf.GetId()), conn: trustedRootConn(t, root)}, + {name: "rejected by the control plane", raw: sign(t, wf.GetId()), cpResult: &pb.AttestationServiceInitResponse_SandboxIdentityResult{Reason: "another token"}, conn: trustedRootConn(t, root)}, + {name: "an identity for another workflow", raw: sign(t, uuid.NewString()), cpResult: verified, conn: trustedRootConn(t, root)}, + {name: "another trusted root", raw: sign(t, wf.GetId()), cpResult: verified, conn: newSigningServiceConn(t, true)}, + {name: "no trusted root", raw: sign(t, wf.GetId()), cpResult: verified, conn: newSigningServiceConn(t, false)}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + action := &AttestationInit{ActionsOpts: &ActionsOpts{CPConnection: tc.conn, Logger: zerolog.Nop()}} + runner := runners.NewChainloopSandbox() + + action.applySandboxIdentity(context.Background(), runner, tc.raw, tc.cpResult, wf) + + assert.Equal(t, tc.wantAuth, runner.IsAuthenticated()) + if tc.wantAuth { + assert.NotNil(t, runner.VerifyCommitSignature(context.Background(), commit)) + } else { + assert.Nil(t, runner.VerifyCommitSignature(context.Background(), commit)) + } + }) + } +} diff --git a/app/controlplane/api/controlplane/v1/signing.pb.go b/app/controlplane/api/controlplane/v1/signing.pb.go index fa5f43445..ff016e0fd 100644 --- a/app/controlplane/api/controlplane/v1/signing.pb.go +++ b/app/controlplane/api/controlplane/v1/signing.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -40,8 +40,11 @@ const ( type GenerateSigningCertRequest struct { state protoimpl.MessageState `protogen:"open.v1"` CertificateSigningRequest []byte `protobuf:"bytes,1,opt,name=certificate_signing_request,json=certificateSigningRequest,proto3" json:"certificate_signing_request,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Request a certificate that can sign the identity of a Chainloop sandbox. + // Only a caller with the dedicated permission gets it, other callers are denied. + SandboxIdentityIssuer bool `protobuf:"varint,2,opt,name=sandbox_identity_issuer,json=sandboxIdentityIssuer,proto3" json:"sandbox_identity_issuer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GenerateSigningCertRequest) Reset() { @@ -81,6 +84,13 @@ func (x *GenerateSigningCertRequest) GetCertificateSigningRequest() []byte { return nil } +func (x *GenerateSigningCertRequest) GetSandboxIdentityIssuer() bool { + if x != nil { + return x.SandboxIdentityIssuer + } + return false +} + type GenerateSigningCertResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Chain *CertificateChain `protobuf:"bytes,1,opt,name=chain,proto3" json:"chain,omitempty"` @@ -265,9 +275,10 @@ var File_controlplane_v1_signing_proto protoreflect.FileDescriptor const file_controlplane_v1_signing_proto_rawDesc = "" + "\n" + - "\x1dcontrolplane/v1/signing.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\"e\n" + + "\x1dcontrolplane/v1/signing.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\"\x9d\x01\n" + "\x1aGenerateSigningCertRequest\x12G\n" + - "\x1bcertificate_signing_request\x18\x01 \x01(\fB\a\xbaH\x04z\x02\x10\x01R\x19certificateSigningRequest\"V\n" + + "\x1bcertificate_signing_request\x18\x01 \x01(\fB\a\xbaH\x04z\x02\x10\x01R\x19certificateSigningRequest\x126\n" + + "\x17sandbox_identity_issuer\x18\x02 \x01(\bR\x15sandboxIdentityIssuer\"V\n" + "\x1bGenerateSigningCertResponse\x127\n" + "\x05chain\x18\x01 \x01(\v2!.controlplane.v1.CertificateChainR\x05chain\"6\n" + "\x10CertificateChain\x12\"\n" + diff --git a/app/controlplane/api/controlplane/v1/signing.proto b/app/controlplane/api/controlplane/v1/signing.proto index e992fad5e..7ebbd9041 100644 --- a/app/controlplane/api/controlplane/v1/signing.proto +++ b/app/controlplane/api/controlplane/v1/signing.proto @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -29,6 +29,9 @@ service SigningService { message GenerateSigningCertRequest { bytes certificate_signing_request = 1 [(buf.validate.field).bytes.min_len = 1]; + // Request a certificate that can sign the identity of a Chainloop sandbox. + // Only a caller with the dedicated permission gets it, other callers are denied. + bool sandbox_identity_issuer = 2; } message GenerateSigningCertResponse { diff --git a/app/controlplane/api/controlplane/v1/signing_grpc.pb.go b/app/controlplane/api/controlplane/v1/signing_grpc.pb.go index fd5dff882..235e7e456 100644 --- a/app/controlplane/api/controlplane/v1/signing_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/signing_grpc.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/app/controlplane/api/controlplane/v1/workflow_run.pb.go b/app/controlplane/api/controlplane/v1/workflow_run.pb.go index d87662b4c..4c53a1241 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow_run.pb.go @@ -608,9 +608,12 @@ type AttestationServiceInitRequest struct { // true: force promote to latest (only pre-release versions). // false: skip latest promotion. // Mutually exclusive with use_latest_version. - MarkAsLatest *bool `protobuf:"varint,9,opt,name=mark_as_latest,json=markAsLatest,proto3,oneof" json:"mark_as_latest,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + MarkAsLatest *bool `protobuf:"varint,9,opt,name=mark_as_latest,json=markAsLatest,proto3,oneof" json:"mark_as_latest,omitempty"` + // Optional identity token that the Chainloop platform gives to a Chainloop sandbox. + // The control plane verifies it only for the CHAINLOOP_SANDBOX runner. + SandboxIdentity string `protobuf:"bytes,10,opt,name=sandbox_identity,json=sandboxIdentity,proto3" json:"sandbox_identity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AttestationServiceInitRequest) Reset() { @@ -706,6 +709,13 @@ func (x *AttestationServiceInitRequest) GetMarkAsLatest() bool { return false } +func (x *AttestationServiceInitRequest) GetSandboxIdentity() string { + if x != nil { + return x.SandboxIdentity + } + return "" +} + type AttestationServiceInitResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Result *AttestationServiceInitResponse_Result `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` @@ -1429,8 +1439,10 @@ type AttestationServiceInitResponse_Result struct { EnableAiAgentCollector bool `protobuf:"varint,8,opt,name=enable_ai_agent_collector,json=enableAiAgentCollector,proto3" json:"enable_ai_agent_collector,omitempty"` // Whether to skip storing the environment variables automatically discovered by the CI runner in the attestation SkipRunnerEnvVars bool `protobuf:"varint,9,opt,name=skip_runner_env_vars,json=skipRunnerEnvVars,proto3" json:"skip_runner_env_vars,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The result of the verification of the sandbox identity, when the request has one + SandboxIdentity *AttestationServiceInitResponse_SandboxIdentityResult `protobuf:"bytes,10,opt,name=sandbox_identity,json=sandboxIdentity,proto3" json:"sandbox_identity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AttestationServiceInitResponse_Result) Reset() { @@ -1519,6 +1531,67 @@ func (x *AttestationServiceInitResponse_Result) GetSkipRunnerEnvVars() bool { return false } +func (x *AttestationServiceInitResponse_Result) GetSandboxIdentity() *AttestationServiceInitResponse_SandboxIdentityResult { + if x != nil { + return x.SandboxIdentity + } + return nil +} + +type AttestationServiceInitResponse_SandboxIdentityResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether the control plane verified the identity and recorded it on the workflow run + Verified bool `protobuf:"varint,1,opt,name=verified,proto3" json:"verified,omitempty"` + // Why the identity did not verify + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttestationServiceInitResponse_SandboxIdentityResult) Reset() { + *x = AttestationServiceInitResponse_SandboxIdentityResult{} + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttestationServiceInitResponse_SandboxIdentityResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttestationServiceInitResponse_SandboxIdentityResult) ProtoMessage() {} + +func (x *AttestationServiceInitResponse_SandboxIdentityResult) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AttestationServiceInitResponse_SandboxIdentityResult.ProtoReflect.Descriptor instead. +func (*AttestationServiceInitResponse_SandboxIdentityResult) Descriptor() ([]byte, []int) { + return file_controlplane_v1_workflow_run_proto_rawDescGZIP(), []int{10, 1} +} + +func (x *AttestationServiceInitResponse_SandboxIdentityResult) GetVerified() bool { + if x != nil { + return x.Verified + } + return false +} + +func (x *AttestationServiceInitResponse_SandboxIdentityResult) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + type AttestationServiceInitResponse_SigningOptions struct { state protoimpl.MessageState `protogen:"open.v1"` // TSA service to be used for signing @@ -1531,7 +1604,7 @@ type AttestationServiceInitResponse_SigningOptions struct { func (x *AttestationServiceInitResponse_SigningOptions) Reset() { *x = AttestationServiceInitResponse_SigningOptions{} - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[23] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1543,7 +1616,7 @@ func (x *AttestationServiceInitResponse_SigningOptions) String() string { func (*AttestationServiceInitResponse_SigningOptions) ProtoMessage() {} func (x *AttestationServiceInitResponse_SigningOptions) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[23] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1556,7 +1629,7 @@ func (x *AttestationServiceInitResponse_SigningOptions) ProtoReflect() protorefl // Deprecated: Use AttestationServiceInitResponse_SigningOptions.ProtoReflect.Descriptor instead. func (*AttestationServiceInitResponse_SigningOptions) Descriptor() ([]byte, []int) { - return file_controlplane_v1_workflow_run_proto_rawDescGZIP(), []int{10, 1} + return file_controlplane_v1_workflow_run_proto_rawDescGZIP(), []int{10, 2} } func (x *AttestationServiceInitResponse_SigningOptions) GetTimestampAuthorityUrl() string { @@ -1583,7 +1656,7 @@ type AttestationServiceStoreResponse_Result struct { func (x *AttestationServiceStoreResponse_Result) Reset() { *x = AttestationServiceStoreResponse_Result{} - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[24] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1595,7 +1668,7 @@ func (x *AttestationServiceStoreResponse_Result) String() string { func (*AttestationServiceStoreResponse_Result) ProtoMessage() {} func (x *AttestationServiceStoreResponse_Result) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[24] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1631,7 +1704,7 @@ type WorkflowRunServiceViewResponse_Result struct { func (x *WorkflowRunServiceViewResponse_Result) Reset() { *x = WorkflowRunServiceViewResponse_Result{} - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[25] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1643,7 +1716,7 @@ func (x *WorkflowRunServiceViewResponse_Result) String() string { func (*WorkflowRunServiceViewResponse_Result) ProtoMessage() {} func (x *WorkflowRunServiceViewResponse_Result) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[25] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1699,7 +1772,7 @@ type WorkflowRunServiceViewResponse_VerificationResult struct { func (x *WorkflowRunServiceViewResponse_VerificationResult) Reset() { *x = WorkflowRunServiceViewResponse_VerificationResult{} - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[26] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1711,7 +1784,7 @@ func (x *WorkflowRunServiceViewResponse_VerificationResult) String() string { func (*WorkflowRunServiceViewResponse_VerificationResult) ProtoMessage() {} func (x *WorkflowRunServiceViewResponse_VerificationResult) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[26] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1751,7 +1824,7 @@ type AttestationServiceGetUploadCredsResponse_Result struct { func (x *AttestationServiceGetUploadCredsResponse_Result) Reset() { *x = AttestationServiceGetUploadCredsResponse_Result{} - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[27] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1763,7 +1836,7 @@ func (x *AttestationServiceGetUploadCredsResponse_Result) String() string { func (*AttestationServiceGetUploadCredsResponse_Result) ProtoMessage() {} func (x *AttestationServiceGetUploadCredsResponse_Result) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_workflow_run_proto_msgTypes[27] + mi := &file_controlplane_v1_workflow_run_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1832,7 +1905,7 @@ const file_controlplane_v1_workflow_run_proto_rawDesc = "" + "\x06result\x18\x01 \x01(\v2=.controlplane.v1.AttestationServiceGetContractResponse.ResultR\x06result\x1a\x8d\x01\n" + "\x06Result\x129\n" + "\bworkflow\x18\x01 \x01(\v2\x1d.controlplane.v1.WorkflowItemR\bworkflow\x12H\n" + - "\bcontract\x18\x02 \x01(\v2,.controlplane.v1.WorkflowContractVersionItemR\bcontract\"\xdd\x03\n" + + "\bcontract\x18\x02 \x01(\v2,.controlplane.v1.WorkflowContractVersionItemR\bcontract\"\x88\x04\n" + "\x1dAttestationServiceInitRequest\x12+\n" + "\x11contract_revision\x18\x01 \x01(\x05R\x10contractRevision\x12\x17\n" + "\ajob_url\x18\x02 \x01(\tR\x06jobUrl\x12M\n" + @@ -1842,10 +1915,12 @@ const file_controlplane_v1_workflow_run_proto_rawDesc = "" + "\x0fproject_version\x18\x06 \x01(\tR\x0eprojectVersion\x128\n" + "\x18require_existing_version\x18\a \x01(\bR\x16requireExistingVersion\x12,\n" + "\x12use_latest_version\x18\b \x01(\bR\x10useLatestVersion\x12)\n" + - "\x0emark_as_latest\x18\t \x01(\bH\x00R\fmarkAsLatest\x88\x01\x01B\x11\n" + - "\x0f_mark_as_latest\"\xc5\x05\n" + + "\x0emark_as_latest\x18\t \x01(\bH\x00R\fmarkAsLatest\x88\x01\x01\x12)\n" + + "\x10sandbox_identity\x18\n" + + " \x01(\tR\x0fsandboxIdentityB\x11\n" + + "\x0f_mark_as_latest\"\x84\a\n" + "\x1eAttestationServiceInitResponse\x12N\n" + - "\x06result\x18\x01 \x01(\v26.controlplane.v1.AttestationServiceInitResponse.ResultR\x06result\x1a\xe9\x03\n" + + "\x06result\x18\x01 \x01(\v26.controlplane.v1.AttestationServiceInitResponse.ResultR\x06result\x1a\xdb\x04\n" + "\x06Result\x12C\n" + "\fworkflow_run\x18\x02 \x01(\v2 .controlplane.v1.WorkflowRunItemR\vworkflowRun\x12\"\n" + "\forganization\x18\x03 \x01(\tR\forganization\x129\n" + @@ -1854,7 +1929,12 @@ const file_controlplane_v1_workflow_run_proto_rawDesc = "" + "\x1apolicies_allowed_hostnames\x18\x06 \x03(\tR\x18policiesAllowedHostnames\x12(\n" + "\x10ui_dashboard_url\x18\a \x01(\tR\x0euiDashboardUrl\x129\n" + "\x19enable_ai_agent_collector\x18\b \x01(\bR\x16enableAiAgentCollector\x12/\n" + - "\x14skip_runner_env_vars\x18\t \x01(\bR\x11skipRunnerEnvVars\x1ag\n" + + "\x14skip_runner_env_vars\x18\t \x01(\bR\x11skipRunnerEnvVars\x12p\n" + + "\x10sandbox_identity\x18\n" + + " \x01(\v2E.controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResultR\x0fsandboxIdentity\x1aK\n" + + "\x15SandboxIdentityResult\x12\x1a\n" + + "\bverified\x18\x01 \x01(\bR\bverified\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\x1ag\n" + "\x0eSigningOptions\x126\n" + "\x17timestamp_authority_url\x18\x01 \x01(\tR\x15timestampAuthorityUrl\x12\x1d\n" + "\n" + @@ -1945,105 +2025,107 @@ func file_controlplane_v1_workflow_run_proto_rawDescGZIP() []byte { } var file_controlplane_v1_workflow_run_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_controlplane_v1_workflow_run_proto_msgTypes = make([]protoimpl.MessageInfo, 28) +var file_controlplane_v1_workflow_run_proto_msgTypes = make([]protoimpl.MessageInfo, 29) var file_controlplane_v1_workflow_run_proto_goTypes = []any{ - (AttestationServiceCancelRequest_TriggerType)(0), // 0: controlplane.v1.AttestationServiceCancelRequest.TriggerType - (*FindOrCreateWorkflowRequest)(nil), // 1: controlplane.v1.FindOrCreateWorkflowRequest - (*FindOrCreateWorkflowResponse)(nil), // 2: controlplane.v1.FindOrCreateWorkflowResponse - (*AttestationServiceGetPolicyRequest)(nil), // 3: controlplane.v1.AttestationServiceGetPolicyRequest - (*AttestationServiceGetPolicyResponse)(nil), // 4: controlplane.v1.AttestationServiceGetPolicyResponse - (*RemotePolicyReference)(nil), // 5: controlplane.v1.RemotePolicyReference - (*AttestationServiceGetPolicyGroupRequest)(nil), // 6: controlplane.v1.AttestationServiceGetPolicyGroupRequest - (*AttestationServiceGetPolicyGroupResponse)(nil), // 7: controlplane.v1.AttestationServiceGetPolicyGroupResponse - (*AttestationServiceGetContractRequest)(nil), // 8: controlplane.v1.AttestationServiceGetContractRequest - (*AttestationServiceGetContractResponse)(nil), // 9: controlplane.v1.AttestationServiceGetContractResponse - (*AttestationServiceInitRequest)(nil), // 10: controlplane.v1.AttestationServiceInitRequest - (*AttestationServiceInitResponse)(nil), // 11: controlplane.v1.AttestationServiceInitResponse - (*AttestationServiceStoreRequest)(nil), // 12: controlplane.v1.AttestationServiceStoreRequest - (*AttestationServiceStoreResponse)(nil), // 13: controlplane.v1.AttestationServiceStoreResponse - (*AttestationServiceCancelRequest)(nil), // 14: controlplane.v1.AttestationServiceCancelRequest - (*AttestationServiceCancelResponse)(nil), // 15: controlplane.v1.AttestationServiceCancelResponse - (*WorkflowRunServiceListRequest)(nil), // 16: controlplane.v1.WorkflowRunServiceListRequest - (*WorkflowRunServiceListResponse)(nil), // 17: controlplane.v1.WorkflowRunServiceListResponse - (*WorkflowRunServiceViewRequest)(nil), // 18: controlplane.v1.WorkflowRunServiceViewRequest - (*WorkflowRunServiceViewResponse)(nil), // 19: controlplane.v1.WorkflowRunServiceViewResponse - (*AttestationServiceGetUploadCredsRequest)(nil), // 20: controlplane.v1.AttestationServiceGetUploadCredsRequest - (*AttestationServiceGetUploadCredsResponse)(nil), // 21: controlplane.v1.AttestationServiceGetUploadCredsResponse - (*AttestationServiceGetContractResponse_Result)(nil), // 22: controlplane.v1.AttestationServiceGetContractResponse.Result - (*AttestationServiceInitResponse_Result)(nil), // 23: controlplane.v1.AttestationServiceInitResponse.Result - (*AttestationServiceInitResponse_SigningOptions)(nil), // 24: controlplane.v1.AttestationServiceInitResponse.SigningOptions - (*AttestationServiceStoreResponse_Result)(nil), // 25: controlplane.v1.AttestationServiceStoreResponse.Result - (*WorkflowRunServiceViewResponse_Result)(nil), // 26: controlplane.v1.WorkflowRunServiceViewResponse.Result - (*WorkflowRunServiceViewResponse_VerificationResult)(nil), // 27: controlplane.v1.WorkflowRunServiceViewResponse.VerificationResult - (*AttestationServiceGetUploadCredsResponse_Result)(nil), // 28: controlplane.v1.AttestationServiceGetUploadCredsResponse.Result - (*WorkflowItem)(nil), // 29: controlplane.v1.WorkflowItem - (*v1.Policy)(nil), // 30: workflowcontract.v1.Policy - (*v1.PolicyGroup)(nil), // 31: workflowcontract.v1.PolicyGroup - (v1.CraftingSchema_Runner_RunnerType)(0), // 32: workflowcontract.v1.CraftingSchema.Runner.RunnerType - (RunStatus)(0), // 33: controlplane.v1.RunStatus - (PolicyViolationsFilter)(0), // 34: controlplane.v1.PolicyViolationsFilter - (PolicyStatusFilter)(0), // 35: controlplane.v1.PolicyStatusFilter - (PolicyGatesFilter)(0), // 36: controlplane.v1.PolicyGatesFilter - (*CursorPaginationRequest)(nil), // 37: controlplane.v1.CursorPaginationRequest - (*WorkflowRunItem)(nil), // 38: controlplane.v1.WorkflowRunItem - (*CursorPaginationResponse)(nil), // 39: controlplane.v1.CursorPaginationResponse - (*WorkflowContractVersionItem)(nil), // 40: controlplane.v1.WorkflowContractVersionItem - (*AttestationItem)(nil), // 41: controlplane.v1.AttestationItem - (*CASBackendItem)(nil), // 42: controlplane.v1.CASBackendItem + (AttestationServiceCancelRequest_TriggerType)(0), // 0: controlplane.v1.AttestationServiceCancelRequest.TriggerType + (*FindOrCreateWorkflowRequest)(nil), // 1: controlplane.v1.FindOrCreateWorkflowRequest + (*FindOrCreateWorkflowResponse)(nil), // 2: controlplane.v1.FindOrCreateWorkflowResponse + (*AttestationServiceGetPolicyRequest)(nil), // 3: controlplane.v1.AttestationServiceGetPolicyRequest + (*AttestationServiceGetPolicyResponse)(nil), // 4: controlplane.v1.AttestationServiceGetPolicyResponse + (*RemotePolicyReference)(nil), // 5: controlplane.v1.RemotePolicyReference + (*AttestationServiceGetPolicyGroupRequest)(nil), // 6: controlplane.v1.AttestationServiceGetPolicyGroupRequest + (*AttestationServiceGetPolicyGroupResponse)(nil), // 7: controlplane.v1.AttestationServiceGetPolicyGroupResponse + (*AttestationServiceGetContractRequest)(nil), // 8: controlplane.v1.AttestationServiceGetContractRequest + (*AttestationServiceGetContractResponse)(nil), // 9: controlplane.v1.AttestationServiceGetContractResponse + (*AttestationServiceInitRequest)(nil), // 10: controlplane.v1.AttestationServiceInitRequest + (*AttestationServiceInitResponse)(nil), // 11: controlplane.v1.AttestationServiceInitResponse + (*AttestationServiceStoreRequest)(nil), // 12: controlplane.v1.AttestationServiceStoreRequest + (*AttestationServiceStoreResponse)(nil), // 13: controlplane.v1.AttestationServiceStoreResponse + (*AttestationServiceCancelRequest)(nil), // 14: controlplane.v1.AttestationServiceCancelRequest + (*AttestationServiceCancelResponse)(nil), // 15: controlplane.v1.AttestationServiceCancelResponse + (*WorkflowRunServiceListRequest)(nil), // 16: controlplane.v1.WorkflowRunServiceListRequest + (*WorkflowRunServiceListResponse)(nil), // 17: controlplane.v1.WorkflowRunServiceListResponse + (*WorkflowRunServiceViewRequest)(nil), // 18: controlplane.v1.WorkflowRunServiceViewRequest + (*WorkflowRunServiceViewResponse)(nil), // 19: controlplane.v1.WorkflowRunServiceViewResponse + (*AttestationServiceGetUploadCredsRequest)(nil), // 20: controlplane.v1.AttestationServiceGetUploadCredsRequest + (*AttestationServiceGetUploadCredsResponse)(nil), // 21: controlplane.v1.AttestationServiceGetUploadCredsResponse + (*AttestationServiceGetContractResponse_Result)(nil), // 22: controlplane.v1.AttestationServiceGetContractResponse.Result + (*AttestationServiceInitResponse_Result)(nil), // 23: controlplane.v1.AttestationServiceInitResponse.Result + (*AttestationServiceInitResponse_SandboxIdentityResult)(nil), // 24: controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult + (*AttestationServiceInitResponse_SigningOptions)(nil), // 25: controlplane.v1.AttestationServiceInitResponse.SigningOptions + (*AttestationServiceStoreResponse_Result)(nil), // 26: controlplane.v1.AttestationServiceStoreResponse.Result + (*WorkflowRunServiceViewResponse_Result)(nil), // 27: controlplane.v1.WorkflowRunServiceViewResponse.Result + (*WorkflowRunServiceViewResponse_VerificationResult)(nil), // 28: controlplane.v1.WorkflowRunServiceViewResponse.VerificationResult + (*AttestationServiceGetUploadCredsResponse_Result)(nil), // 29: controlplane.v1.AttestationServiceGetUploadCredsResponse.Result + (*WorkflowItem)(nil), // 30: controlplane.v1.WorkflowItem + (*v1.Policy)(nil), // 31: workflowcontract.v1.Policy + (*v1.PolicyGroup)(nil), // 32: workflowcontract.v1.PolicyGroup + (v1.CraftingSchema_Runner_RunnerType)(0), // 33: workflowcontract.v1.CraftingSchema.Runner.RunnerType + (RunStatus)(0), // 34: controlplane.v1.RunStatus + (PolicyViolationsFilter)(0), // 35: controlplane.v1.PolicyViolationsFilter + (PolicyStatusFilter)(0), // 36: controlplane.v1.PolicyStatusFilter + (PolicyGatesFilter)(0), // 37: controlplane.v1.PolicyGatesFilter + (*CursorPaginationRequest)(nil), // 38: controlplane.v1.CursorPaginationRequest + (*WorkflowRunItem)(nil), // 39: controlplane.v1.WorkflowRunItem + (*CursorPaginationResponse)(nil), // 40: controlplane.v1.CursorPaginationResponse + (*WorkflowContractVersionItem)(nil), // 41: controlplane.v1.WorkflowContractVersionItem + (*AttestationItem)(nil), // 42: controlplane.v1.AttestationItem + (*CASBackendItem)(nil), // 43: controlplane.v1.CASBackendItem } var file_controlplane_v1_workflow_run_proto_depIdxs = []int32{ - 29, // 0: controlplane.v1.FindOrCreateWorkflowResponse.result:type_name -> controlplane.v1.WorkflowItem - 30, // 1: controlplane.v1.AttestationServiceGetPolicyResponse.policy:type_name -> workflowcontract.v1.Policy + 30, // 0: controlplane.v1.FindOrCreateWorkflowResponse.result:type_name -> controlplane.v1.WorkflowItem + 31, // 1: controlplane.v1.AttestationServiceGetPolicyResponse.policy:type_name -> workflowcontract.v1.Policy 5, // 2: controlplane.v1.AttestationServiceGetPolicyResponse.reference:type_name -> controlplane.v1.RemotePolicyReference - 31, // 3: controlplane.v1.AttestationServiceGetPolicyGroupResponse.group:type_name -> workflowcontract.v1.PolicyGroup + 32, // 3: controlplane.v1.AttestationServiceGetPolicyGroupResponse.group:type_name -> workflowcontract.v1.PolicyGroup 5, // 4: controlplane.v1.AttestationServiceGetPolicyGroupResponse.reference:type_name -> controlplane.v1.RemotePolicyReference 22, // 5: controlplane.v1.AttestationServiceGetContractResponse.result:type_name -> controlplane.v1.AttestationServiceGetContractResponse.Result - 32, // 6: controlplane.v1.AttestationServiceInitRequest.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner.RunnerType + 33, // 6: controlplane.v1.AttestationServiceInitRequest.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner.RunnerType 23, // 7: controlplane.v1.AttestationServiceInitResponse.result:type_name -> controlplane.v1.AttestationServiceInitResponse.Result - 25, // 8: controlplane.v1.AttestationServiceStoreResponse.result:type_name -> controlplane.v1.AttestationServiceStoreResponse.Result + 26, // 8: controlplane.v1.AttestationServiceStoreResponse.result:type_name -> controlplane.v1.AttestationServiceStoreResponse.Result 0, // 9: controlplane.v1.AttestationServiceCancelRequest.trigger:type_name -> controlplane.v1.AttestationServiceCancelRequest.TriggerType - 33, // 10: controlplane.v1.WorkflowRunServiceListRequest.status:type_name -> controlplane.v1.RunStatus - 34, // 11: controlplane.v1.WorkflowRunServiceListRequest.policy_violations:type_name -> controlplane.v1.PolicyViolationsFilter - 35, // 12: controlplane.v1.WorkflowRunServiceListRequest.policy_status:type_name -> controlplane.v1.PolicyStatusFilter - 36, // 13: controlplane.v1.WorkflowRunServiceListRequest.policy_gates:type_name -> controlplane.v1.PolicyGatesFilter - 37, // 14: controlplane.v1.WorkflowRunServiceListRequest.pagination:type_name -> controlplane.v1.CursorPaginationRequest - 38, // 15: controlplane.v1.WorkflowRunServiceListResponse.result:type_name -> controlplane.v1.WorkflowRunItem - 39, // 16: controlplane.v1.WorkflowRunServiceListResponse.pagination:type_name -> controlplane.v1.CursorPaginationResponse - 26, // 17: controlplane.v1.WorkflowRunServiceViewResponse.result:type_name -> controlplane.v1.WorkflowRunServiceViewResponse.Result - 28, // 18: controlplane.v1.AttestationServiceGetUploadCredsResponse.result:type_name -> controlplane.v1.AttestationServiceGetUploadCredsResponse.Result - 29, // 19: controlplane.v1.AttestationServiceGetContractResponse.Result.workflow:type_name -> controlplane.v1.WorkflowItem - 40, // 20: controlplane.v1.AttestationServiceGetContractResponse.Result.contract:type_name -> controlplane.v1.WorkflowContractVersionItem - 38, // 21: controlplane.v1.AttestationServiceInitResponse.Result.workflow_run:type_name -> controlplane.v1.WorkflowRunItem - 24, // 22: controlplane.v1.AttestationServiceInitResponse.Result.signing_options:type_name -> controlplane.v1.AttestationServiceInitResponse.SigningOptions - 38, // 23: controlplane.v1.WorkflowRunServiceViewResponse.Result.workflow_run:type_name -> controlplane.v1.WorkflowRunItem - 41, // 24: controlplane.v1.WorkflowRunServiceViewResponse.Result.attestation:type_name -> controlplane.v1.AttestationItem - 27, // 25: controlplane.v1.WorkflowRunServiceViewResponse.Result.verification:type_name -> controlplane.v1.WorkflowRunServiceViewResponse.VerificationResult - 42, // 26: controlplane.v1.AttestationServiceGetUploadCredsResponse.Result.backend:type_name -> controlplane.v1.CASBackendItem - 1, // 27: controlplane.v1.AttestationService.FindOrCreateWorkflow:input_type -> controlplane.v1.FindOrCreateWorkflowRequest - 8, // 28: controlplane.v1.AttestationService.GetContract:input_type -> controlplane.v1.AttestationServiceGetContractRequest - 10, // 29: controlplane.v1.AttestationService.Init:input_type -> controlplane.v1.AttestationServiceInitRequest - 12, // 30: controlplane.v1.AttestationService.Store:input_type -> controlplane.v1.AttestationServiceStoreRequest - 20, // 31: controlplane.v1.AttestationService.GetUploadCreds:input_type -> controlplane.v1.AttestationServiceGetUploadCredsRequest - 14, // 32: controlplane.v1.AttestationService.Cancel:input_type -> controlplane.v1.AttestationServiceCancelRequest - 3, // 33: controlplane.v1.AttestationService.GetPolicy:input_type -> controlplane.v1.AttestationServiceGetPolicyRequest - 6, // 34: controlplane.v1.AttestationService.GetPolicyGroup:input_type -> controlplane.v1.AttestationServiceGetPolicyGroupRequest - 16, // 35: controlplane.v1.WorkflowRunService.List:input_type -> controlplane.v1.WorkflowRunServiceListRequest - 18, // 36: controlplane.v1.WorkflowRunService.View:input_type -> controlplane.v1.WorkflowRunServiceViewRequest - 2, // 37: controlplane.v1.AttestationService.FindOrCreateWorkflow:output_type -> controlplane.v1.FindOrCreateWorkflowResponse - 9, // 38: controlplane.v1.AttestationService.GetContract:output_type -> controlplane.v1.AttestationServiceGetContractResponse - 11, // 39: controlplane.v1.AttestationService.Init:output_type -> controlplane.v1.AttestationServiceInitResponse - 13, // 40: controlplane.v1.AttestationService.Store:output_type -> controlplane.v1.AttestationServiceStoreResponse - 21, // 41: controlplane.v1.AttestationService.GetUploadCreds:output_type -> controlplane.v1.AttestationServiceGetUploadCredsResponse - 15, // 42: controlplane.v1.AttestationService.Cancel:output_type -> controlplane.v1.AttestationServiceCancelResponse - 4, // 43: controlplane.v1.AttestationService.GetPolicy:output_type -> controlplane.v1.AttestationServiceGetPolicyResponse - 7, // 44: controlplane.v1.AttestationService.GetPolicyGroup:output_type -> controlplane.v1.AttestationServiceGetPolicyGroupResponse - 17, // 45: controlplane.v1.WorkflowRunService.List:output_type -> controlplane.v1.WorkflowRunServiceListResponse - 19, // 46: controlplane.v1.WorkflowRunService.View:output_type -> controlplane.v1.WorkflowRunServiceViewResponse - 37, // [37:47] is the sub-list for method output_type - 27, // [27:37] is the sub-list for method input_type - 27, // [27:27] is the sub-list for extension type_name - 27, // [27:27] is the sub-list for extension extendee - 0, // [0:27] is the sub-list for field type_name + 34, // 10: controlplane.v1.WorkflowRunServiceListRequest.status:type_name -> controlplane.v1.RunStatus + 35, // 11: controlplane.v1.WorkflowRunServiceListRequest.policy_violations:type_name -> controlplane.v1.PolicyViolationsFilter + 36, // 12: controlplane.v1.WorkflowRunServiceListRequest.policy_status:type_name -> controlplane.v1.PolicyStatusFilter + 37, // 13: controlplane.v1.WorkflowRunServiceListRequest.policy_gates:type_name -> controlplane.v1.PolicyGatesFilter + 38, // 14: controlplane.v1.WorkflowRunServiceListRequest.pagination:type_name -> controlplane.v1.CursorPaginationRequest + 39, // 15: controlplane.v1.WorkflowRunServiceListResponse.result:type_name -> controlplane.v1.WorkflowRunItem + 40, // 16: controlplane.v1.WorkflowRunServiceListResponse.pagination:type_name -> controlplane.v1.CursorPaginationResponse + 27, // 17: controlplane.v1.WorkflowRunServiceViewResponse.result:type_name -> controlplane.v1.WorkflowRunServiceViewResponse.Result + 29, // 18: controlplane.v1.AttestationServiceGetUploadCredsResponse.result:type_name -> controlplane.v1.AttestationServiceGetUploadCredsResponse.Result + 30, // 19: controlplane.v1.AttestationServiceGetContractResponse.Result.workflow:type_name -> controlplane.v1.WorkflowItem + 41, // 20: controlplane.v1.AttestationServiceGetContractResponse.Result.contract:type_name -> controlplane.v1.WorkflowContractVersionItem + 39, // 21: controlplane.v1.AttestationServiceInitResponse.Result.workflow_run:type_name -> controlplane.v1.WorkflowRunItem + 25, // 22: controlplane.v1.AttestationServiceInitResponse.Result.signing_options:type_name -> controlplane.v1.AttestationServiceInitResponse.SigningOptions + 24, // 23: controlplane.v1.AttestationServiceInitResponse.Result.sandbox_identity:type_name -> controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult + 39, // 24: controlplane.v1.WorkflowRunServiceViewResponse.Result.workflow_run:type_name -> controlplane.v1.WorkflowRunItem + 42, // 25: controlplane.v1.WorkflowRunServiceViewResponse.Result.attestation:type_name -> controlplane.v1.AttestationItem + 28, // 26: controlplane.v1.WorkflowRunServiceViewResponse.Result.verification:type_name -> controlplane.v1.WorkflowRunServiceViewResponse.VerificationResult + 43, // 27: controlplane.v1.AttestationServiceGetUploadCredsResponse.Result.backend:type_name -> controlplane.v1.CASBackendItem + 1, // 28: controlplane.v1.AttestationService.FindOrCreateWorkflow:input_type -> controlplane.v1.FindOrCreateWorkflowRequest + 8, // 29: controlplane.v1.AttestationService.GetContract:input_type -> controlplane.v1.AttestationServiceGetContractRequest + 10, // 30: controlplane.v1.AttestationService.Init:input_type -> controlplane.v1.AttestationServiceInitRequest + 12, // 31: controlplane.v1.AttestationService.Store:input_type -> controlplane.v1.AttestationServiceStoreRequest + 20, // 32: controlplane.v1.AttestationService.GetUploadCreds:input_type -> controlplane.v1.AttestationServiceGetUploadCredsRequest + 14, // 33: controlplane.v1.AttestationService.Cancel:input_type -> controlplane.v1.AttestationServiceCancelRequest + 3, // 34: controlplane.v1.AttestationService.GetPolicy:input_type -> controlplane.v1.AttestationServiceGetPolicyRequest + 6, // 35: controlplane.v1.AttestationService.GetPolicyGroup:input_type -> controlplane.v1.AttestationServiceGetPolicyGroupRequest + 16, // 36: controlplane.v1.WorkflowRunService.List:input_type -> controlplane.v1.WorkflowRunServiceListRequest + 18, // 37: controlplane.v1.WorkflowRunService.View:input_type -> controlplane.v1.WorkflowRunServiceViewRequest + 2, // 38: controlplane.v1.AttestationService.FindOrCreateWorkflow:output_type -> controlplane.v1.FindOrCreateWorkflowResponse + 9, // 39: controlplane.v1.AttestationService.GetContract:output_type -> controlplane.v1.AttestationServiceGetContractResponse + 11, // 40: controlplane.v1.AttestationService.Init:output_type -> controlplane.v1.AttestationServiceInitResponse + 13, // 41: controlplane.v1.AttestationService.Store:output_type -> controlplane.v1.AttestationServiceStoreResponse + 21, // 42: controlplane.v1.AttestationService.GetUploadCreds:output_type -> controlplane.v1.AttestationServiceGetUploadCredsResponse + 15, // 43: controlplane.v1.AttestationService.Cancel:output_type -> controlplane.v1.AttestationServiceCancelResponse + 4, // 44: controlplane.v1.AttestationService.GetPolicy:output_type -> controlplane.v1.AttestationServiceGetPolicyResponse + 7, // 45: controlplane.v1.AttestationService.GetPolicyGroup:output_type -> controlplane.v1.AttestationServiceGetPolicyGroupResponse + 17, // 46: controlplane.v1.WorkflowRunService.List:output_type -> controlplane.v1.WorkflowRunServiceListResponse + 19, // 47: controlplane.v1.WorkflowRunService.View:output_type -> controlplane.v1.WorkflowRunServiceViewResponse + 38, // [38:48] is the sub-list for method output_type + 28, // [28:38] is the sub-list for method input_type + 28, // [28:28] is the sub-list for extension type_name + 28, // [28:28] is the sub-list for extension extendee + 0, // [0:28] is the sub-list for field type_name } func init() { file_controlplane_v1_workflow_run_proto_init() } @@ -2065,7 +2147,7 @@ func file_controlplane_v1_workflow_run_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_workflow_run_proto_rawDesc), len(file_controlplane_v1_workflow_run_proto_rawDesc)), NumEnums: 1, - NumMessages: 28, + NumMessages: 29, NumExtensions: 0, NumServices: 2, }, diff --git a/app/controlplane/api/controlplane/v1/workflow_run.proto b/app/controlplane/api/controlplane/v1/workflow_run.proto index f29492c05..644c40b35 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.proto +++ b/app/controlplane/api/controlplane/v1/workflow_run.proto @@ -133,6 +133,9 @@ message AttestationServiceInitRequest { // false: skip latest promotion. // Mutually exclusive with use_latest_version. optional bool mark_as_latest = 9; + // Optional identity token that the Chainloop platform gives to a Chainloop sandbox. + // The control plane verifies it only for the CHAINLOOP_SANDBOX runner. + string sandbox_identity = 10; } message AttestationServiceInitResponse { @@ -154,6 +157,15 @@ message AttestationServiceInitResponse { bool enable_ai_agent_collector = 8; // Whether to skip storing the environment variables automatically discovered by the CI runner in the attestation bool skip_runner_env_vars = 9; + // The result of the verification of the sandbox identity, when the request has one + SandboxIdentityResult sandbox_identity = 10; + } + + message SandboxIdentityResult { + // Whether the control plane verified the identity and recorded it on the workflow run + bool verified = 1; + // Why the identity did not verify + string reason = 2; } message SigningOptions { diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/signing.ts b/app/controlplane/api/gen/frontend/controlplane/v1/signing.ts index d53c590b9..da926c430 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/signing.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/signing.ts @@ -7,6 +7,11 @@ export const protobufPackage = "controlplane.v1"; export interface GenerateSigningCertRequest { certificateSigningRequest: Uint8Array; + /** + * Request a certificate that can sign the identity of a Chainloop sandbox. + * Only a caller with the dedicated permission gets it, other callers are denied. + */ + sandboxIdentityIssuer: boolean; } export interface GenerateSigningCertResponse { @@ -39,7 +44,7 @@ export interface GetTrustedRootResponse_TimestampAuthoritiesEntry { } function createBaseGenerateSigningCertRequest(): GenerateSigningCertRequest { - return { certificateSigningRequest: new Uint8Array(0) }; + return { certificateSigningRequest: new Uint8Array(0), sandboxIdentityIssuer: false }; } export const GenerateSigningCertRequest = { @@ -47,6 +52,9 @@ export const GenerateSigningCertRequest = { if (message.certificateSigningRequest.length !== 0) { writer.uint32(10).bytes(message.certificateSigningRequest); } + if (message.sandboxIdentityIssuer === true) { + writer.uint32(16).bool(message.sandboxIdentityIssuer); + } return writer; }, @@ -64,6 +72,13 @@ export const GenerateSigningCertRequest = { message.certificateSigningRequest = reader.bytes(); continue; + case 2: + if (tag !== 16) { + break; + } + + message.sandboxIdentityIssuer = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -78,6 +93,7 @@ export const GenerateSigningCertRequest = { certificateSigningRequest: isSet(object.certificateSigningRequest) ? bytesFromBase64(object.certificateSigningRequest) : new Uint8Array(0), + sandboxIdentityIssuer: isSet(object.sandboxIdentityIssuer) ? Boolean(object.sandboxIdentityIssuer) : false, }; }, @@ -87,6 +103,7 @@ export const GenerateSigningCertRequest = { (obj.certificateSigningRequest = base64FromBytes( message.certificateSigningRequest !== undefined ? message.certificateSigningRequest : new Uint8Array(0), )); + message.sandboxIdentityIssuer !== undefined && (obj.sandboxIdentityIssuer = message.sandboxIdentityIssuer); return obj; }, @@ -97,6 +114,7 @@ export const GenerateSigningCertRequest = { fromPartial, I>>(object: I): GenerateSigningCertRequest { const message = createBaseGenerateSigningCertRequest(); message.certificateSigningRequest = object.certificateSigningRequest ?? new Uint8Array(0); + message.sandboxIdentityIssuer = object.sandboxIdentityIssuer ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts index 5e68be807..e7ae8749c 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts @@ -117,7 +117,14 @@ export interface AttestationServiceInitRequest { * false: skip latest promotion. * Mutually exclusive with use_latest_version. */ - markAsLatest?: boolean | undefined; + markAsLatest?: + | boolean + | undefined; + /** + * Optional identity token that the Chainloop platform gives to a Chainloop sandbox. + * The control plane verifies it only for the CHAINLOOP_SANDBOX runner. + */ + sandboxIdentity: string; } export interface AttestationServiceInitResponse { @@ -140,6 +147,15 @@ export interface AttestationServiceInitResponse_Result { enableAiAgentCollector: boolean; /** Whether to skip storing the environment variables automatically discovered by the CI runner in the attestation */ skipRunnerEnvVars: boolean; + /** The result of the verification of the sandbox identity, when the request has one */ + sandboxIdentity?: AttestationServiceInitResponse_SandboxIdentityResult; +} + +export interface AttestationServiceInitResponse_SandboxIdentityResult { + /** Whether the control plane verified the identity and recorded it on the workflow run */ + verified: boolean; + /** Why the identity did not verify */ + reason: string; } export interface AttestationServiceInitResponse_SigningOptions { @@ -1117,6 +1133,7 @@ function createBaseAttestationServiceInitRequest(): AttestationServiceInitReques requireExistingVersion: false, useLatestVersion: false, markAsLatest: undefined, + sandboxIdentity: "", }; } @@ -1149,6 +1166,9 @@ export const AttestationServiceInitRequest = { if (message.markAsLatest !== undefined) { writer.uint32(72).bool(message.markAsLatest); } + if (message.sandboxIdentity !== "") { + writer.uint32(82).string(message.sandboxIdentity); + } return writer; }, @@ -1222,6 +1242,13 @@ export const AttestationServiceInitRequest = { message.markAsLatest = reader.bool(); continue; + case 10: + if (tag !== 82) { + break; + } + + message.sandboxIdentity = reader.string(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1242,6 +1269,7 @@ export const AttestationServiceInitRequest = { requireExistingVersion: isSet(object.requireExistingVersion) ? Boolean(object.requireExistingVersion) : false, useLatestVersion: isSet(object.useLatestVersion) ? Boolean(object.useLatestVersion) : false, markAsLatest: isSet(object.markAsLatest) ? Boolean(object.markAsLatest) : undefined, + sandboxIdentity: isSet(object.sandboxIdentity) ? String(object.sandboxIdentity) : "", }; }, @@ -1256,6 +1284,7 @@ export const AttestationServiceInitRequest = { message.requireExistingVersion !== undefined && (obj.requireExistingVersion = message.requireExistingVersion); message.useLatestVersion !== undefined && (obj.useLatestVersion = message.useLatestVersion); message.markAsLatest !== undefined && (obj.markAsLatest = message.markAsLatest); + message.sandboxIdentity !== undefined && (obj.sandboxIdentity = message.sandboxIdentity); return obj; }, @@ -1276,6 +1305,7 @@ export const AttestationServiceInitRequest = { message.requireExistingVersion = object.requireExistingVersion ?? false; message.useLatestVersion = object.useLatestVersion ?? false; message.markAsLatest = object.markAsLatest ?? undefined; + message.sandboxIdentity = object.sandboxIdentity ?? ""; return message; }, }; @@ -1351,6 +1381,7 @@ function createBaseAttestationServiceInitResponse_Result(): AttestationServiceIn uiDashboardUrl: "", enableAiAgentCollector: false, skipRunnerEnvVars: false, + sandboxIdentity: undefined, }; } @@ -1380,6 +1411,10 @@ export const AttestationServiceInitResponse_Result = { if (message.skipRunnerEnvVars === true) { writer.uint32(72).bool(message.skipRunnerEnvVars); } + if (message.sandboxIdentity !== undefined) { + AttestationServiceInitResponse_SandboxIdentityResult.encode(message.sandboxIdentity, writer.uint32(82).fork()) + .ldelim(); + } return writer; }, @@ -1446,6 +1481,16 @@ export const AttestationServiceInitResponse_Result = { message.skipRunnerEnvVars = reader.bool(); continue; + case 10: + if (tag !== 82) { + break; + } + + message.sandboxIdentity = AttestationServiceInitResponse_SandboxIdentityResult.decode( + reader, + reader.uint32(), + ); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1469,6 +1514,9 @@ export const AttestationServiceInitResponse_Result = { uiDashboardUrl: isSet(object.uiDashboardUrl) ? String(object.uiDashboardUrl) : "", enableAiAgentCollector: isSet(object.enableAiAgentCollector) ? Boolean(object.enableAiAgentCollector) : false, skipRunnerEnvVars: isSet(object.skipRunnerEnvVars) ? Boolean(object.skipRunnerEnvVars) : false, + sandboxIdentity: isSet(object.sandboxIdentity) + ? AttestationServiceInitResponse_SandboxIdentityResult.fromJSON(object.sandboxIdentity) + : undefined, }; }, @@ -1489,6 +1537,9 @@ export const AttestationServiceInitResponse_Result = { message.uiDashboardUrl !== undefined && (obj.uiDashboardUrl = message.uiDashboardUrl); message.enableAiAgentCollector !== undefined && (obj.enableAiAgentCollector = message.enableAiAgentCollector); message.skipRunnerEnvVars !== undefined && (obj.skipRunnerEnvVars = message.skipRunnerEnvVars); + message.sandboxIdentity !== undefined && (obj.sandboxIdentity = message.sandboxIdentity + ? AttestationServiceInitResponse_SandboxIdentityResult.toJSON(message.sandboxIdentity) + : undefined); return obj; }, @@ -1514,6 +1565,87 @@ export const AttestationServiceInitResponse_Result = { message.uiDashboardUrl = object.uiDashboardUrl ?? ""; message.enableAiAgentCollector = object.enableAiAgentCollector ?? false; message.skipRunnerEnvVars = object.skipRunnerEnvVars ?? false; + message.sandboxIdentity = (object.sandboxIdentity !== undefined && object.sandboxIdentity !== null) + ? AttestationServiceInitResponse_SandboxIdentityResult.fromPartial(object.sandboxIdentity) + : undefined; + return message; + }, +}; + +function createBaseAttestationServiceInitResponse_SandboxIdentityResult(): AttestationServiceInitResponse_SandboxIdentityResult { + return { verified: false, reason: "" }; +} + +export const AttestationServiceInitResponse_SandboxIdentityResult = { + encode( + message: AttestationServiceInitResponse_SandboxIdentityResult, + writer: _m0.Writer = _m0.Writer.create(), + ): _m0.Writer { + if (message.verified === true) { + writer.uint32(8).bool(message.verified); + } + if (message.reason !== "") { + writer.uint32(18).string(message.reason); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): AttestationServiceInitResponse_SandboxIdentityResult { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAttestationServiceInitResponse_SandboxIdentityResult(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.verified = reader.bool(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.reason = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): AttestationServiceInitResponse_SandboxIdentityResult { + return { + verified: isSet(object.verified) ? Boolean(object.verified) : false, + reason: isSet(object.reason) ? String(object.reason) : "", + }; + }, + + toJSON(message: AttestationServiceInitResponse_SandboxIdentityResult): unknown { + const obj: any = {}; + message.verified !== undefined && (obj.verified = message.verified); + message.reason !== undefined && (obj.reason = message.reason); + return obj; + }, + + create, I>>( + base?: I, + ): AttestationServiceInitResponse_SandboxIdentityResult { + return AttestationServiceInitResponse_SandboxIdentityResult.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + object: I, + ): AttestationServiceInitResponse_SandboxIdentityResult { + const message = createBaseAttestationServiceInitResponse_SandboxIdentityResult(); + message.verified = object.verified ?? false; + message.reason = object.reason ?? ""; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json index 45ffc9097..351b2fa4b 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json @@ -27,6 +27,10 @@ "description": "Optional flag to require that the project version already exists", "type": "boolean" }, + "^(sandbox_identity)$": { + "description": "Optional identity token that the Chainloop platform gives to a Chainloop sandbox.\n The control plane verifies it only for the CHAINLOOP_SANDBOX runner.", + "type": "string" + }, "^(use_latest_version)$": { "description": "Use the latest project version instead of specifying one explicitly.\n Mutually exclusive with project_version.", "type": "boolean" @@ -86,6 +90,10 @@ } ] }, + "sandboxIdentity": { + "description": "Optional identity token that the Chainloop platform gives to a Chainloop sandbox.\n The control plane verifies it only for the CHAINLOOP_SANDBOX runner.", + "type": "string" + }, "useLatestVersion": { "description": "Use the latest project version instead of specifying one explicitly.\n Mutually exclusive with project_version.", "type": "boolean" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json index 47b2fade3..7cc03c12b 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json @@ -27,6 +27,10 @@ "description": "Optional flag to require that the project version already exists", "type": "boolean" }, + "^(sandboxIdentity)$": { + "description": "Optional identity token that the Chainloop platform gives to a Chainloop sandbox.\n The control plane verifies it only for the CHAINLOOP_SANDBOX runner.", + "type": "string" + }, "^(useLatestVersion)$": { "description": "Use the latest project version instead of specifying one explicitly.\n Mutually exclusive with project_version.", "type": "boolean" @@ -86,6 +90,10 @@ } ] }, + "sandbox_identity": { + "description": "Optional identity token that the Chainloop platform gives to a Chainloop sandbox.\n The control plane verifies it only for the CHAINLOOP_SANDBOX runner.", + "type": "string" + }, "use_latest_version": { "description": "Use the latest project version instead of specifying one explicitly.\n Mutually exclusive with project_version.", "type": "boolean" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.Result.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.Result.jsonschema.json index 57bbda5b2..cfde4bde9 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.Result.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.Result.jsonschema.json @@ -18,6 +18,10 @@ }, "type": "array" }, + "^(sandbox_identity)$": { + "$ref": "controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.jsonschema.json", + "description": "The result of the verification of the sandbox identity, when the request has one" + }, "^(signing_options)$": { "$ref": "controlplane.v1.AttestationServiceInitResponse.SigningOptions.jsonschema.json", "description": "Signing options" @@ -54,6 +58,10 @@ }, "type": "array" }, + "sandboxIdentity": { + "$ref": "controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.jsonschema.json", + "description": "The result of the verification of the sandbox identity, when the request has one" + }, "signingOptions": { "$ref": "controlplane.v1.AttestationServiceInitResponse.SigningOptions.jsonschema.json", "description": "Signing options" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.Result.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.Result.schema.json index b6d27a74b..1bfe6a479 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.Result.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.Result.schema.json @@ -18,6 +18,10 @@ }, "type": "array" }, + "^(sandboxIdentity)$": { + "$ref": "controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.schema.json", + "description": "The result of the verification of the sandbox identity, when the request has one" + }, "^(signingOptions)$": { "$ref": "controlplane.v1.AttestationServiceInitResponse.SigningOptions.schema.json", "description": "Signing options" @@ -54,6 +58,10 @@ }, "type": "array" }, + "sandbox_identity": { + "$ref": "controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.schema.json", + "description": "The result of the verification of the sandbox identity, when the request has one" + }, "signing_options": { "$ref": "controlplane.v1.AttestationServiceInitResponse.SigningOptions.schema.json", "description": "Signing options" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.jsonschema.json new file mode 100644 index 000000000..099261deb --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.jsonschema.json @@ -0,0 +1,17 @@ +{ + "$id": "controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "reason": { + "description": "Why the identity did not verify", + "type": "string" + }, + "verified": { + "description": "Whether the control plane verified the identity and recorded it on the workflow run", + "type": "boolean" + } + }, + "title": "Sandbox Identity Result", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.schema.json new file mode 100644 index 000000000..879c75908 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.schema.json @@ -0,0 +1,17 @@ +{ + "$id": "controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "reason": { + "description": "Why the identity did not verify", + "type": "string" + }, + "verified": { + "description": "Whether the control plane verified the identity and recorded it on the workflow run", + "type": "boolean" + } + }, + "title": "Sandbox Identity Result", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.GenerateSigningCertRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.GenerateSigningCertRequest.jsonschema.json index 94cce9dd5..01beddc63 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.GenerateSigningCertRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.GenerateSigningCertRequest.jsonschema.json @@ -7,6 +7,10 @@ "minLength": 2, "pattern": "^[A-Za-z0-9+/]*={0,2}$", "type": "string" + }, + "^(sandbox_identity_issuer)$": { + "description": "Request a certificate that can sign the identity of a Chainloop sandbox.\n Only a caller with the dedicated permission gets it, other callers are denied.", + "type": "boolean" } }, "properties": { @@ -14,6 +18,10 @@ "minLength": 2, "pattern": "^[A-Za-z0-9+/]*={0,2}$", "type": "string" + }, + "sandboxIdentityIssuer": { + "description": "Request a certificate that can sign the identity of a Chainloop sandbox.\n Only a caller with the dedicated permission gets it, other callers are denied.", + "type": "boolean" } }, "title": "Generate Signing Cert Request", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.GenerateSigningCertRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.GenerateSigningCertRequest.schema.json index fb0548c1d..04b9c691b 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.GenerateSigningCertRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.GenerateSigningCertRequest.schema.json @@ -7,6 +7,10 @@ "minLength": 2, "pattern": "^[A-Za-z0-9+/]*={0,2}$", "type": "string" + }, + "^(sandboxIdentityIssuer)$": { + "description": "Request a certificate that can sign the identity of a Chainloop sandbox.\n Only a caller with the dedicated permission gets it, other callers are denied.", + "type": "boolean" } }, "properties": { @@ -14,6 +18,10 @@ "minLength": 2, "pattern": "^[A-Za-z0-9+/]*={0,2}$", "type": "string" + }, + "sandbox_identity_issuer": { + "description": "Request a certificate that can sign the identity of a Chainloop sandbox.\n Only a caller with the dedicated permission gets it, other callers are denied.", + "type": "boolean" } }, "title": "Generate Signing Cert Request", diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index f76bedea5..fe5313721 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -26,6 +26,7 @@ import ( "github.com/cenkalti/backoff/v4" cpAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" "github.com/chainloop-dev/chainloop/app/controlplane/internal/dispatcher" "github.com/chainloop-dev/chainloop/app/controlplane/internal/panicguard" @@ -37,6 +38,7 @@ import ( casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" "github.com/chainloop-dev/chainloop/pkg/attestation" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/credentials" errors "github.com/go-kratos/kratos/v2/errors" @@ -201,6 +203,16 @@ func (s *AttestationService) Init(ctx context.Context, req *cpAPI.AttestationSer return nil, handleUseCaseErr(ctx, err, s.log) } + // A sandbox identity that verifies is recorded on the run (R-006) + var ( + sandboxIdentity *biz.SandboxIdentity + sandboxIdentityResult *cpAPI.AttestationServiceInitResponse_SandboxIdentityResult + ) + + if raw := req.GetSandboxIdentity(); raw != "" { + sandboxIdentity, sandboxIdentityResult = s.verifySandboxIdentity(ctx, raw, req.GetRunner(), org.ID, wf) + } + // Create workflowRun opts := &biz.WorkflowRunCreateOpts{ WorkflowID: wf.ID.String(), @@ -213,6 +225,7 @@ func (s *AttestationService) Init(ctx context.Context, req *cpAPI.AttestationSer RequireExistingVersion: req.GetRequireExistingVersion(), MarkAsLatest: req.MarkAsLatest, BlockReleasedVersions: org.BlockAttestationsOnReleasedVersions, + SandboxIdentity: sandboxIdentity, } run, err := s.wrUseCase.Create(ctx, opts) @@ -230,6 +243,7 @@ func (s *AttestationService) Init(ctx context.Context, req *cpAPI.AttestationSer UiDashboardUrl: s.bootstrapConfig.UiDashboardUrl, EnableAiAgentCollector: org.EnableAIAgentCollector, SkipRunnerEnvVars: org.SkipRunnerEnvVars, + SandboxIdentity: sandboxIdentityResult, } resp.SigningOptions = &cpAPI.AttestationServiceInitResponse_SigningOptions{} @@ -247,6 +261,51 @@ func (s *AttestationService) Init(ctx context.Context, req *cpAPI.AttestationSer return &cpAPI.AttestationServiceInitResponse{Result: resp}, nil } +// verifySandboxIdentity verifies the identity token of a Chainloop sandbox for the attested +// workflow and the API token of the caller. A token that does not verify is the same as no token: +// the run records no identity, and the result says why (D-011). +func (s *AttestationService) verifySandboxIdentity(ctx context.Context, raw string, runner schemaapi.CraftingSchema_Runner_RunnerType, orgID string, wf *biz.Workflow) (*biz.SandboxIdentity, *cpAPI.AttestationServiceInitResponse_SandboxIdentityResult) { + notVerified := func(reason string) (*biz.SandboxIdentity, *cpAPI.AttestationServiceInitResponse_SandboxIdentityResult) { + s.log.Infow("msg", "sandbox identity not verified", "reason", reason, "workflow_id", wf.ID.String()) + return nil, &cpAPI.AttestationServiceInitResponse_SandboxIdentityResult{Reason: reason} + } + + if runner != schemaapi.CraftingSchema_Runner_CHAINLOOP_SANDBOX { + return notVerified("only the CHAINLOOP_SANDBOX runner has a sandbox identity") + } + + if !s.signingUseCase.KeylessEnabled() { + return notVerified("keyless signing is not configured") + } + + token := entities.CurrentAPIToken(ctx) + if token == nil { + return notVerified("a sandbox identity needs the Chainloop API token that it names") + } + + tokenID, err := uuid.Parse(token.ID) + if err != nil { + return notVerified("a sandbox identity needs the Chainloop API token that it names") + } + + expected := &sandboxidentity.Expected{OrganizationID: orgID, ProjectID: wf.ProjectID.String(), WorkflowID: wf.ID.String()} + + id, err := s.signingUseCase.VerifySandboxIdentity(ctx, raw, expected, tokenID) + if err != nil { + if biz.IsErrValidation(err) || biz.IsErrNotImplemented(err) { + return notVerified(err.Error()) + } + + // Any other failure also leaves the run without an identity, so the attestation goes on + // as one from a sandbox runner that is not authenticated. + s.log.Errorw("msg", "verifying the sandbox identity", "error", err, "workflow_id", wf.ID.String()) + + return notVerified("the sandbox identity could not be verified") + } + + return id, &cpAPI.AttestationServiceInitResponse_SandboxIdentityResult{Verified: true} +} + func (s *AttestationService) Store(ctx context.Context, req *cpAPI.AttestationServiceStoreRequest) (*cpAPI.AttestationServiceStoreResponse, error) { robotAccount := usercontext.CurrentRobotAccount(ctx) if robotAccount == nil { diff --git a/app/controlplane/internal/service/sandboxidentity_test.go b/app/controlplane/internal/service/sandboxidentity_test.go new file mode 100644 index 000000000..70a6729e4 --- /dev/null +++ b/app/controlplane/internal/service/sandboxidentity_test.go @@ -0,0 +1,218 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "testing" + "time" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/ca" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" + kerrors "github.com/go-kratos/kratos/v2/errors" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + fulcioca "github.com/sigstore/fulcio/pkg/ca" + "github.com/sigstore/fulcio/pkg/ca/ephemeralca" + "github.com/sigstore/fulcio/pkg/identity" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type ephemeralTestCA struct { + ca *ephemeralca.EphemeralCA +} + +func (e ephemeralTestCA) CreateCertificateFromCSR(ctx context.Context, principal identity.Principal, csr *x509.CertificateRequest) (*fulcioca.CodeSigningCertificate, error) { + return e.ca.CreateCertificate(ctx, principal, csr.PublicKey) +} + +func (e ephemeralTestCA) GetRootChain(ctx context.Context) ([]*x509.Certificate, error) { + tb, err := e.ca.TrustBundle(ctx) + if err != nil { + return nil, err + } + + return tb[0], nil +} + +func (e ephemeralTestCA) GetName() string { + return "test" +} + +func newKeylessSigningUseCase(t *testing.T) *biz.SigningUseCase { + t.Helper() + + eca, err := ephemeralca.NewEphemeralCA() + require.NoError(t, err) + authority := ephemeralTestCA{ca: eca} + + return &biz.SigningUseCase{CAs: &ca.CertificateAuthorities{CAs: []ca.CertificateAuthority{authority}, SignerCA: authority}} +} + +func newCSR(t *testing.T) (*ecdsa.PrivateKey, []byte) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: "ephemeral"}}, key) + require.NoError(t, err) + + return key, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}) +} + +func parsePEMChain(t *testing.T, chain []string) []*x509.Certificate { + t.Helper() + + certs := make([]*x509.Certificate, 0, len(chain)) + for _, c := range chain { + parsed, err := cryptoutils.UnmarshalCertificatesFromPEM([]byte(c)) + require.NoError(t, err) + certs = append(certs, parsed...) + } + + return certs +} + +// apiTokenContext is the context of a call with an API token, as the API token middleware sets it. +func apiTokenContext(orgID string, tokenID uuid.UUID) context.Context { + ctx := entities.WithCurrentAPIToken(context.Background(), &entities.APIToken{ID: tokenID.String()}) + ctx = usercontext.WithAuthzSubject(ctx, (&authz.SubjectAPIToken{ID: tokenID.String()}).String()) + + return usercontext.WithRobotAccount(ctx, &usercontext.RobotAccount{OrgID: orgID}) +} + +func TestGenerateSigningCertSandboxIdentityIssuer(t *testing.T) { + const orgID = "my-org" + tokenID := uuid.New() + _, csr := newCSR(t) + + newSvc := func(t *testing.T, policies []*authz.Policy) *SigningService { + return &SigningService{service: newTestServiceWithTokenPolicies(t, policies), signing: newKeylessSigningUseCase(t)} + } + + t.Run("a token with the permission gets a marked certificate", func(t *testing.T) { + svc := newSvc(t, []*authz.Policy{authz.PolicyArtifactUpload, authz.PolicySandboxIdentityIssue}) + + resp, err := svc.GenerateSigningCert(apiTokenContext(orgID, tokenID), &pb.GenerateSigningCertRequest{CertificateSigningRequest: csr, SandboxIdentityIssuer: true}) + require.NoError(t, err) + assert.True(t, sandboxidentity.HasMark(parsePEMChain(t, resp.GetChain().GetCertificates())[0])) + }) + + t.Run("a token without the permission is denied", func(t *testing.T) { + svc := newSvc(t, []*authz.Policy{authz.PolicyArtifactUpload}) + + _, err := svc.GenerateSigningCert(apiTokenContext(orgID, tokenID), &pb.GenerateSigningCertRequest{CertificateSigningRequest: csr, SandboxIdentityIssuer: true}) + require.Error(t, err) + assert.True(t, kerrors.IsForbidden(err)) + }) + + t.Run("a caller without an authorization subject is denied", func(t *testing.T) { + svc := newSvc(t, []*authz.Policy{authz.PolicySandboxIdentityIssue}) + ctx := usercontext.WithRobotAccount(context.Background(), &usercontext.RobotAccount{OrgID: orgID}) + + _, err := svc.GenerateSigningCert(ctx, &pb.GenerateSigningCertRequest{CertificateSigningRequest: csr, SandboxIdentityIssuer: true}) + require.Error(t, err) + assert.True(t, kerrors.IsForbidden(err)) + }) + + t.Run("a request without the flag gets a certificate without the mark", func(t *testing.T) { + svc := newSvc(t, []*authz.Policy{authz.PolicyArtifactUpload}) + + resp, err := svc.GenerateSigningCert(apiTokenContext(orgID, tokenID), &pb.GenerateSigningCertRequest{CertificateSigningRequest: csr}) + require.NoError(t, err) + assert.False(t, sandboxidentity.HasMark(parsePEMChain(t, resp.GetChain().GetCertificates())[0])) + }) +} + +func TestAttestationInitVerifySandboxIdentity(t *testing.T) { + orgID := uuid.NewString() + tokenID := uuid.New() + wf := &biz.Workflow{ID: uuid.New(), ProjectID: uuid.New()} + signing := newKeylessSigningUseCase(t) + + key, csr := newCSR(t) + chain, err := signing.CreateSigningCert(context.Background(), orgID, csr, biz.WithSandboxIdentityMark()) + require.NoError(t, err) + + raw, err := sandboxidentity.Sign(key, parsePEMChain(t, chain), &sandboxidentity.Claims{ + RegisteredClaims: jwt.RegisteredClaims{IssuedAt: jwt.NewNumericDate(time.Now())}, + OrganizationID: orgID, ProjectID: wf.ProjectID.String(), WorkflowID: wf.ID.String(), + SandboxRun: "sandbox-run-1", ChainloopTokenID: tokenID.String(), + }) + require.NoError(t, err) + + svc := &AttestationService{service: newTestServiceWithTokenPolicies(t, nil), signingUseCase: signing} + sandbox := schemaapi.CraftingSchema_Runner_CHAINLOOP_SANDBOX + + t.Run("a valid identity from the named token", func(t *testing.T) { + id, result := svc.verifySandboxIdentity(apiTokenContext(orgID, tokenID), raw, sandbox, orgID, wf) + require.NotNil(t, id) + assert.True(t, result.GetVerified()) + assert.Equal(t, tokenID, id.ChainloopTokenID) + assert.Equal(t, "sandbox-run-1", id.SandboxRun) + }) + + testCases := []struct { + name string + ctx context.Context + runner schemaapi.CraftingSchema_Runner_RunnerType + wf *biz.Workflow + svc *AttestationService + wantMsg string + }{ + {name: "another runner", ctx: apiTokenContext(orgID, tokenID), runner: schemaapi.CraftingSchema_Runner_GITHUB_ACTION, wantMsg: "CHAINLOOP_SANDBOX"}, + {name: "a caller without an API token", ctx: context.Background(), runner: sandbox, wantMsg: "API token"}, + {name: "another caller token", ctx: apiTokenContext(orgID, uuid.New()), runner: sandbox, wantMsg: "another Chainloop token"}, + {name: "another workflow", ctx: apiTokenContext(orgID, tokenID), runner: sandbox, wf: &biz.Workflow{ID: uuid.New(), ProjectID: wf.ProjectID}, wantMsg: "another workflow"}, + { + name: "no keyless signing", ctx: apiTokenContext(orgID, tokenID), runner: sandbox, + svc: &AttestationService{service: newTestServiceWithTokenPolicies(t, nil), signingUseCase: &biz.SigningUseCase{}}, wantMsg: "keyless", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + target, workflow := svc, wf + if tc.svc != nil { + target = tc.svc + } + + if tc.wf != nil { + workflow = tc.wf + } + + id, result := target.verifySandboxIdentity(tc.ctx, raw, tc.runner, orgID, workflow) + assert.Nil(t, id) + assert.False(t, result.GetVerified()) + assert.Contains(t, result.GetReason(), tc.wantMsg) + }) + } +} diff --git a/app/controlplane/internal/service/signing.go b/app/controlplane/internal/service/signing.go index 2a98af8b7..35b58d296 100644 --- a/app/controlplane/internal/service/signing.go +++ b/app/controlplane/internal/service/signing.go @@ -20,6 +20,7 @@ import ( v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/go-kratos/kratos/v2/errors" ) @@ -46,7 +47,16 @@ func (s *SigningService) GenerateSigningCert(ctx context.Context, req *v1.Genera return nil, errors.Unauthorized("missing org", "authentication data is required") } - certs, err := s.signing.CreateSigningCert(ctx, ra.OrgID, req.GetCertificateSigningRequest()) + var opts []biz.SigningCertOption + if req.GetSandboxIdentityIssuer() { + if err := s.authorizeSandboxIdentityIssuer(ctx); err != nil { + return nil, err + } + + opts = append(opts, biz.WithSandboxIdentityMark()) + } + + certs, err := s.signing.CreateSigningCert(ctx, ra.OrgID, req.GetCertificateSigningRequest(), opts...) if err != nil { return nil, handleUseCaseErr(ctx, err, s.log) } @@ -68,3 +78,23 @@ func (s *SigningService) GetTrustedRoot(ctx context.Context, _ *v1.GetTrustedRoo } return resp, nil } + +// authorizeSandboxIdentityIssuer checks that the caller holds the dedicated permission to get a +// certificate that can sign a sandbox identity. Only a system API token of the platform holds it. +func (s *SigningService) authorizeSandboxIdentityIssuer(ctx context.Context) error { + forbidden := errors.Forbidden("forbidden", "not allowed to issue a sandbox identity") + + sub := usercontext.CurrentAuthzSubject(ctx) + if sub == "" || s.authz == nil { + return forbidden + } + + ok, err := s.authz.Enforce(ctx, sub, authz.PolicySandboxIdentityIssue) + if err != nil { + return handleUseCaseErr(ctx, err, s.log) + } else if !ok { + return forbidden + } + + return nil +} diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 89ab52bf3..caab7cc85 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -69,6 +69,7 @@ const ( ResourceProjectMembership = "project_membership" ResourceOrganizationInvitations = "organization_invitations" ResourceDefaultBackend = "default_backend" + ResourceSandboxIdentity = "sandbox_identity" // Top level instance admin role // this is used to know if an user is a super admin of the chainloop instance @@ -182,6 +183,10 @@ var ( // Manage owners (promote to/demote from owner, remove owners) PolicyOrganizationManageOwners = &Policy{OrganizationMemberships, ActionManageOwners} + + // PolicySandboxIdentityIssue allows to get a certificate that can sign the identity of a + // Chainloop sandbox. No role holds it. Only a system API token of the platform can hold it. + PolicySandboxIdentityIssue = &Policy{ResourceSandboxIdentity, ActionCreate} ) // RolesMap The default list of policies for each role diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 2075a76f9..e5faee32c 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -71,6 +71,22 @@ func IsOrgLevelTokenPolicy(p *authz.Policy) bool { }) } +// checkSandboxIdentityPolicy refuses the permission to issue a sandbox identity to a token that is +// not a system token. Only the platform mints system tokens. +func checkSandboxIdentityPolicy(policies []*authz.Policy, isSystem bool) error { + if isSystem { + return nil + } + + if slices.ContainsFunc(policies, func(p *authz.Policy) bool { + return p != nil && p.Resource == authz.PolicySandboxIdentityIssue.Resource && p.Action == authz.PolicySandboxIdentityIssue.Action + }) { + return NewErrValidationStr("only a system token can hold the permission to issue a sandbox identity") + } + + return nil +} + // defaultAuthzPolicies are granted to every token regardless of scope, so each entry must be safe // for a caller confined to a single project. Org-wide capabilities go in orgLevelTokenPolicies. var defaultAuthzPolicies = []*authz.Policy{ @@ -503,6 +519,10 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description policies = uc.DefaultAuthzPolicies } + if err := checkSandboxIdentityPolicy(policies, options.isSystem); err != nil { + return nil, err + } + // A product token's policies come only from the platform, in-process: the refusals below // are defence in depth against the organization-level grant this function appends to // organization-wide tokens, not a classification of every policy. What confines the token diff --git a/app/controlplane/pkg/biz/apitoken_authz_test.go b/app/controlplane/pkg/biz/apitoken_authz_test.go index 6aaefff67..d02e173a0 100644 --- a/app/controlplane/pkg/biz/apitoken_authz_test.go +++ b/app/controlplane/pkg/biz/apitoken_authz_test.go @@ -50,3 +50,39 @@ func TestOrgLevelPoliciesStayOutOfDefaults(t *testing.T) { p.Resource, p.Action) } } + +// The permission to issue a sandbox identity must never reach a token or a user by default (D-004). +func TestSandboxIdentityPolicyIsNeverGrantedByDefault(t *testing.T) { + assert.NotContains(t, defaultAuthzPolicies, authz.PolicySandboxIdentityIssue) + assert.NotContains(t, orgLevelTokenPolicies, authz.PolicySandboxIdentityIssue) + + for role, policies := range authz.RolesMap { + assert.NotContainsf(t, policies, authz.PolicySandboxIdentityIssue, "role %s holds the sandbox identity policy", role) + } +} + +func TestCheckSandboxIdentityPolicy(t *testing.T) { + testCases := []struct { + name string + policies []*authz.Policy + isSystem bool + wantErr bool + }{ + {name: "a system token", policies: []*authz.Policy{authz.PolicyArtifactUpload, authz.PolicySandboxIdentityIssue}, isSystem: true}, + {name: "a token that is not a system token", policies: []*authz.Policy{{Resource: authz.ResourceSandboxIdentity, Action: authz.ActionCreate}}, wantErr: true}, + {name: "a token without the policy", policies: []*authz.Policy{authz.PolicyArtifactUpload}}, + {name: "a token with a nil policy", policies: []*authz.Policy{nil}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := checkSandboxIdentityPolicy(tc.policies, tc.isSystem) + if tc.wantErr { + assert.True(t, IsErrValidation(err)) + return + } + + assert.NoError(t, err) + }) + } +} diff --git a/app/controlplane/pkg/biz/signing.go b/app/controlplane/pkg/biz/signing.go index b1d13b48f..ff7167d55 100644 --- a/app/controlplane/pkg/biz/signing.go +++ b/app/controlplane/pkg/biz/signing.go @@ -27,12 +27,18 @@ import ( "fmt" "net/url" "os" + "strings" + "time" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/ca" + "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" + "github.com/chainloop-dev/chainloop/pkg/attestation/verifier" "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/chainloop-dev/chainloop/pkg/servicelogger" "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" "github.com/sigstore/fulcio/pkg/identity" "github.com/sigstore/sigstore/pkg/cryptoutils" ) @@ -176,8 +182,23 @@ func (s *SigningUseCase) GetSigningCA() ca.CertificateAuthority { return s.CAs.SignerCA } +// SigningCertOption customizes a signing certificate. +type SigningCertOption func(*signingCertOpts) + +type signingCertOpts struct { + sandboxIdentityIssuer bool +} + +// WithSandboxIdentityMark marks the certificate as able to sign the identity of a Chainloop +// sandbox. The caller must hold authz.PolicySandboxIdentityIssue. +func WithSandboxIdentityMark() SigningCertOption { + return func(o *signingCertOpts) { + o.sandboxIdentityIssuer = true + } +} + // CreateSigningCert signs a certificate request with a configured CA, and returns the full certificate chain -func (s *SigningUseCase) CreateSigningCert(ctx context.Context, orgID string, csrRaw []byte) ([]string, error) { +func (s *SigningUseCase) CreateSigningCert(ctx context.Context, orgID string, csrRaw []byte, opts ...SigningCertOption) ([]string, error) { ctx, span := otelx.Start(ctx, signingTracer, "SigningUseCase.CreateSigningCert") defer span.End() @@ -185,6 +206,11 @@ func (s *SigningUseCase) CreateSigningCert(ctx context.Context, orgID string, cs return nil, NewErrNotImplemented("CAs not initialized") } + options := &signingCertOpts{} + for _, opt := range opts { + opt(options) + } + var publicKey crypto.PublicKey if len(csrRaw) == 0 { @@ -213,11 +239,21 @@ func (s *SigningUseCase) CreateSigningCert(ctx context.Context, orgID string, cs if err != nil { return nil, fmt.Errorf("getting signer CA: %w", err) } - csc, err := issuerCA.CreateCertificateFromCSR(ctx, newChainloopPrincipal(orgID), csr) + csc, err := issuerCA.CreateCertificateFromCSR(ctx, newChainloopPrincipal(orgID, options.sandboxIdentityIssuer), csr) if err != nil { return nil, fmt.Errorf("creating certificate: %w", err) } + // A CA that builds the subject itself, for example from the request, does not use the + // principal. Check the result, so that only a caller with the permission gets the mark. + if marked := sandboxidentity.HasMark(csc.FinalCertificate); marked != options.sandboxIdentityIssuer { + if options.sandboxIdentityIssuer { + return nil, NewErrNotImplemented("the signing CA cannot issue certificates for a sandbox identity") + } + + return nil, errors.New("the signing CA issued a certificate with a sandbox identity mark that was not requested") + } + // Generated certificate finalPEM, err := csc.CertPEM() if err != nil { @@ -289,12 +325,14 @@ type TrustedRoot struct { type chainloopPrincipal struct { orgID string + // sandboxIdentityIssuer adds the mark that lets the certificate sign a sandbox identity + sandboxIdentityIssuer bool } var _ identity.Principal = (*chainloopPrincipal)(nil) -func newChainloopPrincipal(orgID string) *chainloopPrincipal { - return &chainloopPrincipal{orgID: orgID} +func newChainloopPrincipal(orgID string, sandboxIdentityIssuer bool) *chainloopPrincipal { + return &chainloopPrincipal{orgID: orgID, sandboxIdentityIssuer: sandboxIdentityIssuer} } func (p *chainloopPrincipal) Name(_ context.Context) string { @@ -306,6 +344,93 @@ func (p *chainloopPrincipal) Embed(_ context.Context, cert *x509.Certificate) er // TODO: Chainloop might have their own private enterprise number with the Internet Assigned Numbers Authority // to embed its own identity information in the resulting certificate cert.Subject = pkix.Name{Organization: []string{p.orgID}} + if p.sandboxIdentityIssuer { + cert.Subject.OrganizationalUnit = []string{sandboxidentity.CertificateMark} + } return nil } + +// SandboxIdentity is the verified identity of a Chainloop sandbox, as recorded on its workflow run. +type SandboxIdentity struct { + // SandboxRun identifies the sandbox run in the platform + SandboxRun string `json:"sandbox_run,omitempty"` + // ChainloopTokenID is the API token that the identity names + ChainloopTokenID uuid.UUID `json:"chainloop_token_id"` + // IssuedAt is the time of signing + IssuedAt time.Time `json:"issued_at"` + // Data is the data that the platform gives to the attestation + Data *sandboxidentity.Data `json:"data,omitempty"` +} + +// HoldsCommitVerification reports whether the identity holds the commit verification that an +// attestation records: the same full commit hash and status name. When the identity has a +// signature algorithm, the attestation must record the same one. Without it, the attestation takes +// the algorithm from the signature of the commit, so it is not compared. +func (i *SandboxIdentity) HoldsCommitVerification(v chainloop.GitHeadVerification) bool { + if i == nil || i.Data == nil || i.Data.CommitVerification == nil || i.Data.CommitVerification.Verification == nil { + return false + } + + cv := i.Data.CommitVerification + if v.Commit == "" || !strings.EqualFold(cv.Commit, v.Commit) || cv.Verification.Status.String() != v.Status { + return false + } + + return cv.Verification.SignatureAlgorithm == "" || cv.Verification.SignatureAlgorithm == v.SignatureAlgorithm +} + +// VerifySandboxIdentity verifies the identity token of a Chainloop sandbox against the certificate +// authorities of the control plane. The identity must be for the expected attestation, and the +// caller must use the Chainloop token that the identity names. +func (s *SigningUseCase) VerifySandboxIdentity(ctx context.Context, raw string, expected *sandboxidentity.Expected, callerTokenID uuid.UUID) (*SandboxIdentity, error) { + ctx, span := otelx.Start(ctx, signingTracer, "SigningUseCase.VerifySandboxIdentity") + defer span.End() + + if s.CAs == nil { + return nil, NewErrNotImplemented("CAs not initialized") + } + + tr, err := s.trustedRootCertificates(ctx) + if err != nil { + return nil, err + } + + id, err := sandboxidentity.Verify(raw, tr, expected) + if err != nil { + return nil, NewErrValidation(err) + } + + tokenID, err := uuid.Parse(id.Claims.ChainloopTokenID) + if err != nil || tokenID != callerTokenID { + return nil, NewErrValidationStr("the identity names another Chainloop token") + } + + return &SandboxIdentity{ + SandboxRun: id.Claims.SandboxRun, + ChainloopTokenID: tokenID, + IssuedAt: id.Claims.IssuedAt.Time, + Data: id.Claims.Data, + }, nil +} + +// trustedRootCertificates returns the root chains of the certificate authorities, keyed as in the +// trusted root that the CLI gets. +func (s *SigningUseCase) trustedRootCertificates(ctx context.Context) (*verifier.TrustedRoot, error) { + tr := &verifier.TrustedRoot{Keys: make(map[string][]*x509.Certificate)} + for _, auth := range s.CAs.GetAuthorities() { + chain, err := auth.GetRootChain(ctx) + if err != nil { + return nil, fmt.Errorf("getting root chain: %w", err) + } + + if len(chain) == 0 { + continue + } + + keyIDSum := sha256.Sum256(chain[0].SubjectKeyId) + tr.Keys[hex.EncodeToString(keyIDSum[:])] = chain + } + + return tr, nil +} diff --git a/app/controlplane/pkg/biz/signing_sandboxidentity_test.go b/app/controlplane/pkg/biz/signing_sandboxidentity_test.go new file mode 100644 index 000000000..3cde009bb --- /dev/null +++ b/app/controlplane/pkg/biz/signing_sandboxidentity_test.go @@ -0,0 +1,207 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz_test + +import ( + "context" + "crypto/ecdsa" + "crypto/x509" + "crypto/x509/pkix" + "strings" + "testing" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + ca2 "github.com/chainloop-dev/chainloop/app/controlplane/pkg/ca" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + fulcioca "github.com/sigstore/fulcio/pkg/ca" + "github.com/sigstore/fulcio/pkg/identity" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fixedSubjectCA is a CA that ignores the principal of the control plane, as a remote CA that +// builds the subject from the request does. +type fixedSubjectCA struct { + *TestCA + subject pkix.Name +} + +type fixedPrincipal struct{ subject pkix.Name } + +func (p fixedPrincipal) Name(_ context.Context) string { return "fixed" } + +func (p fixedPrincipal) Embed(_ context.Context, cert *x509.Certificate) error { + cert.Subject = p.subject + return nil +} + +func (e fixedSubjectCA) CreateCertificateFromCSR(ctx context.Context, _ identity.Principal, csr *x509.CertificateRequest) (*fulcioca.CodeSigningCertificate, error) { + return e.ca.CreateCertificate(ctx, fixedPrincipal{e.subject}, csr.PublicKey) +} + +func signingUseCaseWithCA(ca ca2.CertificateAuthority) *biz.SigningUseCase { + return &biz.SigningUseCase{CAs: &ca2.CertificateAuthorities{CAs: []ca2.CertificateAuthority{ca}, SignerCA: ca}} +} + +func parseChain(t *testing.T, chain []string) []*x509.Certificate { + t.Helper() + + certs := make([]*x509.Certificate, 0, len(chain)) + for _, c := range chain { + parsed, err := cryptoutils.UnmarshalCertificatesFromPEM([]byte(c)) + require.NoError(t, err) + certs = append(certs, parsed...) + } + + return certs +} + +func TestCreateSigningCertSandboxIdentityMark(t *testing.T) { + const orgID = "myorgid" + + t.Run("the certificate has the mark only when it is requested", func(t *testing.T) { + uc := keylessSigningUseCase(t) + _, csr, err := createCSR() + require.NoError(t, err) + + marked, err := uc.CreateSigningCert(context.TODO(), orgID, csr, biz.WithSandboxIdentityMark()) + require.NoError(t, err) + leaf := parseChain(t, marked)[0] + assert.True(t, sandboxidentity.HasMark(leaf)) + assert.Equal(t, []string{orgID}, leaf.Subject.Organization) + + plain, err := uc.CreateSigningCert(context.TODO(), orgID, csr) + require.NoError(t, err) + assert.False(t, sandboxidentity.HasMark(parseChain(t, plain)[0])) + }) + + t.Run("a CA that cannot write the mark gives no certificate", func(t *testing.T) { + testCA, err := NewTestCA() + require.NoError(t, err) + uc := signingUseCaseWithCA(fixedSubjectCA{TestCA: testCA, subject: pkix.Name{CommonName: "from the request"}}) + _, csr, err := createCSR() + require.NoError(t, err) + + _, err = uc.CreateSigningCert(context.TODO(), orgID, csr, biz.WithSandboxIdentityMark()) + require.Error(t, err) + assert.True(t, biz.IsErrNotImplemented(err)) + }) + + t.Run("a mark that was not requested gives no certificate", func(t *testing.T) { + testCA, err := NewTestCA() + require.NoError(t, err) + subject := pkix.Name{Organization: []string{orgID}, OrganizationalUnit: []string{sandboxidentity.CertificateMark}} + uc := signingUseCaseWithCA(fixedSubjectCA{TestCA: testCA, subject: subject}) + _, csr, err := createCSR() + require.NoError(t, err) + + _, err = uc.CreateSigningCert(context.TODO(), orgID, csr) + require.Error(t, err) + }) +} + +func TestVerifySandboxIdentity(t *testing.T) { + const ( + commit = "0123456789abcdef0123456789abcdef01234567" + verified = "verified" + ) + orgID, projectID, workflowID := uuid.NewString(), uuid.NewString(), uuid.NewString() + tokenID := uuid.New() + uc := keylessSigningUseCase(t) + + issue := func(t *testing.T, opts ...biz.SigningCertOption) (*ecdsa.PrivateKey, []*x509.Certificate) { + t.Helper() + key, csr, err := createCSR() + require.NoError(t, err) + chain, err := uc.CreateSigningCert(context.TODO(), orgID, csr, opts...) + require.NoError(t, err) + return key, parseChain(t, chain) + } + + claims := func() *sandboxidentity.Claims { + return &sandboxidentity.Claims{ + RegisteredClaims: jwt.RegisteredClaims{IssuedAt: jwt.NewNumericDate(time.Now())}, + OrganizationID: orgID, ProjectID: projectID, WorkflowID: workflowID, + SandboxRun: "sandbox-run-1", ChainloopTokenID: tokenID.String(), + Data: &sandboxidentity.Data{CommitVerification: &sandboxidentity.CommitVerification{ + Commit: commit, + Verification: &commitverification.CommitVerification{Attempted: true, Status: commitverification.VerificationStatusVerified}, + }}, + } + } + + expected := &sandboxidentity.Expected{OrganizationID: orgID, ProjectID: projectID, WorkflowID: workflowID} + + key, chain := issue(t, biz.WithSandboxIdentityMark()) + raw, err := sandboxidentity.Sign(key, chain, claims()) + require.NoError(t, err) + + t.Run("a valid identity from the named token", func(t *testing.T) { + got, err := uc.VerifySandboxIdentity(context.TODO(), raw, expected, tokenID) + require.NoError(t, err) + assert.Equal(t, tokenID, got.ChainloopTokenID) + assert.Equal(t, "sandbox-run-1", got.SandboxRun) + assert.True(t, got.HoldsCommitVerification(chainloop.GitHeadVerification{Commit: strings.ToUpper(commit), Status: verified})) + assert.False(t, got.HoldsCommitVerification(chainloop.GitHeadVerification{Commit: commit, Status: "unverified"})) + assert.False(t, got.HoldsCommitVerification(chainloop.GitHeadVerification{Commit: "fedcba9876543210fedcba9876543210fedcba98", Status: verified})) + }) + + t.Run("another caller token", func(t *testing.T) { + _, err := uc.VerifySandboxIdentity(context.TODO(), raw, expected, uuid.New()) + require.Error(t, err) + assert.True(t, biz.IsErrValidation(err)) + }) + + t.Run("another workflow", func(t *testing.T) { + other := *expected + other.WorkflowID = uuid.NewString() + _, err := uc.VerifySandboxIdentity(context.TODO(), raw, &other, tokenID) + require.Error(t, err) + assert.True(t, biz.IsErrValidation(err)) + }) + + t.Run("a certificate without the permission has no mark", func(t *testing.T) { + key, chain := issue(t) + raw, err := sandboxidentity.Sign(key, chain, claims()) + require.NoError(t, err) + + _, err = uc.VerifySandboxIdentity(context.TODO(), raw, expected, tokenID) + require.Error(t, err) + assert.True(t, biz.IsErrValidation(err)) + }) + + t.Run("a certificate from another CA", func(t *testing.T) { + key, chain := issue(t, biz.WithSandboxIdentityMark()) + raw, err := sandboxidentity.Sign(key, chain, claims()) + require.NoError(t, err) + + _, err = keylessSigningUseCase(t).VerifySandboxIdentity(context.TODO(), raw, expected, tokenID) + require.Error(t, err) + assert.True(t, biz.IsErrValidation(err)) + }) + + t.Run("no keyless signing", func(t *testing.T) { + _, err := (&biz.SigningUseCase{}).VerifySandboxIdentity(context.TODO(), raw, expected, tokenID) + require.Error(t, err) + assert.True(t, biz.IsErrNotImplemented(err)) + }) +} diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index d21ca04cc..f4f951931 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -42,6 +42,7 @@ import ( "github.com/go-kratos/kratos/v2/log" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/uuid" + intoto "github.com/in-toto/attestation/go/v1" ) var workflowRunTracer = otelx.Tracer("chainloop-controlplane", "biz/workflowrun") @@ -65,6 +66,8 @@ type WorkflowRun struct { // Canonical policy outcome summary (nil if no policies were evaluated or if // the row predates the policy-summary materialization change). PolicyStatus *chainloop.PolicyStatusSummary + // SandboxIdentity is the verified identity of the Chainloop sandbox that started the run, if any + SandboxIdentity *SandboxIdentity } type Attestation struct { @@ -279,6 +282,8 @@ type WorkflowRunCreateOpts struct { // BlockReleasedVersions rejects creating a run against an already-released // (prerelease == false) project version. Set from the org-level setting. BlockReleasedVersions bool + // SandboxIdentity is the verified identity of the Chainloop sandbox that starts the run, if any + SandboxIdentity *SandboxIdentity } type WorkflowRunRepoCreateOpts struct { @@ -293,6 +298,8 @@ type WorkflowRunRepoCreateOpts struct { // BlockReleasedVersions rejects creating a run against an already-released // (prerelease == false) project version. BlockReleasedVersions bool + // SandboxIdentity is the verified identity of the Chainloop sandbox that starts the run, if any + SandboxIdentity *SandboxIdentity } // Create will add a new WorkflowRun, associate it to a schemaVersion and increment the counter in the associated workflow @@ -351,6 +358,7 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat RequireExistingVersion: opts.RequireExistingVersion, MarkAsLatest: opts.MarkAsLatest, BlockReleasedVersions: opts.BlockReleasedVersions, + SandboxIdentity: opts.SandboxIdentity, }) if err != nil { return nil, err @@ -479,9 +487,46 @@ func (uc *WorkflowRunUseCase) ValidateAttestationContract(ctx context.Context, r return fmt.Errorf("extracting predicate: %w", err) } + if err := validateSandboxRunnerInEnvelope(run, dsseEnv, predicate); err != nil { + return err + } + return uc.validateAgainstContract(ctx, run, predicate) } +// validateSandboxRunnerInEnvelope runs validateSandboxRunner on the statement of the envelope. +func validateSandboxRunnerInEnvelope(run *WorkflowRun, dsseEnv *dsse.Envelope, predicate chainloop.NormalizablePredicate) error { + statement, err := chainloop.ExtractStatement(dsseEnv) + if err != nil { + return fmt.Errorf("extracting statement: %w", err) + } + + return validateSandboxRunner(run, statement, predicate) +} + +// validateSandboxRunner rejects an attestation of the Chainloop sandbox runner that claims more +// than the verified sandbox identity of its run holds (R-006). The CLI applies the same rules, +// but a changed CLI can claim anything, so the control plane is the authority. Other runners +// keep their behavior. +func validateSandboxRunner(run *WorkflowRun, statement *intoto.Statement, predicate chainloop.NormalizablePredicate) error { + sandbox := schemaapi.CraftingSchema_Runner_CHAINLOOP_SANDBOX.String() + if run.RunnerType != sandbox && predicate.GetRunnerType() != sandbox { + return nil + } + + if predicate.GetRunnerAuthenticated() && run.SandboxIdentity == nil { + return NewErrValidationStr("the attestation claims an authenticated sandbox runner, but its workflow run has no verified sandbox identity") + } + + for _, v := range chainloop.GitHeadVerifications(statement) { + if !run.SandboxIdentity.HoldsCommitVerification(v) { + return NewErrValidationStr("the attestation records a commit verification that the sandbox identity of its workflow run does not hold") + } + } + + return nil +} + // validateAgainstContract rejects an attestation that does not satisfy the // contract revision pinned on the workflow run when it was initialized. // @@ -589,6 +634,10 @@ func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bu } } + if err := validateSandboxRunnerInEnvelope(run, dsseEnv, predicate); err != nil { + return nil, err + } + if err := uc.validateAgainstContract(ctx, run, predicate); err != nil { return nil, err } diff --git a/app/controlplane/pkg/biz/workflowrun_integration_test.go b/app/controlplane/pkg/biz/workflowrun_integration_test.go index 36309e509..f4c02155c 100644 --- a/app/controlplane/pkg/biz/workflowrun_integration_test.go +++ b/app/controlplane/pkg/biz/workflowrun_integration_test.go @@ -30,6 +30,8 @@ import ( entProjectVersion "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/projectversion" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" "github.com/chainloop-dev/chainloop/pkg/attestation" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" "github.com/chainloop-dev/chainloop/pkg/credentials" creds "github.com/chainloop-dev/chainloop/pkg/credentials/mocks" v2 "github.com/google/go-containerregistry/pkg/v1" @@ -527,6 +529,35 @@ func (s *workflowRunIntegrationTestSuite) TestCreate() { s.Equal(pv, run.ProjectVersion) }) + s.T().Run("records the sandbox identity", func(_ *testing.T) { + identity := &biz.SandboxIdentity{ + SandboxRun: "sandbox-run-1", ChainloopTokenID: uuid.New(), IssuedAt: time.Now().UTC().Truncate(time.Second), + Data: &sandboxidentity.Data{CommitVerification: &sandboxidentity.CommitVerification{ + Commit: "0123456789abcdef0123456789abcdef01234567", + Verification: &commitverification.CommitVerification{Attempted: true, Status: commitverification.VerificationStatusVerified, Platform: "github"}, + }}, + } + + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: schemav1.CraftingSchema_Runner_CHAINLOOP_SANDBOX.String(), SandboxIdentity: identity, + }) + s.Require().NoError(err) + + got, err := s.WorkflowRun.GetByIDInOrg(ctx, s.org.ID, run.ID.String()) + s.Require().NoError(err) + s.Equal(identity, got.SandboxIdentity) + + without, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + }) + s.Require().NoError(err) + + got, err = s.WorkflowRun.GetByIDInOrg(ctx, s.org.ID, without.ID.String()) + s.Require().NoError(err) + s.Nil(got.SandboxIdentity) + }) + s.T().Run("find or create version", func(_ *testing.T) { testCases := []struct { name string diff --git a/app/controlplane/pkg/biz/workflowrun_sandboxidentity_store_test.go b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_store_test.go new file mode 100644 index 000000000..b3a7466a8 --- /dev/null +++ b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_store_test.go @@ -0,0 +1,161 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz_test + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "encoding/pem" + "os" + "testing" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + repoM "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/mocks" + "github.com/chainloop-dev/chainloop/pkg/attestation" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" + "github.com/google/uuid" + "github.com/secure-systems-lab/go-securesystemslib/dsse" + protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" + v1 "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" +) + +// testBundleHead is the git.head commit of testdata/attestations/bundle.json +const testBundleHead = "25f40d9f4b911969c901377eb247f934dde21b9f" + +// newSignedSandboxBundle signs the test attestation as one of the sandbox runner. It records an +// authenticated runner, and a verification with the given status and algorithm for the git.head commit. +func newSignedSandboxBundle(t *testing.T, signing *biz.SigningUseCase, orgID, verificationStatus, algorithm string) []byte { + t.Helper() + + raw, err := os.ReadFile("testdata/attestations/bundle.json") + require.NoError(t, err) + env, err := attestation.DSSEEnvelopeFromBundleBytes(raw) + require.NoError(t, err) + payload, err := base64.StdEncoding.DecodeString(env.Payload) + require.NoError(t, err) + + var statement map[string]any + require.NoError(t, json.Unmarshal(payload, &statement)) + + predicate := statement["predicate"].(map[string]any) + predicate["runnerType"] = schemaapi.CraftingSchema_Runner_CHAINLOOP_SANDBOX.String() + predicate["runnerAuthenticated"] = true + + for _, s := range statement["subject"].([]any) { + subject := s.(map[string]any) + if subject["name"] == "git.head" && verificationStatus != "" { + annotations := subject["annotations"].(map[string]any) + annotations["author.verification_status"] = verificationStatus + if algorithm != "" { + annotations["signature.algorithm"] = algorithm + } + } + } + + payload, err = json.Marshal(statement) + require.NoError(t, err) + + key, csr, err := createCSR() + require.NoError(t, err) + chain, err := signing.CreateSigningCert(context.Background(), orgID, csr) + require.NoError(t, err) + + digest := sha256.Sum256(dsse.PAE(env.PayloadType, payload)) + sig, err := ecdsa.SignASN1(rand.Reader, key, digest[:]) + require.NoError(t, err) + + bundle, err := attestation.BundleFromDSSEEnvelope(&dsse.Envelope{ + PayloadType: env.PayloadType, + Payload: base64.StdEncoding.EncodeToString(payload), + Signatures: []dsse.Signature{{Sig: base64.StdEncoding.EncodeToString(sig)}}, + }) + require.NoError(t, err) + + block, _ := pem.Decode([]byte(chain[0])) + require.NotNil(t, block) + bundle.VerificationMaterial.Content = &protobundle.VerificationMaterial_Certificate{Certificate: &v1.X509Certificate{RawBytes: block.Bytes}} + + out, err := protojson.Marshal(bundle) + require.NoError(t, err) + + return out +} + +// The control plane enforces the sandbox identity on the store path, for any CLI (R-006). +func TestValidateAttestationContractEnforcesSandboxIdentity(t *testing.T) { + const ( + verified = "verified" + commitClaims = "commit verification" + ) + + orgID := uuid.New() + signing := keylessSigningUseCase(t) + + holding := func(commit string) *biz.SandboxIdentity { + return &biz.SandboxIdentity{ChainloopTokenID: uuid.New(), Data: &sandboxidentity.Data{CommitVerification: &sandboxidentity.CommitVerification{ + Commit: commit, + Verification: &commitverification.CommitVerification{Attempted: true, Status: commitverification.VerificationStatusVerified, SignatureAlgorithm: "PGP"}, + }}} + } + + cases := []struct { + name string + identity *biz.SandboxIdentity + status string + algorithm string + // empty when the attestation passes the sandbox check and stops at the contract check + wantErr string + }{ + {name: "an authenticated runner without an identity", wantErr: "authenticated sandbox runner"}, + {name: "a commit verification that the identity holds", identity: holding(testBundleHead), status: verified, algorithm: "PGP"}, + {name: "a commit verification with another signature algorithm", identity: holding(testBundleHead), status: verified, algorithm: "SSH", wantErr: commitClaims}, + {name: "a commit verification for another commit", identity: holding("0123456789abcdef0123456789abcdef01234567"), status: verified, wantErr: commitClaims}, + {name: "a commit verification with another status", identity: holding(testBundleHead), status: "unverified", wantErr: commitClaims}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := repoM.NewWorkflowRunRepo(t) + uc, err := biz.NewWorkflowRunUseCase(&biz.WorkflowRunUseCaseOpts{WfrRepo: repo, SigningUC: signing}) + require.NoError(t, err) + + runID := uuid.New() + run := &biz.WorkflowRun{ID: runID, Workflow: &biz.Workflow{OrgID: orgID}, RunnerType: schemaapi.CraftingSchema_Runner_CHAINLOOP_SANDBOX.String(), SandboxIdentity: tc.identity} + repo.On("FindByID", mock.Anything, runID).Return(run, nil) + + err = uc.ValidateAttestationContract(context.Background(), runID.String(), newSignedSandboxBundle(t, signing, orgID.String(), tc.status, tc.algorithm)) + require.Error(t, err) + assert.True(t, biz.IsErrValidation(err), "unexpected error type: %v", err) + + if tc.wantErr == "" { + assert.Contains(t, err.Error(), "no contract revision") + return + } + + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} diff --git a/app/controlplane/pkg/biz/workflowrun_sandboxidentity_test.go b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_test.go new file mode 100644 index 000000000..6519188af --- /dev/null +++ b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_test.go @@ -0,0 +1,115 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz + +import ( + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" + intoto "github.com/in-toto/attestation/go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestValidateSandboxRunner(t *testing.T) { + const ( + commit = "0123456789abcdef0123456789abcdef01234567" + sandbox = "CHAINLOOP_SANDBOX" + github = "GITHUB_ACTION" + verified = "verified" + commitClaims = "commit verification" + ) + + identity := &SandboxIdentity{Data: &sandboxidentity.Data{CommitVerification: &sandboxidentity.CommitVerification{ + Commit: commit, + Verification: &commitverification.CommitVerification{Attempted: true, Status: commitverification.VerificationStatusVerified}, + }}} + + withAlgorithm := &SandboxIdentity{Data: &sandboxidentity.Data{CommitVerification: &sandboxidentity.CommitVerification{ + Commit: commit, + Verification: &commitverification.CommitVerification{Attempted: true, Status: commitverification.VerificationStatusVerified, SignatureAlgorithm: "PGP"}, + }}} + + // statement records a verification of the commit, and its algorithm after a "/" in status + statement := func(t *testing.T, commit, status string) *intoto.Statement { + t.Helper() + if commit == "" { + return &intoto.Statement{} + } + + fields := map[string]any{"author.verification_status": status} + if name, algorithm, ok := strings.Cut(status, "/"); ok { + fields = map[string]any{"author.verification_status": name, "signature.algorithm": algorithm} + } + + annotations, err := structpb.NewStruct(fields) + require.NoError(t, err) + + return &intoto.Statement{Subject: []*intoto.ResourceDescriptor{ + {Name: chainloop.SubjectGitHead, Digest: map[string]string{"sha1": commit}, Annotations: annotations}, + }} + } + + predicate := func(runnerType string, authenticated bool) chainloop.NormalizablePredicate { + return &chainloop.ProvenancePredicateV02{ProvenancePredicateCommon: &chainloop.ProvenancePredicateCommon{ + RunnerType: runnerType, RunnerAuthenticated: authenticated, + }} + } + + testCases := []struct { + name string + runRunner string + identity *SandboxIdentity + attestedRunner string + authenticated bool + commit, status string + wantErrContains string + }{ + {name: "a CI runner keeps its behavior", runRunner: github, attestedRunner: github, authenticated: true, commit: commit, status: verified}, + {name: "a sandbox runner that claims nothing", runRunner: sandbox, attestedRunner: sandbox}, + {name: "an authenticated sandbox runner with a verified identity", runRunner: sandbox, identity: identity, attestedRunner: sandbox, authenticated: true, commit: commit, status: verified}, + {name: "an authenticated sandbox runner without an identity", runRunner: sandbox, attestedRunner: sandbox, authenticated: true, wantErrContains: "authenticated"}, + {name: "a commit verification without an identity", runRunner: sandbox, attestedRunner: sandbox, commit: commit, status: verified, wantErrContains: commitClaims}, + {name: "a commit verification for another commit", runRunner: sandbox, identity: identity, attestedRunner: sandbox, authenticated: true, commit: "fedcba9876543210fedcba9876543210fedcba98", status: verified, wantErrContains: commitClaims}, + {name: "a commit verification with another status", runRunner: sandbox, identity: identity, attestedRunner: sandbox, authenticated: true, commit: commit, status: "unverified", wantErrContains: commitClaims}, + {name: "a sandbox run that attests another runner", runRunner: sandbox, attestedRunner: github, authenticated: true, wantErrContains: "authenticated"}, + {name: "an attestation that claims the sandbox runner in another run", runRunner: github, attestedRunner: sandbox, commit: commit, status: verified, wantErrContains: commitClaims}, + {name: "the signature algorithm of the identity", runRunner: sandbox, identity: withAlgorithm, attestedRunner: sandbox, authenticated: true, commit: commit, status: verified + "/PGP"}, + {name: "another signature algorithm than the identity", runRunner: sandbox, identity: withAlgorithm, attestedRunner: sandbox, authenticated: true, commit: commit, status: verified + "/SSH", wantErrContains: commitClaims}, + {name: "no signature algorithm, but the identity has one", runRunner: sandbox, identity: withAlgorithm, attestedRunner: sandbox, authenticated: true, commit: commit, status: verified, wantErrContains: commitClaims}, + {name: "an identity without an algorithm accepts the one of the commit signature", runRunner: sandbox, identity: identity, attestedRunner: sandbox, authenticated: true, commit: commit, status: verified + "/SSH"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + run := &WorkflowRun{RunnerType: tc.runRunner, SandboxIdentity: tc.identity} + + err := validateSandboxRunner(run, statement(t, tc.commit, tc.status), predicate(tc.attestedRunner, tc.authenticated)) + if tc.wantErrContains == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.True(t, IsErrValidation(err)) + assert.Contains(t, err.Error(), tc.wantErrContains) + }) + } +} diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20261016133553.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20261016133553.sql new file mode 100644 index 000000000..84dce66f7 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20261016133553.sql @@ -0,0 +1,2 @@ +-- Modify "workflow_runs" table +ALTER TABLE "workflow_runs" ADD COLUMN "sandbox_identity" jsonb NULL; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index ad67be2ba..472c26eb2 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:sz67a3W+tAr+VPRPPJ48+ancduf0og/7OrQnz+ECdyk= +h1:ncfijYWZyxLr04F0SOLyWAdLuLkRLgRTh3foEf0WqIQ= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -144,3 +144,4 @@ h1:sz67a3W+tAr+VPRPPJ48+ancduf0og/7OrQnz+ECdyk= 20260929114227.sql h1:oulkhKH6cKhFva/5mYx6aSaFarM6YgeuTh0Plw8X38E= 20260930160057.sql h1:rKEj/76c/TiBjl/loP4Bld1BPMPTBlCWhaWvN/pJaRY= 20261015173225.sql h1:s5wTFJqcXx+cGUPpzyY93wP/IuizLaQ/9rP/VqtamRE= +20261016133553.sql h1:ixibHERE/niLJdMVq4DQELyjzXogdMPZ1mrd14M96cE= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 2f9a38801..c3c50fb3d 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -837,6 +837,7 @@ var ( {Name: "policy_violations_count", Type: field.TypeInt32, Nullable: true}, {Name: "policy_violations_suppressed", Type: field.TypeInt32, Nullable: true}, {Name: "policy_has_gates", Type: field.TypeBool, Nullable: true}, + {Name: "sandbox_identity", Type: field.TypeJSON, Nullable: true}, {Name: "organization_id", Type: field.TypeUUID}, {Name: "version_id", Type: field.TypeUUID}, {Name: "workflow_id", Type: field.TypeUUID}, @@ -850,25 +851,25 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "workflow_runs_organizations_workflowruns", - Columns: []*schema.Column{WorkflowRunsColumns[20]}, + Columns: []*schema.Column{WorkflowRunsColumns[21]}, RefColumns: []*schema.Column{OrganizationsColumns[0]}, OnDelete: schema.Cascade, }, { Symbol: "workflow_runs_project_versions_runs", - Columns: []*schema.Column{WorkflowRunsColumns[21]}, + Columns: []*schema.Column{WorkflowRunsColumns[22]}, RefColumns: []*schema.Column{ProjectVersionsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "workflow_runs_workflows_workflowruns", - Columns: []*schema.Column{WorkflowRunsColumns[22]}, + Columns: []*schema.Column{WorkflowRunsColumns[23]}, RefColumns: []*schema.Column{WorkflowsColumns[0]}, OnDelete: schema.Cascade, }, { Symbol: "workflow_runs_workflow_contract_versions_contract_version", - Columns: []*schema.Column{WorkflowRunsColumns[23]}, + Columns: []*schema.Column{WorkflowRunsColumns[24]}, RefColumns: []*schema.Column{WorkflowContractVersionsColumns[0]}, OnDelete: schema.Cascade, }, @@ -887,7 +888,7 @@ var ( { Name: "workflowrun_workflow_id_created_at", Unique: false, - Columns: []*schema.Column{WorkflowRunsColumns[22], WorkflowRunsColumns[1]}, + Columns: []*schema.Column{WorkflowRunsColumns[23], WorkflowRunsColumns[1]}, Annotation: &entsql.IndexAnnotation{ DescColumns: map[string]bool{ WorkflowRunsColumns[1].Name: true, @@ -897,7 +898,7 @@ var ( { Name: "workflowrun_workflow_id_state_created_at", Unique: false, - Columns: []*schema.Column{WorkflowRunsColumns[22], WorkflowRunsColumns[3], WorkflowRunsColumns[1]}, + Columns: []*schema.Column{WorkflowRunsColumns[23], WorkflowRunsColumns[3], WorkflowRunsColumns[1]}, Annotation: &entsql.IndexAnnotation{ DescColumns: map[string]bool{ WorkflowRunsColumns[1].Name: true, @@ -907,7 +908,7 @@ var ( { Name: "workflowrun_organization_id_created_at", Unique: false, - Columns: []*schema.Column{WorkflowRunsColumns[20], WorkflowRunsColumns[1]}, + Columns: []*schema.Column{WorkflowRunsColumns[21], WorkflowRunsColumns[1]}, Annotation: &entsql.IndexAnnotation{ DescColumns: map[string]bool{ WorkflowRunsColumns[1].Name: true, @@ -932,12 +933,12 @@ var ( { Name: "workflowrun_workflow_id", Unique: false, - Columns: []*schema.Column{WorkflowRunsColumns[22]}, + Columns: []*schema.Column{WorkflowRunsColumns[23]}, }, { Name: "workflowrun_version_id_workflow_id", Unique: false, - Columns: []*schema.Column{WorkflowRunsColumns[21], WorkflowRunsColumns[22]}, + Columns: []*schema.Column{WorkflowRunsColumns[22], WorkflowRunsColumns[23]}, }, { Name: "workflowrun_policy_status", diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index 8a7ef8aed..460ed0e59 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -19562,6 +19562,7 @@ type WorkflowRunMutation struct { policy_violations_suppressed *int32 addpolicy_violations_suppressed *int32 policy_has_gates *bool + sandbox_identity **biz.SandboxIdentity clearedFields map[string]struct{} workflow *uuid.UUID clearedworkflow bool @@ -20817,6 +20818,55 @@ func (m *WorkflowRunMutation) ResetPolicyHasGates() { delete(m.clearedFields, workflowrun.FieldPolicyHasGates) } +// SetSandboxIdentity sets the "sandbox_identity" field. +func (m *WorkflowRunMutation) SetSandboxIdentity(bi *biz.SandboxIdentity) { + m.sandbox_identity = &bi +} + +// SandboxIdentity returns the value of the "sandbox_identity" field in the mutation. +func (m *WorkflowRunMutation) SandboxIdentity() (r *biz.SandboxIdentity, exists bool) { + v := m.sandbox_identity + if v == nil { + return + } + return *v, true +} + +// OldSandboxIdentity returns the old "sandbox_identity" field's value of the WorkflowRun entity. +// If the WorkflowRun object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *WorkflowRunMutation) OldSandboxIdentity(ctx context.Context) (v *biz.SandboxIdentity, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSandboxIdentity is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSandboxIdentity requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSandboxIdentity: %w", err) + } + return oldValue.SandboxIdentity, nil +} + +// ClearSandboxIdentity clears the value of the "sandbox_identity" field. +func (m *WorkflowRunMutation) ClearSandboxIdentity() { + m.sandbox_identity = nil + m.clearedFields[workflowrun.FieldSandboxIdentity] = struct{}{} +} + +// SandboxIdentityCleared returns if the "sandbox_identity" field was cleared in this mutation. +func (m *WorkflowRunMutation) SandboxIdentityCleared() bool { + _, ok := m.clearedFields[workflowrun.FieldSandboxIdentity] + return ok +} + +// ResetSandboxIdentity resets all changes to the "sandbox_identity" field. +func (m *WorkflowRunMutation) ResetSandboxIdentity() { + m.sandbox_identity = nil + delete(m.clearedFields, workflowrun.FieldSandboxIdentity) +} + // ClearWorkflow clears the "workflow" edge to the Workflow entity. func (m *WorkflowRunMutation) ClearWorkflow() { m.clearedworkflow = true @@ -21064,7 +21114,7 @@ func (m *WorkflowRunMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *WorkflowRunMutation) Fields() []string { - fields := make([]string, 0, 22) + fields := make([]string, 0, 23) if m.created_at != nil { fields = append(fields, workflowrun.FieldCreatedAt) } @@ -21131,6 +21181,9 @@ func (m *WorkflowRunMutation) Fields() []string { if m.policy_has_gates != nil { fields = append(fields, workflowrun.FieldPolicyHasGates) } + if m.sandbox_identity != nil { + fields = append(fields, workflowrun.FieldSandboxIdentity) + } return fields } @@ -21183,6 +21236,8 @@ func (m *WorkflowRunMutation) Field(name string) (ent.Value, bool) { return m.PolicyViolationsSuppressed() case workflowrun.FieldPolicyHasGates: return m.PolicyHasGates() + case workflowrun.FieldSandboxIdentity: + return m.SandboxIdentity() } return nil, false } @@ -21236,6 +21291,8 @@ func (m *WorkflowRunMutation) OldField(ctx context.Context, name string) (ent.Va return m.OldPolicyViolationsSuppressed(ctx) case workflowrun.FieldPolicyHasGates: return m.OldPolicyHasGates(ctx) + case workflowrun.FieldSandboxIdentity: + return m.OldSandboxIdentity(ctx) } return nil, fmt.Errorf("unknown WorkflowRun field %s", name) } @@ -21399,6 +21456,13 @@ func (m *WorkflowRunMutation) SetField(name string, value ent.Value) error { } m.SetPolicyHasGates(v) return nil + case workflowrun.FieldSandboxIdentity: + v, ok := value.(*biz.SandboxIdentity) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSandboxIdentity(v) + return nil } return fmt.Errorf("unknown WorkflowRun field %s", name) } @@ -21561,6 +21625,9 @@ func (m *WorkflowRunMutation) ClearedFields() []string { if m.FieldCleared(workflowrun.FieldPolicyHasGates) { fields = append(fields, workflowrun.FieldPolicyHasGates) } + if m.FieldCleared(workflowrun.FieldSandboxIdentity) { + fields = append(fields, workflowrun.FieldSandboxIdentity) + } return fields } @@ -21620,6 +21687,9 @@ func (m *WorkflowRunMutation) ClearField(name string) error { case workflowrun.FieldPolicyHasGates: m.ClearPolicyHasGates() return nil + case workflowrun.FieldSandboxIdentity: + m.ClearSandboxIdentity() + return nil } return fmt.Errorf("unknown WorkflowRun nullable field %s", name) } @@ -21694,6 +21764,9 @@ func (m *WorkflowRunMutation) ResetField(name string) error { case workflowrun.FieldPolicyHasGates: m.ResetPolicyHasGates() return nil + case workflowrun.FieldSandboxIdentity: + m.ResetSandboxIdentity() + return nil } return fmt.Errorf("unknown WorkflowRun field %s", name) } diff --git a/app/controlplane/pkg/data/ent/schema/workflowrun.go b/app/controlplane/pkg/data/ent/schema/workflowrun.go index 98bbac6ea..5af12cd09 100644 --- a/app/controlplane/pkg/data/ent/schema/workflowrun.go +++ b/app/controlplane/pkg/data/ent/schema/workflowrun.go @@ -80,6 +80,8 @@ func (WorkflowRun) Fields() []ent.Field { field.Int32("policy_violations_count").Optional().Nillable(), field.Int32("policy_violations_suppressed").Optional().Nillable(), field.Bool("policy_has_gates").Optional().Nillable(), + // The verified identity of the Chainloop sandbox that started the run, if any + field.JSON("sandbox_identity", &biz.SandboxIdentity{}).Optional(), } } diff --git a/app/controlplane/pkg/data/ent/workflowrun.go b/app/controlplane/pkg/data/ent/workflowrun.go index e67a2a273..212979a7d 100644 --- a/app/controlplane/pkg/data/ent/workflowrun.go +++ b/app/controlplane/pkg/data/ent/workflowrun.go @@ -70,6 +70,8 @@ type WorkflowRun struct { PolicyViolationsSuppressed *int32 `json:"policy_violations_suppressed,omitempty"` // PolicyHasGates holds the value of the "policy_has_gates" field. PolicyHasGates *bool `json:"policy_has_gates,omitempty"` + // SandboxIdentity holds the value of the "sandbox_identity" field. + SandboxIdentity *biz.SandboxIdentity `json:"sandbox_identity,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the WorkflowRunQuery when eager-loading is set. Edges WorkflowRunEdges `json:"edges"` @@ -165,7 +167,7 @@ func (*WorkflowRun) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case workflowrun.FieldAttestation, workflowrun.FieldAttestationState: + case workflowrun.FieldAttestation, workflowrun.FieldAttestationState, workflowrun.FieldSandboxIdentity: values[i] = new([]byte) case workflowrun.FieldHasPolicyViolations, workflowrun.FieldPolicyHasGates: values[i] = new(sql.NullBool) @@ -342,6 +344,14 @@ func (_m *WorkflowRun) assignValues(columns []string, values []any) error { _m.PolicyHasGates = new(bool) *_m.PolicyHasGates = value.Bool } + case workflowrun.FieldSandboxIdentity: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field sandbox_identity", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.SandboxIdentity); err != nil { + return fmt.Errorf("unmarshal field sandbox_identity: %w", err) + } + } case workflowrun.ForeignKeys[0]: if value, ok := values[i].(*sql.NullScanner); !ok { return fmt.Errorf("unexpected type %T for field workflow_run_contract_version", values[i]) @@ -496,6 +506,9 @@ func (_m *WorkflowRun) String() string { builder.WriteString("policy_has_gates=") builder.WriteString(fmt.Sprintf("%v", *v)) } + builder.WriteString(", ") + builder.WriteString("sandbox_identity=") + builder.WriteString(fmt.Sprintf("%v", _m.SandboxIdentity)) builder.WriteByte(')') return builder.String() } diff --git a/app/controlplane/pkg/data/ent/workflowrun/where.go b/app/controlplane/pkg/data/ent/workflowrun/where.go index 0fc4add6c..70c6a2e32 100644 --- a/app/controlplane/pkg/data/ent/workflowrun/where.go +++ b/app/controlplane/pkg/data/ent/workflowrun/where.go @@ -1092,6 +1092,16 @@ func PolicyHasGatesNotNil() predicate.WorkflowRun { return predicate.WorkflowRun(sql.FieldNotNull(FieldPolicyHasGates)) } +// SandboxIdentityIsNil applies the IsNil predicate on the "sandbox_identity" field. +func SandboxIdentityIsNil() predicate.WorkflowRun { + return predicate.WorkflowRun(sql.FieldIsNull(FieldSandboxIdentity)) +} + +// SandboxIdentityNotNil applies the NotNil predicate on the "sandbox_identity" field. +func SandboxIdentityNotNil() predicate.WorkflowRun { + return predicate.WorkflowRun(sql.FieldNotNull(FieldSandboxIdentity)) +} + // HasWorkflow applies the HasEdge predicate on the "workflow" edge. func HasWorkflow() predicate.WorkflowRun { return predicate.WorkflowRun(func(s *sql.Selector) { diff --git a/app/controlplane/pkg/data/ent/workflowrun/workflowrun.go b/app/controlplane/pkg/data/ent/workflowrun/workflowrun.go index 7fa381dda..b361b70c2 100644 --- a/app/controlplane/pkg/data/ent/workflowrun/workflowrun.go +++ b/app/controlplane/pkg/data/ent/workflowrun/workflowrun.go @@ -61,6 +61,8 @@ const ( FieldPolicyViolationsSuppressed = "policy_violations_suppressed" // FieldPolicyHasGates holds the string denoting the policy_has_gates field in the database. FieldPolicyHasGates = "policy_has_gates" + // FieldSandboxIdentity holds the string denoting the sandbox_identity field in the database. + FieldSandboxIdentity = "sandbox_identity" // EdgeWorkflow holds the string denoting the workflow edge name in mutations. EdgeWorkflow = "workflow" // EdgeOrganization holds the string denoting the organization edge name in mutations. @@ -142,6 +144,7 @@ var Columns = []string{ FieldPolicyViolationsCount, FieldPolicyViolationsSuppressed, FieldPolicyHasGates, + FieldSandboxIdentity, } // ForeignKeys holds the SQL foreign-keys that are owned by the "workflow_runs" diff --git a/app/controlplane/pkg/data/ent/workflowrun_create.go b/app/controlplane/pkg/data/ent/workflowrun_create.go index d2b1f353f..41ab29700 100644 --- a/app/controlplane/pkg/data/ent/workflowrun_create.go +++ b/app/controlplane/pkg/data/ent/workflowrun_create.go @@ -284,6 +284,12 @@ func (_c *WorkflowRunCreate) SetNillablePolicyHasGates(v *bool) *WorkflowRunCrea return _c } +// SetSandboxIdentity sets the "sandbox_identity" field. +func (_c *WorkflowRunCreate) SetSandboxIdentity(v *biz.SandboxIdentity) *WorkflowRunCreate { + _c.mutation.SetSandboxIdentity(v) + return _c +} + // SetID sets the "id" field. func (_c *WorkflowRunCreate) SetID(v uuid.UUID) *WorkflowRunCreate { _c.mutation.SetID(v) @@ -569,6 +575,10 @@ func (_c *WorkflowRunCreate) createSpec() (*WorkflowRun, *sqlgraph.CreateSpec) { _spec.SetField(workflowrun.FieldPolicyHasGates, field.TypeBool, value) _node.PolicyHasGates = &value } + if value, ok := _c.mutation.SandboxIdentity(); ok { + _spec.SetField(workflowrun.FieldSandboxIdentity, field.TypeJSON, value) + _node.SandboxIdentity = value + } if nodes := _c.mutation.WorkflowIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -1081,6 +1091,24 @@ func (u *WorkflowRunUpsert) ClearPolicyHasGates() *WorkflowRunUpsert { return u } +// SetSandboxIdentity sets the "sandbox_identity" field. +func (u *WorkflowRunUpsert) SetSandboxIdentity(v *biz.SandboxIdentity) *WorkflowRunUpsert { + u.Set(workflowrun.FieldSandboxIdentity, v) + return u +} + +// UpdateSandboxIdentity sets the "sandbox_identity" field to the value that was provided on create. +func (u *WorkflowRunUpsert) UpdateSandboxIdentity() *WorkflowRunUpsert { + u.SetExcluded(workflowrun.FieldSandboxIdentity) + return u +} + +// ClearSandboxIdentity clears the value of the "sandbox_identity" field. +func (u *WorkflowRunUpsert) ClearSandboxIdentity() *WorkflowRunUpsert { + u.SetNull(workflowrun.FieldSandboxIdentity) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create except the ID field. // Using this option is equivalent to using: // @@ -1558,6 +1586,27 @@ func (u *WorkflowRunUpsertOne) ClearPolicyHasGates() *WorkflowRunUpsertOne { }) } +// SetSandboxIdentity sets the "sandbox_identity" field. +func (u *WorkflowRunUpsertOne) SetSandboxIdentity(v *biz.SandboxIdentity) *WorkflowRunUpsertOne { + return u.Update(func(s *WorkflowRunUpsert) { + s.SetSandboxIdentity(v) + }) +} + +// UpdateSandboxIdentity sets the "sandbox_identity" field to the value that was provided on create. +func (u *WorkflowRunUpsertOne) UpdateSandboxIdentity() *WorkflowRunUpsertOne { + return u.Update(func(s *WorkflowRunUpsert) { + s.UpdateSandboxIdentity() + }) +} + +// ClearSandboxIdentity clears the value of the "sandbox_identity" field. +func (u *WorkflowRunUpsertOne) ClearSandboxIdentity() *WorkflowRunUpsertOne { + return u.Update(func(s *WorkflowRunUpsert) { + s.ClearSandboxIdentity() + }) +} + // Exec executes the query. func (u *WorkflowRunUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -2202,6 +2251,27 @@ func (u *WorkflowRunUpsertBulk) ClearPolicyHasGates() *WorkflowRunUpsertBulk { }) } +// SetSandboxIdentity sets the "sandbox_identity" field. +func (u *WorkflowRunUpsertBulk) SetSandboxIdentity(v *biz.SandboxIdentity) *WorkflowRunUpsertBulk { + return u.Update(func(s *WorkflowRunUpsert) { + s.SetSandboxIdentity(v) + }) +} + +// UpdateSandboxIdentity sets the "sandbox_identity" field to the value that was provided on create. +func (u *WorkflowRunUpsertBulk) UpdateSandboxIdentity() *WorkflowRunUpsertBulk { + return u.Update(func(s *WorkflowRunUpsert) { + s.UpdateSandboxIdentity() + }) +} + +// ClearSandboxIdentity clears the value of the "sandbox_identity" field. +func (u *WorkflowRunUpsertBulk) ClearSandboxIdentity() *WorkflowRunUpsertBulk { + return u.Update(func(s *WorkflowRunUpsert) { + s.ClearSandboxIdentity() + }) +} + // Exec executes the query. func (u *WorkflowRunUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/app/controlplane/pkg/data/ent/workflowrun_update.go b/app/controlplane/pkg/data/ent/workflowrun_update.go index cfa86cf40..b074f15e3 100644 --- a/app/controlplane/pkg/data/ent/workflowrun_update.go +++ b/app/controlplane/pkg/data/ent/workflowrun_update.go @@ -425,6 +425,18 @@ func (_u *WorkflowRunUpdate) ClearPolicyHasGates() *WorkflowRunUpdate { return _u } +// SetSandboxIdentity sets the "sandbox_identity" field. +func (_u *WorkflowRunUpdate) SetSandboxIdentity(v *biz.SandboxIdentity) *WorkflowRunUpdate { + _u.mutation.SetSandboxIdentity(v) + return _u +} + +// ClearSandboxIdentity clears the value of the "sandbox_identity" field. +func (_u *WorkflowRunUpdate) ClearSandboxIdentity() *WorkflowRunUpdate { + _u.mutation.ClearSandboxIdentity() + return _u +} + // SetContractVersionID sets the "contract_version" edge to the WorkflowContractVersion entity by ID. func (_u *WorkflowRunUpdate) SetContractVersionID(id uuid.UUID) *WorkflowRunUpdate { _u.mutation.SetContractVersionID(id) @@ -716,6 +728,12 @@ func (_u *WorkflowRunUpdate) sqlSave(ctx context.Context) (_node int, err error) if _u.mutation.PolicyHasGatesCleared() { _spec.ClearField(workflowrun.FieldPolicyHasGates, field.TypeBool) } + if value, ok := _u.mutation.SandboxIdentity(); ok { + _spec.SetField(workflowrun.FieldSandboxIdentity, field.TypeJSON, value) + } + if _u.mutation.SandboxIdentityCleared() { + _spec.ClearField(workflowrun.FieldSandboxIdentity, field.TypeJSON) + } if _u.mutation.ContractVersionCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -1259,6 +1277,18 @@ func (_u *WorkflowRunUpdateOne) ClearPolicyHasGates() *WorkflowRunUpdateOne { return _u } +// SetSandboxIdentity sets the "sandbox_identity" field. +func (_u *WorkflowRunUpdateOne) SetSandboxIdentity(v *biz.SandboxIdentity) *WorkflowRunUpdateOne { + _u.mutation.SetSandboxIdentity(v) + return _u +} + +// ClearSandboxIdentity clears the value of the "sandbox_identity" field. +func (_u *WorkflowRunUpdateOne) ClearSandboxIdentity() *WorkflowRunUpdateOne { + _u.mutation.ClearSandboxIdentity() + return _u +} + // SetContractVersionID sets the "contract_version" edge to the WorkflowContractVersion entity by ID. func (_u *WorkflowRunUpdateOne) SetContractVersionID(id uuid.UUID) *WorkflowRunUpdateOne { _u.mutation.SetContractVersionID(id) @@ -1580,6 +1610,12 @@ func (_u *WorkflowRunUpdateOne) sqlSave(ctx context.Context) (_node *WorkflowRun if _u.mutation.PolicyHasGatesCleared() { _spec.ClearField(workflowrun.FieldPolicyHasGates, field.TypeBool) } + if value, ok := _u.mutation.SandboxIdentity(); ok { + _spec.SetField(workflowrun.FieldSandboxIdentity, field.TypeJSON, value) + } + if _u.mutation.SandboxIdentityCleared() { + _spec.ClearField(workflowrun.FieldSandboxIdentity, field.TypeJSON) + } if _u.mutation.ContractVersionCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, diff --git a/app/controlplane/pkg/data/workflowrun.go b/app/controlplane/pkg/data/workflowrun.go index b761d1d06..4ab7c3c7d 100644 --- a/app/controlplane/pkg/data/workflowrun.go +++ b/app/controlplane/pkg/data/workflowrun.go @@ -129,7 +129,7 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC } // Create workflow run - p, err = tx.WorkflowRun.Create(). + create := tx.WorkflowRun.Create(). SetWorkflowID(opts.WorkflowID). SetOrganizationID(wf.OrganizationID). SetVersionID(version.ID). @@ -138,8 +138,14 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC SetRunnerType(opts.RunnerType). AddCasBackendIDs(opts.Backends...). SetContractRevisionLatest(opts.LatestRevision). - SetContractRevisionUsed(opts.UsedRevision). - Save(ctx) + SetContractRevisionUsed(opts.UsedRevision) + + // Leave the column NULL for runs without a sandbox identity, ent would store a JSON null + if opts.SandboxIdentity != nil { + create.SetSandboxIdentity(opts.SandboxIdentity) + } + + p, err = create.Save(ctx) if err != nil { return err } @@ -580,6 +586,7 @@ func entWrToBizWr(ctx context.Context, wr *ent.WorkflowRun) (*biz.WorkflowRun, e ContractRevisionLatest: wr.ContractRevisionLatest, HasPolicyViolations: wr.HasPolicyViolations, PolicyStatus: entWrPolicySummary(wr), + SandboxIdentity: wr.SandboxIdentity, Attestation: &biz.Attestation{ Digest: wr.AttestationDigest, }, diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index c2a6345b5..079f37eaf 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -278,7 +278,17 @@ func (c *Crafter) initCraftingStateFile(ctx context.Context, opts *InitOpts) err c.Logger.Debug().Str("state", c.stateManager.Info(ctx, opts.AttestationID)).Msg("created state file") - return c.LoadCraftingState(ctx, opts.AttestationID) + if err := c.LoadCraftingState(ctx, opts.AttestationID); err != nil { + return err + } + + // Loading the state makes a new runner. Keep the runner of the init when it can carry a + // verified identity, because only the init sets it, and the runner info is resolved next. + if _, ok := opts.Runner.(IdentityRunner); ok && opts.Runner.ID() == c.Runner.ID() { + c.Runner = opts.Runner + } + + return nil } // Reset removes the current crafting state diff --git a/pkg/attestation/crafter/crafter_sandboxidentity_test.go b/pkg/attestation/crafter/crafter_sandboxidentity_test.go new file mode 100644 index 000000000..ccb28abd4 --- /dev/null +++ b/pkg/attestation/crafter/crafter_sandboxidentity_test.go @@ -0,0 +1,88 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crafter_test + +import ( + "context" + "fmt" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" + v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" + "github.com/rs/zerolog" +) + +// The verified identity of the sandbox runner must reach the persisted attestation state: an +// authenticated runner and the commit verification of git.head (R-004, R-005). +func (s *crafterSuite) TestInitPersistsTheSandboxIdentity() { + s.T().Setenv("CHAINLOOP_SANDBOX", "1") + + verifiedIdentity := &sandboxidentity.Identity{Claims: &sandboxidentity.Claims{Data: &sandboxidentity.Data{ + CommitVerification: &sandboxidentity.CommitVerification{ + Commit: s.repoHead, + Verification: &commitverification.CommitVerification{Attempted: true, Status: commitverification.VerificationStatusVerified, Platform: "github"}, + }, + }}} + + testCases := []struct { + name string + identity *sandboxidentity.Identity + wantAuth bool + }{ + {name: "with a verified identity", identity: verifiedIdentity, wantAuth: true}, + {name: "without a verified identity"}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + contract, err := loadSchema("testdata/contracts/empty_generic.yaml") + s.Require().NoError(err) + + statePath := fmt.Sprintf("%s/attestation.json", s.T().TempDir()) + c, err := crafter.NewCrafter(testingStateManager(s.T(), statePath), nil, crafter.WithWorkingDirPath(s.repoPath)) + s.Require().NoError(err) + + runner := runners.NewChainloopSandbox() + runner.SetVerifiedIdentity(tc.identity) + + ctx := context.Background() + s.Require().NoError(c.Init(ctx, &crafter.InitOpts{SchemaV1: contract, WfInfo: s.workflowMetadata, DryRun: true, Runner: runner})) + s.Require().NoError(c.ResolveEnvVars(ctx, "")) + + // Read the state back as the next commands of the attestation do + logger := zerolog.Nop() + loaded, err := crafter.NewCrafter(testingStateManager(s.T(), statePath), nil, crafter.WithLogger(&logger)) + s.Require().NoError(err) + s.Require().NoError(loaded.LoadCraftingState(ctx, "")) + + att := loaded.CraftingState.GetAttestation() + s.Equal(schemaapi.CraftingSchema_Runner_CHAINLOOP_SANDBOX, att.GetRunnerType()) + s.Equal(tc.wantAuth, att.GetRunnerEnvironment().GetAuthenticated()) + + verification := att.GetHead().GetPlatformVerification() + if !tc.wantAuth { + s.Nil(verification) + return + } + + s.Require().NotNil(verification) + s.Equal(v1.Commit_CommitVerification_verified, verification.GetStatus()) + }) + } +} diff --git a/pkg/attestation/crafter/runner.go b/pkg/attestation/crafter/runner.go index f55957dae..05a260f5f 100644 --- a/pkg/attestation/crafter/runner.go +++ b/pkg/attestation/crafter/runner.go @@ -26,6 +26,7 @@ import ( schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" "github.com/rs/zerolog" ) @@ -69,6 +70,17 @@ type SupportedRunner interface { FederatedToken() string } +// IdentityRunner is a runner that proves its identity with a token that the Chainloop platform +// signs. The runner is authenticated only after the token verifies. +type IdentityRunner interface { + // IdentityToken returns the identity token of the run, if any + IdentityToken() string + // SetVerifiedIdentity sets the identity after it verified + SetVerifiedIdentity(id *sandboxidentity.Identity) +} + +var _ IdentityRunner = (*runners.ChainloopSandbox)(nil) + type RunnerM map[schemaapi.CraftingSchema_Runner_RunnerType]SupportedRunner // timeoutCtx is a context with a 15-second timeout diff --git a/pkg/attestation/crafter/runners/chainloopsandbox.go b/pkg/attestation/crafter/runners/chainloopsandbox.go index 46435abc2..c72a851ad 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox.go @@ -21,12 +21,16 @@ import ( schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" ) const chainloopSandboxEnvVar = "CHAINLOOP_SANDBOX" +// ChainloopSandbox is the runner of a Chainloop sandbox. It is authenticated only after its +// identity token verifies, and it gives only the data of a verified identity. type ChainloopSandbox struct { *Generic + identity *sandboxidentity.Identity } func NewChainloopSandbox() *ChainloopSandbox { @@ -60,15 +64,29 @@ func (r *ChainloopSandbox) WorkflowFilePath() string { } func (r *ChainloopSandbox) IsAuthenticated() bool { - return false + return r.identity != nil +} + +// IdentityToken returns the identity token that the platform gave to the sandbox, if any. +func (r *ChainloopSandbox) IdentityToken() string { + return os.Getenv(sandboxidentity.EnvVar) +} + +// SetVerifiedIdentity sets the identity of the sandbox, after the CLI and the control plane +// verified it. A nil identity makes the runner not authenticated. +func (r *ChainloopSandbox) SetVerifiedIdentity(id *sandboxidentity.Identity) { + r.identity = id } func (r *ChainloopSandbox) Environment() RunnerEnvironment { return Unknown } -func (r *ChainloopSandbox) VerifyCommitSignature(_ context.Context, _ string) *commitverification.CommitVerification { - return nil +// VerifyCommitSignature returns the commit verification of the verified identity. The sandbox has +// no SCM token, so the platform verifies the commit and signs the result in the identity. It +// returns nil without a verified identity, and for a commit other than the full commitHash. +func (r *ChainloopSandbox) VerifyCommitSignature(_ context.Context, commitHash string) *commitverification.CommitVerification { + return r.identity.CommitVerification(commitHash) } func (r *ChainloopSandbox) Report(_ []byte, _ string) error { diff --git a/pkg/attestation/crafter/runners/chainloopsandbox_test.go b/pkg/attestation/crafter/runners/chainloopsandbox_test.go index 4ee653655..e286447ea 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox_test.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox_test.go @@ -16,8 +16,12 @@ package runners import ( + "context" + "strings" "testing" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/sandboxidentity" "github.com/stretchr/testify/suite" ) @@ -80,6 +84,20 @@ func (s *chainloopSandboxSuite) TestWorkflowFilePath() { func (s *chainloopSandboxSuite) TestIsAuthenticated() { s.False(s.runner.IsAuthenticated()) + + s.runner.SetVerifiedIdentity(&sandboxidentity.Identity{Claims: &sandboxidentity.Claims{}}) + s.True(s.runner.IsAuthenticated()) + + s.runner.SetVerifiedIdentity(nil) + s.False(s.runner.IsAuthenticated()) +} + +func (s *chainloopSandboxSuite) TestIdentityToken() { + s.T().Setenv(sandboxidentity.EnvVar, "") + s.Empty(s.runner.IdentityToken()) + + s.T().Setenv(sandboxidentity.EnvVar, "a.b.c") + s.Equal("a.b.c", s.runner.IdentityToken()) } func (s *chainloopSandboxSuite) TestEnvironment() { @@ -90,6 +108,52 @@ func (s *chainloopSandboxSuite) TestRunnerName() { s.Equal("CHAINLOOP_SANDBOX", s.runner.ID().String()) } +func (s *chainloopSandboxSuite) TestVerifyCommitSignature() { + const ( + commit = "ea3a974660a030f0314aa1505a64792bf2633b33" + otherCommit = "0d7bb4b1e7b3f5d3e5c5f0f37a2c9a1f7f8b9c10" + ) + + verified := &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusVerified, + Reason: "Commit signed and verified", + Platform: "gitlab", + KeyID: "1", + SignatureAlgorithm: "PGP", + } + + withData := &sandboxidentity.Identity{Claims: &sandboxidentity.Claims{Data: &sandboxidentity.Data{ + CommitVerification: &sandboxidentity.CommitVerification{Commit: commit, Verification: verified}, + }}} + + testCases := []struct { + name string + identity *sandboxidentity.Identity + commit string + want *commitverification.CommitVerification + }{ + {name: "no verified identity", commit: commit}, + {name: "the verification of the attested commit", identity: withData, commit: commit, want: verified}, + {name: "a commit hash in upper case", identity: withData, commit: strings.ToUpper(commit), want: verified}, + {name: "the verification of another commit", identity: withData, commit: otherCommit}, + {name: "an abbreviated commit hash", identity: withData, commit: commit[:7]}, + {name: "no commit to verify", identity: withData, commit: ""}, + {name: "an identity without data", identity: &sandboxidentity.Identity{Claims: &sandboxidentity.Claims{}}, commit: commit}, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + // A value set by hand, as the unsigned hand-off did, records no data (R-005) + t.Setenv("CHAINLOOP_SANDBOX_COMMIT_VERIFICATION", `{"commit":"`+commit+`","verification":{"attempted":true,"status":"verified"}}`) + + runner := NewChainloopSandbox() + runner.SetVerifiedIdentity(tc.identity) + s.Equal(tc.want, runner.VerifyCommitSignature(context.Background(), tc.commit)) + }) + } +} + func (s *chainloopSandboxSuite) SetupTest() { s.runner = NewChainloopSandbox() } diff --git a/pkg/attestation/crafter/runners/commitverification/commitverification.go b/pkg/attestation/crafter/runners/commitverification/commitverification.go index 773cf64f7..27948ca91 100644 --- a/pkg/attestation/crafter/runners/commitverification/commitverification.go +++ b/pkg/attestation/crafter/runners/commitverification/commitverification.go @@ -15,6 +15,8 @@ package commitverification +import "fmt" + // VerificationStatus represents the status of a commit signature verification type VerificationStatus int @@ -31,18 +33,60 @@ const ( VerificationStatusNotApplicable ) +// verificationStatusNames are the text forms of the statuses. They are the same +// as the status names in the attestation. +var verificationStatusNames = map[VerificationStatus]string{ + VerificationStatusUnspecified: "unspecified", + VerificationStatusVerified: "verified", + VerificationStatusUnverified: "unverified", + VerificationStatusUnavailable: "unavailable", + VerificationStatusNotApplicable: "not_applicable", +} + +// String returns the text form of the status. +func (s VerificationStatus) String() string { + if name, ok := verificationStatusNames[s]; ok { + return name + } + + return fmt.Sprintf("VerificationStatus(%d)", int(s)) +} + +// MarshalText encodes the status as its text form, so that JSON carries a name +// and not a number that depends on the order of the constants. +func (s VerificationStatus) MarshalText() ([]byte, error) { + name, ok := verificationStatusNames[s] + if !ok { + return nil, fmt.Errorf("unknown verification status %d", int(s)) + } + + return []byte(name), nil +} + +// UnmarshalText decodes the text form of a status. +func (s *VerificationStatus) UnmarshalText(text []byte) error { + for status, name := range verificationStatusNames { + if name == string(text) { + *s = status + return nil + } + } + + return fmt.Errorf("unknown verification status %q", string(text)) +} + // CommitVerification represents the result of a commit signature verification type CommitVerification struct { // Whether verification was attempted - Attempted bool + Attempted bool `json:"attempted"` // Verification status - Status VerificationStatus + Status VerificationStatus `json:"status"` // Human-readable reason for the status - Reason string + Reason string `json:"reason,omitempty"` // Platform that performed the verification (e.g., "github", "gitlab") - Platform string + Platform string `json:"platform,omitempty"` // Optional: The signing key ID if verified - KeyID string + KeyID string `json:"key_id,omitempty"` // Optional: The signature algorithm used - SignatureAlgorithm string + SignatureAlgorithm string `json:"signature_algorithm,omitempty"` } diff --git a/pkg/attestation/crafter/runners/commitverification/commitverification_test.go b/pkg/attestation/crafter/runners/commitverification/commitverification_test.go new file mode 100644 index 000000000..38b9f7a13 --- /dev/null +++ b/pkg/attestation/crafter/runners/commitverification/commitverification_test.go @@ -0,0 +1,79 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package commitverification + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVerificationStatusText(t *testing.T) { + testCases := []struct { + status VerificationStatus + text string + }{ + {VerificationStatusUnspecified, "unspecified"}, + {VerificationStatusVerified, "verified"}, + {VerificationStatusUnverified, "unverified"}, + {VerificationStatusUnavailable, "unavailable"}, + {VerificationStatusNotApplicable, "not_applicable"}, + } + + for _, tc := range testCases { + t.Run(tc.text, func(t *testing.T) { + got, err := tc.status.MarshalText() + require.NoError(t, err) + assert.Equal(t, tc.text, string(got)) + assert.Equal(t, tc.text, tc.status.String()) + + var parsed VerificationStatus + require.NoError(t, parsed.UnmarshalText([]byte(tc.text))) + assert.Equal(t, tc.status, parsed) + }) + } +} + +func TestVerificationStatusTextErrors(t *testing.T) { + _, err := VerificationStatus(99).MarshalText() + require.Error(t, err) + + var parsed VerificationStatus + require.Error(t, parsed.UnmarshalText([]byte("trusted"))) +} + +const signatureAlgorithmPGP = "PGP" + +func TestCommitVerificationJSON(t *testing.T) { + want := &CommitVerification{ + Attempted: true, + Status: VerificationStatusVerified, + Reason: "Commit signed and verified", + Platform: "gitlab", + KeyID: "1", + SignatureAlgorithm: signatureAlgorithmPGP, + } + + raw, err := json.Marshal(want) + require.NoError(t, err) + assert.JSONEq(t, `{"attempted":true,"status":"verified","reason":"Commit signed and verified","platform":"gitlab","key_id":"1","signature_algorithm":"PGP"}`, string(raw)) + + var got CommitVerification + require.NoError(t, json.Unmarshal(raw, &got)) + assert.Equal(t, want, &got) +} diff --git a/pkg/attestation/renderer/chainloop/chainloop.go b/pkg/attestation/renderer/chainloop/chainloop.go index 77ac9db52..1925becb6 100644 --- a/pkg/attestation/renderer/chainloop/chainloop.go +++ b/pkg/attestation/renderer/chainloop/chainloop.go @@ -44,6 +44,8 @@ type NormalizablePredicate interface { GetPolicyEvaluations() map[string][]*PolicyEvaluation GetPolicyEvaluationsRef() *PolicyEvaluationsRef GetPolicyEvaluationStatus() *PolicyEvaluationStatus + GetRunnerType() string + GetRunnerAuthenticated() bool } type PolicyEvaluationStatus struct { @@ -350,6 +352,49 @@ func (p *ProvenancePredicateCommon) GetMetadata() *Metadata { return p.Metadata } +func (p *ProvenancePredicateCommon) GetRunnerType() string { + return p.RunnerType +} + +// GetRunnerAuthenticated returns whether the attestation records an authenticated runner +func (p *ProvenancePredicateCommon) GetRunnerAuthenticated() bool { + return p.RunnerAuthenticated +} + +// GitHeadVerification is a commit verification that an attestation records for its git.head subject. +type GitHeadVerification struct { + // Commit is the hash of the commit + Commit string + // Status is the name of the verification status + Status string + // SignatureAlgorithm is the signature algorithm that the attestation records, if any + SignatureAlgorithm string +} + +// GitHeadVerifications returns the commit verifications that the git.head subjects of the +// statement record. A subject without a verification status is not returned. +func GitHeadVerifications(statement *intoto.Statement) []GitHeadVerification { + var res []GitHeadVerification + for _, subject := range statement.GetSubject() { + if subject.GetName() != SubjectGitHead { + continue + } + + status, ok := subject.GetAnnotations().GetFields()[subjectGitAnnotationAuthorVerificationStatus] + if !ok { + continue + } + + res = append(res, GitHeadVerification{ + Commit: subject.GetDigest()["sha1"], + Status: status.GetStringValue(), + SignatureAlgorithm: subject.GetAnnotations().GetFields()[subjectGitAnnotationSignatureAlgorithm].GetStringValue(), + }) + } + + return res +} + const ( // Subject names SubjectGitHead = "git.head" diff --git a/pkg/attestation/renderer/chainloop/githead_test.go b/pkg/attestation/renderer/chainloop/githead_test.go new file mode 100644 index 000000000..c06ef173e --- /dev/null +++ b/pkg/attestation/renderer/chainloop/githead_test.go @@ -0,0 +1,62 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chainloop + +import ( + "testing" + + intoto "github.com/in-toto/attestation/go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestGitHeadVerifications(t *testing.T) { + const ( + verified = "verified" + sha1 = "sha1" + pgp = "PGP" + ) + + annotations := func(t *testing.T, fields map[string]any) *structpb.Struct { + t.Helper() + s, err := structpb.NewStruct(fields) + require.NoError(t, err) + return s + } + + statement := &intoto.Statement{Subject: []*intoto.ResourceDescriptor{ + {Name: "workflow.wf"}, + {Name: SubjectGitHead, Digest: map[string]string{sha1: "c1"}, Annotations: annotations(t, map[string]any{subjectGitAnnotationAuthorEmail: "a@b"})}, + {Name: SubjectGitHead, Digest: map[string]string{sha1: "c2"}, Annotations: annotations(t, map[string]any{subjectGitAnnotationAuthorVerificationStatus: verified})}, + {Name: SubjectGitHead, Digest: map[string]string{sha1: "c3"}, Annotations: annotations(t, map[string]any{ + subjectGitAnnotationAuthorVerificationStatus: verified, subjectGitAnnotationSignatureAlgorithm: pgp, + })}, + }} + + got := GitHeadVerifications(statement) + assert.Equal(t, []GitHeadVerification{{Commit: "c2", Status: verified}, {Commit: "c3", Status: verified, SignatureAlgorithm: pgp}}, got) + assert.Empty(t, GitHeadVerifications(&intoto.Statement{})) + assert.Empty(t, GitHeadVerifications(nil)) +} + +func TestPredicateRunner(t *testing.T) { + p := &ProvenancePredicateV02{ProvenancePredicateCommon: &ProvenancePredicateCommon{RunnerType: "CHAINLOOP_SANDBOX", RunnerAuthenticated: true}} + + var normalizable NormalizablePredicate = p + assert.Equal(t, "CHAINLOOP_SANDBOX", normalizable.GetRunnerType()) + assert.True(t, normalizable.GetRunnerAuthenticated()) +} diff --git a/pkg/attestation/sandboxidentity/issuer.go b/pkg/attestation/sandboxidentity/issuer.go new file mode 100644 index 000000000..6d43df19e --- /dev/null +++ b/pkg/attestation/sandboxidentity/issuer.go @@ -0,0 +1,93 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandboxidentity + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "strings" + "time" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/sigstore/sigstore/pkg/cryptoutils" +) + +// Issuer issues identity tokens. The platform uses it each time it spawns a sandbox, with a +// client authenticated as a caller that holds the dedicated permission. +type Issuer struct { + client pb.SigningServiceClient +} + +// NewIssuer returns an issuer that gets its certificates from the signing service of the control plane. +func NewIssuer(client pb.SigningServiceClient) *Issuer { + return &Issuer{client: client} +} + +// Issue makes a new key, gets a certificate with the mark for it, and signs the claims. It sets +// the time of signing. The key is discarded after it signs, so it signs only this identity. +func (i *Issuer) Issue(ctx context.Context, claims *Claims) (string, error) { + if claims == nil { + return "", errors.New("claims are required") + } + + // Work on a copy, the time of signing is set below + toSign := *claims + toSign.IssuedAt = jwt.NewNumericDate(time.Now()) + if err := toSign.validate(); err != nil { + return "", err + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", fmt.Errorf("generating a key: %w", err) + } + + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: uuid.NewString()}}, key) + if err != nil { + return "", fmt.Errorf("generating a certificate request: %w", err) + } + + resp, err := i.client.GenerateSigningCert(ctx, &pb.GenerateSigningCertRequest{ + CertificateSigningRequest: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr}), + SandboxIdentityIssuer: true, + }) + if err != nil { + return "", fmt.Errorf("getting a certificate: %w", err) + } + + chain, err := cryptoutils.UnmarshalCertificatesFromPEM([]byte(strings.Join(resp.GetChain().GetCertificates(), "\n"))) + if err != nil { + return "", fmt.Errorf("parsing the certificate chain: %w", err) + } + + // A control plane without support for the mark ignores the request + if len(chain) == 0 || !HasMark(chain[0]) { + return "", errors.New("the control plane did not mark the certificate for a sandbox identity") + } + + toSign.IssuedAt = jwt.NewNumericDate(time.Now()) + + return Sign(key, chain, &toSign) +} diff --git a/pkg/attestation/sandboxidentity/issuer_test.go b/pkg/attestation/sandboxidentity/issuer_test.go new file mode 100644 index 000000000..873567b3d --- /dev/null +++ b/pkg/attestation/sandboxidentity/issuer_test.go @@ -0,0 +1,110 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandboxidentity + +import ( + "context" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "math/big" + "testing" + "time" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +// fakeSigningClient issues certificates from a test CA, as the signing service of the control +// plane does. markDenied simulates a control plane that ignores or refuses the mark. +type fakeSigningClient struct { + pb.SigningServiceClient + t *testing.T + ca *testCA + markDenied bool + err error + gotIssuer bool +} + +func (c *fakeSigningClient) GenerateSigningCert(_ context.Context, req *pb.GenerateSigningCertRequest, _ ...grpc.CallOption) (*pb.GenerateSigningCertResponse, error) { + c.gotIssuer = req.GetSandboxIdentityIssuer() + if c.err != nil { + return nil, c.err + } + + csr, err := cryptoutils.ParseCSR(req.GetCertificateSigningRequest()) + require.NoError(c.t, err) + + subject := pkix.Name{Organization: []string{testOrg}} + if req.GetSandboxIdentityIssuer() && !c.markDenied { + subject.OrganizationalUnit = []string{CertificateMark} + } + + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: subject, + NotBefore: time.Now().Add(-time.Second), NotAfter: time.Now().Add(10 * time.Minute), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + }, c.ca.cert, csr.PublicKey, c.ca.key) + require.NoError(c.t, err) + + leaf, err := cryptoutils.MarshalCertificateToPEM(&x509.Certificate{Raw: der}) + require.NoError(c.t, err) + root, err := cryptoutils.MarshalCertificateToPEM(c.ca.cert) + require.NoError(c.t, err) + + return &pb.GenerateSigningCertResponse{Chain: &pb.CertificateChain{Certificates: []string{string(leaf), string(root)}}}, nil +} + +func TestIssuer(t *testing.T) { + ca := newTestCA(t) + + t.Run("issues an identity that verifies", func(t *testing.T) { + client := &fakeSigningClient{t: t, ca: ca} + claims := validClaims(time.Time{}) + claims.IssuedAt = nil + + raw, err := NewIssuer(client).Issue(context.Background(), claims) + require.NoError(t, err) + assert.True(t, client.gotIssuer) + + id, err := Verify(raw, ca.trustedRoot(), validExpected()) + require.NoError(t, err) + assert.NotNil(t, id.CommitVerification(testCommit)) + assert.Nil(t, claims.IssuedAt, "the claims of the caller do not change") + }) + + t.Run("a certificate without the mark gives no identity", func(t *testing.T) { + _, err := NewIssuer(&fakeSigningClient{t: t, ca: ca, markDenied: true}).Issue(context.Background(), validClaims(time.Time{})) + require.Error(t, err) + }) + + t.Run("a control plane error gives no identity", func(t *testing.T) { + _, err := NewIssuer(&fakeSigningClient{t: t, ca: ca, err: errors.New("denied")}).Issue(context.Background(), validClaims(time.Time{})) + require.Error(t, err) + }) + + t.Run("invalid claims give no identity", func(t *testing.T) { + claims := validClaims(time.Time{}) + claims.ChainloopTokenID = "" + + _, err := NewIssuer(&fakeSigningClient{t: t, ca: ca}).Issue(context.Background(), claims) + require.Error(t, err) + }) +} diff --git a/pkg/attestation/sandboxidentity/sandboxidentity.go b/pkg/attestation/sandboxidentity/sandboxidentity.go new file mode 100644 index 000000000..ea0d0c35c --- /dev/null +++ b/pkg/attestation/sandboxidentity/sandboxidentity.go @@ -0,0 +1,316 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package sandboxidentity defines the identity token that the Chainloop platform gives to each +// Chainloop sandbox that it spawns, and how to sign and verify it. +// +// The token is a JWT signed with a new key for each sandbox. The key has a short-lived certificate +// from the control plane CA, and the certificate chain is in the x5c header. The CA marks the +// certificate only for a caller that holds the dedicated permission, so only the platform can sign +// an identity. The token has no expiry of its own: the control plane accepts it only together with +// the Chainloop token that it names, and that token expires. +package sandboxidentity + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "time" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/verifier" + "github.com/golang-jwt/jwt/v5" +) + +const ( + // EnvVar holds the identity token in the environment of the sandbox. + EnvVar = "CHAINLOOP_SANDBOX_IDENTITY" + + // CertificateMark is the value in the organizational unit of the certificate subject that + // says that the certificate can sign an identity. Only the CA writes the subject. + CertificateMark = "chainloop-sandbox-identity" + + // MaxClockSkew is the clock difference allowed between the platform and the CA when the time + // of signing is compared with the certificate lifetime. + MaxClockSkew = time.Minute + + x5cHeader = "x5c" +) + +// ErrInvalidIdentity is the error for an identity token that does not verify. +var ErrInvalidIdentity = errors.New("invalid sandbox identity") + +var ( + validMethods = []string{jwt.SigningMethodES256.Alg(), jwt.SigningMethodES384.Alg(), jwt.SigningMethodES512.Alg()} + fullCommitHash = regexp.MustCompile(`^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$`) +) + +// Claims are the claims of an identity token. +type Claims struct { + // RegisteredClaims holds the time of signing (iat). The token has no expiry. + jwt.RegisteredClaims + // OrganizationID is the organization of the attestation. + OrganizationID string `json:"org"` + // ProjectID is the project of the attested workflow. + ProjectID string `json:"project"` + // WorkflowID is the attested workflow. + WorkflowID string `json:"workflow"` + // SandboxRun identifies the sandbox run in the platform, so that a reader can trace an + // attestation to the run that made it. + SandboxRun string `json:"sandbox_run,omitempty"` + // ChainloopTokenID is the ID of the Chainloop API token that the platform gives to the sandbox. + // The control plane accepts the identity only from this token. + ChainloopTokenID string `json:"chainloop_token"` + // Data is the data that the platform gives to the attestation. + Data *Data `json:"data,omitempty"` +} + +// Data is the data of a sandbox run that the attestation can trust. +type Data struct { + // CommitVerification is the verification of the attested commit by its SCM provider. + CommitVerification *CommitVerification `json:"commit_verification,omitempty"` +} + +// CommitVerification is a commit verification result and the commit that it applies to. +type CommitVerification struct { + // Commit is the full hash of the verified commit. + Commit string `json:"commit"` + // Verification is the result of the verification. + Verification *commitverification.CommitVerification `json:"verification"` +} + +// Expected names the attestation that an identity must be for. +type Expected struct { + // OrganizationID is the organization of the attestation. When empty, it is not compared: the + // workflow belongs to one organization, and the CLI does not know the organization ID. + OrganizationID string + // ProjectID is the project of the attestation. Required. + ProjectID string + // WorkflowID is the workflow of the attestation. Required. + WorkflowID string +} + +// Identity is an identity token that verified. +type Identity struct { + // Claims are the claims of the token. + Claims *Claims + // Certificate is the certificate that signed the token. + Certificate *x509.Certificate +} + +// CommitVerification returns the commit verification of the identity, only when it is for the +// full commitHash. It returns nil for another or an abbreviated hash. +func (i *Identity) CommitVerification(commitHash string) *commitverification.CommitVerification { + if i == nil || i.Claims == nil || i.Claims.Data == nil || i.Claims.Data.CommitVerification == nil || commitHash == "" { + return nil + } + + cv := i.Claims.Data.CommitVerification + if !strings.EqualFold(cv.Commit, commitHash) { + return nil + } + + return cv.Verification +} + +// HasMark reports whether the certificate can sign an identity. +func HasMark(cert *x509.Certificate) bool { + return cert != nil && slices.Contains(cert.Subject.OrganizationalUnit, CertificateMark) +} + +// Sign signs the claims with the key, and puts the certificate chain, leaf first, in the header. +func Sign(key *ecdsa.PrivateKey, chain []*x509.Certificate, claims *Claims) (string, error) { + if key == nil { + return "", errors.New("a signing key is required") + } + + if len(chain) == 0 { + return "", errors.New("a certificate chain is required") + } + + if err := claims.validate(); err != nil { + return "", err + } + + var method jwt.SigningMethod + switch key.Curve { + case elliptic.P256(): + method = jwt.SigningMethodES256 + case elliptic.P384(): + method = jwt.SigningMethodES384 + case elliptic.P521(): + method = jwt.SigningMethodES512 + default: + return "", fmt.Errorf("unsupported curve %s", key.Curve.Params().Name) + } + + token := jwt.NewWithClaims(method, claims) + token.Header[x5cHeader] = encodeChain(chain) + + return token.SignedString(key) +} + +// Verify verifies an identity token against the trusted root of the control plane, and checks +// that it is for the expected attestation. Any error wraps ErrInvalidIdentity. +func Verify(raw string, tr *verifier.TrustedRoot, expected *Expected) (*Identity, error) { + id, err := verify(raw, tr, expected) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidIdentity, err) + } + + return id, nil +} + +func verify(raw string, tr *verifier.TrustedRoot, expected *Expected) (*Identity, error) { + if tr == nil { + return nil, errors.New("no trusted root") + } + + if expected == nil || expected.ProjectID == "" || expected.WorkflowID == "" { + return nil, errors.New("the expected project and workflow are required") + } + + var leaf *x509.Certificate + keyFunc := func(token *jwt.Token) (any, error) { + chain, err := decodeChain(token.Header[x5cHeader]) + if err != nil { + return nil, err + } + + leaf = chain[0] + // The chain in the header is not trusted. Only the trusted root validates the leaf. + if _, err := verifier.ValidateCertificate(leaf, tr); err != nil { + return nil, err + } + + if !HasMark(leaf) { + return nil, errors.New("the certificate cannot sign an identity") + } + + return leaf.PublicKey, nil + } + + claims := &Claims{} + if _, err := jwt.NewParser(jwt.WithValidMethods(validMethods)).ParseWithClaims(raw, claims, keyFunc); err != nil { + return nil, err + } + + if err := claims.validate(); err != nil { + return nil, err + } + + // The CA writes the organization of the caller in the certificate subject. + if len(leaf.Subject.Organization) != 1 || leaf.Subject.Organization[0] != claims.OrganizationID { + return nil, errors.New("the certificate is for another organization") + } + + // The certificate is short-lived and the key signs only once, so the time of signing must be + // inside the certificate lifetime. + iat := claims.IssuedAt.Time + if iat.Before(leaf.NotBefore.Add(-MaxClockSkew)) || iat.After(leaf.NotAfter.Add(MaxClockSkew)) { + return nil, errors.New("the time of signing is outside the certificate lifetime") + } + + if expected.OrganizationID != "" && claims.OrganizationID != expected.OrganizationID { + return nil, errors.New("the identity is for another organization") + } + + if claims.ProjectID != expected.ProjectID { + return nil, errors.New("the identity is for another project") + } + + if claims.WorkflowID != expected.WorkflowID { + return nil, errors.New("the identity is for another workflow") + } + + return &Identity{Claims: claims, Certificate: leaf}, nil +} + +func (c *Claims) validate() error { + if c == nil { + return errors.New("no claims") + } + + if c.IssuedAt == nil { + return errors.New("the time of signing is required") + } + + if c.OrganizationID == "" || c.ProjectID == "" || c.WorkflowID == "" { + return errors.New("the organization, the project and the workflow are required") + } + + if c.ChainloopTokenID == "" { + return errors.New("the Chainloop token is required") + } + + if c.Data == nil || c.Data.CommitVerification == nil { + return nil + } + + cv := c.Data.CommitVerification + if !fullCommitHash.MatchString(cv.Commit) { + return errors.New("the commit verification needs a full commit hash") + } + + if cv.Verification == nil || cv.Verification.Status == commitverification.VerificationStatusUnspecified { + return errors.New("the commit verification has no result") + } + + return nil +} + +func encodeChain(chain []*x509.Certificate) []string { + encoded := make([]string, 0, len(chain)) + for _, cert := range chain { + encoded = append(encoded, base64.StdEncoding.EncodeToString(cert.Raw)) + } + + return encoded +} + +func decodeChain(header any) ([]*x509.Certificate, error) { + values, ok := header.([]any) + if !ok || len(values) == 0 { + return nil, errors.New("the token has no certificate chain") + } + + chain := make([]*x509.Certificate, 0, len(values)) + for _, v := range values { + s, ok := v.(string) + if !ok { + return nil, errors.New("invalid certificate chain") + } + + der, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("decoding the certificate chain: %w", err) + } + + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("parsing the certificate chain: %w", err) + } + + chain = append(chain, cert) + } + + return chain, nil +} diff --git a/pkg/attestation/sandboxidentity/sandboxidentity_test.go b/pkg/attestation/sandboxidentity/sandboxidentity_test.go new file mode 100644 index 000000000..d1ad0f8ab --- /dev/null +++ b/pkg/attestation/sandboxidentity/sandboxidentity_test.go @@ -0,0 +1,355 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandboxidentity + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "math/big" + "strings" + "testing" + "time" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/chainloop-dev/chainloop/pkg/attestation/verifier" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testOrg = "5d0b4b7c-4b2e-4a51-9a4b-2f0a2b1f6e10" + testProject = "6e1c5c8d-5c3f-4b62-8b5c-3f1b3c2f7f21" + testWorkflow = "7f2d6d9e-6d4f-4c73-9c6d-4f2c4d3f8f32" + testToken = "8f3e7eaf-7e5f-4d84-8d7e-5f3d5e4f9f43" + testCommit = "0123456789abcdef0123456789abcdef01234567" +) + +// testCA is a certificate authority that issues short-lived code signing certificates, as the +// control plane CA does. +type testCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey +} + +func newTestCA(t *testing.T) *testCA { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test root"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + SubjectKeyId: []byte("test-root-key-id"), + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + return &testCA{cert: cert, key: key} +} + +func (ca *testCA) trustedRoot() *verifier.TrustedRoot { + sum := sha256.Sum256(ca.cert.SubjectKeyId) + + return &verifier.TrustedRoot{Keys: map[string][]*x509.Certificate{hex.EncodeToString(sum[:]): {ca.cert}}} +} + +// issue returns a leaf certificate for a new key, with the given subject and lifetime. +func (ca *testCA) issue(t *testing.T, subject pkix.Name, notBefore time.Time) (*ecdsa.PrivateKey, *x509.Certificate) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: subject, + NotBefore: notBefore, + NotAfter: notBefore.Add(10 * time.Minute), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + return key, cert +} + +func markedSubject(org string) pkix.Name { + return pkix.Name{Organization: []string{org}, OrganizationalUnit: []string{CertificateMark}} +} + +func validClaims(iat time.Time) *Claims { + return &Claims{ + RegisteredClaims: jwt.RegisteredClaims{IssuedAt: jwt.NewNumericDate(iat)}, + OrganizationID: testOrg, + ProjectID: testProject, + WorkflowID: testWorkflow, + SandboxRun: "sandbox-run-1", + ChainloopTokenID: testToken, + Data: &Data{CommitVerification: &CommitVerification{ + Commit: testCommit, + Verification: &commitverification.CommitVerification{ + Attempted: true, Status: commitverification.VerificationStatusVerified, Platform: "github", + }, + }}, + } +} + +func validExpected() *Expected { + return &Expected{OrganizationID: testOrg, ProjectID: testProject, WorkflowID: testWorkflow} +} + +func TestSignAndVerify(t *testing.T) { + ca := newTestCA(t) + now := time.Now() + key, leaf := ca.issue(t, markedSubject(testOrg), now.Add(-time.Minute)) + + raw, err := Sign(key, []*x509.Certificate{leaf, ca.cert}, validClaims(now)) + require.NoError(t, err) + + id, err := Verify(raw, ca.trustedRoot(), validExpected()) + require.NoError(t, err) + + assert.Equal(t, testToken, id.Claims.ChainloopTokenID) + assert.Equal(t, "sandbox-run-1", id.Claims.SandboxRun) + assert.Equal(t, leaf.SerialNumber, id.Certificate.SerialNumber) + + t.Run("the commit verification applies only to its full commit hash", func(t *testing.T) { + got := id.CommitVerification(strings.ToUpper(testCommit)) + require.NotNil(t, got) + assert.Equal(t, commitverification.VerificationStatusVerified, got.Status) + + assert.Nil(t, id.CommitVerification(testCommit[:12])) + assert.Nil(t, id.CommitVerification("fedcba9876543210fedcba9876543210fedcba98")) + assert.Nil(t, id.CommitVerification("")) + }) + + t.Run("the CLI does not know the organization", func(t *testing.T) { + expected := validExpected() + expected.OrganizationID = "" + + _, err := Verify(raw, ca.trustedRoot(), expected) + assert.NoError(t, err) + }) +} + +func TestVerifyRejects(t *testing.T) { + ca := newTestCA(t) + now := time.Now() + notBefore := now.Add(-time.Minute) + key, leaf := ca.issue(t, markedSubject(testOrg), notBefore) + + // sign does not check the claims, so that the verifier sees the invalid ones + sign := func(t *testing.T, key *ecdsa.PrivateKey, chain []*x509.Certificate, claims *Claims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header[x5cHeader] = encodeChain(chain) + raw, err := token.SignedString(key) + require.NoError(t, err) + return raw + } + + otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + unmarkedKey, unmarkedLeaf := ca.issue(t, pkix.Name{Organization: []string{testOrg}}, notBefore) + + otherCA := newTestCA(t) + otherCAKey, otherCALeaf := otherCA.issue(t, markedSubject(testOrg), notBefore) + + otherOrgKey, otherOrgLeaf := ca.issue(t, markedSubject("another-org"), notBefore) + + testCases := []struct { + name string + raw func(t *testing.T) string + expected *Expected + }{ + { + name: "a signature by another key", + raw: func(t *testing.T) string { return sign(t, otherKey, []*x509.Certificate{leaf}, validClaims(now)) }, + }, + { + name: "a certificate without the mark", + raw: func(t *testing.T) string { + return sign(t, unmarkedKey, []*x509.Certificate{unmarkedLeaf}, validClaims(now)) + }, + }, + { + name: "a certificate from an untrusted CA", + raw: func(t *testing.T) string { + return sign(t, otherCAKey, []*x509.Certificate{otherCALeaf}, validClaims(now)) + }, + }, + { + name: "a certificate for another organization than the token", + raw: func(t *testing.T) string { + return sign(t, otherOrgKey, []*x509.Certificate{otherOrgLeaf}, validClaims(now)) + }, + }, + { + name: "another workflow", + raw: func(t *testing.T) string { return sign(t, key, []*x509.Certificate{leaf}, validClaims(now)) }, + expected: &Expected{OrganizationID: testOrg, ProjectID: testProject, WorkflowID: "another-workflow"}, + }, + { + name: "another project", + raw: func(t *testing.T) string { return sign(t, key, []*x509.Certificate{leaf}, validClaims(now)) }, + expected: &Expected{OrganizationID: testOrg, ProjectID: "another-project", WorkflowID: testWorkflow}, + }, + { + name: "another organization than the attestation", + raw: func(t *testing.T) string { return sign(t, key, []*x509.Certificate{leaf}, validClaims(now)) }, + expected: &Expected{OrganizationID: "another-org", ProjectID: testProject, WorkflowID: testWorkflow}, + }, + { + name: "no expected workflow", + raw: func(t *testing.T) string { return sign(t, key, []*x509.Certificate{leaf}, validClaims(now)) }, + expected: &Expected{OrganizationID: testOrg, ProjectID: testProject}, + }, + { + name: "a time of signing before the certificate lifetime", + raw: func(t *testing.T) string { + return sign(t, key, []*x509.Certificate{leaf}, validClaims(notBefore.Add(-2*MaxClockSkew))) + }, + }, + { + name: "a time of signing after the certificate lifetime", + raw: func(t *testing.T) string { + return sign(t, key, []*x509.Certificate{leaf}, validClaims(leaf.NotAfter.Add(2*MaxClockSkew))) + }, + }, + { + name: "no time of signing", + raw: func(t *testing.T) string { + claims := validClaims(now) + claims.IssuedAt = nil + return sign(t, key, []*x509.Certificate{leaf}, claims) + }, + }, + { + name: "no certificate chain", + raw: func(t *testing.T) string { + raw, err := jwt.NewWithClaims(jwt.SigningMethodES256, validClaims(now)).SignedString(key) + require.NoError(t, err) + return raw + }, + }, + { + name: "a symmetric signature", + raw: func(t *testing.T) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, validClaims(now)) + token.Header[x5cHeader] = encodeChain([]*x509.Certificate{leaf}) + raw, err := token.SignedString([]byte("secret")) + require.NoError(t, err) + return raw + }, + }, + { + name: "an abbreviated commit hash", + raw: func(t *testing.T) string { + claims := validClaims(now) + claims.Data.CommitVerification.Commit = testCommit[:12] + return sign(t, key, []*x509.Certificate{leaf}, claims) + }, + }, + { + name: "a commit verification without a status", + raw: func(t *testing.T) string { + claims := validClaims(now) + claims.Data.CommitVerification.Verification.Status = commitverification.VerificationStatusUnspecified + return sign(t, key, []*x509.Certificate{leaf}, claims) + }, + }, + { + name: "a commit verification without a result", + raw: func(t *testing.T) string { + claims := validClaims(now) + claims.Data.CommitVerification.Verification = nil + return sign(t, key, []*x509.Certificate{leaf}, claims) + }, + }, + { + name: "no Chainloop token", + raw: func(t *testing.T) string { + claims := validClaims(now) + claims.ChainloopTokenID = "" + return sign(t, key, []*x509.Certificate{leaf}, claims) + }, + }, + { + name: "a value that is not a token", + raw: func(_ *testing.T) string { return "not-a-token" }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + expected := tc.expected + if expected == nil { + expected = validExpected() + } + + id, err := Verify(tc.raw(t), ca.trustedRoot(), expected) + require.ErrorIs(t, err, ErrInvalidIdentity) + assert.Nil(t, id) + }) + } + + t.Run("no trusted root", func(t *testing.T) { + _, err := Verify(sign(t, key, []*x509.Certificate{leaf}, validClaims(now)), nil, validExpected()) + require.ErrorIs(t, err, ErrInvalidIdentity) + }) +} + +func TestSignRejectsInvalidClaims(t *testing.T) { + ca := newTestCA(t) + key, leaf := ca.issue(t, markedSubject(testOrg), time.Now().Add(-time.Minute)) + + claims := validClaims(time.Now()) + claims.WorkflowID = "" + + _, err := Sign(key, []*x509.Certificate{leaf}, claims) + require.Error(t, err) + + _, err = Sign(key, nil, validClaims(time.Now())) + require.Error(t, err) +} + +func TestHasMark(t *testing.T) { + assert.True(t, HasMark(&x509.Certificate{Subject: markedSubject(testOrg)})) + assert.False(t, HasMark(&x509.Certificate{Subject: pkix.Name{Organization: []string{testOrg}}})) + assert.False(t, HasMark(nil)) +} diff --git a/pkg/attestation/verifier/verifier.go b/pkg/attestation/verifier/verifier.go index 48054144d..c8de388b4 100644 --- a/pkg/attestation/verifier/verifier.go +++ b/pkg/attestation/verifier/verifier.go @@ -28,6 +28,7 @@ import ( "github.com/sigstore/cosign/v3/pkg/cosign" protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" sigstorebundle "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore/pkg/signature" sigdsee "github.com/sigstore/sigstore/pkg/signature/dsse" "google.golang.org/protobuf/encoding/protojson" ) @@ -137,16 +138,9 @@ func checkCertOrganization(cert *x509.Certificate, expected string) error { // verifyCertSignature validates the signing certificate against the trusted root // chain and verifies the DSSE envelope signature with the certificate's key. func verifyCertSignature(ctx context.Context, bundle *protobundle.Bundle, signingCert *x509.Certificate, tr *TrustedRoot) error { - akiSum := sha256.Sum256(signingCert.AuthorityKeyId) - aki := hex.EncodeToString(akiSum[:]) - chain, ok := tr.Keys[aki] - if !ok { - return fmt.Errorf("trusted root not found for signing key with AKI %s", aki) - } - - verifier, err := cosign.ValidateAndUnpackCertWithChain(signingCert, chain, &cosign.CheckOpts{IgnoreSCT: true}) + verifier, err := ValidateCertificate(signingCert, tr) if err != nil { - return fmt.Errorf("validating the certificate: %w", err) + return err } dsseVerifier, err := dsse.NewEnvelopeVerifier(&sigdsee.VerifierAdapter{SignatureVerifier: verifier}) @@ -160,3 +154,26 @@ func verifyCertSignature(ctx context.Context, bundle *protobundle.Bundle, signin return nil } + +// ValidateCertificate validates a short-lived signing certificate against the chain of the trusted +// root that issued it, and returns a verifier for its key. The chain is checked at the time the +// certificate was issued, so a certificate that expired later still validates. +func ValidateCertificate(signingCert *x509.Certificate, tr *TrustedRoot) (signature.Verifier, error) { + if signingCert == nil || tr == nil { + return nil, ErrMissingVerificationMaterial + } + + akiSum := sha256.Sum256(signingCert.AuthorityKeyId) + aki := hex.EncodeToString(akiSum[:]) + chain, ok := tr.Keys[aki] + if !ok { + return nil, fmt.Errorf("trusted root not found for signing key with AKI %s", aki) + } + + verifier, err := cosign.ValidateAndUnpackCertWithChain(signingCert, chain, &cosign.CheckOpts{IgnoreSCT: true}) + if err != nil { + return nil, fmt.Errorf("validating the certificate: %w", err) + } + + return verifier, nil +}