certificates/acme/challenge.go
David Cowden 8ae32f50f2 acme: Fix comment style to appease linter
The linter likes comments on public functions to start with their name,
for some reason...
2020-05-13 05:04:49 -07:00

647 lines
18 KiB
Go

package acme
import (
"crypto"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/asn1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"net"
"net/http"
"strings"
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/cli/jose"
"github.com/smallstep/nosql"
)
// Challenge is a subset of the challenge type containing only those attributes
// required for responses in the ACME protocol.
type Challenge struct {
Type string `json:"type"`
Status string `json:"status"`
Token string `json:"token"`
Validated string `json:"validated,omitempty"`
URL string `json:"url"`
Error *AError `json:"error,omitempty"`
RetryAfter string `json:"retry_after,omitempty"`
ID string `json:"-"`
AuthzID string `json:"-"`
}
// ToLog enables response logging.
func (c *Challenge) ToLog() (interface{}, error) {
b, err := json.Marshal(c)
if err != nil {
return nil, ServerInternalErr(errors.Wrap(err, "error marshaling challenge for logging"))
}
return string(b), nil
}
// GetID returns the Challenge ID.
func (c *Challenge) GetID() string {
return c.ID
}
// GetAuthzID returns the parent Authz ID that owns the Challenge.
func (c *Challenge) GetAuthzID() string {
return c.AuthzID
}
type httpGetter func(string) (*http.Response, error)
type lookupTxt func(string) ([]string, error)
type tlsDialer func(network, addr string, config *tls.Config) (*tls.Conn, error)
type validateOptions struct {
httpGet httpGetter
lookupTxt lookupTxt
tlsDial tlsDialer
}
// challenge is the interface ACME challenege types must implement.
type challenge interface {
save(db nosql.DB, swap challenge) error
validate(*jose.JSONWebKey, validateOptions) (challenge, error)
getType() string
getError() *AError
getValue() string
getStatus() string
getID() string
getAuthzID() string
getToken() string
getRetry() *Retry
clone() *baseChallenge
getAccountID() string
getValidated() time.Time
getCreated() time.Time
toACME(*directory, provisioner.Interface) (*Challenge, error)
}
// ChallengeOptions is the type used to created a new Challenge.
type ChallengeOptions struct {
AccountID string
AuthzID string
ProvisionerID string
Identifier Identifier
}
// baseChallenge is the base Challenge type that others build from.
type baseChallenge struct {
ID string `json:"id"`
AccountID string `json:"accountID"`
AuthzID string `json:"authzID"`
Type string `json:"type"`
Status string `json:"status"`
Token string `json:"token"`
Value string `json:"value"`
Created time.Time `json:"created"`
Validated time.Time `json:"validated"`
Error *AError `json:"error"`
Retry *Retry `json:"retry"`
}
func newBaseChallenge(accountID, authzID string) (*baseChallenge, error) {
id, err := randID()
if err != nil {
return nil, Wrap(err, "error generating random id for ACME challenge")
}
token, err := randID()
if err != nil {
return nil, Wrap(err, "error generating token for ACME challenge")
}
return &baseChallenge{
ID: id,
AccountID: accountID,
AuthzID: authzID,
Status: StatusPending,
Token: token,
Created: clock.Now(),
}, nil
}
// getID returns the id of the baseChallenge.
func (bc *baseChallenge) getID() string {
return bc.ID
}
// getAuthzID returns the authz ID of the baseChallenge.
func (bc *baseChallenge) getAuthzID() string {
return bc.AuthzID
}
// getAccountID returns the account id of the baseChallenge.
func (bc *baseChallenge) getAccountID() string {
return bc.AccountID
}
// getType returns the type of the baseChallenge.
func (bc *baseChallenge) getType() string {
return bc.Type
}
// getValue returns the type of the baseChallenge.
func (bc *baseChallenge) getValue() string {
return bc.Value
}
// getStatus returns the status of the baseChallenge.
func (bc *baseChallenge) getStatus() string {
return bc.Status
}
// getToken returns the token of the baseChallenge.
func (bc *baseChallenge) getToken() string {
return bc.Token
}
// getRetry returns the retry state of the baseChallenge
func (bc *baseChallenge) getRetry() *Retry {
return bc.Retry
}
// getValidated returns the validated time of the baseChallenge.
func (bc *baseChallenge) getValidated() time.Time {
return bc.Validated
}
// getCreated returns the created time of the baseChallenge.
func (bc *baseChallenge) getCreated() time.Time {
return bc.Created
}
// getCreated returns the created time of the baseChallenge.
func (bc *baseChallenge) getError() *AError {
return bc.Error
}
// toACME converts the internal Challenge type into the public acmeChallenge
// type for presentation in the ACME protocol.
func (bc *baseChallenge) toACME(dir *directory, p provisioner.Interface) (*Challenge, error) {
ac := &Challenge{
Type: bc.getType(),
Status: bc.getStatus(),
Token: bc.getToken(),
URL: dir.getLink(ChallengeLink, URLSafeProvisionerName(p), true, bc.getID()),
ID: bc.getID(),
AuthzID: bc.getAuthzID(),
}
if !bc.Validated.IsZero() {
ac.Validated = bc.Validated.Format(time.RFC3339)
}
if bc.Error != nil {
ac.Error = bc.Error
}
if bc.Retry != nil && bc.Status == StatusProcessing {
ac.RetryAfter = bc.Retry.NextAttempt
}
return ac, nil
}
// save writes the challenge to disk. For new challenges 'old' should be nil,
// otherwise 'old' should be a pointer to the acme challenge as it was at the
// start of the request. This method will fail if the value currently found
// in the bucket/row does not match the value of 'old'.
func (bc *baseChallenge) save(db nosql.DB, old challenge) error {
newB, err := json.Marshal(bc)
if err != nil {
return ServerInternalErr(errors.Wrap(err,
"error marshaling new acme challenge"))
}
var oldB []byte
if old == nil {
oldB = nil
} else {
oldB, err = json.Marshal(old)
if err != nil {
return ServerInternalErr(errors.Wrap(err,
"error marshaling old acme challenge"))
}
}
_, swapped, err := db.CmpAndSwap(challengeTable, []byte(bc.ID), oldB, newB)
switch {
case err != nil:
return ServerInternalErr(errors.Wrap(err, "error saving acme challenge"))
case !swapped:
return ServerInternalErr(errors.New("error saving acme challenge; " +
"acme challenge has changed since last read"))
default:
return nil
}
}
func (bc *baseChallenge) clone() *baseChallenge {
u := *bc
if bc.Retry != nil {
r := *bc.Retry
u.Retry = &r
}
return &u
}
func (bc *baseChallenge) validate(jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) {
return nil, ServerInternalErr(errors.New("unimplemented"))
}
func (bc *baseChallenge) storeError(db nosql.DB, err *Error) error {
clone := bc.clone()
clone.Error = err.ToACME()
return clone.save(db, bc)
}
// unmarshalChallenge unmarshals a challenge type into the correct sub-type.
func unmarshalChallenge(data []byte) (challenge, error) {
var getType struct {
Type string `json:"type"`
}
if err := json.Unmarshal(data, &getType); err != nil {
return nil, ServerInternalErr(errors.Wrap(err, "error unmarshaling challenge type"))
}
switch getType.Type {
case "dns-01":
var bc baseChallenge
if err := json.Unmarshal(data, &bc); err != nil {
return nil, ServerInternalErr(errors.Wrap(err, "error unmarshaling "+
"challenge type into dns01Challenge"))
}
return &dns01Challenge{&bc}, nil
case "http-01":
var bc baseChallenge
if err := json.Unmarshal(data, &bc); err != nil {
return nil, ServerInternalErr(errors.Wrap(err, "error unmarshaling "+
"challenge type into http01Challenge"))
}
return &http01Challenge{&bc}, nil
case "tls-alpn-01":
var bc baseChallenge
if err := json.Unmarshal(data, &bc); err != nil {
return nil, ServerInternalErr(errors.Wrap(err, "error unmarshaling "+
"challenge type into tlsALPN01Challenge"))
}
return &tlsALPN01Challenge{&bc}, nil
default:
return nil, ServerInternalErr(errors.Errorf("unexpected challenge type %s", getType.Type))
}
}
func (bc *baseChallenge) morph() challenge {
switch bc.getType() {
case "dns-01":
return &dns01Challenge{bc}
case "http-01":
return &http01Challenge{bc}
case "tls-alpn-01":
return &tlsALPN01Challenge{bc}
default:
panic("unrecognized challenge type: " + bc.getType())
}
}
// Retry information for challenges is internally relevant and needs to be stored in the DB, but should not be part
// of the public challenge API apart from the Retry-After header.
type Retry struct {
Owner int `json:"owner"`
ProvisionerID string `json:"provisionerid"`
NumAttempts int `json:"numattempts"`
MaxAttempts int `json:"maxattempts"`
NextAttempt string `json:"nextattempt"`
}
// Active returns a boolean indicating whether a Retry struct has remaining attempts or not.
func (r *Retry) Active() bool {
return r.NumAttempts < r.MaxAttempts
}
// http01Challenge represents an http-01 acme challenge.
type http01Challenge struct {
*baseChallenge
}
// newHTTP01Challenge returns a new acme http-01 challenge.
func newHTTP01Challenge(db nosql.DB, ops ChallengeOptions) (challenge, error) {
bc, err := newBaseChallenge(ops.AccountID, ops.AuthzID)
if err != nil {
return nil, err
}
bc.Type = "http-01"
bc.Value = ops.Identifier.Value
hc := &http01Challenge{bc}
if err := hc.save(db, nil); err != nil {
return nil, err
}
return hc, nil
}
// Validate attempts to validate the challenge. If the challenge has been
// satisfactorily validated, the 'status' and 'validated' attributes are
// updated.
func (hc *http01Challenge) validate(jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) {
// If already valid or invalid then return without performing validation.
switch hc.getStatus() {
case StatusPending:
panic("pending challenges must first be moved to the processing state")
case StatusProcessing:
break
case StatusValid, StatusInvalid:
return hc, nil
default:
panic("unknown challenge state: " + hc.getStatus())
}
up := &http01Challenge{hc.baseChallenge.clone()}
url := fmt.Sprintf("http://%s/.well-known/acme-challenge/%s", hc.Value, hc.Token)
resp, err := vo.httpGet(url)
if err != nil {
e := errors.Wrapf(err, "error doing http GET for url %s", url)
up.Error = ConnectionErr(e).ToACME()
return up, nil
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
e := errors.Errorf("error doing http GET for url %s with status code %d", url, resp.StatusCode)
up.Error = ConnectionErr(e).ToACME()
return up, nil
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
e := errors.Wrapf(err, "error reading response body for url %s", url)
up.Error = ServerInternalErr(e).ToACME()
return up, nil
}
keyAuth := strings.Trim(string(body), "\r\n")
expected, err := KeyAuthorization(hc.Token, jwk)
if err != nil {
return nil, err
}
if keyAuth != expected {
// add base challenge fail validation
e := errors.Errorf("keyAuthorization does not match; expected %s, but got %s", expected, keyAuth)
up.Error = RejectedIdentifierErr(e).ToACME()
up.Status = StatusInvalid
return up, nil
}
up.Status = StatusValid
up.Validated = clock.Now()
up.Error = nil
up.Retry = nil
return up, nil
}
type tlsALPN01Challenge struct {
*baseChallenge
}
// newTLSALPN01Challenge returns a new acme tls-alpn-01 challenge.
func newTLSALPN01Challenge(db nosql.DB, ops ChallengeOptions) (challenge, error) {
bc, err := newBaseChallenge(ops.AccountID, ops.AuthzID)
if err != nil {
return nil, err
}
bc.Type = "tls-alpn-01"
bc.Value = ops.Identifier.Value
tc := &tlsALPN01Challenge{bc}
if err := tc.save(db, nil); err != nil {
return nil, err
}
return tc, nil
}
func (tc *tlsALPN01Challenge) validate(jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) {
// If already valid or invalid then return without performing validation.
switch tc.getStatus() {
case StatusPending:
panic("pending challenges must first be moved to the processing state")
case StatusProcessing:
break
case StatusValid, StatusInvalid:
return tc, nil
default:
panic("unknown challenge state: " + tc.getStatus())
}
up := &tlsALPN01Challenge{tc.baseChallenge.clone()}
config := &tls.Config{
NextProtos: []string{"acme-tls/1"},
ServerName: tc.Value,
InsecureSkipVerify: true, // we expect a self-signed challenge certificate
}
hostPort := net.JoinHostPort(tc.Value, "443")
conn, err := vo.tlsDial("tcp", hostPort, config)
if err != nil {
e := errors.Wrapf(err, "error doing TLS dial for %s", hostPort)
up.Error = ConnectionErr(e).ToACME()
return up, nil
}
defer conn.Close()
cs := conn.ConnectionState()
certs := cs.PeerCertificates
if len(certs) == 0 {
e := errors.Errorf("%s challenge for %s resulted in no certificates", tc.Type, tc.Value)
up.Error = RejectedIdentifierErr(e).ToACME()
return up, nil
}
if !cs.NegotiatedProtocolIsMutual || cs.NegotiatedProtocol != "acme-tls/1" {
e := errors.Errorf("cannot negotiate ALPN acme-tls/1 protocol for tls-alpn-01 challenge")
up.Error = RejectedIdentifierErr(e).ToACME()
return up, nil
}
leafCert := certs[0]
if len(leafCert.DNSNames) != 1 || !strings.EqualFold(leafCert.DNSNames[0], tc.Value) {
e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+
"leaf certificate must contain a single DNS name, %v", tc.Value)
up.Error = RejectedIdentifierErr(e).ToACME()
return up, nil
}
idPeAcmeIdentifier := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31}
idPeAcmeIdentifierV1Obsolete := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 30, 1}
foundIDPeAcmeIdentifierV1Obsolete := false
keyAuth, err := KeyAuthorization(tc.Token, jwk)
if err != nil {
return nil, err
}
hashedKeyAuth := sha256.Sum256([]byte(keyAuth))
for _, ext := range leafCert.Extensions {
if idPeAcmeIdentifier.Equal(ext.Id) {
if !ext.Critical {
e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: " +
"acmeValidationV1 extension not critical")
up.Error = IncorrectResponseErr(e).ToACME()
return up, nil
}
var extValue []byte
rest, err := asn1.Unmarshal(ext.Value, &extValue)
if err != nil || len(rest) > 0 || len(hashedKeyAuth) != len(extValue) {
e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: " +
"malformed acmeValidationV1 extension value")
up.Error = IncorrectResponseErr(e).ToACME()
return up, nil
}
if subtle.ConstantTimeCompare(hashedKeyAuth[:], extValue) != 1 {
e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: "+
"expected acmeValidationV1 extension value %s for this challenge but got %s",
hex.EncodeToString(hashedKeyAuth[:]), hex.EncodeToString(extValue))
up.Error = IncorrectResponseErr(e).ToACME()
// There is an appropriate value, but it doesn't match.
up.Status = StatusInvalid
return up, nil
}
up.Validated = clock.Now()
up.Status = StatusValid
up.Error = nil
up.Retry = nil
return up, nil
}
if idPeAcmeIdentifierV1Obsolete.Equal(ext.Id) {
foundIDPeAcmeIdentifierV1Obsolete = true
}
}
if foundIDPeAcmeIdentifierV1Obsolete {
e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: " +
"obsolete id-pe-acmeIdentifier in acmeValidationV1 extension")
up.Error = IncorrectResponseErr(e).ToACME()
return up, nil
}
e := errors.Errorf("incorrect certificate for tls-alpn-01 challenge: " +
"missing acmeValidationV1 extension")
up.Error = IncorrectResponseErr(e).ToACME()
return tc, nil
}
// dns01Challenge represents an dns-01 acme challenge.
type dns01Challenge struct {
*baseChallenge
}
// newDNS01Challenge returns a new acme dns-01 challenge.
func newDNS01Challenge(db nosql.DB, ops ChallengeOptions) (challenge, error) {
bc, err := newBaseChallenge(ops.AccountID, ops.AuthzID)
if err != nil {
return nil, err
}
bc.Type = "dns-01"
bc.Value = ops.Identifier.Value
dc := &dns01Challenge{bc}
if err := dc.save(db, nil); err != nil {
return nil, err
}
return dc, nil
}
// validate attempts to validate the challenge. If the challenge has been
// satisfactorily validated, the 'status' and 'validated' attributes are
// updated.
func (dc *dns01Challenge) validate(jwk *jose.JSONWebKey, vo validateOptions) (challenge, error) {
// If already valid or invalid then return without performing validation.
switch dc.getStatus() {
case StatusPending:
panic("pending challenges must first be moved to the processing state")
case StatusProcessing:
break
case StatusValid, StatusInvalid:
return dc, nil
default:
panic("unknown challenge state: " + dc.getStatus())
}
up := &dns01Challenge{dc.baseChallenge.clone()}
// Normalize domain for wildcard DNS names
// This is done to avoid making TXT lookups for domains like
// _acme-challenge.*.example.com
// Instead perform txt lookup for _acme-challenge.example.com
domain := strings.TrimPrefix(dc.Value, "*.")
record := "_acme-challenge." + domain
txtRecords, err := vo.lookupTxt(record)
if err != nil {
e := errors.Wrapf(err, "error looking up TXT records for domain %s", domain)
up.Error = DNSErr(e).ToACME()
return dc, nil
}
expectedKeyAuth, err := KeyAuthorization(dc.Token, jwk)
if err != nil {
return nil, err
}
h := sha256.Sum256([]byte(expectedKeyAuth))
expected := base64.RawURLEncoding.EncodeToString(h[:])
if len(txtRecords) == 0 {
e := errors.Errorf("no TXT record found at '%s'", record)
up.Error = DNSErr(e).ToACME()
return up, nil
}
for _, r := range txtRecords {
if r == expected {
up.Validated = time.Now().UTC()
up.Status = StatusValid
up.Error = nil
up.Retry = nil
return up, nil
}
}
up.Status = StatusInvalid
e := errors.Errorf("keyAuthorization does not match; expected %s, but got %s",
expectedKeyAuth, txtRecords)
up.Error = IncorrectResponseErr(e).ToACME()
return up, nil
}
// KeyAuthorization creates the ACME key authorization value from a token
// and a jwk.
func KeyAuthorization(token string, jwk *jose.JSONWebKey) (string, error) {
thumbprint, err := jwk.Thumbprint(crypto.SHA256)
if err != nil {
return "", ServerInternalErr(errors.Wrap(err, "error generating JWK thumbprint"))
}
encPrint := base64.RawURLEncoding.EncodeToString(thumbprint)
return fmt.Sprintf("%s.%s", token, encPrint), nil
}
// getChallenge retrieves and unmarshals an ACME challenge type from the database.
func getChallenge(db nosql.DB, id string) (challenge, error) {
b, err := db.Get(challengeTable, []byte(id))
if nosql.IsErrNotFound(err) {
return nil, MalformedErr(errors.Wrapf(err, "challenge %s not found", id))
} else if err != nil {
return nil, ServerInternalErr(errors.Wrapf(err, "error loading challenge %s", id))
}
ch, err := unmarshalChallenge(b)
if err != nil {
return nil, err
}
return ch, nil
}