Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add intermediate CA implementation with in-memory signer #494

Closed
wants to merge 1 commit into from
Closed
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
11 changes: 8 additions & 3 deletions cmd/app/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/sigstore/fulcio/pkg/ca/ephemeralca"
"github.com/sigstore/fulcio/pkg/ca/fileca"
googlecav1 "github.com/sigstore/fulcio/pkg/ca/googleca/v1"
"github.com/sigstore/fulcio/pkg/ca/intermediateca"
"github.com/sigstore/fulcio/pkg/ca/x509ca"
"github.com/sigstore/fulcio/pkg/config"
"github.com/sigstore/fulcio/pkg/ctl"
Expand All @@ -53,7 +54,7 @@ func newServeCmd() *cobra.Command {

cmd.Flags().StringVarP(&serveCmdConfigFilePath, "config", "c", "", "config file containing all settings")
cmd.Flags().String("log_type", "dev", "logger type to use (dev/prod)")
cmd.Flags().String("ca", "", "googleca | pkcs11ca | fileca | ephemeralca (for testing)")
cmd.Flags().String("ca", "", "googleca | pkcs11ca | fileca | intermediateca | ephemeralca (for testing)")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given the dependency on google CA service for intermediateca, does it make sense to have the name reflect that somehow?

cmd.Flags().String("aws-hsm-root-ca-path", "", "Path to root CA on disk (only used with AWS HSM)")
cmd.Flags().String("gcp_private_ca_parent", "", "private ca parent: /projects/<project>/locations/<location>/<name> (only used with --ca googleca)")
cmd.Flags().String("hsm-caroot-id", "", "HSM ID for Root CA (only used with --ca pkcs11ca)")
Expand Down Expand Up @@ -101,7 +102,6 @@ func runServeCmd(cmd *cobra.Command, args []string) {
// There's a MarkDeprecated function in cobra/pflags, but it doesn't use log.Logger
log.Logger.Warn("gcp_private_ca_version is deprecated and will soon be removed; please remove it")
}

case "fileca":
if !viper.IsSet("fileca-cert") {
log.Logger.Fatal("fileca-cert must be set to certificate path when using fileca")
Expand All @@ -112,7 +112,10 @@ func runServeCmd(cmd *cobra.Command, args []string) {
if !viper.IsSet("fileca-key-passwd") {
log.Logger.Fatal("fileca-key-passwd must be set to encryption password for private key file when using fileca")
}

case "intermediateca":
if !viper.IsSet("gcp_private_ca_parent") {
log.Logger.Fatal("gcp_private_ca_parent must be set when using intermediateca")
}
case "ephemeralca":
// this is a no-op since this is a self-signed in-memory CA for testing
default:
Expand Down Expand Up @@ -153,6 +156,8 @@ func runServeCmd(cmd *cobra.Command, args []string) {
baseca, err = fileca.NewFileCA(certFile, keyFile, keyPass, watch)
case "ephemeralca":
baseca, err = ephemeralca.NewEphemeralCA()
case "intermediateca":
baseca, err = intermediateca.NewIntermediateCA(cmd.Context(), viper.GetString("gcp_private_ca_parent"))
default:
err = fmt.Errorf("invalid value for configured CA: %v", baseca)
}
Expand Down
215 changes: 215 additions & 0 deletions pkg/ca/intermediateca/intermediateca.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Copyright 2022 The Sigstore 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 intermediateca

import (
"context"
"crypto"
"crypto/rand"
"crypto/x509"
"errors"
"sync"
"time"

privateca "cloud.google.com/go/security/privateca/apiv1"
"github.com/sigstore/fulcio/pkg/ca"
"github.com/sigstore/fulcio/pkg/ca/x509ca"
"github.com/sigstore/fulcio/pkg/challenges"
"github.com/sigstore/fulcio/pkg/log"
"github.com/sigstore/sigstore/pkg/cryptoutils"
"github.com/sigstore/sigstore/pkg/signature"
privatecapb "google.golang.org/genproto/googleapis/cloud/security/privateca/v1"
"google.golang.org/protobuf/types/known/durationpb"
)

// TODO: Takes signer (KMS/on-disk/in-memory) and way to fetch intermediate CA cert + chain

type intermediateCA struct {
sync.RWMutex

// certs is a chain of certificates from intermediate to root
certs []*x509.Certificate
signer crypto.Signer

// GCP CA Service
parent string
client *privateca.CertificateAuthorityClient

updatedCerts chan []*x509.Certificate
}

func fetchCACertificate(ctx context.Context, parent string, client *privateca.CertificateAuthorityClient, signer crypto.Signer) ([]*x509.Certificate, error) {
pemPubKey, err := cryptoutils.MarshalPublicKeyToPEM(signer.Public())
if err != nil {
return nil, err
}

isCa := true
// default value of 0 for int32
var maxIssuerPathLength int32

csr := &privatecapb.CreateCertificateRequest{
Parent: parent,
Certificate: &privatecapb.Certificate{
// Two week lifetime for CA certificate
Lifetime: durationpb.New(time.Hour * 24 * 14),
CertificateConfig: &privatecapb.Certificate_Config{
Config: &privatecapb.CertificateConfig{
PublicKey: &privatecapb.PublicKey{
Format: privatecapb.PublicKey_PEM,
Key: pemPubKey,
},
X509Config: &privatecapb.X509Parameters{
KeyUsage: &privatecapb.KeyUsage{
BaseKeyUsage: &privatecapb.KeyUsage_KeyUsageOptions{
CertSign: true,
CrlSign: true,
},
ExtendedKeyUsage: &privatecapb.KeyUsage_ExtendedKeyUsageOptions{
CodeSigning: true,
},
},
CaOptions: &privatecapb.X509Parameters_CaOptions{
IsCa: &isCa,
MaxIssuerPathLength: &maxIssuerPathLength,
},
},
SubjectConfig: &privatecapb.CertificateConfig_SubjectConfig{
Subject: &privatecapb.Subject{
CommonName: "sigstore-intermediate",
Organization: "sigstore.dev",
},
},
},
},
},
}

resp, err := client.CreateCertificate(ctx, csr)
if err != nil {
return nil, err
}

var pemCerts []string
pemCerts = append(pemCerts, resp.PemCertificate)
pemCerts = append(pemCerts, resp.PemCertificateChain...)

var parsedCerts []*x509.Certificate
for _, c := range pemCerts {
certs, err := cryptoutils.UnmarshalCertificatesFromPEM([]byte(c))
if err != nil {
return nil, err
}
if len(certs) != 1 {
return nil, errors.New("unexpected number of certificates returned")
}
parsedCerts = append(parsedCerts, certs[0])
}
log.Logger.Info("Current CA certificate chain:")
log.Logger.Info(pemCerts)
haydentherapper marked this conversation as resolved.
Show resolved Hide resolved

return parsedCerts, nil
}

func refreshCACertificate(ctx context.Context, ica *intermediateCA) {
ticker := time.NewTicker(time.Hour * 4)
for range ticker.C {
ica.RLock()
currentCert := ica.certs[0]
ica.RUnlock()

// Refresh certificate 7 days before expiration
if time.Until(currentCert.NotAfter) < (time.Hour * 24 * 7) {
certs, err := fetchCACertificate(ctx, ica.parent, ica.client, ica.signer)
if err != nil {
// An intermittent error is acceptable
log.Logger.Error(err)
haydentherapper marked this conversation as resolved.
Show resolved Hide resolved
continue
}
ica.updatedCerts <- certs
}
}
}

func updateCACertificate(ica *intermediateCA) {
for certs := range ica.updatedCerts {
ica.Lock()
ica.certs = certs
ica.Unlock()
}
}

func NewIntermediateCA(ctx context.Context, parent string) (ca.CertificateAuthority, error) {
var ica intermediateCA

signer, _, err := signature.NewDefaultECDSASignerVerifier()
if err != nil {
return nil, err
}
ica.signer = signer

client, err := privateca.NewCertificateAuthorityClient(ctx)
if err != nil {
return nil, err
}
ica.client = client
ica.parent = parent

ica.certs, err = fetchCACertificate(ctx, ica.parent, ica.client, ica.signer)
if err != nil {
return nil, err
}

ica.updatedCerts = make(chan []*x509.Certificate)

// Start goroutine to periodically check and refresh CA certificate and chain
go refreshCACertificate(ctx, &ica)
// Start goroutine to update CA certificate and chain
go updateCACertificate(&ica)

return &ica, nil
}

func (ica *intermediateCA) CreateCertificate(ctx context.Context, challenge *challenges.ChallengeResult) (*ca.CodeSigningCertificate, error) {
cert, err := x509ca.MakeX509(challenge)
if err != nil {
return nil, err
}

parentCA, privateKey := ica.getX509KeyPair()
haydentherapper marked this conversation as resolved.
Show resolved Hide resolved

finalCertBytes, err := x509.CreateCertificate(rand.Reader, cert, parentCA, challenge.PublicKey, privateKey)
if err != nil {
return nil, err
}

ica.RLock()
defer ica.RUnlock()
return ca.CreateCSCFromDER(challenge, finalCertBytes, ica.certs)
}

func (ica *intermediateCA) Root(ctx context.Context) ([]byte, error) {
ica.RLock()
defer ica.RUnlock()

return cryptoutils.MarshalCertificatesToPEM(ica.certs)
}

func (ica *intermediateCA) getX509KeyPair() (*x509.Certificate, crypto.Signer) {
ica.RLock()
defer ica.RUnlock()
return ica.certs[0], ica.signer
}