Skip to content

Commit

Permalink
Embed SCTs in issued certificates
Browse files Browse the repository at this point in the history
This adds support for embedding SCTs in certificates instead of
returning a header with a detached SCTs. This is done by implementing an
SCT interface for a signer. For example, GCP CA Service will not
support embedded SCTs, but KMS will.

This heavily leverages the Go CT library. I've removed the custom
client in favor of the CT library client, which includes more
verification and retry logic. Note that there's a TODO to include the
public key of the CT log in Fulcio so that the SCT is checked before
returning a response.

A certificate is signed twice, which adds an extra remote call to KMS.
The first certificate is added to the CT log via AddPreChain instead of
AddChain.

The Cosign client will need to be updated to support embedded SCTs.

Signed-off-by: Hayden Blauzvern <[email protected]>
  • Loading branch information
haydentherapper committed Apr 9, 2022
1 parent e01e40e commit ff879bc
Show file tree
Hide file tree
Showing 15 changed files with 745 additions and 397 deletions.
26 changes: 22 additions & 4 deletions cmd/app/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"strings"
"time"

ctclient "github.com/google/certificate-transparency-go/client"
"github.com/google/certificate-transparency-go/jsonclient"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/sigstore/fulcio/pkg/api"
Expand All @@ -34,10 +36,10 @@ import (
"github.com/sigstore/fulcio/pkg/ca/kmsca"
"github.com/sigstore/fulcio/pkg/ca/x509ca"
"github.com/sigstore/fulcio/pkg/config"
"github.com/sigstore/fulcio/pkg/ctl"
"github.com/sigstore/fulcio/pkg/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uber.org/zap"
)

const serveCmdEnvPrefix = "FULCIO_SERVE"
Expand Down Expand Up @@ -73,6 +75,15 @@ func newServeCmd() *cobra.Command {
return cmd
}

// Adaptor for logging with the CT log
type logAdaptor struct {
logger *zap.SugaredLogger
}

func (la logAdaptor) Printf(s string, args ...interface{}) {
la.logger.Infof(s, args...)
}

func runServeCmd(cmd *cobra.Command, args []string) {
// If a config file is provided, modify the viper config to locate and read it
if err := checkServeCmdConfigFile(); err != nil {
Expand Down Expand Up @@ -181,10 +192,17 @@ func runServeCmd(cmd *cobra.Command, args []string) {
host, port := viper.GetString("host"), viper.GetString("port")
log.Logger.Infof("%s:%s", host, port)

var ctClient ctl.Client
var ctClient *ctclient.LogClient
if logURL := viper.GetString("ct-log-url"); logURL != "" {
ctClient = ctl.New(logURL)
ctClient = ctl.WithLogging(ctClient, log.Logger)
ctClient, err = ctclient.New(logURL,
&http.Client{Timeout: 30 * time.Second},
jsonclient.Options{
Logger: logAdaptor{logger: log.Logger},
// TODO: Add public key from CT Log for verification.
})
if err != nil {
log.Logger.Fatal(err)
}
}

var handler http.Handler
Expand Down
6 changes: 3 additions & 3 deletions config/logid.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@


function get_log_id() {
curl -s --retry-connrefused --retry 10 http://trillian-log-server:8090/metrics |grep "^quota_acquired_tokens{spec=\"trees"|head -1|awk ' { print $1 } '|sed -e 's/[^0-9]*//g' > /tmp/logid
curl -s --retry-connrefused --retry 10 http://trillian-log-server:8095/metrics |grep "^quota_acquired_tokens{spec=\"trees"|head -1|awk ' { print $1 } '|sed -e 's/[^0-9]*//g' > /tmp/logid
}

function create_log () {
/go/bin/createtree -admin_server trillian-log-server:8091 > /tmp/logid
/go/bin/createtree -admin_server trillian-log-server:8096 > /tmp/logid
echo -n "Created log ID " && cat /tmp/logid
}

Expand Down Expand Up @@ -48,5 +48,5 @@ if ! [[ -s /etc/config/ct_server.cfg ]]; then
else
echo " found."
configid=`cat /etc/config/ct_server.cfg|grep log_id|awk ' { print $2 } '`
echo "Exisiting configuration uses log ID $configid, exiting"
echo "Existing configuration uses log ID $configid, exiting"
fi
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/coreos/go-oidc/v3 v3.1.0
github.com/fsnotify/fsnotify v1.5.1
github.com/go-chi/chi v4.1.2+incompatible
github.com/google/certificate-transparency-go v1.1.2
github.com/google/go-cmp v0.5.7
github.com/hashicorp/golang-lru v0.5.4
github.com/magiconair/properties v1.8.6
Expand All @@ -19,7 +20,6 @@ require (
github.com/sigstore/sigstore v1.2.1-0.20220401110139-0e610e39782f
github.com/spf13/cobra v1.4.0
github.com/spf13/viper v1.10.1
github.com/stretchr/testify v1.7.1
go.step.sm/crypto v0.16.1
go.uber.org/multierr v1.7.0 // indirect
go.uber.org/zap v1.21.0
Expand Down
485 changes: 482 additions & 3 deletions go.sum

Large diffs are not rendered by default.

59 changes: 18 additions & 41 deletions pkg/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,10 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
Expand All @@ -39,18 +37,17 @@ import (
"testing"
"time"

ctclient "github.com/google/certificate-transparency-go/client"
"github.com/google/certificate-transparency-go/jsonclient"
"github.com/sigstore/fulcio/pkg/ca"
"github.com/sigstore/fulcio/pkg/ca/ephemeralca"
"github.com/sigstore/fulcio/pkg/challenges"
"github.com/sigstore/fulcio/pkg/config"
"github.com/sigstore/fulcio/pkg/ctl"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
)

// base64 encoded placeholder for SCT
const (
testSCT = "ZXhhbXBsZXNjdAo="
expectedNoRootMessage = "{\"code\":500,\"message\":\"error communicating with CA backend\"}\n"
)

Expand Down Expand Up @@ -626,44 +623,17 @@ func newOIDCIssuer(t *testing.T) (jose.Signer, string) {
return signer, *testIssuer
}

// This is private in pkg/ctl, so making a copy here.
type certChain struct {
Chain []string `json:"chain"`
}

func fakeCTLogServer(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("No body")
}
var chain certChain
json.Unmarshal(body, &chain)
if len(chain.Chain) != 2 {
t.Fatalf("did not get expected chain for input, wanted 2 entries, got %d", len(chain.Chain))
}
// Just make sure we can decode it.
for _, chainEntry := range chain.Chain {
_, err := base64.StdEncoding.DecodeString(chainEntry)
if err != nil {
t.Fatalf("failed to decode incoming chain entry: %v", err)
}
}

// Create a fake response.
resp := &ctl.CertChainResponse{
SctVersion: 1,
ID: "testid",
Timestamp: time.Now().Unix(),
}
responseBytes, err := json.Marshal(&resp)
if err != nil {
t.Fatalf("failed to marshal response: %v", err)
}
w.WriteHeader(http.StatusOK)
w.Header().Set("SCT", testSCT)
fmt.Fprint(w, string(responseBytes))
addJSONResp := `{
"sct_version":0,
"id":"KHYaGJAn++880NYaAY12sFBXKcenQRvMvfYE9F1CYVM=",
"timestamp":1337,
"extensions":"",
"signature":"BAMARjBEAiAIc21J5ZbdKZHw5wLxCP+MhBEsV5+nfvGyakOIv6FOvAIgWYMZb6Pw///uiNM7QTg2Of1OqmK1GbeGuEl9VJN8v8c="
}`
fmt.Fprint(w, string(addJSONResp))
}))
}

Expand All @@ -681,7 +651,13 @@ func createCA(cfg *config.FulcioConfig, t *testing.T) (*ephemeralca.EphemeralCA,
}

// Create a test HTTP server to host our API.
h := New(ctl.New(ctlogServer.URL), eca)
ctClient, err := ctclient.New(ctlogServer.URL,
&http.Client{Timeout: 30 * time.Second},
jsonclient.Options{})
if err != nil {
t.Fatalf("error creating CT client: %v", err)
}
h := New(ctClient, eca)
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// For each request, infuse context with our snapshot of the FulcioConfig.
Expand All @@ -690,6 +666,7 @@ func createCA(cfg *config.FulcioConfig, t *testing.T) (*ephemeralca.EphemeralCA,
h.ServeHTTP(rw, r.WithContext(ctx))
}))
t.Cleanup(server.Close)
t.Cleanup(ctlogServer.Close)

return eca, server.URL
}
Expand Down
57 changes: 49 additions & 8 deletions pkg/api/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ import (
"strings"

"github.com/coreos/go-oidc/v3/oidc"
ct "github.com/google/certificate-transparency-go"
ctclient "github.com/google/certificate-transparency-go/client"
certauth "github.com/sigstore/fulcio/pkg/ca"
"github.com/sigstore/fulcio/pkg/challenges"
"github.com/sigstore/fulcio/pkg/config"
"github.com/sigstore/fulcio/pkg/ctl"
"github.com/sigstore/fulcio/pkg/log"
"github.com/sigstore/sigstore/pkg/cryptoutils"
)
Expand All @@ -56,14 +57,14 @@ const (
)

type api struct {
ct ctl.Client
ct *ctclient.LogClient
ca certauth.CertificateAuthority

*http.ServeMux
}

// New creates a new http.Handler for serving the Fulcio API.
func New(ct ctl.Client, ca certauth.CertificateAuthority) http.Handler {
func New(ct *ctclient.LogClient, ca certauth.CertificateAuthority) http.Handler {
var a api
a.ServeMux = http.NewServeMux()
a.HandleFunc(signingCertPath, a.signingCert)
Expand Down Expand Up @@ -177,8 +178,8 @@ func (a *api) signingCert(w http.ResponseWriter, req *http.Request) {

var csc *certauth.CodeSigningCertificate
var sctBytes []byte
// TODO: prefer embedding SCT if possible
if _, ok := a.ca.(certauth.EmbeddedSCTCA); !ok {
// For CAs that do not support embedded SCTs or if the CT log is not configured
if sctCa, ok := a.ca.(certauth.EmbeddedSCTCA); !ok || a.ct == nil {
// currently configured CA doesn't support pre-certificate flow required to embed SCT in final certificate
csc, err = a.ca.CreateCertificate(ctx, subject)
if err != nil {
Expand All @@ -192,9 +193,14 @@ func (a *api) signingCert(w http.ResponseWriter, req *http.Request) {
return
}

// Submit to CTL
// submit to CTL
if a.ct != nil {
sct, err := a.ct.AddChain(csc)
chain := []ct.ASN1Cert{}
chain = append(chain, ct.ASN1Cert{Data: csc.FinalCertificate.Raw})
for _, c := range csc.FinalChain {
chain = append(chain, ct.ASN1Cert{Data: c.Raw})
}
sct, err := a.ct.AddChain(ctx, chain)
if err != nil {
handleFulcioAPIError(w, req, http.StatusInternalServerError, err, failedToEnterCertInCTL)
return
Expand All @@ -207,6 +213,39 @@ func (a *api) signingCert(w http.ResponseWriter, req *http.Request) {
} else {
logger.Info("Skipping CT log upload.")
}
} else {
precert, err := sctCa.CreatePrecertificate(ctx, subject)
if err != nil {
// if the error was due to invalid input in the request, return HTTP 400
if _, ok := err.(certauth.ValidationError); ok {
handleFulcioAPIError(w, req, http.StatusBadRequest, err, err.Error())
return
}
// otherwise return a 500 error to reflect that it is a transient server issue that the client can't resolve
handleFulcioAPIError(w, req, http.StatusInternalServerError, err, genericCAError)
}
// submit precertificate and chain to CT log
chain := []ct.ASN1Cert{}
chain = append(chain, ct.ASN1Cert{Data: precert.PreCert.Raw})
for _, c := range precert.CertChain {
chain = append(chain, ct.ASN1Cert{Data: c.Raw})
}
sct, err := a.ct.AddPreChain(ctx, chain)
if err != nil {
handleFulcioAPIError(w, req, http.StatusInternalServerError, err, failedToEnterCertInCTL)
return
}
csc, err = sctCa.IssueFinalCertificate(ctx, precert, sct)
if err != nil {
// if the error was due to invalid input in the request, return HTTP 400
if _, ok := err.(certauth.ValidationError); ok {
handleFulcioAPIError(w, req, http.StatusBadRequest, err, err.Error())
return
}
// otherwise return a 500 error to reflect that it is a transient server issue that the client can't resolve
handleFulcioAPIError(w, req, http.StatusInternalServerError, err, genericCAError)
return
}
}

metricNewEntries.Inc()
Expand Down Expand Up @@ -235,7 +274,9 @@ func (a *api) signingCert(w http.ResponseWriter, req *http.Request) {
}

// Set the SCT and Content-Type headers, and then respond with a 201 Created.
w.Header().Add("SCT", base64.StdEncoding.EncodeToString(sctBytes))
if len(sctBytes) != 0 {
w.Header().Add("SCT", base64.StdEncoding.EncodeToString(sctBytes))
}
w.Header().Add("Content-Type", "application/pem-certificate-chain")
w.WriteHeader(http.StatusCreated)
// Write the PEM encoded certificate chain to the response body.
Expand Down
15 changes: 13 additions & 2 deletions pkg/ca/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ package ca
import (
"bytes"
"context"
"crypto"
"crypto/x509"
"strings"

ct "github.com/google/certificate-transparency-go"
"github.com/sigstore/fulcio/pkg/challenges"
"github.com/sigstore/sigstore/pkg/cryptoutils"
)
Expand All @@ -33,9 +35,17 @@ type CodeSigningCertificate struct {
finalChainPEM []byte
}

// CodeSigningPreCertificate holds a precertificate and chain.
type CodeSigningPreCertificate struct {
// Subject contains information about the OIDC identity of the caller.
Subject *challenges.ChallengeResult
// PreCert contains the precertificate. Not a valid certificate due to a critical poison extension.
PreCert *x509.Certificate
// CertChain contains the certificate chain to verify the precertificate.
CertChain []*x509.Certificate
// PrivateKey contains the signing key used to sign the precertificate. Will be used to sign the certificate.
// Included in case the signing key is rotated in between precertificate generation and final issuance.
PrivateKey crypto.Signer
}

func CreateCSCFromPEM(subject *challenges.ChallengeResult, cert string, chain []string) (*CodeSigningCertificate, error) {
Expand Down Expand Up @@ -107,15 +117,16 @@ func (c *CodeSigningCertificate) ChainPEM() ([]byte, error) {
return c.finalChainPEM, err
}

// CertificateAuthority only returns the SCT in detached format
// CertificateAuthority implements certificate creation with a detached SCT and fetching the CA trust bundle.
type CertificateAuthority interface {
CreateCertificate(ctx context.Context, challenge *challenges.ChallengeResult) (*CodeSigningCertificate, error)
Root(ctx context.Context) ([]byte, error)
}

// EmbeddedSCTCA implements precertificate and certificate issuance. Certificates will contain an embedded SCT.
type EmbeddedSCTCA interface {
CreatePrecertificate(ctx context.Context, challenge *challenges.ChallengeResult) (*CodeSigningPreCertificate, error)
IssueFinalCertificate(ctx context.Context, precert *CodeSigningPreCertificate) (*CodeSigningCertificate, error)
IssueFinalCertificate(ctx context.Context, precert *CodeSigningPreCertificate, sct *ct.SignedCertificateTimestamp) (*CodeSigningCertificate, error)
}

// ValidationError indicates that there is an issue with the content in the HTTP Request that
Expand Down
Loading

0 comments on commit ff879bc

Please sign in to comment.