Add rudimentary (and incomplete) support for SCEP

This commit is contained in:
Herman Slatman 2021-02-12 12:03:08 +01:00 committed by max furman
parent ff7b829aa2
commit 48c86716a0
10 changed files with 698 additions and 12 deletions

View file

@ -442,9 +442,13 @@ func (a *Authority) init() error {
audiences := a.config.GetAudiences()
a.provisioners = provisioner.NewCollection(audiences)
config := provisioner.Config{
Claims: claimer.Claims(),
Audiences: audiences,
DB: a.db,
// TODO: this probably shouldn't happen like this; via SignAuth instead?
IntermediateCert: a.config.IntermediateCert,
SigningKey: a.config.IntermediateKey,
CACertificates: a.rootX509Certs,
Claims: claimer.Claims(),
Audiences: audiences,
DB: a.db,
SSHKeys: &provisioner.SSHKeys{
UserKeys: sshKeys.UserKeys,
HostKeys: sshKeys.HostKeys,

View file

@ -142,6 +142,8 @@ const (
TypeK8sSA Type = 8
// TypeSSHPOP is used to indicate the SSHPOP provisioners.
TypeSSHPOP Type = 9
// TypeSCEP is used to indicate the SCEP provisioners
TypeSCEP Type = 10
)
// String returns the string representation of the type.
@ -165,6 +167,8 @@ func (t Type) String() string {
return "K8sSA"
case TypeSSHPOP:
return "SSHPOP"
case TypeSCEP:
return "SCEP"
default:
return ""
}
@ -179,6 +183,10 @@ type SSHKeys struct {
// Config defines the default parameters used in the initialization of
// provisioners.
type Config struct {
// TODO: these probably shouldn't be here but passed via SignAuth
IntermediateCert string
SigningKey string
CACertificates []*x509.Certificate
// Claims are the default claims.
Claims Claims
// Audiences are the audiences used in the default provisioner, (JWK).
@ -233,6 +241,8 @@ func (l *List) UnmarshalJSON(data []byte) error {
p = &K8sSA{}
case "sshpop":
p = &SSHPOP{}
case "scep":
p = &SCEP{}
default:
// Skip unsupported provisioners. A client using this method may be
// compiled with a version of smallstep/certificates that does not

View file

@ -0,0 +1,116 @@
package provisioner
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"github.com/pkg/errors"
)
// SCEP is the SCEP provisioner type, an entity that can authorize the
// SCEP provisioning flow
type SCEP struct {
*base
Type string `json:"type"`
Name string `json:"name"`
// ForceCN bool `json:"forceCN,omitempty"`
// Claims *Claims `json:"claims,omitempty"`
// Options *Options `json:"options,omitempty"`
// claimer *Claimer
IntermediateCert string
SigningKey string
CACertificates []*x509.Certificate
}
// GetID returns the provisioner unique identifier.
func (s SCEP) GetID() string {
return "scep/" + s.Name
}
// GetName returns the name of the provisioner.
func (s *SCEP) GetName() string {
return s.Name
}
// GetType returns the type of provisioner.
func (s *SCEP) GetType() Type {
return TypeSCEP
}
// GetEncryptedKey returns the base provisioner encrypted key if it's defined.
func (s *SCEP) GetEncryptedKey() (string, string, bool) {
return "", "", false
}
// GetTokenID returns the identifier of the token.
func (s *SCEP) GetTokenID(ott string) (string, error) {
return "", errors.New("scep provisioner does not implement GetTokenID")
}
// GetCACertificates returns the CA certificate chain
// TODO: this should come from the authority instead?
func (s *SCEP) GetCACertificates() []*x509.Certificate {
pemtxt, _ := ioutil.ReadFile(s.IntermediateCert) // TODO: move reading key to init? That's probably safer.
block, _ := pem.Decode([]byte(pemtxt))
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
fmt.Println(err)
}
// TODO: return chain? I'm not sure if the client understands it correctly
return []*x509.Certificate{cert}
}
func (s *SCEP) GetSigningKey() *rsa.PrivateKey {
keyBytes, err := ioutil.ReadFile(s.SigningKey)
if err != nil {
return nil
}
block, _ := pem.Decode([]byte(keyBytes))
if block == nil {
return nil
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
fmt.Println(err)
return nil
}
return key
}
// Init initializes and validates the fields of a JWK type.
func (s *SCEP) Init(config Config) (err error) {
switch {
case s.Type == "":
return errors.New("provisioner type cannot be empty")
case s.Name == "":
return errors.New("provisioner name cannot be empty")
}
// // Update claims with global ones
// if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
// return err
// }
s.IntermediateCert = config.IntermediateCert
s.SigningKey = config.SigningKey
s.CACertificates = config.CACertificates
return err
}
// Interface guards
var (
_ Interface = (*SCEP)(nil)
//_ scep.Provisioner = (*SCEP)(nil)
)

View file

@ -22,6 +22,7 @@ import (
"github.com/smallstep/certificates/db"
"github.com/smallstep/certificates/logging"
"github.com/smallstep/certificates/monitoring"
"github.com/smallstep/certificates/scep"
"github.com/smallstep/certificates/server"
"github.com/smallstep/nosql"
)
@ -179,17 +180,39 @@ func (ca *CA) Init(config *config.Config) (*CA, error) {
mgmtHandler.Route(r)
})
}
if ca.shouldServeSCEPEndpoints() {
scepPrefix := "scep"
scepAuthority, err := scep.New(auth, scep.AuthorityOptions{
Service: auth.GetSCEPService(),
DNS: dns,
Prefix: scepPrefix,
})
if err != nil {
return nil, errors.Wrap(err, "error creating SCEP authority")
}
scepRouterHandler := scepAPI.New(scepAuthority)
// According to the RFC (https://tools.ietf.org/html/rfc8894#section-7.10),
// SCEP operations are performed using HTTP, so that's why the API is mounted
// to the insecure mux.
insecureMux.Route("/"+scepPrefix, func(r chi.Router) {
scepRouterHandler.Route(r)
})
// The RFC also mentions usage of HTTPS, but seems to advise
// against it, because of potential interoperability issues.
// Currently I think it's not bad to use HTTPS also, so that's
// why I've kept the API endpoints in both muxes and both HTTP
// as well as HTTPS can be used to request certificates
// using SCEP.
mux.Route("/"+scepPrefix, func(r chi.Router) {
scepRouterHandler.Route(r)
})
}
// helpful routine for logging all routes //
/*
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
fmt.Printf("%s %s\n", method, route)
return nil
}
if err := chi.Walk(mux, walkFunc); err != nil {
fmt.Printf("Logging err: %s\n", err.Error())
}
*/
//dumpRoutes(mux)
// Add monitoring if configured
if len(config.Monitoring) > 0 {
@ -331,3 +354,23 @@ func (ca *CA) getTLSConfig(auth *authority.Authority) (*tls.Config, error) {
return tlsConfig, nil
}
// shouldMountSCEPEndpoints returns if the CA should be
// configured with endpoints for SCEP. This is assumed to be
// true if a SCEPService exists, which is true in case a
// SCEP provisioner was configured.
func (ca *CA) shouldServeSCEPEndpoints() bool {
return ca.auth.GetSCEPService() != nil
}
//nolint // ignore linters to allow keeping this function around for debugging
func dumpRoutes(mux chi.Routes) {
// helpful routine for logging all routes //
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
fmt.Printf("%s %s\n", method, route)
return nil
}
if err := chi.Walk(mux, walkFunc); err != nil {
fmt.Printf("Logging err: %s\n", err.Error())
}
}

1
go.mod
View file

@ -13,6 +13,7 @@ require (
github.com/google/uuid v1.1.2
github.com/googleapis/gax-go/v2 v2.0.5
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
github.com/micromdm/scep v1.0.0
github.com/newrelic/go-agent v2.15.0+incompatible
github.com/pkg/errors v0.9.1
github.com/rs/xid v1.2.1

2
go.sum
View file

@ -234,6 +234,8 @@ github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGe
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/miekg/pkcs11 v1.0.3-0.20190429190417-a667d056470f h1:eVB9ELsoq5ouItQBr5Tj334bhPJG/MX+m7rTchmzVUQ=
github.com/miekg/pkcs11 v1.0.3-0.20190429190417-a667d056470f/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/micromdm/scep v1.0.0 h1:ai//kcZnxZPq1YE/MatiE2bIRD94KOAwZRpN1fhVQXY=
github.com/micromdm/scep v1.0.0/go.mod h1:CID2SixSr5FvoauZdAFUSpQkn5MAuSy9oyURMGOJbag=
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=

367
scep/api.go Normal file
View file

@ -0,0 +1,367 @@
package scep
import (
"context"
"crypto"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"io/ioutil"
"math/big"
"math/rand"
"net/http"
"strings"
"time"
"github.com/smallstep/certificates/acme"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/authority/provisioner"
microscep "github.com/micromdm/scep/scep"
)
// Handler is the ACME request handler.
type Handler struct {
Auth Interface
}
// New returns a new ACME API router.
func NewAPI(scepAuth Interface) api.RouterHandler {
return &Handler{scepAuth}
}
// Route traffic and implement the Router interface.
func (h *Handler) Route(r api.Router) {
//getLink := h.Auth.GetLinkExplicit
//fmt.Println(getLink)
//r.MethodFunc("GET", "/bla", h.baseURLFromRequest(h.lookupProvisioner(nil)))
//r.MethodFunc("GET", getLink(acme.NewNonceLink, "{provisionerID}", false, nil), h.baseURLFromRequest(h.lookupProvisioner(h.addNonce(h.GetNonce))))
r.MethodFunc(http.MethodGet, "/", h.lookupProvisioner(h.Get))
r.MethodFunc(http.MethodPost, "/", h.lookupProvisioner(h.Post))
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
scepRequest, err := decodeSCEPRequest(r)
if err != nil {
fmt.Println(err)
fmt.Println("not a scep get request")
w.WriteHeader(500)
}
scepResponse := SCEPResponse{Operation: scepRequest.Operation}
switch scepRequest.Operation {
case opnGetCACert:
err := h.GetCACert(w, r, scepResponse)
if err != nil {
fmt.Println(err)
}
case opnGetCACaps:
err := h.GetCACaps(w, r, scepResponse)
if err != nil {
fmt.Println(err)
}
case opnPKIOperation:
default:
}
}
func (h *Handler) Post(w http.ResponseWriter, r *http.Request) {
scepRequest, err := decodeSCEPRequest(r)
if err != nil {
fmt.Println(err)
fmt.Println("not a scep post request")
w.WriteHeader(500)
}
scepResponse := SCEPResponse{Operation: scepRequest.Operation}
switch scepRequest.Operation {
case opnPKIOperation:
err := h.PKIOperation(w, r, scepRequest, scepResponse)
if err != nil {
fmt.Println(err)
}
default:
}
}
const maxPayloadSize = 2 << 20
func decodeSCEPRequest(r *http.Request) (SCEPRequest, error) {
defer r.Body.Close()
method := r.Method
query := r.URL.Query()
var operation string
if _, ok := query["operation"]; ok {
operation = query.Get("operation")
}
switch method {
case http.MethodGet:
switch operation {
case opnGetCACert, opnGetCACaps:
return SCEPRequest{
Operation: operation,
Message: []byte{},
}, nil
case opnPKIOperation:
var message string
if _, ok := query["message"]; ok {
message = query.Get("message")
}
decodedMessage, err := base64.URLEncoding.DecodeString(message)
if err != nil {
return SCEPRequest{}, err
}
return SCEPRequest{
Operation: operation,
Message: decodedMessage,
}, nil
default:
return SCEPRequest{}, fmt.Errorf("unsupported operation: %s", operation)
}
case http.MethodPost:
body, err := ioutil.ReadAll(io.LimitReader(r.Body, maxPayloadSize))
if err != nil {
return SCEPRequest{}, err
}
return SCEPRequest{
Operation: operation,
Message: body,
}, nil
default:
return SCEPRequest{}, fmt.Errorf("unsupported method: %s", method)
}
}
type nextHTTP = func(http.ResponseWriter, *http.Request)
// lookupProvisioner loads the provisioner associated with the request.
// Responds 404 if the provisioner does not exist.
func (h *Handler) lookupProvisioner(next nextHTTP) nextHTTP {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// TODO: make this configurable; and we might want to look at being able to provide multiple,
// like the actual ACME one? The below assumes a SCEP provider (scep/) called "scep1" exists.
p, err := h.Auth.LoadProvisionerByID("scep/scep1")
if err != nil {
api.WriteError(w, err)
return
}
scepProvisioner, ok := p.(*provisioner.SCEP)
if !ok {
api.WriteError(w, acme.AccountDoesNotExistErr(errors.New("provisioner must be of type SCEP")))
return
}
ctx = context.WithValue(ctx, acme.ProvisionerContextKey, Provisioner(scepProvisioner))
next(w, r.WithContext(ctx))
}
}
func (h *Handler) GetCACert(w http.ResponseWriter, r *http.Request, scepResponse SCEPResponse) error {
ctx := r.Context()
p, err := ProvisionerFromContext(ctx)
if err != nil {
return err
}
// TODO: get the CA Certificates from the (signing) authority instead? I think that should be doable
certs := p.GetCACertificates()
if len(certs) == 0 {
scepResponse.CACertNum = 0
scepResponse.Err = errors.New("missing CA Cert")
} else if len(certs) == 1 {
scepResponse.Data = certs[0].Raw
scepResponse.CACertNum = 1
} else {
data, err := microscep.DegenerateCertificates(certs)
scepResponse.Data = data
scepResponse.Err = err
}
return writeSCEPResponse(w, scepResponse)
}
func (h *Handler) GetCACaps(w http.ResponseWriter, r *http.Request, scepResponse SCEPResponse) error {
ctx := r.Context()
_, err := ProvisionerFromContext(ctx)
if err != nil {
return err
}
// TODO: get the actual capabilities from provisioner config
scepResponse.Data = formatCapabilities(defaultCapabilities)
return writeSCEPResponse(w, scepResponse)
}
func (h *Handler) PKIOperation(w http.ResponseWriter, r *http.Request, scepRequest SCEPRequest, scepResponse SCEPResponse) error {
msg, err := microscep.ParsePKIMessage(scepRequest.Message)
if err != nil {
return err
}
ctx := r.Context()
p, err := ProvisionerFromContext(ctx)
if err != nil {
return err
}
certs := p.GetCACertificates()
key := p.GetSigningKey()
ca := certs[0]
if err := msg.DecryptPKIEnvelope(ca, key); err != nil {
return err
}
if msg.MessageType == microscep.PKCSReq {
// TODO: CSR validation, like challenge password
}
csr := msg.CSRReqMessage.CSR
id, err := createKeyIdentifier(csr.PublicKey)
if err != nil {
return err
}
serial := big.NewInt(int64(rand.Int63())) // TODO: serial logic?
days := 40
template := &x509.Certificate{
SerialNumber: serial,
Subject: csr.Subject,
NotBefore: time.Now().Add(-600).UTC(),
NotAfter: time.Now().AddDate(0, 0, days).UTC(),
SubjectKeyId: id,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageClientAuth,
},
SignatureAlgorithm: csr.SignatureAlgorithm,
EmailAddresses: csr.EmailAddresses,
}
certRep, err := msg.SignCSR(ca, key, template)
if err != nil {
return err
}
//cert := certRep.CertRepMessage.Certificate
//name := certName(cert)
// TODO: check if CN already exists, if renewal is allowed and if existing should be revoked; fail if not
// TODO: store the new cert for CN locally
scepResponse.Data = certRep.Raw
return writeSCEPResponse(w, scepResponse)
}
func certName(cert *x509.Certificate) string {
if cert.Subject.CommonName != "" {
return cert.Subject.CommonName
}
return string(cert.Signature)
}
// createKeyIdentifier create an identifier for public keys
// according to the first method in RFC5280 section 4.2.1.2.
func createKeyIdentifier(pub crypto.PublicKey) ([]byte, error) {
keyBytes, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return nil, err
}
id := sha1.Sum(keyBytes)
return id[:], nil
}
func formatCapabilities(caps []string) []byte {
return []byte(strings.Join(caps, "\n"))
}
// writeSCEPResponse writes a SCEP response back to the SCEP client.
func writeSCEPResponse(w http.ResponseWriter, response SCEPResponse) error {
if response.Err != nil {
http.Error(w, response.Err.Error(), http.StatusInternalServerError)
return nil
}
w.Header().Set("Content-Type", contentHeader(response.Operation, response.CACertNum))
w.Write(response.Data)
return nil
}
var (
// TODO: check the default capabilities
defaultCapabilities = []string{
"Renewal",
"SHA-1",
"SHA-256",
"AES",
"DES3",
"SCEPStandard",
"POSTPKIOperation",
}
)
const (
certChainHeader = "application/x-x509-ca-ra-cert"
leafHeader = "application/x-x509-ca-cert"
pkiOpHeader = "application/x-pki-message"
)
func contentHeader(operation string, certNum int) string {
switch operation {
case opnGetCACert:
if certNum > 1 {
return certChainHeader
}
return leafHeader
case opnPKIOperation:
return pkiOpHeader
default:
return "text/plain"
}
}
// ProvisionerFromContext searches the context for a provisioner. Returns the
// provisioner or an error.
func ProvisionerFromContext(ctx context.Context) (Provisioner, error) {
val := ctx.Value(acme.ProvisionerContextKey)
if val == nil {
return nil, acme.ServerInternalErr(errors.New("provisioner expected in request context"))
}
pval, ok := val.(Provisioner)
if !ok || pval == nil {
return nil, acme.ServerInternalErr(errors.New("provisioner in context is not a SCEP provisioner"))
}
return pval, nil
}

88
scep/authority.go Normal file
View file

@ -0,0 +1,88 @@
package scep
import (
"crypto/x509"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/nosql"
)
// Interface is the SCEP authority interface.
type Interface interface {
// GetDirectory(ctx context.Context) (*Directory, error)
// NewNonce() (string, error)
// UseNonce(string) error
// DeactivateAccount(ctx context.Context, accID string) (*Account, error)
// GetAccount(ctx context.Context, accID string) (*Account, error)
// GetAccountByKey(ctx context.Context, key *jose.JSONWebKey) (*Account, error)
// NewAccount(ctx context.Context, ao AccountOptions) (*Account, error)
// UpdateAccount(context.Context, string, []string) (*Account, error)
// GetAuthz(ctx context.Context, accID string, authzID string) (*Authz, error)
// ValidateChallenge(ctx context.Context, accID string, chID string, key *jose.JSONWebKey) (*Challenge, error)
// FinalizeOrder(ctx context.Context, accID string, orderID string, csr *x509.CertificateRequest) (*Order, error)
// GetOrder(ctx context.Context, accID string, orderID string) (*Order, error)
// GetOrdersByAccount(ctx context.Context, accID string) ([]string, error)
// NewOrder(ctx context.Context, oo OrderOptions) (*Order, error)
// GetCertificate(string, string) ([]byte, error)
LoadProvisionerByID(string) (provisioner.Interface, error)
// GetLink(ctx context.Context, linkType Link, absoluteLink bool, inputs ...string) string
// GetLinkExplicit(linkType Link, provName string, absoluteLink bool, baseURL *url.URL, inputs ...string) string
GetCACerts() ([]*x509.Certificate, error)
}
// Authority is the layer that handles all SCEP interactions.
type Authority struct {
//certificates []*x509.Certificate
//authConfig authority.AuthConfig
backdate provisioner.Duration
db nosql.DB
// dir *directory
signAuth SignAuthority
}
// AuthorityOptions required to create a new SCEP Authority.
type AuthorityOptions struct {
Certificates []*x509.Certificate
//AuthConfig authority.AuthConfig
Backdate provisioner.Duration
// DB is the database used by nosql.
DB nosql.DB
// DNS the host used to generate accurate SCEP links. By default the authority
// will use the Host from the request, so this value will only be used if
// request.Host is empty.
DNS string
// Prefix is a URL path prefix under which the SCEP api is served. This
// prefix is required to generate accurate SCEP links.
Prefix string
}
// SignAuthority is the interface implemented by a CA authority.
type SignAuthority interface {
Sign(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error)
LoadProvisionerByID(string) (provisioner.Interface, error)
}
// LoadProvisionerByID calls out to the SignAuthority interface to load a
// provisioner by ID.
func (a *Authority) LoadProvisionerByID(id string) (provisioner.Interface, error) {
return a.signAuth.LoadProvisionerByID(id)
}
func (a *Authority) GetCACerts() ([]*x509.Certificate, error) {
// TODO: implement the SCEP authority
return []*x509.Certificate{}, nil
}
// Interface guards
var (
_ Interface = (*Authority)(nil)
)

17
scep/provisioner.go Normal file
View file

@ -0,0 +1,17 @@
package scep
import (
"crypto/rsa"
"crypto/x509"
)
// Provisioner is an interface that implements a subset of the provisioner.Interface --
// only those methods required by the SCEP api/authority.
type Provisioner interface {
// AuthorizeSign(ctx context.Context, token string) ([]provisioner.SignOption, error)
// GetName() string
// DefaultTLSCertDuration() time.Duration
// GetOptions() *provisioner.Options
GetCACertificates() []*x509.Certificate
GetSigningKey() *rsa.PrivateKey
}

38
scep/scep.go Normal file
View file

@ -0,0 +1,38 @@
package scep
import (
database "github.com/smallstep/certificates/db"
)
const (
opnGetCACert = "GetCACert"
opnGetCACaps = "GetCACaps"
opnPKIOperation = "PKIOperation"
)
// New returns a new Authority that implements the SCEP interface.
func New(signAuth SignAuthority, ops AuthorityOptions) (*Authority, error) {
if _, ok := ops.DB.(*database.SimpleDB); !ok {
// TODO: see ACME implementation
}
return &Authority{
//certificates: ops.Certificates,
backdate: ops.Backdate,
db: ops.DB,
signAuth: signAuth,
}, nil
}
// SCEPRequest is a SCEP server request.
type SCEPRequest struct {
Operation string
Message []byte
}
// SCEPResponse is a SCEP server response.
type SCEPResponse struct {
Operation string
CACertNum int
Data []byte
Err error
}