forked from TrueCloudLab/certificates
Compare commits
6 commits
master
...
herman/sce
Author | SHA1 | Date | |
---|---|---|---|
|
b2bf2c330b | ||
|
8fc3a46387 | ||
|
6985b4be62 | ||
|
a1f187e3df | ||
|
180162bd6a | ||
|
0377fe559b |
15 changed files with 353 additions and 282 deletions
21
api/api.go
21
api/api.go
|
@ -244,11 +244,24 @@ func (p ProvisionersResponse) MarshalJSON() ([]byte, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
old := scepProv.ChallengePassword
|
||||
type old struct {
|
||||
challengePassword string
|
||||
decrypterCertificate []byte
|
||||
decrypterKey string
|
||||
decrypterKeyPassword string
|
||||
}
|
||||
o := old{scepProv.ChallengePassword, scepProv.DecrypterCertificate, scepProv.DecrypterKey, scepProv.DecrypterKeyPassword}
|
||||
scepProv.ChallengePassword = "*** REDACTED ***"
|
||||
defer func(p string) { //nolint:gocritic // defer in loop required to restore initial state of provisioners
|
||||
scepProv.ChallengePassword = p
|
||||
}(old)
|
||||
scepProv.DecrypterCertificate = []byte("*** REDACTED ***")
|
||||
scepProv.DecrypterKey = "*** REDACTED ***"
|
||||
scepProv.DecrypterKeyPassword = "*** REDACTED ***"
|
||||
|
||||
defer func(o old) { //nolint:gocritic // defer in loop required to restore initial state of provisioners
|
||||
scepProv.ChallengePassword = o.challengePassword
|
||||
scepProv.DecrypterCertificate = o.decrypterCertificate
|
||||
scepProv.DecrypterKey = o.decrypterKey
|
||||
scepProv.DecrypterKeyPassword = o.decrypterKeyPassword
|
||||
}(o)
|
||||
}
|
||||
|
||||
var list = struct {
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
|
@ -61,7 +62,7 @@ type Authority struct {
|
|||
x509Enforcers []provisioner.CertificateEnforcer
|
||||
|
||||
// SCEP CA
|
||||
scepService *scep.Service
|
||||
scepAuthority *scep.Authority
|
||||
|
||||
// SSH CA
|
||||
sshHostPassword []byte
|
||||
|
@ -261,6 +262,15 @@ func (a *Authority) ReloadAdminResources(ctx context.Context) error {
|
|||
a.config.AuthorityConfig.Admins = adminList
|
||||
a.admins = adminClxn
|
||||
|
||||
// update the SCEP service with the currently active SCEP
|
||||
// provisioner names and revalidate the configuration.
|
||||
if a.scepAuthority != nil {
|
||||
a.scepAuthority.UpdateProvisioners(a.getSCEPProvisionerNames())
|
||||
if err := a.scepAuthority.Validate(); err != nil {
|
||||
log.Printf("failed validating SCEP authority: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -640,48 +650,63 @@ func (a *Authority) init() error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Check if a KMS with decryption capability is required and available
|
||||
if a.requiresDecrypter() {
|
||||
if _, ok := a.keyManager.(kmsapi.Decrypter); !ok {
|
||||
return errors.New("keymanager doesn't provide crypto.Decrypter")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: decide if this is a good approach for providing the SCEP functionality
|
||||
// It currently mirrors the logic for the x509CAService
|
||||
if a.requiresSCEPService() && a.scepService == nil {
|
||||
// The SCEP functionality is provided through an instance of
|
||||
// scep.Service. It is initialized once when the CA is started.
|
||||
// TODO(hs): should the SCEP service support reloading? For example,
|
||||
// when the admin resources are reloaded, specifically the provisioners,
|
||||
// it can happen that the SCEP service is no longer required and can
|
||||
// be destroyed, or that it needs to be instantiated. It may also need
|
||||
// to be revalidated, because not all SCEP provisioner may have a
|
||||
// valid decrypter available.
|
||||
if a.requiresSCEP() && a.GetSCEP() == nil {
|
||||
var options scep.Options
|
||||
|
||||
// Read intermediate and create X509 signer and decrypter for default CAS.
|
||||
options.CertificateChain, err = pemutil.ReadCertificateBundle(a.config.IntermediateCert)
|
||||
options.Roots = a.rootX509Certs
|
||||
options.Intermediates, err = pemutil.ReadCertificateBundle(a.config.IntermediateCert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options.CertificateChain = append(options.CertificateChain, a.rootX509Certs...)
|
||||
options.Signer, err = a.keyManager.CreateSigner(&kmsapi.CreateSignerRequest{
|
||||
options.SignerCert = options.Intermediates[0]
|
||||
if options.Signer, err = a.keyManager.CreateSigner(&kmsapi.CreateSignerRequest{
|
||||
SigningKey: a.config.IntermediateKey,
|
||||
Password: a.password,
|
||||
})
|
||||
if err != nil {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if km, ok := a.keyManager.(kmsapi.Decrypter); ok {
|
||||
options.Decrypter, err = km.CreateDecrypter(&kmsapi.CreateDecrypterRequest{
|
||||
// TODO(hs): instead of creating the decrypter here, pass the
|
||||
// intermediate key + chain down to the SCEP service / authority,
|
||||
// and only instantiate it when required there. Is that possible?
|
||||
// Also with entering passwords?
|
||||
// TODO(hs): if moving the logic, try improving the logic for the
|
||||
// decrypter password too? Right now it needs to be entered multiple
|
||||
// times; I've observed it to be three times maximum, every time
|
||||
// the intermediate key is read.
|
||||
_, isRSA := options.Signer.Public().(*rsa.PublicKey)
|
||||
if km, ok := a.keyManager.(kmsapi.Decrypter); ok && isRSA {
|
||||
if decrypter, err := km.CreateDecrypter(&kmsapi.CreateDecrypterRequest{
|
||||
DecryptionKey: a.config.IntermediateKey,
|
||||
Password: a.password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}); err == nil {
|
||||
// only pass the decrypter down when it was successfully created,
|
||||
// meaning it's an RSA key, and `CreateDecrypter` did not fail.
|
||||
options.Decrypter = decrypter
|
||||
}
|
||||
}
|
||||
|
||||
a.scepService, err = scep.NewService(ctx, options)
|
||||
// provide the current SCEP provisioner names, so that the provisioners
|
||||
// can be validated when the CA is started.
|
||||
options.SCEPProvisionerNames = a.getSCEPProvisionerNames()
|
||||
|
||||
// create a new SCEP authority
|
||||
a.scepAuthority, err = scep.New(a, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: mimick the x509CAService GetCertificateAuthority here too?
|
||||
// validate the SCEP authority
|
||||
if err := a.scepAuthority.Validate(); err != nil {
|
||||
a.initLogf("failed validating SCEP authority: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load X509 constraints engine.
|
||||
|
@ -833,17 +858,9 @@ func (a *Authority) IsRevoked(sn string) (bool, error) {
|
|||
return a.db.IsRevoked(sn)
|
||||
}
|
||||
|
||||
// requiresDecrypter returns whether the Authority
|
||||
// requires a KMS that provides a crypto.Decrypter
|
||||
// Currently this is only required when SCEP is
|
||||
// enabled.
|
||||
func (a *Authority) requiresDecrypter() bool {
|
||||
return a.requiresSCEPService()
|
||||
}
|
||||
|
||||
// requiresSCEPService iterates over the configured provisioners
|
||||
// and determines if one of them is a SCEP provisioner.
|
||||
func (a *Authority) requiresSCEPService() bool {
|
||||
// requiresSCEP iterates over the configured provisioners
|
||||
// and determines if at least one of them is a SCEP provisioner.
|
||||
func (a *Authority) requiresSCEP() bool {
|
||||
for _, p := range a.config.AuthorityConfig.Provisioners {
|
||||
if p.GetType() == provisioner.TypeSCEP {
|
||||
return true
|
||||
|
@ -852,13 +869,18 @@ func (a *Authority) requiresSCEPService() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// GetSCEPService returns the configured SCEP Service.
|
||||
//
|
||||
// TODO: this function is intended to exist temporarily in order to make SCEP
|
||||
// work more easily. It can be made more correct by using the right
|
||||
// interfaces/abstractions after it works as expected.
|
||||
func (a *Authority) GetSCEPService() *scep.Service {
|
||||
return a.scepService
|
||||
func (a *Authority) getSCEPProvisionerNames() (names []string) {
|
||||
for _, p := range a.config.AuthorityConfig.Provisioners {
|
||||
if p.GetType() == provisioner.TypeSCEP {
|
||||
names = append(names, p.GetName())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetSCEP returns the configured SCEP Authority
|
||||
func (a *Authority) GetSCEP() *scep.Authority {
|
||||
return a.scepAuthority
|
||||
}
|
||||
|
||||
func (a *Authority) startCRLGenerator() error {
|
||||
|
|
|
@ -478,7 +478,7 @@ func testScepAuthority(t *testing.T, opts ...Option) *Authority {
|
|||
return a
|
||||
}
|
||||
|
||||
func TestAuthority_GetSCEPService(t *testing.T) {
|
||||
func TestAuthority_GetSCEP(t *testing.T) {
|
||||
_ = testScepAuthority(t)
|
||||
p := provisioner.List{
|
||||
&provisioner.SCEP{
|
||||
|
@ -542,7 +542,7 @@ func TestAuthority_GetSCEPService(t *testing.T) {
|
|||
return
|
||||
}
|
||||
if tt.wantService {
|
||||
if got := a.GetSCEPService(); (got != nil) != tt.wantService {
|
||||
if got := a.GetSCEP(); (got != nil) != tt.wantService {
|
||||
t.Errorf("Authority.GetSCEPService() = %v, wantService %v", got, tt.wantService)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,13 +2,19 @@ package provisioner
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/subtle"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"go.step.sm/crypto/kms"
|
||||
kmsapi "go.step.sm/crypto/kms/apiv1"
|
||||
"go.step.sm/linkedca"
|
||||
|
||||
"github.com/smallstep/certificates/webhook"
|
||||
|
@ -32,6 +38,12 @@ type SCEP struct {
|
|||
// MinimumPublicKeyLength is the minimum length for public keys in CSRs
|
||||
MinimumPublicKeyLength int `json:"minimumPublicKeyLength,omitempty"`
|
||||
|
||||
// TODO
|
||||
KMS *kms.Options `json:"kms,omitempty"`
|
||||
DecrypterCertificate []byte `json:"decrypterCertificate"`
|
||||
DecrypterKey string `json:"decrypterKey"`
|
||||
DecrypterKeyPassword string `json:"decrypterKeyPassword"`
|
||||
|
||||
// Numerical identifier for the ContentEncryptionAlgorithm as defined in github.com/mozilla-services/pkcs7
|
||||
// at https://github.com/mozilla-services/pkcs7/blob/33d05740a3526e382af6395d3513e73d4e66d1cb/encrypt.go#L63
|
||||
// Defaults to 0, being DES-CBC
|
||||
|
@ -41,6 +53,9 @@ type SCEP struct {
|
|||
ctl *Controller
|
||||
encryptionAlgorithm int
|
||||
challengeValidationController *challengeValidationController
|
||||
keyManager kmsapi.KeyManager
|
||||
decrypter crypto.Decrypter
|
||||
decrypterCertificate *x509.Certificate
|
||||
}
|
||||
|
||||
// GetID returns the provisioner unique identifier.
|
||||
|
@ -177,6 +192,45 @@ func (s *SCEP) Init(config Config) (err error) {
|
|||
s.GetOptions().GetWebhooks(),
|
||||
)
|
||||
|
||||
if s.KMS != nil {
|
||||
if s.keyManager, err = kms.New(context.Background(), *s.KMS); err != nil {
|
||||
return fmt.Errorf("failed initializing kms: %w", err)
|
||||
}
|
||||
km, ok := s.keyManager.(kmsapi.Decrypter)
|
||||
if !ok {
|
||||
return fmt.Errorf("%q is not a kmsapi.Decrypter", s.KMS.Type)
|
||||
}
|
||||
if s.DecrypterKey != "" || len(s.DecrypterCertificate) > 0 {
|
||||
if s.decrypter, err = km.CreateDecrypter(&kmsapi.CreateDecrypterRequest{
|
||||
DecryptionKey: s.DecrypterKey,
|
||||
Password: []byte(s.DecrypterKeyPassword),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed creating decrypter: %w", err)
|
||||
}
|
||||
|
||||
// parse the decrypter certificate
|
||||
block, rest := pem.Decode(s.DecrypterCertificate)
|
||||
if len(rest) > 0 {
|
||||
return errors.New("failed parsing decrypter certificate: trailing data")
|
||||
}
|
||||
if block == nil {
|
||||
return errors.New("failed parsing decrypter certificate: no PEM block found")
|
||||
}
|
||||
if s.decrypterCertificate, err = x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return fmt.Errorf("failed parsing decrypter certificate: %w", err)
|
||||
}
|
||||
|
||||
// validate the decrypter key
|
||||
decrypterPublicKey, ok := s.decrypter.Public().(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("only RSA keys are supported")
|
||||
}
|
||||
if !decrypterPublicKey.Equal(s.decrypterCertificate.PublicKey) {
|
||||
return errors.New("mismatch between decryption certificate and decrypter public keys")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add other, SCEP specific, options?
|
||||
|
||||
s.ctl, err = NewController(s, s.Claims, config, s.Options)
|
||||
|
@ -259,3 +313,7 @@ func (s *SCEP) selectValidationMethod() validationMethod {
|
|||
}
|
||||
return validationMethodNone
|
||||
}
|
||||
|
||||
func (s *SCEP) GetDecrypter() (*x509.Certificate, crypto.Decrypter) {
|
||||
return s.decrypterCertificate, s.decrypter
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/square/go-jose.v2/jwt"
|
||||
|
@ -15,6 +16,7 @@ import (
|
|||
"go.step.sm/cli-utils/step"
|
||||
"go.step.sm/cli-utils/ui"
|
||||
"go.step.sm/crypto/jose"
|
||||
"go.step.sm/crypto/kms"
|
||||
"go.step.sm/linkedca"
|
||||
|
||||
"github.com/smallstep/certificates/authority/admin"
|
||||
|
@ -235,7 +237,7 @@ func (a *Authority) StoreProvisioner(ctx context.Context, prov *linkedca.Provisi
|
|||
}
|
||||
|
||||
if err := certProv.Init(provisionerConfig); err != nil {
|
||||
return admin.WrapError(admin.ErrorBadRequestType, err, "error validating configuration for provisioner %s", prov.Name)
|
||||
return admin.WrapError(admin.ErrorBadRequestType, err, "error validating configuration for provisioner %q", prov.Name)
|
||||
}
|
||||
|
||||
// Store to database -- this will set the ID.
|
||||
|
@ -960,7 +962,7 @@ func ProvisionerToCertificates(p *linkedca.Provisioner) (provisioner.Interface,
|
|||
}, nil
|
||||
case *linkedca.ProvisionerDetails_SCEP:
|
||||
cfg := d.SCEP
|
||||
return &provisioner.SCEP{
|
||||
s := &provisioner.SCEP{
|
||||
ID: p.Id,
|
||||
Type: p.Type.String(),
|
||||
Name: p.Name,
|
||||
|
@ -972,7 +974,19 @@ func ProvisionerToCertificates(p *linkedca.Provisioner) (provisioner.Interface,
|
|||
EncryptionAlgorithmIdentifier: int(cfg.EncryptionAlgorithmIdentifier),
|
||||
Claims: claims,
|
||||
Options: options,
|
||||
}, nil
|
||||
}
|
||||
if decrypter := cfg.GetDecrypter(); decrypter != nil {
|
||||
if dkms := decrypter.GetKms(); dkms != nil {
|
||||
s.KMS = &kms.Options{
|
||||
Type: kms.Type(strings.ToLower(linkedca.KMS_Type_name[int32(dkms.Type)])),
|
||||
CredentialsFile: dkms.CredentialsFile,
|
||||
}
|
||||
}
|
||||
s.DecrypterCertificate = decrypter.DecrypterCertificate
|
||||
s.DecrypterKey = decrypter.DecrypterKey
|
||||
s.DecrypterKeyPassword = decrypter.DecrypterKeyPassword
|
||||
}
|
||||
return s, nil
|
||||
case *linkedca.ProvisionerDetails_Nebula:
|
||||
var roots []byte
|
||||
for i, root := range d.Nebula.GetRoots() {
|
||||
|
|
27
ca/ca.go
27
ca/ca.go
|
@ -250,19 +250,24 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) {
|
|||
|
||||
var scepAuthority *scep.Authority
|
||||
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")
|
||||
// validate the SCEP authority configuration. Currently this
|
||||
// will not result in a failure to start if one or more SCEP
|
||||
// provisioners are not correctly configured. Only a log will
|
||||
// be emitted.
|
||||
scepAuthority = auth.GetSCEP()
|
||||
if err := scepAuthority.Validate(); err != nil {
|
||||
err = errors.Wrap(err, "failed validating SCEP authority")
|
||||
shouldFail := false
|
||||
if shouldFail {
|
||||
return nil, err
|
||||
}
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
// 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.
|
||||
scepPrefix := "scep"
|
||||
insecureMux.Route("/"+scepPrefix, func(r chi.Router) {
|
||||
scepAPI.Route(r)
|
||||
})
|
||||
|
@ -584,10 +589,10 @@ func (ca *CA) getTLSConfig(auth *authority.Authority) (*tls.Config, *tls.Config,
|
|||
|
||||
// shouldServeSCEPEndpoints 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.
|
||||
// true if a SCEPService exists, which is true in case at
|
||||
// least one SCEP provisioner was configured.
|
||||
func (ca *CA) shouldServeSCEPEndpoints() bool {
|
||||
return ca.auth.GetSCEPService() != nil
|
||||
return ca.auth.GetSCEP() != nil
|
||||
}
|
||||
|
||||
//nolint:unused // useful for debugging
|
||||
|
|
2
go.mod
2
go.mod
|
@ -146,3 +146,5 @@ require (
|
|||
|
||||
// use github.com/smallstep/pkcs7 fork with patches applied
|
||||
replace go.mozilla.org/pkcs7 => github.com/smallstep/pkcs7 v0.0.0-20230302202335-4c094085c948
|
||||
|
||||
replace go.step.sm/linkedca => ./../linkedca
|
||||
|
|
2
go.sum
2
go.sum
|
@ -1066,8 +1066,6 @@ go.step.sm/cli-utils v0.7.6 h1:YkpLVrepmy2c5+eaz/wduiGxlgrRx3YdAStE37if25g=
|
|||
go.step.sm/cli-utils v0.7.6/go.mod h1:j+FxFZ2gbWkAJl0eded/rksuxmNqWpmyxbkXcukGJaY=
|
||||
go.step.sm/crypto v0.31.0 h1:8ZG/BxC+0+LzPpk/764h5yubpG3GfxcRVR4E+Aye72g=
|
||||
go.step.sm/crypto v0.31.0/go.mod h1:Dv4lpkijKiZVkoc6zp+Xaw1xmy+voia1mykvbpQIvuc=
|
||||
go.step.sm/linkedca v0.19.1 h1:uY0ByT/uB3FCQ8zIo9mU7MWG7HKf5sDXNEBeN94MuP8=
|
||||
go.step.sm/linkedca v0.19.1/go.mod h1:vPV2ad3LFQJmV7XWt87VlnJSs6UOqgsbVGVWe3veEmI=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
|
|
|
@ -221,7 +221,7 @@ func lookupProvisioner(next http.HandlerFunc) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, scep.ProvisionerContextKey, scep.Provisioner(prov))
|
||||
ctx = scep.NewProvisionerContext(ctx, scep.Provisioner(prov))
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,10 @@ package scep
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
microx509util "github.com/micromdm/scep/v2/cryptoutil/x509util"
|
||||
microscep "github.com/micromdm/scep/v2/scep"
|
||||
|
@ -18,12 +18,13 @@ import (
|
|||
|
||||
// Authority is the layer that handles all SCEP interactions.
|
||||
type Authority struct {
|
||||
prefix string
|
||||
dns string
|
||||
intermediateCertificate *x509.Certificate
|
||||
caCerts []*x509.Certificate // TODO(hs): change to use these instead of root and intermediate
|
||||
service *Service
|
||||
signAuth SignAuthority
|
||||
roots []*x509.Certificate
|
||||
intermediates []*x509.Certificate
|
||||
signerCertificate *x509.Certificate
|
||||
signer crypto.Signer
|
||||
defaultDecrypter crypto.Decrypter
|
||||
scepProvisionerNames []string
|
||||
}
|
||||
|
||||
type authorityKey struct{}
|
||||
|
@ -49,19 +50,6 @@ func MustFromContext(ctx context.Context) *Authority {
|
|||
}
|
||||
}
|
||||
|
||||
// AuthorityOptions required to create a new SCEP Authority.
|
||||
type AuthorityOptions struct {
|
||||
// Service provides the certificate chain, the signer and the decrypter to the Authority
|
||||
Service *Service
|
||||
// DNS is 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 for a signing authority
|
||||
type SignAuthority interface {
|
||||
Sign(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error)
|
||||
|
@ -69,26 +57,54 @@ type SignAuthority interface {
|
|||
}
|
||||
|
||||
// New returns a new Authority that implements the SCEP interface.
|
||||
func New(signAuth SignAuthority, ops AuthorityOptions) (*Authority, error) {
|
||||
func New(signAuth SignAuthority, opts Options) (*Authority, error) {
|
||||
if err := opts.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authority := &Authority{
|
||||
prefix: ops.Prefix,
|
||||
dns: ops.DNS,
|
||||
signAuth: signAuth,
|
||||
signAuth: signAuth, // TODO: provide signAuth through context instead?
|
||||
roots: opts.Roots,
|
||||
intermediates: opts.Intermediates,
|
||||
signerCertificate: opts.SignerCert,
|
||||
signer: opts.Signer,
|
||||
defaultDecrypter: opts.Decrypter,
|
||||
scepProvisionerNames: opts.SCEPProvisionerNames,
|
||||
}
|
||||
|
||||
// TODO: this is not really nice to do; the Service should be removed
|
||||
// in its entirety to make this more interoperable with the rest of
|
||||
// step-ca, I think.
|
||||
if ops.Service != nil {
|
||||
authority.caCerts = ops.Service.certificateChain
|
||||
// TODO(hs): look into refactoring SCEP into using just caCerts everywhere, if it makes sense for more elaborate SCEP configuration. Keeping it like this for clarity (for now).
|
||||
authority.intermediateCertificate = ops.Service.certificateChain[0]
|
||||
authority.service = ops.Service
|
||||
}
|
||||
|
||||
return authority, nil
|
||||
}
|
||||
|
||||
// Validate validates if the SCEP Authority has a valid configuration.
|
||||
// The validation includes a check if a decrypter is available, either
|
||||
// an authority wide decrypter, or a provisioner specific decrypter.
|
||||
func (a *Authority) Validate() error {
|
||||
noDefaultDecrypterAvailable := a.defaultDecrypter == nil
|
||||
for _, name := range a.scepProvisionerNames {
|
||||
p, err := a.LoadProvisionerByName(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed loading provisioner %q: %w", name, err)
|
||||
}
|
||||
if scepProv, ok := p.(*provisioner.SCEP); ok {
|
||||
cert, decrypter := scepProv.GetDecrypter()
|
||||
// TODO(hs): return sentinel/typed error, to be able to ignore/log these cases during init?
|
||||
if cert == nil && noDefaultDecrypterAvailable {
|
||||
return fmt.Errorf("SCEP provisioner %q does not have a decrypter certificate", name)
|
||||
}
|
||||
if decrypter == nil && noDefaultDecrypterAvailable {
|
||||
return fmt.Errorf("SCEP provisioner %q does not have decrypter", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProvisioners updates the SCEP Authority with the new, and hopefully
|
||||
// current SCEP provisioners configured. This allows the Authority to be
|
||||
// validated with the latest data.
|
||||
func (a *Authority) UpdateProvisioners(scepProvisionerNames []string) {
|
||||
a.scepProvisionerNames = scepProvisionerNames
|
||||
}
|
||||
|
||||
var (
|
||||
// TODO: check the default capabilities; https://tools.ietf.org/html/rfc8894#section-3.5.2
|
||||
defaultCapabilities = []string{
|
||||
|
@ -108,87 +124,52 @@ func (a *Authority) LoadProvisionerByName(name string) (provisioner.Interface, e
|
|||
return a.signAuth.LoadProvisionerByName(name)
|
||||
}
|
||||
|
||||
// GetLinkExplicit returns the requested link from the directory.
|
||||
func (a *Authority) GetLinkExplicit(provName string, abs bool, baseURL *url.URL, inputs ...string) string {
|
||||
return a.getLinkExplicit(provName, abs, baseURL, inputs...)
|
||||
}
|
||||
|
||||
// getLinkExplicit returns an absolute or partial path to the given resource and a base
|
||||
// URL dynamically obtained from the request for which the link is being calculated.
|
||||
func (a *Authority) getLinkExplicit(provisionerName string, abs bool, baseURL *url.URL, _ ...string) string {
|
||||
link := "/" + provisionerName
|
||||
if abs {
|
||||
// Copy the baseURL value from the pointer. https://github.com/golang/go/issues/38351
|
||||
u := url.URL{}
|
||||
if baseURL != nil {
|
||||
u = *baseURL
|
||||
}
|
||||
|
||||
// If no Scheme is set, then default to http (in case of SCEP)
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "http"
|
||||
}
|
||||
|
||||
// If no Host is set, then use the default (first DNS attr in the ca.json).
|
||||
if u.Host == "" {
|
||||
u.Host = a.dns
|
||||
}
|
||||
|
||||
u.Path = a.prefix + link
|
||||
return u.String()
|
||||
}
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
// GetCACertificates returns the certificate (chain) for the CA
|
||||
func (a *Authority) GetCACertificates(ctx context.Context) ([]*x509.Certificate, error) {
|
||||
// TODO: this should return: the "SCEP Server (RA)" certificate, the issuing CA up to and excl. the root
|
||||
// GetCACertificates returns the certificate (chain) for the CA.
|
||||
//
|
||||
// This methods returns the "SCEP Server (RA)" certificate, the issuing CA up to and excl. the root.
|
||||
// Some clients do need the root certificate however; also see: https://github.com/openxpki/openxpki/issues/73
|
||||
//
|
||||
// This means we might need to think about if we should use the current intermediate CA
|
||||
// certificate as the "SCEP Server (RA)" certificate. It might be better to have a distinct
|
||||
// RA certificate, with a corresponding rsa.PrivateKey, just for SCEP usage, which is signed by
|
||||
// the intermediate CA. Will need to look how we can provide this nicely within step-ca.
|
||||
//
|
||||
// This might also mean that we might want to use a distinct instance of KMS for doing the key operations,
|
||||
// so that we can use RSA just for SCEP.
|
||||
// In case a provisioner specific decrypter is available, this is used as the "SCEP Server (RA)" certificate
|
||||
// instead of the CA intermediate directly. This uses a distinct instance of a KMS for doing the SCEp key
|
||||
// operations, so that RSA can be used for just SCEP.
|
||||
//
|
||||
// Using an RA does not seem to exist in https://tools.ietf.org/html/rfc8894, but is mentioned in
|
||||
// https://tools.ietf.org/id/draft-nourse-scep-21.html. Will continue using the CA directly for now.
|
||||
//
|
||||
// The certificate to use should probably depend on the (configured) provisioner and may
|
||||
// use a distinct certificate, apart from the intermediate.
|
||||
// https://tools.ietf.org/id/draft-nourse-scep-21.html.
|
||||
func (a *Authority) GetCACertificates(ctx context.Context) (certs []*x509.Certificate, err error) {
|
||||
p := provisionerFromContext(ctx)
|
||||
|
||||
p, err := provisionerFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// if a provisioner specific RSA decrypter is available, it is returned as
|
||||
// the first certificate.
|
||||
if decrypterCertificate, _ := p.GetDecrypter(); decrypterCertificate != nil {
|
||||
certs = append(certs, decrypterCertificate)
|
||||
}
|
||||
|
||||
if len(a.caCerts) == 0 {
|
||||
return nil, errors.New("no intermediate certificate available in SCEP authority")
|
||||
}
|
||||
// TODO(hs): ensure logic is in place that checks the signer is the first
|
||||
// intermediate and that there are no double certificates.
|
||||
certs = append(certs, a.intermediates...)
|
||||
|
||||
certs := []*x509.Certificate{}
|
||||
certs = append(certs, a.caCerts[0])
|
||||
|
||||
// NOTE: we're adding the CA roots here, but they are (highly likely) different than what the RFC means.
|
||||
// Clients are responsible to select the right cert(s) to use, though.
|
||||
if p.ShouldIncludeRootInChain() && len(a.caCerts) > 1 {
|
||||
certs = append(certs, a.caCerts[1])
|
||||
// the CA roots are added for completeness when configured to do so. Clients
|
||||
// are responsible to select the right cert(s) to store and use.
|
||||
if p.ShouldIncludeRootInChain() {
|
||||
certs = append(certs, a.roots...)
|
||||
}
|
||||
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// DecryptPKIEnvelope decrypts an enveloped message
|
||||
func (a *Authority) DecryptPKIEnvelope(_ context.Context, msg *PKIMessage) error {
|
||||
func (a *Authority) DecryptPKIEnvelope(ctx context.Context, msg *PKIMessage) error {
|
||||
p7c, err := pkcs7.Parse(msg.P7.Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing pkcs7 content: %w", err)
|
||||
}
|
||||
|
||||
envelope, err := p7c.Decrypt(a.intermediateCertificate, a.service.decrypter)
|
||||
cert, pkey, err := a.selectDecrypter(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed selecting decrypter: %w", err)
|
||||
}
|
||||
|
||||
envelope, err := p7c.Decrypt(cert, pkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error decrypting encrypted pkcs7 content: %w", err)
|
||||
}
|
||||
|
@ -208,6 +189,9 @@ func (a *Authority) DecryptPKIEnvelope(_ context.Context, msg *PKIMessage) error
|
|||
if err != nil {
|
||||
return fmt.Errorf("parse CSR from pkiEnvelope: %w", err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return fmt.Errorf("invalid CSR signature; %w", err)
|
||||
}
|
||||
// check for challengePassword
|
||||
cp, err := microx509util.ParseChallengePassword(msg.pkiEnvelope)
|
||||
if err != nil {
|
||||
|
@ -226,6 +210,21 @@ func (a *Authority) DecryptPKIEnvelope(_ context.Context, msg *PKIMessage) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func (a *Authority) selectDecrypter(ctx context.Context) (cert *x509.Certificate, pkey crypto.PrivateKey, err error) {
|
||||
p := provisionerFromContext(ctx)
|
||||
|
||||
// return provisioner specific decrypter, if available
|
||||
if cert, pkey = p.GetDecrypter(); cert != nil && pkey != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// fallback to the CA wide decrypter
|
||||
cert = a.signerCertificate
|
||||
pkey = a.defaultDecrypter
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// SignCSR creates an x509.Certificate based on a CSR template and Cert Authority credentials
|
||||
// returns a new PKIMessage with CertRep data
|
||||
func (a *Authority) SignCSR(ctx context.Context, csr *x509.CertificateRequest, msg *PKIMessage) (*PKIMessage, error) {
|
||||
|
@ -234,10 +233,7 @@ func (a *Authority) SignCSR(ctx context.Context, csr *x509.CertificateRequest, m
|
|||
// poll for the status. It seems to be similar as what can happen in ACME, so might want to model
|
||||
// the implementation after the one in the ACME authority. Requires storage, etc.
|
||||
|
||||
p, err := provisionerFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := provisionerFromContext(ctx)
|
||||
|
||||
// check if CSRReqMessage has already been decrypted
|
||||
if msg.CSRReqMessage.CSR == nil {
|
||||
|
@ -358,10 +354,11 @@ func (a *Authority) SignCSR(ctx context.Context, csr *x509.CertificateRequest, m
|
|||
// as the first certificate in the array
|
||||
signedData.AddCertificate(cert)
|
||||
|
||||
authCert := a.intermediateCertificate
|
||||
authCert := a.signerCertificate
|
||||
signer := a.signer
|
||||
|
||||
// sign the attributes
|
||||
if err := signedData.AddSigner(authCert, a.service.signer, config); err != nil {
|
||||
if err := signedData.AddSigner(authCert, signer, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -429,7 +426,7 @@ func (a *Authority) CreateFailureResponse(_ context.Context, _ *x509.Certificate
|
|||
}
|
||||
|
||||
// sign the attributes
|
||||
if err := signedData.AddSigner(a.intermediateCertificate, a.service.signer, config); err != nil {
|
||||
if err := signedData.AddSigner(a.signerCertificate, a.signer, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -457,10 +454,7 @@ func (a *Authority) CreateFailureResponse(_ context.Context, _ *x509.Certificate
|
|||
|
||||
// GetCACaps returns the CA capabilities
|
||||
func (a *Authority) GetCACaps(ctx context.Context) []string {
|
||||
p, err := provisionerFromContext(ctx)
|
||||
if err != nil {
|
||||
return defaultCapabilities
|
||||
}
|
||||
p := provisionerFromContext(ctx)
|
||||
|
||||
caps := p.GetCapabilities()
|
||||
if len(caps) == 0 {
|
||||
|
@ -477,9 +471,6 @@ func (a *Authority) GetCACaps(ctx context.Context) []string {
|
|||
}
|
||||
|
||||
func (a *Authority) ValidateChallenge(ctx context.Context, challenge, transactionID string) error {
|
||||
p, err := provisionerFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := provisionerFromContext(ctx)
|
||||
return p.ValidateChallenge(ctx, challenge, transactionID)
|
||||
}
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
package scep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ContextKey is the key type for storing and searching for SCEP request
|
||||
// essentials in the context of a request.
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
// ProvisionerContextKey provisioner key
|
||||
ProvisionerContextKey = ContextKey("provisioner")
|
||||
)
|
||||
|
||||
// provisionerFromContext searches the context for a SCEP provisioner.
|
||||
// Returns the provisioner or an error.
|
||||
func provisionerFromContext(ctx context.Context) (Provisioner, error) {
|
||||
val := ctx.Value(ProvisionerContextKey)
|
||||
if val == nil {
|
||||
return nil, errors.New("provisioner expected in request context")
|
||||
}
|
||||
p, ok := val.(Provisioner)
|
||||
if !ok || p == nil {
|
||||
return nil, errors.New("provisioner in context is not a SCEP provisioner")
|
||||
}
|
||||
return p, nil
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package scep
|
||||
|
||||
import "crypto/x509"
|
||||
|
||||
type DB interface {
|
||||
StoreCertificate(crt *x509.Certificate) error
|
||||
}
|
|
@ -4,65 +4,76 @@ import (
|
|||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
// CertificateChain is the issuer certificate, along with any other bundled certificates
|
||||
// to be returned in the chain for consumers. Configured in the ca.json crt property.
|
||||
CertificateChain []*x509.Certificate
|
||||
// Roots contains the (federated) CA roots certificate(s)
|
||||
Roots []*x509.Certificate `json:"-"`
|
||||
// Intermediates points issuer certificate, along with any other bundled certificates
|
||||
// to be returned in the chain for consumers.
|
||||
Intermediates []*x509.Certificate `json:"-"`
|
||||
// SignerCert points to the certificate of the CA signer. It usually is the same as the
|
||||
// first certificate in the CertificateChain.
|
||||
SignerCert *x509.Certificate `json:"-"`
|
||||
// Signer signs CSRs in SCEP. Configured in the ca.json key property.
|
||||
Signer crypto.Signer `json:"-"`
|
||||
// Decrypter decrypts encrypted SCEP messages. Configured in the ca.json key property.
|
||||
Decrypter crypto.Decrypter `json:"-"`
|
||||
// SCEPProvisionerNames contains the currently configured SCEP provioner names. These
|
||||
// are used to be able to load the provisioners when the SCEP authority is being
|
||||
// validated.
|
||||
SCEPProvisionerNames []string
|
||||
}
|
||||
|
||||
type comparablePublicKey interface {
|
||||
Equal(crypto.PublicKey) bool
|
||||
}
|
||||
|
||||
// Validate checks the fields in Options.
|
||||
func (o *Options) Validate() error {
|
||||
if o.CertificateChain == nil {
|
||||
return errors.New("certificate chain not configured correctly")
|
||||
switch {
|
||||
case len(o.Intermediates) == 0:
|
||||
return errors.New("no intermediate certificate available for SCEP authority")
|
||||
case o.Signer == nil:
|
||||
return errors.New("no signer available for SCEP authority")
|
||||
case o.SignerCert == nil:
|
||||
return errors.New("no signer certificate available for SCEP authority")
|
||||
}
|
||||
|
||||
if len(o.CertificateChain) < 1 {
|
||||
return errors.New("certificate chain should at least have one certificate")
|
||||
// check if the signer (intermediate CA) certificate has the same public key as
|
||||
// the signer. According to the RFC it seems valid to have different keys for
|
||||
// the intermediate and the CA signing new certificates, so this might change
|
||||
// in the future.
|
||||
signerPublicKey := o.Signer.Public().(comparablePublicKey)
|
||||
if !signerPublicKey.Equal(o.SignerCert.PublicKey) {
|
||||
return errors.New("mismatch between signer certificate and public key")
|
||||
}
|
||||
|
||||
// According to the RFC: https://tools.ietf.org/html/rfc8894#section-3.1, SCEP
|
||||
// can be used with something different than RSA, but requires the encryption
|
||||
// to be performed using the challenge password. An older version of specification
|
||||
// states that only RSA is supported: https://tools.ietf.org/html/draft-nourse-scep-23#section-2.1.1
|
||||
// Other algorithms than RSA do not seem to be supported in certnanny/sscep, but it might work
|
||||
// decrypter can be nil in case a signing only key is used; validation complete.
|
||||
if o.Decrypter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If a decrypter is available, check that it's backed by an RSA key. According to the
|
||||
// RFC: https://tools.ietf.org/html/rfc8894#section-3.1, SCEP can be used with something
|
||||
// different than RSA, but requires the encryption to be performed using the challenge
|
||||
// password in that case. An older version of specification states that only RSA is
|
||||
// supported: https://tools.ietf.org/html/draft-nourse-scep-23#section-2.1.1. Other
|
||||
// algorithms do not seem to be supported in certnanny/sscep, but it might work
|
||||
// in micromdm/scep. Currently only RSA is allowed, but it might be an option
|
||||
// to try other algorithms in the future.
|
||||
intermediate := o.CertificateChain[0]
|
||||
if intermediate.PublicKeyAlgorithm != x509.RSA {
|
||||
return errors.New("only the RSA algorithm is (currently) supported")
|
||||
}
|
||||
|
||||
// TODO: add checks for key usage?
|
||||
|
||||
signerPublicKey, ok := o.Signer.Public().(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return errors.New("only RSA public keys are (currently) supported as signers")
|
||||
}
|
||||
|
||||
// check if the intermediate ca certificate has the same public key as the signer.
|
||||
// According to the RFC it seems valid to have different keys for the intermediate
|
||||
// and the CA signing new certificates, so this might change in the future.
|
||||
if !signerPublicKey.Equal(intermediate.PublicKey) {
|
||||
return errors.New("mismatch between certificate chain and signer public keys")
|
||||
}
|
||||
|
||||
decrypterPublicKey, ok := o.Decrypter.Public().(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return errors.New("only RSA public keys are (currently) supported as decrypters")
|
||||
return errors.New("only RSA keys are (currently) supported as decrypters")
|
||||
}
|
||||
|
||||
// check if intermediate public key is the same as the decrypter public key.
|
||||
// In certnanny/sscep it's mentioned that the signing key can be different
|
||||
// from the decrypting (and encrypting) key. Currently that's not supported.
|
||||
if !decrypterPublicKey.Equal(intermediate.PublicKey) {
|
||||
// from the decrypting (and encrypting) key. These options are only used and
|
||||
// validated when the intermediate CA is also used as the decrypter, though,
|
||||
// so they should match.
|
||||
if !decrypterPublicKey.Equal(o.SignerCert.PublicKey) {
|
||||
return errors.New("mismatch between certificate chain and decrypter public keys")
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@ package scep
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"time"
|
||||
|
||||
"github.com/smallstep/certificates/authority/provisioner"
|
||||
|
@ -16,6 +18,25 @@ type Provisioner interface {
|
|||
GetOptions() *provisioner.Options
|
||||
GetCapabilities() []string
|
||||
ShouldIncludeRootInChain() bool
|
||||
GetDecrypter() (*x509.Certificate, crypto.Decrypter)
|
||||
GetContentEncryptionAlgorithm() int
|
||||
ValidateChallenge(ctx context.Context, challenge, transactionID string) error
|
||||
}
|
||||
|
||||
// provisionerKey is the key type for storing and searching a
|
||||
// SCEP provisioner in the context.
|
||||
type provisionerKey struct{}
|
||||
|
||||
// provisionerFromContext searches the context for a SCEP provisioner.
|
||||
// Returns the provisioner or panics if no SCEP provisioner is found.
|
||||
func provisionerFromContext(ctx context.Context) Provisioner {
|
||||
p, ok := ctx.Value(provisionerKey{}).(Provisioner)
|
||||
if !ok {
|
||||
panic("SCEP provisioner expected in request context")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func NewProvisionerContext(ctx context.Context, p Provisioner) context.Context {
|
||||
return context.WithValue(ctx, provisionerKey{}, p)
|
||||
}
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
package scep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
)
|
||||
|
||||
// Service is a wrapper for crypto.Signer and crypto.Decrypter
|
||||
type Service struct {
|
||||
certificateChain []*x509.Certificate
|
||||
signer crypto.Signer
|
||||
decrypter crypto.Decrypter
|
||||
}
|
||||
|
||||
// NewService returns a new Service type.
|
||||
func NewService(_ context.Context, opts Options) (*Service, error) {
|
||||
if err := opts.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: should this become similar to the New CertificateAuthorityService as in x509CAService?
|
||||
return &Service{
|
||||
certificateChain: opts.CertificateChain,
|
||||
signer: opts.Signer,
|
||||
decrypter: opts.Decrypter,
|
||||
}, nil
|
||||
}
|
Loading…
Reference in a new issue