mirror of
https://github.com/smallstep/certificates.git
synced 2024-10-31 03:20:16 +00:00
1516 lines
45 KiB
Go
1516 lines
45 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/pkg/errors"
|
|
"github.com/smallstep/assert"
|
|
"github.com/smallstep/certificates/acme"
|
|
"github.com/smallstep/certificates/authority"
|
|
"github.com/smallstep/certificates/authority/provisioner"
|
|
"go.step.sm/crypto/jose"
|
|
"go.step.sm/crypto/keyutil"
|
|
"go.step.sm/crypto/x509util"
|
|
"golang.org/x/crypto/ocsp"
|
|
)
|
|
|
|
// v is a utility function to return the pointer to an integer
|
|
func v(v int) *int {
|
|
return &v
|
|
}
|
|
|
|
func generateSerial() (*big.Int, error) {
|
|
return rand.Int(rand.Reader, big.NewInt(1000000000000000000))
|
|
}
|
|
|
|
// generateCertKeyPair generates fresh x509 certificate/key pairs for testing
|
|
func generateCertKeyPair() (*x509.Certificate, crypto.Signer, error) {
|
|
|
|
pub, priv, err := keyutil.GenerateKeyPair("EC", "P-256", 0)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
serial, err := generateSerial()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
template := &x509.Certificate{
|
|
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
|
Issuer: pkix.Name{CommonName: "Test ACME Revoke Certificate"},
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
|
IsCA: false,
|
|
MaxPathLen: 0,
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
|
NotBefore: now,
|
|
NotAfter: now.Add(time.Hour),
|
|
SerialNumber: serial,
|
|
}
|
|
|
|
signer, ok := priv.(crypto.Signer)
|
|
if !ok {
|
|
return nil, nil, errors.Errorf("result is not a crypto.Signer: type %T", priv)
|
|
}
|
|
|
|
cert, err := x509util.CreateCertificate(template, template, pub, signer)
|
|
|
|
return cert, signer, err
|
|
}
|
|
|
|
var errUnsupportedKey = fmt.Errorf("unknown key type; only RSA and ECDSA are supported")
|
|
|
|
// keyID is the account identity provided by a CA during registration.
|
|
type keyID string
|
|
|
|
// noKeyID indicates that jwsEncodeJSON should compute and use JWK instead of a KID.
|
|
// See jwsEncodeJSON for details.
|
|
const noKeyID = keyID("")
|
|
|
|
// jwsEncodeJSON signs claimset using provided key and a nonce.
|
|
// The result is serialized in JSON format containing either kid or jwk
|
|
// fields based on the provided keyID value.
|
|
//
|
|
// If kid is non-empty, its quoted value is inserted in the protected head
|
|
// as "kid" field value. Otherwise, JWK is computed using jwkEncode and inserted
|
|
// as "jwk" field value. The "jwk" and "kid" fields are mutually exclusive.
|
|
//
|
|
// See https://tools.ietf.org/html/rfc7515#section-7.
|
|
//
|
|
// If nonce is empty, it will not be encoded into the header.
|
|
// Implementation taken from github.com/mholt/acmez, which seems to be based on
|
|
// https://github.com/golang/crypto/blob/master/acme/jws.go.
|
|
func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid keyID, nonce, u string) ([]byte, error) {
|
|
alg, sha := jwsHasher(key.Public())
|
|
if alg == "" || !sha.Available() {
|
|
return nil, errUnsupportedKey
|
|
}
|
|
|
|
phead, err := jwsHead(alg, nonce, u, kid, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload string
|
|
if claimset != nil {
|
|
cs, err := json.Marshal(claimset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload = base64.RawURLEncoding.EncodeToString(cs)
|
|
}
|
|
|
|
payloadToSign := []byte(phead + "." + payload)
|
|
hash := sha.New()
|
|
_, _ = hash.Write(payloadToSign)
|
|
digest := hash.Sum(nil)
|
|
|
|
sig, err := jwsSign(key, sha, digest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return jwsFinal(sha, sig, phead, payload)
|
|
}
|
|
|
|
// jwsHasher indicates suitable JWS algorithm name and a hash function
|
|
// to use for signing a digest with the provided key.
|
|
// It returns ("", 0) if the key is not supported.
|
|
// Implementation taken from github.com/mholt/acmez, which seems to be based on
|
|
// https://github.com/golang/crypto/blob/master/acme/jws.go.
|
|
func jwsHasher(pub crypto.PublicKey) (string, crypto.Hash) {
|
|
switch pub := pub.(type) {
|
|
case *rsa.PublicKey:
|
|
return "RS256", crypto.SHA256
|
|
case *ecdsa.PublicKey:
|
|
switch pub.Params().Name {
|
|
case "P-256":
|
|
return "ES256", crypto.SHA256
|
|
case "P-384":
|
|
return "ES384", crypto.SHA384
|
|
case "P-521":
|
|
return "ES512", crypto.SHA512
|
|
}
|
|
}
|
|
return "", 0
|
|
}
|
|
|
|
// jwsSign signs the digest using the given key.
|
|
// The hash is unused for ECDSA keys.
|
|
//
|
|
// Note: non-stdlib crypto.Signer implementations are expected to return
|
|
// the signature in the format as specified in RFC7518.
|
|
// See https://tools.ietf.org/html/rfc7518 for more details.
|
|
// Implementation taken from github.com/mholt/acmez, which seems to be based on
|
|
// https://github.com/golang/crypto/blob/master/acme/jws.go.
|
|
func jwsSign(key crypto.Signer, hash crypto.Hash, digest []byte) ([]byte, error) {
|
|
if key, ok := key.(*ecdsa.PrivateKey); ok {
|
|
// The key.Sign method of ecdsa returns ASN1-encoded signature.
|
|
// So, we use the package Sign function instead
|
|
// to get R and S values directly and format the result accordingly.
|
|
r, s, err := ecdsa.Sign(rand.Reader, key, digest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rb, sb := r.Bytes(), s.Bytes()
|
|
size := key.Params().BitSize / 8
|
|
if size%8 > 0 {
|
|
size++
|
|
}
|
|
sig := make([]byte, size*2)
|
|
copy(sig[size-len(rb):], rb)
|
|
copy(sig[size*2-len(sb):], sb)
|
|
return sig, nil
|
|
}
|
|
return key.Sign(rand.Reader, digest, hash)
|
|
}
|
|
|
|
// jwsHead constructs the protected JWS header for the given fields.
|
|
// Since jwk and kid are mutually-exclusive, the jwk will be encoded
|
|
// only if kid is empty. If nonce is empty, it will not be encoded.
|
|
// Implementation taken from github.com/mholt/acmez, which seems to be based on
|
|
// https://github.com/golang/crypto/blob/master/acme/jws.go.
|
|
func jwsHead(alg, nonce, u string, kid keyID, key crypto.Signer) (string, error) {
|
|
phead := fmt.Sprintf(`{"alg":%q`, alg)
|
|
if kid == noKeyID {
|
|
jwk, err := jwkEncode(key.Public())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
phead += fmt.Sprintf(`,"jwk":%s`, jwk)
|
|
} else {
|
|
phead += fmt.Sprintf(`,"kid":%q`, kid)
|
|
}
|
|
if nonce != "" {
|
|
phead += fmt.Sprintf(`,"nonce":%q`, nonce)
|
|
}
|
|
phead += fmt.Sprintf(`,"url":%q}`, u)
|
|
phead = base64.RawURLEncoding.EncodeToString([]byte(phead))
|
|
return phead, nil
|
|
}
|
|
|
|
// jwkEncode encodes public part of an RSA or ECDSA key into a JWK.
|
|
// The result is also suitable for creating a JWK thumbprint.
|
|
// https://tools.ietf.org/html/rfc7517
|
|
// Implementation taken from github.com/mholt/acmez, which seems to be based on
|
|
// https://github.com/golang/crypto/blob/master/acme/jws.go.
|
|
func jwkEncode(pub crypto.PublicKey) (string, error) {
|
|
switch pub := pub.(type) {
|
|
case *rsa.PublicKey:
|
|
// https://tools.ietf.org/html/rfc7518#section-6.3.1
|
|
n := pub.N
|
|
e := big.NewInt(int64(pub.E))
|
|
// Field order is important.
|
|
// See https://tools.ietf.org/html/rfc7638#section-3.3 for details.
|
|
return fmt.Sprintf(`{"e":%q,"kty":"RSA","n":%q}`,
|
|
base64.RawURLEncoding.EncodeToString(e.Bytes()),
|
|
base64.RawURLEncoding.EncodeToString(n.Bytes()),
|
|
), nil
|
|
case *ecdsa.PublicKey:
|
|
// https://tools.ietf.org/html/rfc7518#section-6.2.1
|
|
p := pub.Curve.Params()
|
|
n := p.BitSize / 8
|
|
if p.BitSize%8 != 0 {
|
|
n++
|
|
}
|
|
x := pub.X.Bytes()
|
|
if n > len(x) {
|
|
x = append(make([]byte, n-len(x)), x...)
|
|
}
|
|
y := pub.Y.Bytes()
|
|
if n > len(y) {
|
|
y = append(make([]byte, n-len(y)), y...)
|
|
}
|
|
// Field order is important.
|
|
// See https://tools.ietf.org/html/rfc7638#section-3.3 for details.
|
|
return fmt.Sprintf(`{"crv":%q,"kty":"EC","x":%q,"y":%q}`,
|
|
p.Name,
|
|
base64.RawURLEncoding.EncodeToString(x),
|
|
base64.RawURLEncoding.EncodeToString(y),
|
|
), nil
|
|
}
|
|
return "", errUnsupportedKey
|
|
}
|
|
|
|
// jwsFinal constructs the final JWS object.
|
|
// Implementation taken from github.com/mholt/acmez, which seems to be based on
|
|
// https://github.com/golang/crypto/blob/master/acme/jws.go.
|
|
func jwsFinal(sha crypto.Hash, sig []byte, phead, payload string) ([]byte, error) {
|
|
enc := struct {
|
|
Protected string `json:"protected"`
|
|
Payload string `json:"payload"`
|
|
Sig string `json:"signature"`
|
|
}{
|
|
Protected: phead,
|
|
Payload: payload,
|
|
Sig: base64.RawURLEncoding.EncodeToString(sig),
|
|
}
|
|
result, err := json.Marshal(&enc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type mockCA struct {
|
|
MockIsRevoked func(sn string) (bool, error)
|
|
MockRevoke func(ctx context.Context, opts *authority.RevokeOptions) error
|
|
}
|
|
|
|
func (m *mockCA) Sign(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockCA) IsRevoked(sn string) (bool, error) {
|
|
if m.MockIsRevoked != nil {
|
|
return m.MockIsRevoked(sn)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (m *mockCA) Revoke(ctx context.Context, opts *authority.RevokeOptions) error {
|
|
if m.MockRevoke != nil {
|
|
return m.MockRevoke(ctx, opts)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCA) LoadProvisionerByName(string) (provisioner.Interface, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func Test_validateReasonCode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
reasonCode *int
|
|
want *acme.Error
|
|
}{
|
|
{
|
|
name: "ok",
|
|
reasonCode: v(ocsp.Unspecified),
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "fail/too-low",
|
|
reasonCode: v(-1),
|
|
want: acme.NewError(acme.ErrorBadRevocationReasonType, "reasonCode out of bounds"),
|
|
},
|
|
{
|
|
name: "fail/too-high",
|
|
reasonCode: v(11),
|
|
want: acme.NewError(acme.ErrorBadRevocationReasonType, "reasonCode out of bounds"),
|
|
},
|
|
{
|
|
name: "fail/missing-7",
|
|
reasonCode: v(7),
|
|
|
|
want: acme.NewError(acme.ErrorBadRevocationReasonType, "reasonCode out of bounds"),
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateReasonCode(tt.reasonCode)
|
|
if (err != nil) != (tt.want != nil) {
|
|
t.Errorf("validateReasonCode() = %v, want %v", err, tt.want)
|
|
}
|
|
if err != nil {
|
|
assert.Equals(t, err.Type, tt.want.Type)
|
|
assert.Equals(t, err.Detail, tt.want.Detail)
|
|
assert.Equals(t, err.Status, tt.want.Status)
|
|
assert.Equals(t, err.Err.Error(), tt.want.Err.Error())
|
|
assert.Equals(t, err.Detail, tt.want.Detail)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_reason(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
reasonCode int
|
|
want string
|
|
}{
|
|
{
|
|
name: "unspecified reason",
|
|
reasonCode: ocsp.Unspecified,
|
|
want: "unspecified reason",
|
|
},
|
|
{
|
|
name: "key compromised",
|
|
reasonCode: ocsp.KeyCompromise,
|
|
want: "key compromised",
|
|
},
|
|
{
|
|
name: "ca compromised",
|
|
reasonCode: ocsp.CACompromise,
|
|
want: "ca compromised",
|
|
},
|
|
{
|
|
name: "affiliation changed",
|
|
reasonCode: ocsp.AffiliationChanged,
|
|
want: "affiliation changed",
|
|
},
|
|
{
|
|
name: "superseded",
|
|
reasonCode: ocsp.Superseded,
|
|
want: "superseded",
|
|
},
|
|
{
|
|
name: "cessation of operation",
|
|
reasonCode: ocsp.CessationOfOperation,
|
|
want: "cessation of operation",
|
|
},
|
|
{
|
|
name: "certificate hold",
|
|
reasonCode: ocsp.CertificateHold,
|
|
want: "certificate hold",
|
|
},
|
|
{
|
|
name: "remove from crl",
|
|
reasonCode: ocsp.RemoveFromCRL,
|
|
want: "remove from crl",
|
|
},
|
|
{
|
|
name: "privilege withdrawn",
|
|
reasonCode: ocsp.PrivilegeWithdrawn,
|
|
want: "privilege withdrawn",
|
|
},
|
|
{
|
|
name: "aa compromised",
|
|
reasonCode: ocsp.AACompromise,
|
|
want: "aa compromised",
|
|
},
|
|
{
|
|
name: "default",
|
|
reasonCode: -1,
|
|
want: "unspecified reason",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := reason(tt.reasonCode); got != tt.want {
|
|
t.Errorf("reason() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_revokeOptions(t *testing.T) {
|
|
cert, _, err := generateCertKeyPair()
|
|
assert.FatalError(t, err)
|
|
type args struct {
|
|
serial string
|
|
certToBeRevoked *x509.Certificate
|
|
reasonCode *int
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want *authority.RevokeOptions
|
|
}{
|
|
{
|
|
name: "ok/no-reasoncode",
|
|
args: args{
|
|
serial: "1234",
|
|
certToBeRevoked: cert,
|
|
},
|
|
want: &authority.RevokeOptions{
|
|
Serial: "1234",
|
|
Crt: cert,
|
|
ACME: true,
|
|
},
|
|
},
|
|
{
|
|
name: "ok/including-reasoncode",
|
|
args: args{
|
|
serial: "1234",
|
|
certToBeRevoked: cert,
|
|
reasonCode: v(ocsp.KeyCompromise),
|
|
},
|
|
want: &authority.RevokeOptions{
|
|
Serial: "1234",
|
|
Crt: cert,
|
|
ACME: true,
|
|
ReasonCode: 1,
|
|
Reason: "key compromised",
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := revokeOptions(tt.args.serial, tt.args.certToBeRevoked, tt.args.reasonCode); !cmp.Equal(got, tt.want) {
|
|
t.Errorf("revokeOptions() diff =\n%s", cmp.Diff(got, tt.want))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandler_RevokeCert(t *testing.T) {
|
|
prov := &provisioner.ACME{
|
|
Type: "ACME",
|
|
Name: "testprov",
|
|
}
|
|
escProvName := url.PathEscape(prov.GetName())
|
|
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
|
|
|
|
chiCtx := chi.NewRouteContext()
|
|
revokeURL := fmt.Sprintf("%s/acme/%s/revoke-cert", baseURL.String(), escProvName)
|
|
|
|
cert, key, err := generateCertKeyPair()
|
|
assert.FatalError(t, err)
|
|
rp := &revokePayload{
|
|
Certificate: base64.RawURLEncoding.EncodeToString(cert.Raw),
|
|
}
|
|
payloadBytes, err := json.Marshal(rp)
|
|
assert.FatalError(t, err)
|
|
|
|
jws := &jose.JSONWebSignature{
|
|
Signatures: []jose.Signature{
|
|
{
|
|
Protected: jose.Header{
|
|
Algorithm: jose.ES256,
|
|
KeyID: "bar",
|
|
ExtraHeaders: map[jose.HeaderKey]interface{}{
|
|
"url": revokeURL,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
type test struct {
|
|
db acme.DB
|
|
ca acme.CertificateAuthority
|
|
ctx context.Context
|
|
statusCode int
|
|
err *acme.Error
|
|
}
|
|
|
|
var tests = map[string]func(t *testing.T) test{
|
|
"fail/no-jws": func(t *testing.T) test {
|
|
ctx := context.Background()
|
|
return test{
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("jws expected in request context"),
|
|
}
|
|
},
|
|
"fail/nil-jws": func(t *testing.T) test {
|
|
ctx := context.WithValue(context.Background(), jwsContextKey, nil)
|
|
return test{
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("jws expected in request context"),
|
|
}
|
|
},
|
|
"fail/no-provisioner": func(t *testing.T) test {
|
|
ctx := context.WithValue(context.Background(), jwsContextKey, jws)
|
|
return test{
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("provisioner does not exist"),
|
|
}
|
|
},
|
|
"fail/nil-provisioner": func(t *testing.T) test {
|
|
ctx := context.WithValue(context.Background(), jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, provisionerContextKey, nil)
|
|
return test{
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("provisioner does not exist"),
|
|
}
|
|
},
|
|
"fail/no-payload": func(t *testing.T) test {
|
|
ctx := context.WithValue(context.Background(), jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, provisionerContextKey, prov)
|
|
return test{
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("payload does not exist"),
|
|
}
|
|
},
|
|
"fail/nil-payload": func(t *testing.T) test {
|
|
ctx := context.WithValue(context.Background(), jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, nil)
|
|
return test{
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("payload does not exist"),
|
|
}
|
|
},
|
|
"fail/unmarshal-payload": func(t *testing.T) test {
|
|
malformedPayload := []byte(`{"payload":malformed?}`)
|
|
ctx := context.WithValue(context.Background(), jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: malformedPayload})
|
|
return test{
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("error unmarshaling payload"),
|
|
}
|
|
},
|
|
"fail/wrong-certificate-encoding": func(t *testing.T) test {
|
|
wrongPayload := &revokePayload{
|
|
Certificate: base64.StdEncoding.EncodeToString(cert.Raw),
|
|
}
|
|
wronglyEncodedPayloadBytes, err := json.Marshal(wrongPayload)
|
|
assert.FatalError(t, err)
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: wronglyEncodedPayloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
return test{
|
|
ctx: ctx,
|
|
statusCode: 400,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:malformed",
|
|
Status: 400,
|
|
Detail: "The request message was malformed",
|
|
},
|
|
}
|
|
},
|
|
"fail/no-certificate-encoded": func(t *testing.T) test {
|
|
emptyPayload := &revokePayload{
|
|
Certificate: base64.RawURLEncoding.EncodeToString([]byte{}),
|
|
}
|
|
emptyPayloadBytes, err := json.Marshal(emptyPayload)
|
|
assert.FatalError(t, err)
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: emptyPayloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
return test{
|
|
ctx: ctx,
|
|
statusCode: 400,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:malformed",
|
|
Status: 400,
|
|
Detail: "The request message was malformed",
|
|
},
|
|
}
|
|
},
|
|
"fail/db.GetCertificateBySerial": func(t *testing.T) test {
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
return nil, errors.New("force")
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("error retrieving certificate by serial"),
|
|
}
|
|
},
|
|
"fail/different-certificate-contents": func(t *testing.T) test {
|
|
aDifferentCert, _, err := generateCertKeyPair()
|
|
assert.FatalError(t, err)
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
Leaf: aDifferentCert,
|
|
}, nil
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("certificate raw bytes are not equal"),
|
|
}
|
|
},
|
|
"fail/no-account": func(t *testing.T) test {
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ctx: ctx,
|
|
statusCode: 400,
|
|
err: acme.NewError(acme.ErrorAccountDoesNotExistType, "account not in context"),
|
|
}
|
|
},
|
|
"fail/nil-account": func(t *testing.T) test {
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, accContextKey, nil)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ctx: ctx,
|
|
statusCode: 400,
|
|
err: acme.NewError(acme.ErrorAccountDoesNotExistType, "account not in context"),
|
|
}
|
|
},
|
|
"fail/account-not-valid": func(t *testing.T) test {
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusInvalid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "accountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 403,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:unauthorized",
|
|
Detail: "No authorization provided for name 127.0.0.1",
|
|
Status: 403,
|
|
},
|
|
}
|
|
},
|
|
"fail/db.GetAuthorizationsByAccountID-error": func(t *testing.T) test {
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusValid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "differentAccountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
MockGetAuthorizationsByAccountID: func(ctx context.Context, accountID string) ([]*acme.Authorization, error) {
|
|
return nil, errors.New("force")
|
|
},
|
|
}
|
|
ca := &mockCA{}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: acme.NewErrorISE("error retrieving authorizations for Account %s", "accountID"),
|
|
}
|
|
},
|
|
"fail/account-not-authorized": func(t *testing.T) test {
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusValid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "differentAccountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
MockGetAuthorizationsByAccountID: func(ctx context.Context, accountID string) ([]*acme.Authorization, error) {
|
|
assert.Equals(t, "accountID", accountID)
|
|
return []*acme.Authorization{
|
|
{
|
|
AccountID: "accountID",
|
|
Status: acme.StatusValid,
|
|
Identifier: acme.Identifier{
|
|
Type: acme.IP,
|
|
Value: "127.0.1.0",
|
|
},
|
|
},
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 403,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:unauthorized",
|
|
Detail: "No authorization provided for name 127.0.0.1",
|
|
Status: 403,
|
|
},
|
|
}
|
|
},
|
|
"fail/unauthorized-certificate-key": func(t *testing.T) test {
|
|
_, unauthorizedKey, err := generateCertKeyPair()
|
|
assert.FatalError(t, err)
|
|
jwsPayload := &revokePayload{
|
|
Certificate: base64.RawURLEncoding.EncodeToString(cert.Raw),
|
|
ReasonCode: v(2),
|
|
}
|
|
jwsBytes, err := jwsEncodeJSON(rp, unauthorizedKey, "", "nonce", revokeURL)
|
|
assert.FatalError(t, err)
|
|
parsedJWS, err := jose.ParseJWS(string(jwsBytes))
|
|
assert.FatalError(t, err)
|
|
unauthorizedPayloadBytes, err := json.Marshal(jwsPayload)
|
|
assert.FatalError(t, err)
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: unauthorizedPayloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, parsedJWS)
|
|
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "accountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{}
|
|
acmeErr := acme.NewError(acme.ErrorUnauthorizedType, "verification of jws using certificate public key failed")
|
|
acmeErr.Detail = "No authorization provided for name 127.0.0.1"
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 403,
|
|
err: acmeErr,
|
|
}
|
|
},
|
|
"fail/certificate-revoked-check-fails": func(t *testing.T) test {
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusValid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "accountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{
|
|
MockIsRevoked: func(sn string) (bool, error) {
|
|
return false, errors.New("force")
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:serverInternal",
|
|
Detail: "The server experienced an internal error",
|
|
Status: 500,
|
|
},
|
|
}
|
|
},
|
|
"fail/certificate-already-revoked": func(t *testing.T) test {
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusValid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "accountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{
|
|
MockIsRevoked: func(sn string) (bool, error) {
|
|
return true, nil
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 400,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:alreadyRevoked",
|
|
Detail: "Certificate already revoked",
|
|
Status: 400,
|
|
},
|
|
}
|
|
},
|
|
"fail/invalid-reasoncode": func(t *testing.T) test {
|
|
invalidReasonPayload := &revokePayload{
|
|
Certificate: base64.RawURLEncoding.EncodeToString(cert.Raw),
|
|
ReasonCode: v(7),
|
|
}
|
|
invalidReasonCodePayloadBytes, err := json.Marshal(invalidReasonPayload)
|
|
assert.FatalError(t, err)
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusValid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: invalidReasonCodePayloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "accountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{
|
|
MockIsRevoked: func(sn string) (bool, error) {
|
|
return false, nil
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 400,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:badRevocationReason",
|
|
Detail: "The revocation reason provided is not allowed by the server",
|
|
Status: 400,
|
|
},
|
|
}
|
|
},
|
|
"fail/prov.AuthorizeRevoke": func(t *testing.T) test {
|
|
assert.FatalError(t, err)
|
|
mockACMEProv := &acme.MockProvisioner{
|
|
MauthorizeRevoke: func(ctx context.Context, token string) error {
|
|
return errors.New("force")
|
|
},
|
|
}
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusValid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, mockACMEProv)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "accountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{
|
|
MockIsRevoked: func(sn string) (bool, error) {
|
|
return false, nil
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:serverInternal",
|
|
Detail: "The server experienced an internal error",
|
|
Status: 500,
|
|
},
|
|
}
|
|
},
|
|
"fail/ca.Revoke": func(t *testing.T) test {
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusValid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "accountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{
|
|
MockRevoke: func(ctx context.Context, opts *authority.RevokeOptions) error {
|
|
return errors.New("force")
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 500,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:serverInternal",
|
|
Detail: "The server experienced an internal error",
|
|
Status: 500,
|
|
},
|
|
}
|
|
},
|
|
"fail/ca.Revoke-already-revoked": func(t *testing.T) test {
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusValid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "accountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{
|
|
MockIsRevoked: func(sn string) (bool, error) {
|
|
return false, nil
|
|
},
|
|
MockRevoke: func(ctx context.Context, opts *authority.RevokeOptions) error {
|
|
return fmt.Errorf("certificate with serial number '%s' is already revoked", cert.SerialNumber.String())
|
|
},
|
|
}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 400,
|
|
err: acme.NewError(acme.ErrorAlreadyRevokedType, "certificate with serial number '%s' is already revoked", cert.SerialNumber.String()),
|
|
}
|
|
},
|
|
"ok/using-account-key": func(t *testing.T) test {
|
|
acc := &acme.Account{ID: "accountID", Status: acme.StatusValid}
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, accContextKey, acc)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "accountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 200,
|
|
}
|
|
},
|
|
"ok/using-certificate-key": func(t *testing.T) test {
|
|
jwsBytes, err := jwsEncodeJSON(rp, key, "", "nonce", revokeURL)
|
|
assert.FatalError(t, err)
|
|
jws, err := jose.ParseJWS(string(jwsBytes))
|
|
assert.FatalError(t, err)
|
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
|
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payloadBytes})
|
|
ctx = context.WithValue(ctx, jwsContextKey, jws)
|
|
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx)
|
|
db := &acme.MockDB{
|
|
MockGetCertificateBySerial: func(ctx context.Context, serial string) (*acme.Certificate, error) {
|
|
assert.Equals(t, cert.SerialNumber.String(), serial)
|
|
return &acme.Certificate{
|
|
AccountID: "someDifferentAccountID",
|
|
Leaf: cert,
|
|
}, nil
|
|
},
|
|
}
|
|
ca := &mockCA{}
|
|
return test{
|
|
db: db,
|
|
ca: ca,
|
|
ctx: ctx,
|
|
statusCode: 200,
|
|
}
|
|
},
|
|
}
|
|
for name, setup := range tests {
|
|
tc := setup(t)
|
|
t.Run(name, func(t *testing.T) {
|
|
h := &Handler{linker: NewLinker("dns", "acme"), db: tc.db, ca: tc.ca}
|
|
req := httptest.NewRequest("POST", revokeURL, nil)
|
|
req = req.WithContext(tc.ctx)
|
|
w := httptest.NewRecorder()
|
|
h.RevokeCert(w, req)
|
|
res := w.Result()
|
|
|
|
assert.Equals(t, res.StatusCode, tc.statusCode)
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
assert.FatalError(t, err)
|
|
|
|
if res.StatusCode >= 400 && assert.NotNil(t, tc.err) {
|
|
var ae acme.Error
|
|
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &ae))
|
|
|
|
assert.Equals(t, ae.Type, tc.err.Type)
|
|
assert.Equals(t, ae.Detail, tc.err.Detail)
|
|
assert.Equals(t, ae.Identifier, tc.err.Identifier)
|
|
assert.Equals(t, ae.Subproblems, tc.err.Subproblems)
|
|
assert.Equals(t, res.Header["Content-Type"], []string{"application/problem+json"})
|
|
} else {
|
|
assert.True(t, bytes.Equal(bytes.TrimSpace(body), []byte{}))
|
|
assert.Equals(t, int64(0), req.ContentLength)
|
|
assert.Equals(t, []string{fmt.Sprintf("<%s/acme/%s/directory>;rel=\"index\"", baseURL.String(), escProvName)}, res.Header["Link"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandler_isAccountAuthorized(t *testing.T) {
|
|
type test struct {
|
|
db acme.DB
|
|
ctx context.Context
|
|
existingCert *acme.Certificate
|
|
certToBeRevoked *x509.Certificate
|
|
account *acme.Account
|
|
err *acme.Error
|
|
}
|
|
accountID := "accountID"
|
|
var tests = map[string]func(t *testing.T) test{
|
|
"fail/account-invalid": func(t *testing.T) test {
|
|
account := &acme.Account{
|
|
ID: accountID,
|
|
Status: acme.StatusInvalid,
|
|
}
|
|
certToBeRevoked := &x509.Certificate{
|
|
Subject: pkix.Name{
|
|
CommonName: "127.0.0.1",
|
|
},
|
|
}
|
|
return test{
|
|
ctx: context.TODO(),
|
|
certToBeRevoked: certToBeRevoked,
|
|
account: account,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:unauthorized",
|
|
Status: http.StatusForbidden,
|
|
Detail: "No authorization provided for name 127.0.0.1",
|
|
Err: errors.New("account 'accountID' has status 'invalid'"),
|
|
},
|
|
}
|
|
},
|
|
"fail/no-certificate-identifiers": func(t *testing.T) test {
|
|
account := &acme.Account{
|
|
ID: accountID,
|
|
Status: acme.StatusValid,
|
|
}
|
|
certToBeRevoked := &x509.Certificate{}
|
|
existingCert := &acme.Certificate{
|
|
AccountID: "differentAccountID",
|
|
}
|
|
return test{
|
|
ctx: context.TODO(),
|
|
existingCert: existingCert,
|
|
certToBeRevoked: certToBeRevoked,
|
|
account: account,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:unauthorized",
|
|
Status: http.StatusForbidden,
|
|
Detail: "No authorization provided",
|
|
Err: errors.New("cannot authorize revocation without providing identifiers to authorize"),
|
|
},
|
|
}
|
|
},
|
|
"fail/db.GetAuthorizationsByAccountID-error": func(t *testing.T) test {
|
|
account := &acme.Account{
|
|
ID: accountID,
|
|
Status: acme.StatusValid,
|
|
}
|
|
certToBeRevoked := &x509.Certificate{
|
|
Subject: pkix.Name{
|
|
CommonName: "127.0.0.1",
|
|
},
|
|
}
|
|
existingCert := &acme.Certificate{
|
|
AccountID: "differentAccountID",
|
|
}
|
|
return test{
|
|
db: &acme.MockDB{
|
|
MockGetAuthorizationsByAccountID: func(ctx context.Context, accountID string) ([]*acme.Authorization, error) {
|
|
assert.Equals(t, "accountID", accountID)
|
|
return nil, errors.New("force")
|
|
},
|
|
},
|
|
ctx: context.TODO(),
|
|
existingCert: existingCert,
|
|
certToBeRevoked: certToBeRevoked,
|
|
account: account,
|
|
err: acme.NewErrorISE("error retrieving authorizations for Account %s: force", accountID),
|
|
}
|
|
},
|
|
"fail/no-valid-authorizations": func(t *testing.T) test {
|
|
account := &acme.Account{
|
|
ID: accountID,
|
|
Status: acme.StatusValid,
|
|
}
|
|
certToBeRevoked := &x509.Certificate{
|
|
Subject: pkix.Name{
|
|
CommonName: "127.0.0.1",
|
|
},
|
|
}
|
|
existingCert := &acme.Certificate{
|
|
AccountID: "differentAccountID",
|
|
}
|
|
return test{
|
|
db: &acme.MockDB{
|
|
MockGetAuthorizationsByAccountID: func(ctx context.Context, accountID string) ([]*acme.Authorization, error) {
|
|
assert.Equals(t, "accountID", accountID)
|
|
return []*acme.Authorization{
|
|
{
|
|
AccountID: accountID,
|
|
Status: acme.StatusInvalid,
|
|
},
|
|
}, nil
|
|
},
|
|
},
|
|
ctx: context.TODO(),
|
|
existingCert: existingCert,
|
|
certToBeRevoked: certToBeRevoked,
|
|
account: account,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:unauthorized",
|
|
Status: http.StatusForbidden,
|
|
Detail: "No authorization provided for name 127.0.0.1",
|
|
Err: errors.New("account 'accountID' does not have valid authorizations"),
|
|
},
|
|
}
|
|
},
|
|
"fail/authorizations-do-not-match-identifiers": func(t *testing.T) test {
|
|
account := &acme.Account{
|
|
ID: accountID,
|
|
Status: acme.StatusValid,
|
|
}
|
|
certToBeRevoked := &x509.Certificate{
|
|
Subject: pkix.Name{
|
|
CommonName: "127.0.0.1",
|
|
},
|
|
}
|
|
existingCert := &acme.Certificate{
|
|
AccountID: "differentAccountID",
|
|
}
|
|
return test{
|
|
db: &acme.MockDB{
|
|
MockGetAuthorizationsByAccountID: func(ctx context.Context, accountID string) ([]*acme.Authorization, error) {
|
|
assert.Equals(t, "accountID", accountID)
|
|
return []*acme.Authorization{
|
|
{
|
|
AccountID: accountID,
|
|
Status: acme.StatusValid,
|
|
Identifier: acme.Identifier{
|
|
Type: acme.IP,
|
|
Value: "127.0.0.2",
|
|
},
|
|
},
|
|
}, nil
|
|
},
|
|
},
|
|
ctx: context.TODO(),
|
|
existingCert: existingCert,
|
|
certToBeRevoked: certToBeRevoked,
|
|
account: account,
|
|
err: &acme.Error{
|
|
Type: "urn:ietf:params:acme:error:unauthorized",
|
|
Status: http.StatusForbidden,
|
|
Detail: "No authorization provided for name 127.0.0.1",
|
|
Err: errors.New("account 'accountID' does not have authorizations for all identifiers"),
|
|
},
|
|
}
|
|
},
|
|
"ok": func(t *testing.T) test {
|
|
account := &acme.Account{
|
|
ID: accountID,
|
|
Status: acme.StatusValid,
|
|
}
|
|
certToBeRevoked := &x509.Certificate{
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
|
}
|
|
existingCert := &acme.Certificate{
|
|
AccountID: "differentAccountID",
|
|
}
|
|
return test{
|
|
db: &acme.MockDB{
|
|
MockGetAuthorizationsByAccountID: func(ctx context.Context, accountID string) ([]*acme.Authorization, error) {
|
|
assert.Equals(t, "accountID", accountID)
|
|
return []*acme.Authorization{
|
|
{
|
|
AccountID: accountID,
|
|
Status: acme.StatusValid,
|
|
Identifier: acme.Identifier{
|
|
Type: acme.IP,
|
|
Value: "127.0.0.1",
|
|
},
|
|
},
|
|
}, nil
|
|
},
|
|
},
|
|
ctx: context.TODO(),
|
|
existingCert: existingCert,
|
|
certToBeRevoked: certToBeRevoked,
|
|
account: account,
|
|
err: nil,
|
|
}
|
|
},
|
|
}
|
|
for name, setup := range tests {
|
|
tc := setup(t)
|
|
t.Run(name, func(t *testing.T) {
|
|
h := &Handler{db: tc.db}
|
|
acmeErr := h.isAccountAuthorized(tc.ctx, tc.existingCert, tc.certToBeRevoked, tc.account)
|
|
|
|
expectError := tc.err != nil
|
|
gotError := acmeErr != nil
|
|
if expectError != gotError {
|
|
t.Errorf("expected: %t, got: %t", expectError, gotError)
|
|
return
|
|
}
|
|
|
|
if !gotError {
|
|
return // nothing to check; return early
|
|
}
|
|
|
|
assert.Equals(t, acmeErr.Err.Error(), tc.err.Err.Error())
|
|
assert.Equals(t, acmeErr.Type, tc.err.Type)
|
|
assert.Equals(t, acmeErr.Status, tc.err.Status)
|
|
assert.Equals(t, acmeErr.Detail, tc.err.Detail)
|
|
assert.Equals(t, acmeErr.Identifier, tc.err.Identifier)
|
|
assert.Equals(t, acmeErr.Subproblems, tc.err.Subproblems)
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_identifierKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
identifier acme.Identifier
|
|
want string
|
|
}{
|
|
{
|
|
name: "ip",
|
|
identifier: acme.Identifier{
|
|
Type: acme.IP,
|
|
Value: "10.0.0.1",
|
|
},
|
|
want: "ip|10.0.0.1",
|
|
},
|
|
{
|
|
name: "dns",
|
|
identifier: acme.Identifier{
|
|
Type: acme.DNS,
|
|
Value: "*.example.com",
|
|
},
|
|
want: "dns|*.example.com",
|
|
},
|
|
{
|
|
name: "unknown",
|
|
identifier: acme.Identifier{
|
|
Type: "InvalidType",
|
|
Value: "<<>>",
|
|
},
|
|
want: "unsupported|<<>>",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := identifierKey(tt.identifier)
|
|
if !cmp.Equal(tt.want, got) {
|
|
t.Errorf("identifierKey() diff = \n%s", cmp.Diff(tt.want, got))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_extractIdentifiers(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cert *x509.Certificate
|
|
want map[string]acme.Identifier
|
|
}{
|
|
{
|
|
name: "ip",
|
|
cert: &x509.Certificate{
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
|
},
|
|
want: map[string]acme.Identifier{
|
|
"ip|127.0.0.1": {
|
|
Type: acme.IP,
|
|
Value: "127.0.0.1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "dns",
|
|
cert: &x509.Certificate{
|
|
DNSNames: []string{"*.example.com"},
|
|
},
|
|
want: map[string]acme.Identifier{
|
|
"dns|*.example.com": {
|
|
Type: acme.DNS,
|
|
Value: "*.example.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "dns-subject",
|
|
cert: &x509.Certificate{
|
|
Subject: pkix.Name{
|
|
CommonName: "www.example.com",
|
|
},
|
|
},
|
|
want: map[string]acme.Identifier{
|
|
"dns|www.example.com": {
|
|
Type: acme.DNS,
|
|
Value: "www.example.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "ip-subject",
|
|
cert: &x509.Certificate{
|
|
Subject: pkix.Name{
|
|
CommonName: "127.0.0.1",
|
|
},
|
|
},
|
|
want: map[string]acme.Identifier{
|
|
"dns|127.0.0.1": { // this is the currently expected behavior
|
|
Type: acme.DNS,
|
|
Value: "127.0.0.1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "combined",
|
|
cert: &x509.Certificate{
|
|
Subject: pkix.Name{
|
|
CommonName: "127.0.0.1",
|
|
},
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("127.0.0.2")},
|
|
DNSNames: []string{"*.example.com", "www.example.com"},
|
|
},
|
|
want: map[string]acme.Identifier{
|
|
"ip|127.0.0.1": {
|
|
Type: acme.IP,
|
|
Value: "127.0.0.1",
|
|
},
|
|
"ip|127.0.0.2": {
|
|
Type: acme.IP,
|
|
Value: "127.0.0.2",
|
|
},
|
|
"dns|*.example.com": {
|
|
Type: acme.DNS,
|
|
Value: "*.example.com",
|
|
},
|
|
"dns|www.example.com": {
|
|
Type: acme.DNS,
|
|
Value: "www.example.com",
|
|
},
|
|
"dns|127.0.0.1": { // this is the currently expected behavior
|
|
Type: acme.DNS,
|
|
Value: "127.0.0.1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "ip-duplicates",
|
|
cert: &x509.Certificate{
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("127.0.0.1"), net.ParseIP("127.0.0.2")},
|
|
},
|
|
want: map[string]acme.Identifier{
|
|
"ip|127.0.0.1": {
|
|
Type: acme.IP,
|
|
Value: "127.0.0.1",
|
|
},
|
|
"ip|127.0.0.2": {
|
|
Type: acme.IP,
|
|
Value: "127.0.0.2",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "dns-duplicates",
|
|
cert: &x509.Certificate{
|
|
DNSNames: []string{"*.example.com", "www.example.com", "www.example.com"},
|
|
},
|
|
want: map[string]acme.Identifier{
|
|
"dns|*.example.com": {
|
|
Type: acme.DNS,
|
|
Value: "*.example.com",
|
|
},
|
|
"dns|www.example.com": {
|
|
Type: acme.DNS,
|
|
Value: "www.example.com",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := extractIdentifiers(tt.cert)
|
|
if !cmp.Equal(tt.want, got) {
|
|
t.Errorf("extractIdentifiers() diff=\n%s", cmp.Diff(tt.want, got))
|
|
}
|
|
})
|
|
}
|
|
}
|