From 6509d7e9557bea9ff6c02036205282606414f24e Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 9 Oct 2026 09:15:55 +0200 Subject: [PATCH 1/7] feat(runners): record a given commit verification in the sandbox runner The CHAINLOOP_SANDBOX runner gets no SCM token, so it cannot ask the provider whether the attested commit is signed. The platform can verify the commit before it starts the sandbox. The runner now reads that result from CHAINLOOP_SANDBOX_COMMIT_VERIFICATION and returns it only when its commit hash is the attested commit. A malformed value records the unavailable status. CommitVerification gets JSON tags, and its status encodes as a name, so the platform and the CLI share one format. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 0ebed99f-c817-4242-9a55-3c64202ac7f3 --- .../crafter/runners/chainloopsandbox.go | 43 +++++- .../crafter/runners/chainloopsandbox_test.go | 123 ++++++++++++++++++ .../commitverification/commitverification.go | 56 +++++++- .../commitverification_test.go | 79 +++++++++++ 4 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 pkg/attestation/crafter/runners/commitverification/commitverification_test.go diff --git a/pkg/attestation/crafter/runners/chainloopsandbox.go b/pkg/attestation/crafter/runners/chainloopsandbox.go index 46435abc2..b1d14b73a 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox.go @@ -17,7 +17,10 @@ package runners import ( "context" + "encoding/json" + "fmt" "os" + "strings" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" @@ -25,6 +28,19 @@ import ( const chainloopSandboxEnvVar = "CHAINLOOP_SANDBOX" +// ChainloopSandboxCommitVerificationEnvVar holds a SandboxCommitVerification as JSON. +// The sandbox gets no SCM token, so the platform verifies the commit before it starts +// the sandbox and gives the result to the runner in this variable. +const ChainloopSandboxCommitVerificationEnvVar = "CHAINLOOP_SANDBOX_COMMIT_VERIFICATION" + +// SandboxCommitVerification is a commit verification result and the commit that it applies to. +type SandboxCommitVerification 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"` +} + type ChainloopSandbox struct { *Generic } @@ -67,8 +83,31 @@ func (r *ChainloopSandbox) Environment() RunnerEnvironment { return Unknown } -func (r *ChainloopSandbox) VerifyCommitSignature(_ context.Context, _ string) *commitverification.CommitVerification { - return nil +// VerifyCommitSignature returns the verification that the platform gave in +// ChainloopSandboxCommitVerificationEnvVar. It returns nil when no verification was +// given, or when the verification is for a different commit than commitHash. +func (r *ChainloopSandbox) VerifyCommitSignature(_ context.Context, commitHash string) *commitverification.CommitVerification { + raw := os.Getenv(ChainloopSandboxCommitVerificationEnvVar) + if raw == "" || commitHash == "" { + return nil + } + + var given SandboxCommitVerification + if err := json.Unmarshal([]byte(raw), &given); err != nil || given.Verification == nil { + return &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusUnavailable, + Reason: fmt.Sprintf("invalid value in %s", ChainloopSandboxCommitVerificationEnvVar), + } + } + + // The result is about one commit only. Never apply it to the commit of another + // checkout, and never match an abbreviated hash. + if !strings.EqualFold(given.Commit, commitHash) { + return nil + } + + return given.Verification } 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..1b6530a8a 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox_test.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox_test.go @@ -16,8 +16,11 @@ package runners import ( + "context" + "encoding/json" "testing" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" "github.com/stretchr/testify/suite" ) @@ -90,6 +93,126 @@ func (s *chainloopSandboxSuite) TestRunnerName() { s.Equal("CHAINLOOP_SANDBOX", s.runner.ID().String()) } +func (s *chainloopSandboxSuite) TestVerifyCommitSignature() { + const ( + commit = "ea3a974660a030f0314aa1505a64792bf2633b33" + otherCommit = "0d7bb4b1e7b3f5d3e5c5f0f37a2c9a1f7f8b9c10" + platform = "gitlab" + ) + + verified := &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusVerified, + Reason: "Commit signed and verified", + Platform: platform, + KeyID: "1", + SignatureAlgorithm: "PGP", + } + verifiedJSON := `{"attempted":true,"status":"verified","reason":"Commit signed and verified","platform":"gitlab","key_id":"1","signature_algorithm":"PGP"}` + + invalid := &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusUnavailable, + Reason: "invalid value in CHAINLOOP_SANDBOX_COMMIT_VERIFICATION", + } + + testCases := []struct { + name string + env string + commit string + want *commitverification.CommitVerification + }{ + { + name: "no verification provided", + commit: commit, + }, + { + name: "verification of the attested commit", + env: `{"commit":"` + commit + `","verification":` + verifiedJSON + `}`, + commit: commit, + want: verified, + }, + { + name: "commit hash in upper case", + env: `{"commit":"EA3A974660A030F0314AA1505A64792BF2633B33","verification":` + verifiedJSON + `}`, + commit: commit, + want: verified, + }, + { + name: "commit not signed", + env: `{"commit":"` + commit + `","verification":{"attempted":true,"status":"not_applicable","reason":"Commit is not signed","platform":"gitlab"}}`, + commit: commit, + want: &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusNotApplicable, + Reason: "Commit is not signed", + Platform: platform, + }, + }, + { + name: "verification of another commit", + env: `{"commit":"` + otherCommit + `","verification":` + verifiedJSON + `}`, + commit: commit, + }, + { + name: "abbreviated commit hash", + env: `{"commit":"ea3a974","verification":` + verifiedJSON + `}`, + commit: commit, + }, + { + name: "no commit to verify", + env: `{"commit":"` + commit + `","verification":` + verifiedJSON + `}`, + commit: "", + }, + { + name: "malformed value", + env: `{"commit":`, + commit: commit, + want: invalid, + }, + { + name: "value without a verification", + env: `{"commit":"` + commit + `"}`, + commit: commit, + want: invalid, + }, + { + name: "unknown status", + env: `{"commit":"` + commit + `","verification":{"attempted":true,"status":"trusted"}}`, + commit: commit, + want: invalid, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + t.Setenv(ChainloopSandboxCommitVerificationEnvVar, tc.env) + + got := s.runner.VerifyCommitSignature(context.Background(), tc.commit) + s.Equal(tc.want, got) + }) + } +} + +func (s *chainloopSandboxSuite) TestSandboxCommitVerificationRoundTrip() { + const commit = "ea3a974660a030f0314aa1505a64792bf2633b33" + + want := &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusUnverified, + Reason: "Signature not verified: unverified_key", + Platform: "gitlab", + KeyID: "2", + SignatureAlgorithm: "PGP", + } + + raw, err := json.Marshal(SandboxCommitVerification{Commit: commit, Verification: want}) + s.Require().NoError(err) + s.T().Setenv(ChainloopSandboxCommitVerificationEnvVar, string(raw)) + + s.Equal(want, s.runner.VerifyCommitSignature(context.Background(), 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) +} From ed0e82e6e0619e577d3aea90678d1b17e2d4f8dd Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Tue, 13 Oct 2026 14:44:18 +0200 Subject: [PATCH 2/7] fix(runners): record unavailable for a given verification without a status A given verification with no status, or with the unspecified status, carries no result. The runner now records the unavailable status for it, as it does for a value without a verification. Before, the attestation recorded the unspecified status. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 4503bdcc-d428-4e05-ade6-966360bf5543 --- pkg/attestation/crafter/runners/chainloopsandbox.go | 4 +++- .../crafter/runners/chainloopsandbox_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/attestation/crafter/runners/chainloopsandbox.go b/pkg/attestation/crafter/runners/chainloopsandbox.go index b1d14b73a..078d7d0ca 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox.go @@ -92,8 +92,10 @@ func (r *ChainloopSandbox) VerifyCommitSignature(_ context.Context, commitHash s return nil } + // A verification without a status carries no result, the same as a missing one. var given SandboxCommitVerification - if err := json.Unmarshal([]byte(raw), &given); err != nil || given.Verification == nil { + if err := json.Unmarshal([]byte(raw), &given); err != nil || given.Verification == nil || + given.Verification.Status == commitverification.VerificationStatusUnspecified { return &commitverification.CommitVerification{ Attempted: true, Status: commitverification.VerificationStatusUnavailable, diff --git a/pkg/attestation/crafter/runners/chainloopsandbox_test.go b/pkg/attestation/crafter/runners/chainloopsandbox_test.go index 1b6530a8a..572b33404 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox_test.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox_test.go @@ -182,6 +182,18 @@ func (s *chainloopSandboxSuite) TestVerifyCommitSignature() { commit: commit, want: invalid, }, + { + name: "verification without a status", + env: `{"commit":"` + commit + `","verification":{"attempted":true,"platform":"gitlab"}}`, + commit: commit, + want: invalid, + }, + { + name: "unspecified status", + env: `{"commit":"` + commit + `","verification":{"attempted":true,"status":"unspecified"}}`, + commit: commit, + want: invalid, + }, } for _, tc := range testCases { From 85b21bbcd1d24e080ab4a893e2bb87c43c661986 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 16 Oct 2026 15:54:18 +0200 Subject: [PATCH 3/7] feat(sandboxidentity): add the identity token of a Chainloop sandbox The platform gives a signed identity token to each Chainloop sandbox that it spawns. The new sandboxidentity package defines the token 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 with the dedicated permission (R-002). The token names the organization, the project, the workflow, the sandbox run and the Chainloop token of the sandbox, and it holds the commit verification with the full commit hash (R-003). It has no expiry of its own (D-013). Verify checks the chain against the trusted root of the control plane, the mark, the organization, the project and the workflow, that the time of signing is inside the certificate lifetime (D-007), and the signature. The Issuer helper lets the platform issue a token in one call. The signing request gets the sandbox_identity_issuer flag, and the attestation init request gets the sandbox_identity token. The verifier exports ValidateCertificate, so the identity and the attestation use the same certificate check. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 4503bdcc-d428-4e05-ade6-966360bf5543 --- .../api/controlplane/v1/signing.pb.go | 21 +- .../api/controlplane/v1/signing.proto | 5 +- .../api/controlplane/v1/signing_grpc.pb.go | 2 +- .../api/controlplane/v1/workflow_run.pb.go | 308 +++++++++------ .../api/controlplane/v1/workflow_run.proto | 12 + .../gen/frontend/controlplane/v1/signing.ts | 20 +- .../frontend/controlplane/v1/workflow_run.ts | 134 ++++++- ...estationServiceInitRequest.jsonschema.json | 8 + ....AttestationServiceInitRequest.schema.json | 8 + ...ServiceInitResponse.Result.jsonschema.json | 8 + ...tionServiceInitResponse.Result.schema.json | 8 + ...onse.SandboxIdentityResult.jsonschema.json | 17 + ...Response.SandboxIdentityResult.schema.json | 17 + ...GenerateSigningCertRequest.jsonschema.json | 8 + ....v1.GenerateSigningCertRequest.schema.json | 8 + pkg/attestation/sandboxidentity/issuer.go | 93 +++++ .../sandboxidentity/issuer_test.go | 110 ++++++ .../sandboxidentity/sandboxidentity.go | 316 ++++++++++++++++ .../sandboxidentity/sandboxidentity_test.go | 355 ++++++++++++++++++ pkg/attestation/verifier/verifier.go | 35 +- 20 files changed, 1362 insertions(+), 131 deletions(-) create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.jsonschema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitResponse.SandboxIdentityResult.schema.json create mode 100644 pkg/attestation/sandboxidentity/issuer.go create mode 100644 pkg/attestation/sandboxidentity/issuer_test.go create mode 100644 pkg/attestation/sandboxidentity/sandboxidentity.go create mode 100644 pkg/attestation/sandboxidentity/sandboxidentity_test.go 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/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 +} From 4dd2229dc6e99f300084f00b840340c4ce445dba Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 16 Oct 2026 15:54:28 +0200 Subject: [PATCH 4/7] feat(controlplane): issue and enforce sandbox identities The control plane now issues the certificates that sign a sandbox identity, and verifies and enforces the identity. - A new policy, PolicySandboxIdentityIssue, is the dedicated permission. No role holds it, and only a system API token can hold it (R-002, D-004). The signing service marks the certificate only for a caller that holds it. The CA writes the mark in the organizational unit of the subject (D-005). A CA that does not write the subject itself gives no marked certificate, and a mark that was not requested gives no certificate. - At attestation init, the control plane verifies the identity of a CHAINLOOP_SANDBOX run against its own CA, for the attested workflow, and only from the Chainloop token that the identity names (R-003, D-012). It records the identity on the workflow run, and tells the CLI the result. A token that does not verify is the same as no token (D-011). - When it stores an attestation of the sandbox runner, the control plane rejects an authenticated runner without a verified identity, and a commit verification that the identity does not hold (R-006, D-010). Other runners keep their behavior. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 4503bdcc-d428-4e05-ade6-966360bf5543 --- .../internal/service/attestation.go | 59 +++++ .../internal/service/sandboxidentity_test.go | 218 ++++++++++++++++++ app/controlplane/internal/service/signing.go | 32 ++- app/controlplane/pkg/authz/authz.go | 5 + app/controlplane/pkg/biz/apitoken.go | 20 ++ .../pkg/biz/apitoken_authz_test.go | 36 +++ app/controlplane/pkg/biz/signing.go | 127 +++++++++- .../pkg/biz/signing_sandboxidentity_test.go | 203 ++++++++++++++++ app/controlplane/pkg/biz/workflowrun.go | 49 ++++ .../pkg/biz/workflowrun_integration_test.go | 31 +++ .../workflowrun_sandboxidentity_store_test.go | 150 ++++++++++++ .../biz/workflowrun_sandboxidentity_test.go | 99 ++++++++ .../ent/migrate/migrations/20261016133553.sql | 2 + .../pkg/data/ent/migrate/migrations/atlas.sum | 3 +- .../pkg/data/ent/migrate/schema.go | 19 +- app/controlplane/pkg/data/ent/mutation.go | 75 +++++- .../pkg/data/ent/schema/workflowrun.go | 2 + app/controlplane/pkg/data/ent/workflowrun.go | 15 +- .../pkg/data/ent/workflowrun/where.go | 10 + .../pkg/data/ent/workflowrun/workflowrun.go | 3 + .../pkg/data/ent/workflowrun_create.go | 70 ++++++ .../pkg/data/ent/workflowrun_update.go | 36 +++ app/controlplane/pkg/data/workflowrun.go | 13 +- .../renderer/chainloop/chainloop.go | 39 ++++ .../renderer/chainloop/githead_test.go | 55 +++++ 25 files changed, 1351 insertions(+), 20 deletions(-) create mode 100644 app/controlplane/internal/service/sandboxidentity_test.go create mode 100644 app/controlplane/pkg/biz/signing_sandboxidentity_test.go create mode 100644 app/controlplane/pkg/biz/workflowrun_sandboxidentity_store_test.go create mode 100644 app/controlplane/pkg/biz/workflowrun_sandboxidentity_test.go create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20261016133553.sql create mode 100644 pkg/attestation/renderer/chainloop/githead_test.go 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..880fa7838 100644 --- a/app/controlplane/pkg/biz/signing.go +++ b/app/controlplane/pkg/biz/signing.go @@ -27,12 +27,17 @@ 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/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 +181,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 +205,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 +238,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 +324,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 +343,88 @@ 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 a verification of the full commit +// hash, with the given status name. +func (i *SandboxIdentity) HoldsCommitVerification(commitHash, status string) bool { + if i == nil || i.Data == nil || i.Data.CommitVerification == nil || i.Data.CommitVerification.Verification == nil { + return false + } + + cv := i.Data.CommitVerification + + return commitHash != "" && strings.EqualFold(cv.Commit, commitHash) && cv.Verification.Status.String() == status +} + +// 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..f545b5864 --- /dev/null +++ b/app/controlplane/pkg/biz/signing_sandboxidentity_test.go @@ -0,0 +1,203 @@ +// +// 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/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" + 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(strings.ToUpper(commit), "verified")) + assert.False(t, got.HoldsCommitVerification(commit, "unverified")) + assert.False(t, got.HoldsCommitVerification("fedcba9876543210fedcba9876543210fedcba98", "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..45bba35aa 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.Commit, v.Status) { + 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..18041f617 --- /dev/null +++ b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_store_test.go @@ -0,0 +1,150 @@ +// +// 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 for the git.head commit. +func newSignedSandboxBundle(t *testing.T, signing *biz.SigningUseCase, orgID string, verificationStatus 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 != "" { + subject["annotations"].(map[string]any)["author.verification_status"] = verificationStatus + } + } + + 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) { + 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}, + }}} + } + + cases := []struct { + name string + identity *biz.SandboxIdentity + status 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"}, + {name: "a commit verification for another commit", identity: holding("0123456789abcdef0123456789abcdef01234567"), status: "verified", wantErr: "commit verification"}, + {name: "a commit verification with another status", identity: holding(testBundleHead), status: "unverified", wantErr: "commit verification"}, + } + + 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)) + 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..7508eb2d2 --- /dev/null +++ b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_test.go @@ -0,0 +1,99 @@ +// +// 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 ( + "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}, + }}} + + statement := func(t *testing.T, commit, status string) *intoto.Statement { + t.Helper() + if commit == "" { + return &intoto.Statement{} + } + + annotations, err := structpb.NewStruct(map[string]any{"author.verification_status": status}) + 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}, + } + + 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/renderer/chainloop/chainloop.go b/pkg/attestation/renderer/chainloop/chainloop.go index 77ac9db52..b542d1a5a 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,43 @@ 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 +} + +// 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()}) + } + + 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..e09dc764f --- /dev/null +++ b/pkg/attestation/renderer/chainloop/githead_test.go @@ -0,0 +1,55 @@ +// +// 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" + + 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})}, + }} + + got := GitHeadVerifications(statement) + assert.Equal(t, []GitHeadVerification{{Commit: "c2", Status: verified}}, 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()) +} From f907159084af1ee25f7e271ad7a1ac1db56213cc Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 16 Oct 2026 15:54:36 +0200 Subject: [PATCH 5/7] feat(runners): authenticate the sandbox runner with its verified identity The CHAINLOOP_SANDBOX runner reads the identity token from CHAINLOOP_SANDBOX_IDENTITY. At attestation init, the CLI sends the token to the control plane. When the control plane verified it, the CLI also verifies it against the trusted root of the control plane, in the same way as attestation verify. Only then is the runner authenticated (R-004), and only then does it return the commit verification of the identity, for the full hash of the attested commit (R-005). A token that does not verify, or a control plane that does not know the identity, gives a runner that is not authenticated and has no platform data. The CLI logs the reason (D-011). This replaces the unsigned hand-off in CHAINLOOP_SANDBOX_COMMIT_VERIFICATION and its "unavailable" status for an unreadable value. No released CLI reads that value. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 4503bdcc-d428-4e05-ade6-966360bf5543 --- app/cli/pkg/action/attestation_init.go | 59 +++++++ .../attestation_init_sandboxidentity_test.go | 162 ++++++++++++++++++ pkg/attestation/crafter/runner.go | 12 ++ .../crafter/runners/chainloopsandbox.go | 63 +++---- .../crafter/runners/chainloopsandbox_test.go | 143 ++++------------ 5 files changed, 289 insertions(+), 150 deletions(-) create mode 100644 app/cli/pkg/action/attestation_init_sandboxidentity_test.go 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/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 078d7d0ca..c72a851ad 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox.go @@ -17,32 +17,20 @@ package runners import ( "context" - "encoding/json" - "fmt" "os" - "strings" 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" -// ChainloopSandboxCommitVerificationEnvVar holds a SandboxCommitVerification as JSON. -// The sandbox gets no SCM token, so the platform verifies the commit before it starts -// the sandbox and gives the result to the runner in this variable. -const ChainloopSandboxCommitVerificationEnvVar = "CHAINLOOP_SANDBOX_COMMIT_VERIFICATION" - -// SandboxCommitVerification is a commit verification result and the commit that it applies to. -type SandboxCommitVerification 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"` -} - +// 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 { @@ -76,40 +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 } -// VerifyCommitSignature returns the verification that the platform gave in -// ChainloopSandboxCommitVerificationEnvVar. It returns nil when no verification was -// given, or when the verification is for a different commit than commitHash. +// 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 { - raw := os.Getenv(ChainloopSandboxCommitVerificationEnvVar) - if raw == "" || commitHash == "" { - return nil - } - - // A verification without a status carries no result, the same as a missing one. - var given SandboxCommitVerification - if err := json.Unmarshal([]byte(raw), &given); err != nil || given.Verification == nil || - given.Verification.Status == commitverification.VerificationStatusUnspecified { - return &commitverification.CommitVerification{ - Attempted: true, - Status: commitverification.VerificationStatusUnavailable, - Reason: fmt.Sprintf("invalid value in %s", ChainloopSandboxCommitVerificationEnvVar), - } - } - - // The result is about one commit only. Never apply it to the commit of another - // checkout, and never match an abbreviated hash. - if !strings.EqualFold(given.Commit, commitHash) { - return nil - } - - return given.Verification + 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 572b33404..e286447ea 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox_test.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox_test.go @@ -17,10 +17,11 @@ package runners import ( "context" - "encoding/json" + "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" ) @@ -83,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() { @@ -97,134 +112,48 @@ func (s *chainloopSandboxSuite) TestVerifyCommitSignature() { const ( commit = "ea3a974660a030f0314aa1505a64792bf2633b33" otherCommit = "0d7bb4b1e7b3f5d3e5c5f0f37a2c9a1f7f8b9c10" - platform = "gitlab" ) verified := &commitverification.CommitVerification{ Attempted: true, Status: commitverification.VerificationStatusVerified, Reason: "Commit signed and verified", - Platform: platform, + Platform: "gitlab", KeyID: "1", SignatureAlgorithm: "PGP", } - verifiedJSON := `{"attempted":true,"status":"verified","reason":"Commit signed and verified","platform":"gitlab","key_id":"1","signature_algorithm":"PGP"}` - invalid := &commitverification.CommitVerification{ - Attempted: true, - Status: commitverification.VerificationStatusUnavailable, - Reason: "invalid value in CHAINLOOP_SANDBOX_COMMIT_VERIFICATION", - } + withData := &sandboxidentity.Identity{Claims: &sandboxidentity.Claims{Data: &sandboxidentity.Data{ + CommitVerification: &sandboxidentity.CommitVerification{Commit: commit, Verification: verified}, + }}} testCases := []struct { - name string - env string - commit string - want *commitverification.CommitVerification + name string + identity *sandboxidentity.Identity + commit string + want *commitverification.CommitVerification }{ - { - name: "no verification provided", - commit: commit, - }, - { - name: "verification of the attested commit", - env: `{"commit":"` + commit + `","verification":` + verifiedJSON + `}`, - commit: commit, - want: verified, - }, - { - name: "commit hash in upper case", - env: `{"commit":"EA3A974660A030F0314AA1505A64792BF2633B33","verification":` + verifiedJSON + `}`, - commit: commit, - want: verified, - }, - { - name: "commit not signed", - env: `{"commit":"` + commit + `","verification":{"attempted":true,"status":"not_applicable","reason":"Commit is not signed","platform":"gitlab"}}`, - commit: commit, - want: &commitverification.CommitVerification{ - Attempted: true, - Status: commitverification.VerificationStatusNotApplicable, - Reason: "Commit is not signed", - Platform: platform, - }, - }, - { - name: "verification of another commit", - env: `{"commit":"` + otherCommit + `","verification":` + verifiedJSON + `}`, - commit: commit, - }, - { - name: "abbreviated commit hash", - env: `{"commit":"ea3a974","verification":` + verifiedJSON + `}`, - commit: commit, - }, - { - name: "no commit to verify", - env: `{"commit":"` + commit + `","verification":` + verifiedJSON + `}`, - commit: "", - }, - { - name: "malformed value", - env: `{"commit":`, - commit: commit, - want: invalid, - }, - { - name: "value without a verification", - env: `{"commit":"` + commit + `"}`, - commit: commit, - want: invalid, - }, - { - name: "unknown status", - env: `{"commit":"` + commit + `","verification":{"attempted":true,"status":"trusted"}}`, - commit: commit, - want: invalid, - }, - { - name: "verification without a status", - env: `{"commit":"` + commit + `","verification":{"attempted":true,"platform":"gitlab"}}`, - commit: commit, - want: invalid, - }, - { - name: "unspecified status", - env: `{"commit":"` + commit + `","verification":{"attempted":true,"status":"unspecified"}}`, - commit: commit, - want: invalid, - }, + {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) { - t.Setenv(ChainloopSandboxCommitVerificationEnvVar, tc.env) + // 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"}}`) - got := s.runner.VerifyCommitSignature(context.Background(), tc.commit) - s.Equal(tc.want, got) + runner := NewChainloopSandbox() + runner.SetVerifiedIdentity(tc.identity) + s.Equal(tc.want, runner.VerifyCommitSignature(context.Background(), tc.commit)) }) } } -func (s *chainloopSandboxSuite) TestSandboxCommitVerificationRoundTrip() { - const commit = "ea3a974660a030f0314aa1505a64792bf2633b33" - - want := &commitverification.CommitVerification{ - Attempted: true, - Status: commitverification.VerificationStatusUnverified, - Reason: "Signature not verified: unverified_key", - Platform: "gitlab", - KeyID: "2", - SignatureAlgorithm: "PGP", - } - - raw, err := json.Marshal(SandboxCommitVerification{Commit: commit, Verification: want}) - s.Require().NoError(err) - s.T().Setenv(ChainloopSandboxCommitVerificationEnvVar, string(raw)) - - s.Equal(want, s.runner.VerifyCommitSignature(context.Background(), commit)) -} - func (s *chainloopSandboxSuite) SetupTest() { s.runner = NewChainloopSandbox() } From 4f77ad63ed9aba04c46e84e585decc5d727e93ea Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 16 Oct 2026 16:15:29 +0200 Subject: [PATCH 6/7] fix(crafter): keep the verified sandbox identity after init Init loads the state that it wrote, and the load makes a new runner. A new CHAINLOOP_SANDBOX runner has no verified identity, so the runner info that init resolves next recorded authenticated=false, while git.head had the commit verification of the identity (R-004). Init now keeps its own runner when the runner can carry a verified identity. The other runners do not change. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 4503bdcc-d428-4e05-ade6-966360bf5543 --- pkg/attestation/crafter/crafter.go | 12 ++- .../crafter/crafter_sandboxidentity_test.go | 88 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 pkg/attestation/crafter/crafter_sandboxidentity_test.go 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()) + }) + } +} From a26107c29cba32afa16b2fabf04aa12e8a0b9fec Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 16 Oct 2026 16:15:29 +0200 Subject: [PATCH 7/7] fix(controlplane): compare the signature algorithm of a sandbox commit verification The git.head subject also records the signature algorithm, and the renderer takes it from the commit verification first. When the identity holds a signature algorithm, the control plane now also requires the attestation to record the same one (R-006). When the identity has no algorithm, the attestation takes it from the signature of the commit, so the control plane does not compare it. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 4503bdcc-d428-4e05-ade6-966360bf5543 --- app/controlplane/pkg/biz/signing.go | 14 +++++--- .../pkg/biz/signing_sandboxidentity_test.go | 12 ++++--- app/controlplane/pkg/biz/workflowrun.go | 2 +- .../workflowrun_sandboxidentity_store_test.go | 33 ++++++++++++------- .../biz/workflowrun_sandboxidentity_test.go | 18 +++++++++- .../renderer/chainloop/chainloop.go | 8 ++++- .../renderer/chainloop/githead_test.go | 15 ++++++--- 7 files changed, 76 insertions(+), 26 deletions(-) diff --git a/app/controlplane/pkg/biz/signing.go b/app/controlplane/pkg/biz/signing.go index 880fa7838..ff7167d55 100644 --- a/app/controlplane/pkg/biz/signing.go +++ b/app/controlplane/pkg/biz/signing.go @@ -32,6 +32,7 @@ import ( 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" @@ -362,16 +363,21 @@ type SandboxIdentity struct { Data *sandboxidentity.Data `json:"data,omitempty"` } -// HoldsCommitVerification reports whether the identity holds a verification of the full commit -// hash, with the given status name. -func (i *SandboxIdentity) HoldsCommitVerification(commitHash, status string) bool { +// 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 commitHash != "" && strings.EqualFold(cv.Commit, commitHash) && cv.Verification.Status.String() == status + return cv.Verification.SignatureAlgorithm == "" || cv.Verification.SignatureAlgorithm == v.SignatureAlgorithm } // VerifySandboxIdentity verifies the identity token of a Chainloop sandbox against the certificate diff --git a/app/controlplane/pkg/biz/signing_sandboxidentity_test.go b/app/controlplane/pkg/biz/signing_sandboxidentity_test.go index f545b5864..3cde009bb 100644 --- a/app/controlplane/pkg/biz/signing_sandboxidentity_test.go +++ b/app/controlplane/pkg/biz/signing_sandboxidentity_test.go @@ -27,6 +27,7 @@ import ( "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" @@ -119,7 +120,10 @@ func TestCreateSigningCertSandboxIdentityMark(t *testing.T) { } func TestVerifySandboxIdentity(t *testing.T) { - const commit = "0123456789abcdef0123456789abcdef01234567" + const ( + commit = "0123456789abcdef0123456789abcdef01234567" + verified = "verified" + ) orgID, projectID, workflowID := uuid.NewString(), uuid.NewString(), uuid.NewString() tokenID := uuid.New() uc := keylessSigningUseCase(t) @@ -156,9 +160,9 @@ func TestVerifySandboxIdentity(t *testing.T) { require.NoError(t, err) assert.Equal(t, tokenID, got.ChainloopTokenID) assert.Equal(t, "sandbox-run-1", got.SandboxRun) - assert.True(t, got.HoldsCommitVerification(strings.ToUpper(commit), "verified")) - assert.False(t, got.HoldsCommitVerification(commit, "unverified")) - assert.False(t, got.HoldsCommitVerification("fedcba9876543210fedcba9876543210fedcba98", "verified")) + 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) { diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index 45bba35aa..f4f951931 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -519,7 +519,7 @@ func validateSandboxRunner(run *WorkflowRun, statement *intoto.Statement, predic } for _, v := range chainloop.GitHeadVerifications(statement) { - if !run.SandboxIdentity.HoldsCommitVerification(v.Commit, v.Status) { + if !run.SandboxIdentity.HoldsCommitVerification(v) { return NewErrValidationStr("the attestation records a commit verification that the sandbox identity of its workflow run does not hold") } } diff --git a/app/controlplane/pkg/biz/workflowrun_sandboxidentity_store_test.go b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_store_test.go index 18041f617..b3a7466a8 100644 --- a/app/controlplane/pkg/biz/workflowrun_sandboxidentity_store_test.go +++ b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_store_test.go @@ -46,8 +46,8 @@ import ( 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 for the git.head commit. -func newSignedSandboxBundle(t *testing.T, signing *biz.SigningUseCase, orgID string, verificationStatus string) []byte { +// 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") @@ -67,7 +67,11 @@ func newSignedSandboxBundle(t *testing.T, signing *biz.SigningUseCase, orgID str for _, s := range statement["subject"].([]any) { subject := s.(map[string]any) if subject["name"] == "git.head" && verificationStatus != "" { - subject["annotations"].(map[string]any)["author.verification_status"] = verificationStatus + annotations := subject["annotations"].(map[string]any) + annotations["author.verification_status"] = verificationStatus + if algorithm != "" { + annotations["signature.algorithm"] = algorithm + } } } @@ -102,27 +106,34 @@ func newSignedSandboxBundle(t *testing.T, signing *biz.SigningUseCase, orgID str // 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}, + Verification: &commitverification.CommitVerification{Attempted: true, Status: commitverification.VerificationStatusVerified, SignatureAlgorithm: "PGP"}, }}} } cases := []struct { - name string - identity *biz.SandboxIdentity - status string + 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"}, - {name: "a commit verification for another commit", identity: holding("0123456789abcdef0123456789abcdef01234567"), status: "verified", wantErr: "commit verification"}, - {name: "a commit verification with another status", identity: holding(testBundleHead), status: "unverified", wantErr: "commit verification"}, + {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 { @@ -135,7 +146,7 @@ func TestValidateAttestationContractEnforcesSandboxIdentity(t *testing.T) { 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)) + 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) diff --git a/app/controlplane/pkg/biz/workflowrun_sandboxidentity_test.go b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_test.go index 7508eb2d2..6519188af 100644 --- a/app/controlplane/pkg/biz/workflowrun_sandboxidentity_test.go +++ b/app/controlplane/pkg/biz/workflowrun_sandboxidentity_test.go @@ -16,6 +16,7 @@ package biz import ( + "strings" "testing" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" @@ -41,13 +42,24 @@ func TestValidateSandboxRunner(t *testing.T) { 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{} } - annotations, err := structpb.NewStruct(map[string]any{"author.verification_status": status}) + 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{ @@ -79,6 +91,10 @@ func TestValidateSandboxRunner(t *testing.T) { {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 { diff --git a/pkg/attestation/renderer/chainloop/chainloop.go b/pkg/attestation/renderer/chainloop/chainloop.go index b542d1a5a..1925becb6 100644 --- a/pkg/attestation/renderer/chainloop/chainloop.go +++ b/pkg/attestation/renderer/chainloop/chainloop.go @@ -367,6 +367,8 @@ type GitHeadVerification struct { 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 @@ -383,7 +385,11 @@ func GitHeadVerifications(statement *intoto.Statement) []GitHeadVerification { continue } - res = append(res, GitHeadVerification{Commit: subject.GetDigest()["sha1"], Status: status.GetStringValue()}) + res = append(res, GitHeadVerification{ + Commit: subject.GetDigest()["sha1"], + Status: status.GetStringValue(), + SignatureAlgorithm: subject.GetAnnotations().GetFields()[subjectGitAnnotationSignatureAlgorithm].GetStringValue(), + }) } return res diff --git a/pkg/attestation/renderer/chainloop/githead_test.go b/pkg/attestation/renderer/chainloop/githead_test.go index e09dc764f..c06ef173e 100644 --- a/pkg/attestation/renderer/chainloop/githead_test.go +++ b/pkg/attestation/renderer/chainloop/githead_test.go @@ -25,7 +25,11 @@ import ( ) func TestGitHeadVerifications(t *testing.T) { - const verified = "verified" + const ( + verified = "verified" + sha1 = "sha1" + pgp = "PGP" + ) annotations := func(t *testing.T, fields map[string]any) *structpb.Struct { t.Helper() @@ -36,12 +40,15 @@ func TestGitHeadVerifications(t *testing.T) { 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: "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}}, got) + 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)) }