Skip to content

Commit 65be1d6

Browse files
authored
feat(keyless): make keyless signing available in the CLI (#862)
Signed-off-by: Jose I. Paris <[email protected]>
1 parent 01e0e20 commit 65be1d6

File tree

7 files changed

+330
-92
lines changed

7 files changed

+330
-92
lines changed

app/cli/cmd/attestation_push.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,6 @@ func newAttestationPushCmd() *cobra.Command {
4949
Annotations: map[string]string{
5050
useWorkflowRobotAccount: "true",
5151
},
52-
PreRunE: func(cmd *cobra.Command, args []string) error {
53-
if pkPath == "" {
54-
return errors.New("a path to the private key is required")
55-
}
56-
57-
return nil
58-
},
5952
RunE: func(cmd *cobra.Command, args []string) error {
6053
info, err := executableInfo()
6154
if err != nil {
@@ -100,7 +93,7 @@ func newAttestationPushCmd() *cobra.Command {
10093
},
10194
}
10295

103-
cmd.Flags().StringVarP(&pkPath, "key", "k", "", "reference (path or env variable name) to the cosign private key that will be used to sign the attestation")
96+
cmd.Flags().StringVarP(&pkPath, "key", "k", "", "reference (path or env variable name) to the cosign or KMS key that will be used to sign the attestation")
10497
cmd.Flags().StringSliceVar(&annotationsFlag, "annotation", nil, "additional annotation in the format of key=value")
10598
flagAttestationID(cmd)
10699

app/cli/internal/action/attestation_push.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
2626
"github.com/chainloop-dev/chainloop/internal/attestation/crafter"
2727
"github.com/chainloop-dev/chainloop/internal/attestation/renderer"
28+
"github.com/chainloop-dev/chainloop/internal/attestation/signer"
2829
"github.com/secure-systems-lab/go-securesystemslib/dsse"
2930
"google.golang.org/grpc"
3031
"google.golang.org/protobuf/types/known/timestamppb"
@@ -131,7 +132,9 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru
131132
// Indicate that we are done with the attestation
132133
action.c.CraftingState.Attestation.FinishedAt = timestamppb.New(time.Now())
133134

134-
renderer, err := renderer.NewAttestationRenderer(action.c.CraftingState, action.keyPath, action.cliVersion, action.cliDigest, renderer.WithLogger(action.Logger))
135+
wrappedSigner := signer.GetSigner(action.keyPath, action.Logger, pb.NewSigningServiceClient(action.CPConnection))
136+
renderer, err := renderer.NewAttestationRenderer(action.c.CraftingState, action.cliVersion, action.cliDigest, wrappedSigner,
137+
renderer.WithLogger(action.Logger))
135138
if err != nil {
136139
return nil, err
137140
}

app/controlplane/internal/biz/signing.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func NewChainloopSigningUseCase(ca ca.CertificateAuthority) *SigningUseCase {
3939
// CreateSigningCert signs a certificate request with a configured CA, and returns the full certificate chain
4040
func (s *SigningUseCase) CreateSigningCert(ctx context.Context, orgID string, csrRaw []byte) ([]string, error) {
4141
if s.CA == nil {
42-
return nil, errors.New("CA not initialized")
42+
return nil, NewErrValidation(errors.New("CA not initialized"))
4343
}
4444

4545
var publicKey crypto.PublicKey

internal/attestation/renderer/renderer.go

Lines changed: 11 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -17,30 +17,25 @@ package renderer
1717

1818
import (
1919
"bytes"
20-
"context"
2120
"encoding/json"
2221
"errors"
2322
"fmt"
24-
"io"
25-
"os"
26-
"syscall"
2723

2824
v1 "github.com/chainloop-dev/chainloop/internal/attestation/crafter/api/attestation/v1"
2925
"github.com/chainloop-dev/chainloop/internal/attestation/renderer/chainloop"
3026
intoto "github.com/in-toto/attestation/go/v1"
3127
"github.com/rs/zerolog"
3228
"github.com/secure-systems-lab/go-securesystemslib/dsse"
33-
"github.com/sigstore/cosign/v2/pkg/signature"
29+
sigstoresigner "github.com/sigstore/sigstore/pkg/signature"
3430
sigdsee "github.com/sigstore/sigstore/pkg/signature/dsse"
35-
"golang.org/x/term"
3631
"google.golang.org/protobuf/encoding/protojson"
3732
)
3833

3934
type AttestationRenderer struct {
40-
logger zerolog.Logger
41-
signingKeyPath string
42-
att *v1.Attestation
43-
renderer r
35+
logger zerolog.Logger
36+
att *v1.Attestation
37+
renderer r
38+
signer sigstoresigner.Signer
4439
}
4540

4641
type r interface {
@@ -55,16 +50,16 @@ func WithLogger(logger zerolog.Logger) Opt {
5550
}
5651
}
5752

58-
func NewAttestationRenderer(state *v1.CraftingState, keyPath, builderVersion, builderDigest string, opts ...Opt) (*AttestationRenderer, error) {
53+
func NewAttestationRenderer(state *v1.CraftingState, builderVersion, builderDigest string, signer sigstoresigner.Signer, opts ...Opt) (*AttestationRenderer, error) {
5954
if state.GetAttestation() == nil {
6055
return nil, errors.New("attestation not initialized")
6156
}
6257

6358
r := &AttestationRenderer{
64-
logger: zerolog.Nop(),
65-
signingKeyPath: keyPath,
66-
att: state.GetAttestation(),
67-
renderer: chainloop.NewChainloopRendererV02(state.GetAttestation(), builderVersion, builderDigest),
59+
logger: zerolog.Nop(),
60+
att: state.GetAttestation(),
61+
signer: signer,
62+
renderer: chainloop.NewChainloopRendererV02(state.GetAttestation(), builderVersion, builderDigest),
6863
}
6964

7065
for _, opt := range opts {
@@ -93,15 +88,7 @@ func (ab *AttestationRenderer) Render() (*dsse.Envelope, error) {
9388
return nil, err
9489
}
9590

96-
ab.logger.Debug().Str("path", ab.signingKeyPath).Msg("loading key")
97-
98-
signer, err := signature.SignerFromKeyRef(context.Background(), ab.signingKeyPath, getPass)
99-
if err != nil {
100-
return nil, err
101-
}
102-
103-
wrappedSigner := sigdsee.WrapSigner(signer, "application/vnd.in-toto+json")
104-
91+
wrappedSigner := sigdsee.WrapSigner(ab.signer, "application/vnd.in-toto+json")
10592
signedAtt, err := wrappedSigner.SignMessage(bytes.NewReader(rawStatement))
10693
if err != nil {
10794
return nil, fmt.Errorf("signing message: %w", err)
@@ -114,61 +101,3 @@ func (ab *AttestationRenderer) Render() (*dsse.Envelope, error) {
114101

115102
return &dseeEnvelope, nil
116103
}
117-
118-
func getPass(confirm bool) ([]byte, error) {
119-
read := readPasswordFn(confirm)
120-
return read()
121-
}
122-
123-
// based on cosign code
124-
// https://pkg.go.dev/github.com/sigstore/cosign/cmd/cosign/cli/generate
125-
func readPasswordFn(confirm bool) func() ([]byte, error) {
126-
pw, ok := os.LookupEnv("CHAINLOOP_SIGNING_PASSWORD")
127-
switch {
128-
case ok:
129-
return func() ([]byte, error) {
130-
return []byte(pw), nil
131-
}
132-
case isTerminal():
133-
return func() ([]byte, error) {
134-
return getPassFromTerm(confirm)
135-
}
136-
// Handle piped in passwords.
137-
default:
138-
return func() ([]byte, error) {
139-
return io.ReadAll(os.Stdin)
140-
}
141-
}
142-
}
143-
144-
func isTerminal() bool {
145-
stat, _ := os.Stdin.Stat()
146-
return (stat.Mode() & os.ModeCharDevice) != 0
147-
}
148-
149-
func getPassFromTerm(confirm bool) ([]byte, error) {
150-
fmt.Fprint(os.Stderr, "Enter password for private key: ")
151-
// Unnecessary convert of syscall.Stdin on *nix, but Windows is a uintptr
152-
// nolint:unconvert
153-
pw1, err := term.ReadPassword(int(syscall.Stdin))
154-
if err != nil {
155-
return nil, err
156-
}
157-
fmt.Fprintln(os.Stderr)
158-
if !confirm {
159-
return pw1, nil
160-
}
161-
fmt.Fprint(os.Stderr, "Enter password for private key again: ")
162-
// Unnecessary convert of syscall.Stdin on *nix, but Windows is a uintptr
163-
// nolint:unconvert
164-
confirmpw, err := term.ReadPassword(int(syscall.Stdin))
165-
fmt.Fprintln(os.Stderr)
166-
if err != nil {
167-
return nil, err
168-
}
169-
170-
if string(pw1) != string(confirmpw) {
171-
return nil, errors.New("passwords do not match")
172-
}
173-
return pw1, nil
174-
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//
2+
// Copyright 2024 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package chainloop
17+
18+
import (
19+
"context"
20+
"crypto"
21+
"crypto/ecdsa"
22+
"crypto/elliptic"
23+
"crypto/rand"
24+
"crypto/x509"
25+
"crypto/x509/pkix"
26+
"encoding/pem"
27+
"fmt"
28+
"io"
29+
"sync"
30+
31+
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
32+
"github.com/rs/zerolog"
33+
sigstoresigner "github.com/sigstore/sigstore/pkg/signature"
34+
)
35+
36+
// Signer is a keyless signer for Chainloop
37+
type Signer struct {
38+
sigstoresigner.Signer
39+
40+
signingServiceClient pb.SigningServiceClient
41+
logger zerolog.Logger
42+
mu sync.Mutex
43+
}
44+
45+
var _ sigstoresigner.Signer = (*Signer)(nil)
46+
47+
func NewSigner(sc pb.SigningServiceClient, logger zerolog.Logger) *Signer {
48+
return &Signer{
49+
signingServiceClient: sc,
50+
logger: logger,
51+
}
52+
}
53+
54+
func (cs *Signer) SignMessage(message io.Reader, opts ...sigstoresigner.SignOption) ([]byte, error) {
55+
err := cs.ensureInitiated(context.Background())
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
return cs.Signer.SignMessage(message, opts...)
61+
}
62+
63+
// ensureInitiated makes sure the signer is fully initialized and can be used right away
64+
// (i.e. it has performed the CSR challenge with chainloop)
65+
func (cs *Signer) ensureInitiated(ctx context.Context) error {
66+
cs.mu.Lock()
67+
defer cs.mu.Unlock()
68+
69+
if cs.Signer != nil {
70+
return nil
71+
}
72+
73+
var err error
74+
75+
// key is not provided, let's create one
76+
cs.logger.Debug().Msg("generating a keyless signer")
77+
cs.Signer, err = cs.keyLessSigner(ctx)
78+
if err != nil {
79+
return fmt.Errorf("getting a keyless signer: %w", err)
80+
}
81+
82+
return nil
83+
}
84+
85+
type certificateRequest struct {
86+
PrivateKey *ecdsa.PrivateKey
87+
// CertificateRequestPEM contains the signed public key and the CSR metadata
88+
CertificateRequestPEM []byte
89+
}
90+
91+
func (cs *Signer) keyLessSigner(ctx context.Context) (sigstoresigner.Signer, error) {
92+
request, err := cs.createCertificateRequest()
93+
if err != nil {
94+
return nil, fmt.Errorf("creating certificate request: %w", err)
95+
}
96+
_, err = cs.certFromChainloop(ctx, request)
97+
if err != nil {
98+
return nil, fmt.Errorf("getting a certificate from chainloop: %w", err)
99+
}
100+
sv, err := sigstoresigner.LoadECDSASignerVerifier(request.PrivateKey, crypto.SHA256)
101+
if err != nil {
102+
return nil, fmt.Errorf("loading ECDSA signer from private key: %w", err)
103+
}
104+
105+
return sv, nil
106+
}
107+
108+
// createCertificateRequest generates a new CSR to be sent to Chainloop platform
109+
func (cs *Signer) createCertificateRequest() (*certificateRequest, error) {
110+
cs.logger.Debug().Msg("generating new certificate request")
111+
112+
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
113+
if err != nil {
114+
return nil, fmt.Errorf("generating cert: %w", err)
115+
}
116+
csrTmpl := &x509.CertificateRequest{Subject: pkix.Name{CommonName: "ephemeral certificate"}}
117+
derCSR, err := x509.CreateCertificateRequest(rand.Reader, csrTmpl, priv)
118+
if err != nil {
119+
return nil, fmt.Errorf("generating certificate request: %w", err)
120+
}
121+
122+
// Encode CSR to PEM
123+
pemCSR := pem.EncodeToMemory(&pem.Block{
124+
Type: "CERTIFICATE REQUEST",
125+
Bytes: derCSR,
126+
})
127+
128+
return &certificateRequest{
129+
CertificateRequestPEM: pemCSR,
130+
PrivateKey: priv,
131+
}, nil
132+
}
133+
134+
// certFromChainloop gets a full certificate chain from a CSR
135+
func (cs *Signer) certFromChainloop(ctx context.Context, req *certificateRequest) ([]string, error) {
136+
cr := pb.GenerateSigningCertRequest{
137+
CertificateSigningRequest: req.CertificateRequestPEM,
138+
}
139+
140+
// call chainloop
141+
resp, err := cs.signingServiceClient.GenerateSigningCert(ctx, &cr)
142+
if err != nil {
143+
return nil, fmt.Errorf("generating signing cert: %w", err)
144+
}
145+
146+
// get full chain
147+
return resp.GetChain().GetCertificates(), nil
148+
}

0 commit comments

Comments
 (0)