Improve test coverage of X.509 revoking
This commit is contained in:
parent
c538be4385
commit
23c9e6f3e0
3 changed files with 308 additions and 22 deletions
|
@ -19,6 +19,7 @@ package revoking
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
|
"math/big"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A Repository for storing certificate status information
|
// A Repository for storing certificate status information
|
||||||
|
@ -26,4 +27,5 @@ type Repository interface {
|
||||||
// StoreRevocation stores information about a revoked certificate.
|
// StoreRevocation stores information about a revoked certificate.
|
||||||
StoreRevocation(*pkix.RevokedCertificate) error
|
StoreRevocation(*pkix.RevokedCertificate) error
|
||||||
RevokedCertificates() ([]pkix.RevokedCertificate, error)
|
RevokedCertificates() ([]pkix.RevokedCertificate, error)
|
||||||
|
NextCRLNumber() (*big.Int, error)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,6 @@ import (
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/asn1"
|
"encoding/asn1"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -71,11 +70,7 @@ func (r CRLReason) String() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r CRLReason) BuildExtension() pkix.Extension {
|
func (r CRLReason) BuildExtension() pkix.Extension {
|
||||||
extBytes, err := asn1.Marshal(r)
|
extBytes, _ := asn1.Marshal(r)
|
||||||
if err != nil {
|
|
||||||
// we can panic here because all values of CRLReason must be ASN.1 marshal-able
|
|
||||||
log.Panicf("could not marshal revocation reason: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pkix.Extension{Id: OidCRLReason, Value: extBytes}
|
return pkix.Extension{Id: OidCRLReason, Value: extBytes}
|
||||||
}
|
}
|
||||||
|
@ -103,6 +98,13 @@ type RevokeCertificate struct {
|
||||||
reason CRLReason
|
reason CRLReason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewRevokeCertificate(serialNumber *big.Int, reason CRLReason) *RevokeCertificate {
|
||||||
|
return &RevokeCertificate{
|
||||||
|
serialNumber: serialNumber,
|
||||||
|
reason: reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type CRLInformation struct {
|
type CRLInformation struct {
|
||||||
CRL []byte // DER encoded CRL
|
CRL []byte // DER encoded CRL
|
||||||
}
|
}
|
||||||
|
@ -127,9 +129,15 @@ func (r *X509Revoking) CreateCRL() (*CRLInformation, error) {
|
||||||
return nil, fmt.Errorf("could not get revocation information: %w", err)
|
return nil, fmt.Errorf("could not get revocation information: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextNumber, err := r.repository.NextCRLNumber()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not get next CRL number: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
list, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{
|
list, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{
|
||||||
SignatureAlgorithm: r.crlAlgorithm,
|
SignatureAlgorithm: r.crlAlgorithm,
|
||||||
RevokedCertificates: revoked,
|
RevokedCertificates: revoked,
|
||||||
|
Number: nextNumber,
|
||||||
}, r.crlIssuer, r.signer)
|
}, r.crlIssuer, r.signer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not sign revocation list: %w", err)
|
return nil, fmt.Errorf("could not sign revocation list: %w", err)
|
||||||
|
|
|
@ -15,22 +15,37 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package revoking
|
package revoking_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.cacert.org/cacert-gosigner/pkg/x509/revoking"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testRepo struct {
|
type testRepo struct {
|
||||||
revoked []big.Int
|
crlNumber *big.Int
|
||||||
|
revoked []*big.Int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testRepo) NextCRLNumber() (*big.Int, error) {
|
||||||
|
newNumber := new(big.Int).Add(t.crlNumber, big.NewInt(1))
|
||||||
|
|
||||||
|
t.crlNumber = newNumber
|
||||||
|
|
||||||
|
return t.crlNumber, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testRepo) RevokedCertificates() ([]pkix.RevokedCertificate, error) {
|
func (t *testRepo) RevokedCertificates() ([]pkix.RevokedCertificate, error) {
|
||||||
|
@ -40,7 +55,7 @@ func (t *testRepo) RevokedCertificates() ([]pkix.RevokedCertificate, error) {
|
||||||
serialNumber := s
|
serialNumber := s
|
||||||
|
|
||||||
result[i] = pkix.RevokedCertificate{
|
result[i] = pkix.RevokedCertificate{
|
||||||
SerialNumber: &serialNumber,
|
SerialNumber: serialNumber,
|
||||||
RevocationTime: time.Now(),
|
RevocationTime: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,11 +64,57 @@ func (t *testRepo) RevokedCertificates() ([]pkix.RevokedCertificate, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testRepo) StoreRevocation(revoked *pkix.RevokedCertificate) error {
|
func (t *testRepo) StoreRevocation(revoked *pkix.RevokedCertificate) error {
|
||||||
t.revoked = append(t.revoked, *revoked.SerialNumber)
|
t.revoked = append(t.revoked, revoked.SerialNumber)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type brokenRepo struct{}
|
||||||
|
|
||||||
|
func (r *brokenRepo) NextCRLNumber() (*big.Int, error) {
|
||||||
|
return nil, errors.New("don't know")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *brokenRepo) RevokedCertificates() ([]pkix.RevokedCertificate, error) {
|
||||||
|
return nil, errors.New("no revocations for you")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*brokenRepo) StoreRevocation(_ *pkix.RevokedCertificate) error {
|
||||||
|
return errors.New("cannot store")
|
||||||
|
}
|
||||||
|
|
||||||
|
type brokenRepoNoCrlNumber struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b brokenRepoNoCrlNumber) StoreRevocation(certificate *pkix.RevokedCertificate) error {
|
||||||
|
// do nothing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b brokenRepoNoCrlNumber) RevokedCertificates() ([]pkix.RevokedCertificate, error) {
|
||||||
|
return make([]pkix.RevokedCertificate, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b brokenRepoNoCrlNumber) NextCRLNumber() (*big.Int, error) {
|
||||||
|
return nil, errors.New("don't know")
|
||||||
|
}
|
||||||
|
|
||||||
|
type brokenRepoNoRevocations struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b brokenRepoNoRevocations) StoreRevocation(certificate *pkix.RevokedCertificate) error {
|
||||||
|
// do nothing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b brokenRepoNoRevocations) RevokedCertificates() ([]pkix.RevokedCertificate, error) {
|
||||||
|
return nil, errors.New("no revocations known")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b brokenRepoNoRevocations) NextCRLNumber() (*big.Int, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func randomSerial(t *testing.T) *big.Int {
|
func randomSerial(t *testing.T) *big.Int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
@ -65,15 +126,173 @@ func randomSerial(t *testing.T) *big.Int {
|
||||||
return serial
|
return serial
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRevoking(t *testing.T) {
|
func TestX509Revoking_Revoke(t *testing.T) {
|
||||||
testRepository := testRepo{revoked: make([]big.Int, 0)}
|
testRepository := testRepo{revoked: make([]*big.Int, 0), crlNumber: big.NewInt(0)}
|
||||||
|
|
||||||
|
caKey, caCertificate := prepareTestCA(t)
|
||||||
|
|
||||||
|
r := revoking.NewX509Revoking(&testRepository, x509.ECDSAWithSHA256, caCertificate, caKey)
|
||||||
|
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not create random serial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke, err := r.Revoke(revoking.NewRevokeCertificate(serial, revoking.CRLReasonKeyCompromise))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, revoking.CRLReasonKeyCompromise.BuildExtension(), revoke.Extensions[0])
|
||||||
|
assert.Equal(t, serial, revoke.SerialNumber)
|
||||||
|
|
||||||
|
assert.Contains(t, testRepository.revoked, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestX509Revoking_Revoke_BrokenRepo(t *testing.T) {
|
||||||
|
caKey, caCertificate := prepareTestCA(t)
|
||||||
|
|
||||||
|
r := revoking.NewX509Revoking(&brokenRepo{}, x509.SHA256WithRSA, caCertificate, caKey)
|
||||||
|
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not create random serial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke, err := r.Revoke(revoking.NewRevokeCertificate(serial, revoking.CRLReasonKeyCompromise))
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "could not store revocation")
|
||||||
|
assert.Nil(t, revoke)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestX509Revoking_CreateCRL(t *testing.T) {
|
||||||
|
key, certificate := prepareTestCA(t)
|
||||||
|
|
||||||
|
r := revoking.NewX509Revoking(
|
||||||
|
&testRepo{revoked: make([]*big.Int, 0), crlNumber: big.NewInt(0)},
|
||||||
|
x509.SHA256WithRSA,
|
||||||
|
certificate,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not create random serial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.Revoke(revoking.NewRevokeCertificate(serial, revoking.CRLReasonKeyCompromise))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
crl, err := r.CreateCRL()
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, crl)
|
||||||
|
assert.NotEmpty(t, crl.CRL)
|
||||||
|
|
||||||
|
parsedCRL, err := x509.ParseCRL(crl.CRL)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, certificate.Subject.ToRDNSequence(), parsedCRL.TBSCertList.Issuer)
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
|
||||||
|
for _, item := range parsedCRL.TBSCertList.RevokedCertificates {
|
||||||
|
if item.SerialNumber.Cmp(serial) == 0 {
|
||||||
|
// standard library x509.CreateRevocationList does not support
|
||||||
|
// entry extensions according to RFC-5280 Section 5.3, therefore
|
||||||
|
// item.Extensions always is empty.
|
||||||
|
//
|
||||||
|
// otherwise the following assert would be useful
|
||||||
|
//
|
||||||
|
// assert.Contains(t, item.Extensions, revoking.CRLReasonKeyCompromise.BuildExtension())
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestX509Revoking_CreateCRL_BrokenRepoNoRevocations(t *testing.T) {
|
||||||
|
caKey, caCertificate := prepareTestCA(t)
|
||||||
|
|
||||||
|
r := revoking.NewX509Revoking(&brokenRepoNoRevocations{}, x509.SHA256WithRSA, caCertificate, caKey)
|
||||||
|
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not create random serial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.Revoke(revoking.NewRevokeCertificate(serial, revoking.CRLReasonKeyCompromise))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
crl, err := r.CreateCRL()
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "could not get revocation information")
|
||||||
|
assert.Nil(t, crl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestX509Revoking_CreateCRL_BrokenRepoNoCRLNumber(t *testing.T) {
|
||||||
|
caKey, caCertificate := prepareTestCA(t)
|
||||||
|
|
||||||
|
r := revoking.NewX509Revoking(&brokenRepoNoCrlNumber{}, x509.SHA256WithRSA, caCertificate, caKey)
|
||||||
|
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not create random serial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.Revoke(revoking.NewRevokeCertificate(serial, revoking.CRLReasonKeyCompromise))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
crl, err := r.CreateCRL()
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "could not get next CRL number")
|
||||||
|
assert.Nil(t, crl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestX509Revoking_CreateCRL_WrongAlgorithm(t *testing.T) {
|
||||||
|
key, certificate := prepareTestCA(t)
|
||||||
|
|
||||||
|
r := revoking.NewX509Revoking(
|
||||||
|
&testRepo{revoked: make([]*big.Int, 0), crlNumber: big.NewInt(0)},
|
||||||
|
x509.ECDSAWithSHA256,
|
||||||
|
certificate,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not create random serial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.Revoke(revoking.NewRevokeCertificate(serial, revoking.CRLReasonKeyCompromise))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
crl, err := r.CreateCRL()
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "could not sign revocation list")
|
||||||
|
assert.Nil(t, crl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareTestCA(t *testing.T) (*rsa.PrivateKey, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
caKey, err := rsa.GenerateKey(rand.Reader, 3072)
|
caKey, err := rsa.GenerateKey(rand.Reader, 3072)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("could not generate key pair: %v", err)
|
t.Fatalf("could not generate key pair: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
caTemplate := &x509.Certificate{Subject: pkix.Name{CommonName: "Test CA"}, SerialNumber: randomSerial(t)}
|
caTemplate := &x509.Certificate{
|
||||||
|
Subject: pkix.Name{CommonName: "Test CA"},
|
||||||
|
SerialNumber: randomSerial(t),
|
||||||
|
IsCA: true,
|
||||||
|
MaxPathLenZero: true,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||||
|
}
|
||||||
|
|
||||||
certificateBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caKey.Public(), caKey)
|
certificateBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caKey.Public(), caKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -85,18 +304,75 @@ func TestRevoking(t *testing.T) {
|
||||||
t.Fatalf("could not create test CA certificate: %v", err)
|
t.Fatalf("could not create test CA certificate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := NewX509Revoking(&testRepository, x509.ECDSAWithSHA256, caCertificate, caKey)
|
return caKey, caCertificate
|
||||||
|
|
||||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("could not create random serial: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
revoke, err := r.Revoke(&RevokeCertificate{serialNumber: serial, reason: CRLReasonKeyCompromise})
|
func TestCRLReason_BuildExtension(t *testing.T) {
|
||||||
|
reasons := []revoking.CRLReason{
|
||||||
|
revoking.CRLReasonUnspecified,
|
||||||
|
revoking.CRLReasonKeyCompromise,
|
||||||
|
revoking.CRLReasonCACompromise,
|
||||||
|
revoking.CRLReasonAffiliationChanged,
|
||||||
|
revoking.CRLReasonSuperseded,
|
||||||
|
revoking.CRLReasonCessationOfOperation,
|
||||||
|
revoking.CRLReasonCertificateHold,
|
||||||
|
revoking.CRLReasonRemoveFromCRL,
|
||||||
|
revoking.CRLReasonPrivilegeWithdrawn,
|
||||||
|
revoking.CRLReasonAACompromise,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reason := range reasons {
|
||||||
|
t.Run(fmt.Sprintf("reason %d", int(reason)), func(t *testing.T) {
|
||||||
|
ext := reason.BuildExtension()
|
||||||
|
|
||||||
|
assert.Equal(t, revoking.OidCRLReason, ext.Id)
|
||||||
|
|
||||||
|
var value int
|
||||||
|
|
||||||
|
rest, err := asn1.Unmarshal(ext.Value, &value)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, rest, 0)
|
||||||
assert.Equal(t, CRLReasonKeyCompromise.BuildExtension(), revoke.Extensions[0])
|
assert.Equal(t, value, int(reason))
|
||||||
assert.Equal(t, serial, revoke.SerialNumber)
|
})
|
||||||
|
}
|
||||||
assert.Contains(t, testRepository.revoked, *serial)
|
}
|
||||||
|
|
||||||
|
func TestCRLReason_String(t *testing.T) {
|
||||||
|
reasons := []revoking.CRLReason{
|
||||||
|
revoking.CRLReasonUnspecified,
|
||||||
|
revoking.CRLReasonKeyCompromise,
|
||||||
|
revoking.CRLReasonCACompromise,
|
||||||
|
revoking.CRLReasonAffiliationChanged,
|
||||||
|
revoking.CRLReasonSuperseded,
|
||||||
|
revoking.CRLReasonCessationOfOperation,
|
||||||
|
revoking.CRLReasonCertificateHold,
|
||||||
|
revoking.CRLReasonRemoveFromCRL,
|
||||||
|
revoking.CRLReasonPrivilegeWithdrawn,
|
||||||
|
revoking.CRLReasonAACompromise,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reason := range reasons {
|
||||||
|
t.Run(fmt.Sprintf("reason %d", int(reason)), func(t *testing.T) {
|
||||||
|
assert.NotEmpty(t, reason.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var customReason revoking.CRLReason = 40
|
||||||
|
|
||||||
|
assert.Equal(t, customReason.String(), revoking.CRLReasonUnspecified.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReason(t *testing.T) {
|
||||||
|
expected := map[string]revoking.CRLReason{
|
||||||
|
"keyCompromise": revoking.CRLReasonKeyCompromise,
|
||||||
|
"kEyCoMpRoMiSe": revoking.CRLReasonKeyCompromise,
|
||||||
|
"unknown": revoking.CRLReasonUnspecified,
|
||||||
|
"": revoking.CRLReasonUnspecified,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, reason := range expected {
|
||||||
|
t.Run(fmt.Sprintf("parse '%s'", key), func(t *testing.T) {
|
||||||
|
assert.Equal(t, revoking.ParseReason(key), reason)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue