From ffdd58ea3c0bc10a85f1e49165e698a523ca159e Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Fri, 12 Feb 2021 12:03:08 +0100 Subject: [PATCH] Add rudimentary (and incomplete) support for SCEP --- authority/authority.go | 10 +- authority/provisioner/provisioner.go | 10 + authority/provisioner/scep.go | 116 +++++++++ ca/ca.go | 22 ++ go.mod | 1 + go.sum | 2 + scep/api.go | 367 +++++++++++++++++++++++++++ scep/authority.go | 88 +++++++ scep/provisioner.go | 17 ++ scep/scep.go | 38 +++ 10 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 authority/provisioner/scep.go create mode 100644 scep/api.go create mode 100644 scep/authority.go create mode 100644 scep/provisioner.go create mode 100644 scep/scep.go diff --git a/authority/authority.go b/authority/authority.go index 4518abdf..34ccf78d 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -329,9 +329,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, diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index aed1900a..c279bcc9 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -141,6 +141,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. @@ -164,6 +166,8 @@ func (t Type) String() string { return "K8sSA" case TypeSSHPOP: return "SSHPOP" + case TypeSCEP: + return "SCEP" default: return "" } @@ -178,6 +182,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). @@ -232,6 +240,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 diff --git a/authority/provisioner/scep.go b/authority/provisioner/scep.go new file mode 100644 index 00000000..741add16 --- /dev/null +++ b/authority/provisioner/scep.go @@ -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) +) diff --git a/ca/ca.go b/ca/ca.go index 3c57b759..37fb6ad4 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -18,6 +18,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" ) @@ -143,6 +144,27 @@ func (ca *CA) Init(config *authority.Config) (*CA, error) { acmeRouterHandler.Route(r) }) + // TODO: THIS SHOULDN'T HAPPEN (or should become configurable) + // Current SCEP client I'm testing with doesn't seem to easily trust untrusted certs. + tlsConfig = nil + + scepPrefix := "scep" + scepAuthority, err := scep.New(auth, scep.AuthorityOptions{ + //Certificates: certificates, + //AuthConfig: *config.AuthorityConfig, + //Backdate: *config.AuthorityConfig.Backdate, + DB: auth.GetDatabase().(nosql.DB), + DNS: dns, + Prefix: scepPrefix, + }) + if err != nil { + return nil, errors.Wrap(err, "error creating SCEP authority") + } + scepRouterHandler := scep.NewAPI(scepAuthority) + 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 { diff --git a/go.mod b/go.mod index 5a1cd270..c81bf9dc 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,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 diff --git a/go.sum b/go.sum index b61746e8..25fef498 100644 --- a/go.sum +++ b/go.sum @@ -214,6 +214,8 @@ github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +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= diff --git a/scep/api.go b/scep/api.go new file mode 100644 index 00000000..1f33dfd5 --- /dev/null +++ b/scep/api.go @@ -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 +} diff --git a/scep/authority.go b/scep/authority.go new file mode 100644 index 00000000..9a10357c --- /dev/null +++ b/scep/authority.go @@ -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) +) diff --git a/scep/provisioner.go b/scep/provisioner.go new file mode 100644 index 00000000..4de40621 --- /dev/null +++ b/scep/provisioner.go @@ -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 +} diff --git a/scep/scep.go b/scep/scep.go new file mode 100644 index 00000000..c88027ff --- /dev/null +++ b/scep/scep.go @@ -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 +}