mirror of
https://github.com/smallstep/certificates.git
synced 2024-10-31 03:20:16 +00:00
477 lines
14 KiB
Go
477 lines
14 KiB
Go
// Package api implements a SCEP HTTP server.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/smallstep/pkcs7"
|
|
smallscep "github.com/smallstep/scep"
|
|
|
|
"github.com/smallstep/certificates/api"
|
|
"github.com/smallstep/certificates/api/log"
|
|
"github.com/smallstep/certificates/authority"
|
|
"github.com/smallstep/certificates/authority/provisioner"
|
|
"github.com/smallstep/certificates/scep"
|
|
)
|
|
|
|
const (
|
|
opnGetCACert = "GetCACert"
|
|
opnGetCACaps = "GetCACaps"
|
|
opnPKIOperation = "PKIOperation"
|
|
|
|
// TODO: add other (more optional) operations and handling
|
|
)
|
|
|
|
const maxPayloadSize = 2 << 20
|
|
|
|
// request is a SCEP server request.
|
|
type request struct {
|
|
Operation string
|
|
Message []byte
|
|
}
|
|
|
|
// Response is a SCEP server Response.
|
|
type Response struct {
|
|
Operation string
|
|
CACertNum int
|
|
Data []byte
|
|
Certificate *x509.Certificate
|
|
Error error
|
|
}
|
|
|
|
// handler is the SCEP request handler.
|
|
type handler struct {
|
|
auth *scep.Authority
|
|
}
|
|
|
|
// Route traffic and implement the Router interface.
|
|
//
|
|
// Deprecated: use scep.Route(r api.Router)
|
|
func (h *handler) Route(r api.Router) {
|
|
route(r, func(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := scep.NewContext(r.Context(), h.auth)
|
|
next(w, r.WithContext(ctx))
|
|
}
|
|
})
|
|
}
|
|
|
|
// New returns a new SCEP API router.
|
|
//
|
|
// Deprecated: use scep.Route(r api.Router)
|
|
func New(auth *scep.Authority) api.RouterHandler {
|
|
return &handler{auth: auth}
|
|
}
|
|
|
|
// Route traffic and implement the Router interface.
|
|
func Route(r api.Router) {
|
|
route(r, nil)
|
|
}
|
|
|
|
func route(r api.Router, middleware func(next http.HandlerFunc) http.HandlerFunc) {
|
|
getHandler := lookupProvisioner(Get)
|
|
postHandler := lookupProvisioner(Post)
|
|
|
|
// For backward compatibility.
|
|
if middleware != nil {
|
|
getHandler = middleware(getHandler)
|
|
postHandler = middleware(postHandler)
|
|
}
|
|
|
|
r.MethodFunc(http.MethodGet, "/{provisionerName}/*", getHandler)
|
|
r.MethodFunc(http.MethodGet, "/{provisionerName}", getHandler)
|
|
r.MethodFunc(http.MethodPost, "/{provisionerName}/*", postHandler)
|
|
r.MethodFunc(http.MethodPost, "/{provisionerName}", postHandler)
|
|
}
|
|
|
|
// Get handles all SCEP GET requests
|
|
func Get(w http.ResponseWriter, r *http.Request) {
|
|
req, err := decodeRequest(r)
|
|
if err != nil {
|
|
fail(w, fmt.Errorf("invalid scep get request: %w", err))
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
var res Response
|
|
|
|
switch req.Operation {
|
|
case opnGetCACert:
|
|
res, err = GetCACert(ctx)
|
|
case opnGetCACaps:
|
|
res, err = GetCACaps(ctx)
|
|
case opnPKIOperation:
|
|
res, err = PKIOperation(ctx, req)
|
|
default:
|
|
err = fmt.Errorf("unknown operation: %s", req.Operation)
|
|
}
|
|
|
|
if err != nil {
|
|
fail(w, fmt.Errorf("scep get request failed: %w", err))
|
|
return
|
|
}
|
|
|
|
writeResponse(w, res)
|
|
}
|
|
|
|
// Post handles all SCEP POST requests
|
|
func Post(w http.ResponseWriter, r *http.Request) {
|
|
req, err := decodeRequest(r)
|
|
if err != nil {
|
|
fail(w, fmt.Errorf("invalid scep post request: %w", err))
|
|
return
|
|
}
|
|
|
|
var res Response
|
|
switch req.Operation {
|
|
case opnPKIOperation:
|
|
res, err = PKIOperation(r.Context(), req)
|
|
default:
|
|
err = fmt.Errorf("unknown operation: %s", req.Operation)
|
|
}
|
|
|
|
if err != nil {
|
|
fail(w, fmt.Errorf("scep post request failed: %w", err))
|
|
return
|
|
}
|
|
|
|
writeResponse(w, res)
|
|
}
|
|
|
|
func decodeRequest(r *http.Request) (request, error) {
|
|
defer r.Body.Close()
|
|
|
|
method := r.Method
|
|
query, err := url.ParseQuery(r.URL.RawQuery)
|
|
if err != nil {
|
|
return request{}, fmt.Errorf("failed parsing URL query: %w", err)
|
|
}
|
|
|
|
operation := query.Get("operation")
|
|
if operation == "" {
|
|
return request{}, errors.New("no operation provided")
|
|
}
|
|
|
|
switch method {
|
|
case http.MethodGet:
|
|
switch operation {
|
|
case opnGetCACert, opnGetCACaps:
|
|
return request{
|
|
Operation: operation,
|
|
Message: []byte{},
|
|
}, nil
|
|
case opnPKIOperation:
|
|
message := query.Get("message")
|
|
decodedMessage, err := decodeMessage(message, r)
|
|
if err != nil {
|
|
return request{}, fmt.Errorf("failed decoding message: %w", err)
|
|
}
|
|
return request{
|
|
Operation: operation,
|
|
Message: decodedMessage,
|
|
}, nil
|
|
default:
|
|
return request{}, fmt.Errorf("unsupported operation: %s", operation)
|
|
}
|
|
case http.MethodPost:
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxPayloadSize))
|
|
if err != nil {
|
|
return request{}, fmt.Errorf("failed reading request body: %w", err)
|
|
}
|
|
return request{
|
|
Operation: operation,
|
|
Message: body,
|
|
}, nil
|
|
default:
|
|
return request{}, fmt.Errorf("unsupported method: %s", method)
|
|
}
|
|
}
|
|
|
|
func decodeMessage(message string, r *http.Request) ([]byte, error) {
|
|
if message == "" {
|
|
return nil, errors.New("message must not be empty")
|
|
}
|
|
|
|
// decode the message, which should be base64 standard encoded. Any characters that
|
|
// were escaped in the original query, were unescaped as part of url.ParseQuery, so
|
|
// that doesn't need to be performed here. Return early if successful.
|
|
decodedMessage, err := base64.StdEncoding.DecodeString(message)
|
|
if err == nil {
|
|
return decodedMessage, nil
|
|
}
|
|
|
|
// only interested in corrupt input errors below this. This type of error is the
|
|
// most likely to return, but better safe than sorry.
|
|
var cie base64.CorruptInputError
|
|
if !errors.As(err, &cie) {
|
|
return nil, fmt.Errorf("failed base64 decoding message: %w", err)
|
|
}
|
|
|
|
// the below code is a workaround for macOS when it sends a GET PKIOperation, which seems to result
|
|
// in a query with the '+' and '/' not being percent encoded; only the padding ('=') is encoded.
|
|
// When that is unescaped in the code before this, this results in invalid base64. The workaround
|
|
// is to obtain the original query, extract the message, apply transformation(s) to make it valid
|
|
// base64 and try decoding it again. If it succeeds, the happy path can be followed with the patched
|
|
// message. Otherwise we still return an error.
|
|
rawQuery, err := parseRawQuery(r.URL.RawQuery)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse raw query: %w", err)
|
|
}
|
|
|
|
rawMessage := rawQuery.Get("message")
|
|
if rawMessage == "" {
|
|
return nil, errors.New("no message in raw query")
|
|
}
|
|
|
|
rawMessage = strings.ReplaceAll(rawMessage, "%3D", "=") // apparently the padding arrives encoded; the others (+, /) not?
|
|
decodedMessage, err = base64.StdEncoding.DecodeString(rawMessage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed base64 decoding raw message: %w", err)
|
|
}
|
|
|
|
return decodedMessage, nil
|
|
}
|
|
|
|
// parseRawQuery parses a URL query into url.Values. It skips
|
|
// unescaping keys and values. This code is based on url.ParseQuery.
|
|
func parseRawQuery(query string) (url.Values, error) {
|
|
m := make(url.Values)
|
|
err := parseRawQueryWithoutUnescaping(m, query)
|
|
return m, err
|
|
}
|
|
|
|
// parseRawQueryWithoutUnescaping parses the raw query into url.Values, skipping
|
|
// unescaping of the parts. This code is based on url.parseQuery.
|
|
func parseRawQueryWithoutUnescaping(m url.Values, query string) (err error) {
|
|
for query != "" {
|
|
var key string
|
|
key, query, _ = strings.Cut(query, "&")
|
|
if strings.Contains(key, ";") {
|
|
return errors.New("invalid semicolon separator in query")
|
|
}
|
|
if key == "" {
|
|
continue
|
|
}
|
|
key, value, _ := strings.Cut(key, "=")
|
|
m[key] = append(m[key], value)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// lookupProvisioner loads the provisioner associated with the request.
|
|
// Responds 404 if the provisioner does not exist.
|
|
func lookupProvisioner(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "provisionerName")
|
|
provisionerName, err := url.PathUnescape(name)
|
|
if err != nil {
|
|
fail(w, fmt.Errorf("error url unescaping provisioner name '%s'", name))
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
auth := authority.MustFromContext(ctx)
|
|
p, err := auth.LoadProvisionerByName(provisionerName)
|
|
if err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
|
|
prov, ok := p.(*provisioner.SCEP)
|
|
if !ok {
|
|
fail(w, errors.New("provisioner must be of type SCEP"))
|
|
return
|
|
}
|
|
|
|
ctx = scep.NewProvisionerContext(ctx, scep.Provisioner(prov))
|
|
next(w, r.WithContext(ctx))
|
|
}
|
|
}
|
|
|
|
// GetCACert returns the CA certificates in a SCEP response
|
|
func GetCACert(ctx context.Context) (Response, error) {
|
|
auth := scep.MustFromContext(ctx)
|
|
certs, err := auth.GetCACertificates(ctx)
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
|
|
if len(certs) == 0 {
|
|
return Response{}, errors.New("missing CA cert")
|
|
}
|
|
|
|
res := Response{
|
|
Operation: opnGetCACert,
|
|
CACertNum: len(certs),
|
|
}
|
|
|
|
if len(certs) == 1 {
|
|
res.Data = certs[0].Raw
|
|
} else {
|
|
// create degenerate pkcs7 certificate structure, according to
|
|
// https://tools.ietf.org/html/rfc8894#section-4.2.1.2, because
|
|
// not signed or encrypted data has to be returned.
|
|
data, err := smallscep.DegenerateCertificates(certs)
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
res.Data = data
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// GetCACaps returns the CA capabilities in a SCEP response
|
|
func GetCACaps(ctx context.Context) (Response, error) {
|
|
auth := scep.MustFromContext(ctx)
|
|
caps := auth.GetCACaps(ctx)
|
|
|
|
res := Response{
|
|
Operation: opnGetCACaps,
|
|
Data: formatCapabilities(caps),
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// PKIOperation performs PKI operations and returns a SCEP response
|
|
func PKIOperation(ctx context.Context, req request) (Response, error) {
|
|
// parse the message using smallscep implementation
|
|
microMsg, err := smallscep.ParsePKIMessage(req.Message)
|
|
if err != nil {
|
|
// return the error, because we can't use the msg for creating a CertRep
|
|
return Response{}, err
|
|
}
|
|
|
|
// this is essentially doing the same as smallscep.ParsePKIMessage, but
|
|
// gives us access to the p7 itself in scep.PKIMessage. Essentially a small
|
|
// wrapper for the smallscep implementation.
|
|
p7, err := pkcs7.Parse(microMsg.Raw)
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
|
|
// copy over properties to our internal PKIMessage
|
|
msg := &scep.PKIMessage{
|
|
TransactionID: microMsg.TransactionID,
|
|
MessageType: microMsg.MessageType,
|
|
SenderNonce: microMsg.SenderNonce,
|
|
Raw: microMsg.Raw,
|
|
P7: p7,
|
|
}
|
|
|
|
auth := scep.MustFromContext(ctx)
|
|
if err := auth.DecryptPKIEnvelope(ctx, msg); err != nil {
|
|
return Response{}, err
|
|
}
|
|
|
|
// NOTE: at this point we have sufficient information for returning nicely signed CertReps
|
|
csr := msg.CSRReqMessage.CSR
|
|
transactionID := string(msg.TransactionID)
|
|
challengePassword := msg.CSRReqMessage.ChallengePassword
|
|
|
|
// NOTE: we're blocking the RenewalReq if the challenge does not match, because otherwise we don't have any authentication.
|
|
// The macOS SCEP client performs renewals using PKCSreq. The CertNanny SCEP client will use PKCSreq with challenge too, it seems,
|
|
// even if using the renewal flow as described in the README.md. MicroMDM SCEP client also only does PKCSreq by default, unless
|
|
// a certificate exists; then it will use RenewalReq. Adding the challenge check here may be a small breaking change for clients.
|
|
// We'll have to see how it works out.
|
|
if msg.MessageType == smallscep.PKCSReq || msg.MessageType == smallscep.RenewalReq {
|
|
if err := auth.ValidateChallenge(ctx, csr, challengePassword, transactionID); err != nil {
|
|
if errors.Is(err, provisioner.ErrSCEPChallengeInvalid) {
|
|
return createFailureResponse(ctx, csr, msg, smallscep.BadRequest, err)
|
|
}
|
|
return createFailureResponse(ctx, csr, msg, smallscep.BadRequest, errors.New("failed validating challenge password"))
|
|
}
|
|
}
|
|
|
|
// TODO: authorize renewal: we can authorize renewals with the challenge password (if reusable secrets are used).
|
|
// Renewals OPTIONALLY include the challenge if the existing cert is used as authentication, but client SHOULD omit the challenge.
|
|
// This means that for renewal requests we should check the certificate provided to be signed before by the CA. We could
|
|
// enforce use of the challenge if we want too. That way we could be more flexible in terms of authentication scheme (i.e. reusing
|
|
// tokens from other provisioners, calling a webhook, storing multiple secrets, allowing them to be multi-use, etc).
|
|
// Authentication by the (self-signed) certificate with an optional challenge is required; supporting renewals incl. verification
|
|
// of the client cert is not.
|
|
|
|
certRep, err := auth.SignCSR(ctx, csr, msg)
|
|
if err != nil {
|
|
if notifyErr := auth.NotifyFailure(ctx, csr, transactionID, 0, err.Error()); notifyErr != nil {
|
|
// TODO(hs): ignore this error case? It's not critical if the notification fails; but logging it might be good
|
|
_ = notifyErr
|
|
}
|
|
return createFailureResponse(ctx, csr, msg, smallscep.BadRequest, fmt.Errorf("error when signing new certificate: %w", err))
|
|
}
|
|
|
|
if notifyErr := auth.NotifySuccess(ctx, csr, certRep.Certificate, transactionID); notifyErr != nil {
|
|
// TODO(hs): ignore this error case? It's not critical if the notification fails; but logging it might be good
|
|
_ = notifyErr
|
|
}
|
|
|
|
res := Response{
|
|
Operation: opnPKIOperation,
|
|
Data: certRep.Raw,
|
|
Certificate: certRep.Certificate,
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func formatCapabilities(caps []string) []byte {
|
|
return []byte(strings.Join(caps, "\r\n"))
|
|
}
|
|
|
|
// writeResponse writes a SCEP response back to the SCEP client.
|
|
func writeResponse(w http.ResponseWriter, res Response) {
|
|
if res.Error != nil {
|
|
log.Error(w, res.Error)
|
|
}
|
|
|
|
if res.Certificate != nil {
|
|
api.LogCertificate(w, res.Certificate)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", contentHeader(res))
|
|
_, _ = w.Write(res.Data)
|
|
}
|
|
|
|
func fail(w http.ResponseWriter, err error) {
|
|
log.Error(w, err)
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
func createFailureResponse(ctx context.Context, csr *x509.CertificateRequest, msg *scep.PKIMessage, info smallscep.FailInfo, failError error) (Response, error) {
|
|
auth := scep.MustFromContext(ctx)
|
|
certRepMsg, err := auth.CreateFailureResponse(ctx, csr, msg, scep.FailInfoName(info), failError.Error())
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
return Response{
|
|
Operation: opnPKIOperation,
|
|
Data: certRepMsg.Raw,
|
|
Error: failError,
|
|
}, nil
|
|
}
|
|
|
|
func contentHeader(r Response) string {
|
|
switch r.Operation {
|
|
default:
|
|
return "text/plain"
|
|
case opnGetCACert:
|
|
if r.CACertNum > 1 {
|
|
return "application/x-x509-ca-ra-cert"
|
|
}
|
|
return "application/x-x509-ca-cert"
|
|
case opnPKIOperation:
|
|
return "application/x-pki-message"
|
|
}
|
|
}
|