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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions app/cli/pkg/action/attestation_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
162 changes: 162 additions & 0 deletions app/cli/pkg/action/attestation_init_sandboxidentity_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
})
}
}
21 changes: 16 additions & 5 deletions app/controlplane/api/controlplane/v1/signing.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion app/controlplane/api/controlplane/v1/signing.proto
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion app/controlplane/api/controlplane/v1/signing_grpc.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading