Implement ACME order API (#20127)

* Implement ACME new-order API
 - This is a very rough draft for the new order ACME API

* Add ACME order list API

* Implement ACME Get order API

* Misc order related fixes

 - Filter authorizations in GetOrders for valid
 - Validate notBefore and notAfter dates make sense
 - Add <order>/cert URL path to order response if set to valid

* Return account status within err authorized, if the account key verified
This commit is contained in:
Steven Clark 2023-04-14 10:54:48 -04:00 committed by GitHub
parent e7c0d5744b
commit d324aa0d15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 625 additions and 9 deletions

View File

@ -27,6 +27,15 @@ const (
ACMEAuthorizationRevoked ACMEAuthorizationStatusType = "revoked"
)
type ACMEOrderStatusType string
const (
ACMEOrderPending ACMEOrderStatusType = "pending"
ACMEOrderProcessing ACMEOrderStatusType = "processing"
ACMEOrderValid ACMEOrderStatusType = "valid"
ACMEOrderInvalid ACMEOrderStatusType = "invalid"
)
type ACMEChallengeType string
const (

View File

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"strings"
"sync"
"sync/atomic"
"time"
@ -19,10 +20,9 @@ const (
nonceExpiry = 15 * time.Minute
// Path Prefixes
acmePathPrefix = "acme/"
acmeAccountPrefix = acmePathPrefix + "accounts/"
acmeThumbprintPrefix = acmePathPrefix + "account-thumbprints/"
acmeAuthroizationPrefix = acmePathPrefix + "authz/"
acmePathPrefix = "acme/"
acmeAccountPrefix = acmePathPrefix + "accounts/"
acmeThumbprintPrefix = acmePathPrefix + "account-thumbprints/"
)
type acmeState struct {
@ -134,6 +134,17 @@ type acmeAccount struct {
Jwk []byte `json:"jwk"`
}
type acmeOrder struct {
OrderId string `json:"-"`
AccountId string `json:"account-id"`
Status ACMEOrderStatusType `json:"status"`
Expires string `json:"expires"`
NotBefore string `json:"not-before"`
NotAfter string `json:"not-after"`
Identifiers []*ACMEIdentifier `json:"identifiers"`
AuthorizationIds []string `json:"authorization-ids"`
}
func (a *acmeState) CreateAccount(ac *acmeContext, c *jwsCtx, contact []string, termsOfServiceAgreed bool) (*acmeAccount, error) {
// Write out the thumbprint value/entry out first, if we get an error mid-way through
// this is easier to recover from. The new kid with the same existing public key
@ -267,7 +278,9 @@ func (a *acmeState) LoadAuthorization(ac *acmeContext, userCtx *jwsCtx, authId s
return nil, fmt.Errorf("malformed authorization identifier")
}
entry, err := ac.sc.Storage.Get(ac.sc.Context, acmeAuthroizationPrefix+authId)
authorizationPath := getAuthorizationPath(userCtx.Kid, authId)
entry, err := ac.sc.Storage.Get(ac.sc.Context, authorizationPath)
if err != nil {
return nil, fmt.Errorf("error loading authorization: %w", err)
}
@ -294,12 +307,16 @@ func (a *acmeState) SaveAuthorization(ac *acmeContext, authz *ACMEAuthorization)
return fmt.Errorf("invalid authorization, missing id")
}
json, err := logical.StorageEntryJSON(acmeAuthroizationPrefix+authz.Id, authz)
if authz.AccountId == "" {
return fmt.Errorf("invalid authorization, missing account id")
}
path := getAuthorizationPath(authz.AccountId, authz.Id)
json, err := logical.StorageEntryJSON(path, authz)
if err != nil {
return fmt.Errorf("error creating authorization entry: %w", err)
}
if err := ac.sc.Storage.Put(ac.sc.Context, json); err != nil {
if err = ac.sc.Storage.Put(ac.sc.Context, json); err != nil {
return fmt.Errorf("error writing authorization entry: %w", err)
}
@ -353,3 +370,77 @@ func (a *acmeState) ParseRequestParams(ac *acmeContext, data *framework.FieldDat
return &c, m, nil
}
func (a *acmeState) LoadOrder(ac *acmeContext, userCtx *jwsCtx, orderId string) (*acmeOrder, error) {
path := getOrderPath(userCtx.Kid, orderId)
entry, err := ac.sc.Storage.Get(ac.sc.Context, path)
if err != nil {
return nil, fmt.Errorf("error loading order: %w", err)
}
if entry == nil {
return nil, fmt.Errorf("order does not exist: %w", ErrMalformed)
}
var order acmeOrder
err = entry.DecodeJSON(&order)
if err != nil {
return nil, fmt.Errorf("error decoding order: %w", err)
}
if userCtx.Kid != order.AccountId {
return nil, ErrUnauthorized
}
order.OrderId = orderId
return &order, nil
}
func (a *acmeState) SaveOrder(ac *acmeContext, order *acmeOrder) error {
if order.OrderId == "" {
return fmt.Errorf("invalid order, missing order id")
}
if order.AccountId == "" {
return fmt.Errorf("invalid order, missing account id")
}
path := getOrderPath(order.AccountId, order.OrderId)
json, err := logical.StorageEntryJSON(path, order)
if err != nil {
return fmt.Errorf("error serializing order entry: %w", err)
}
if err = ac.sc.Storage.Put(ac.sc.Context, json); err != nil {
return fmt.Errorf("error writing order entry: %w", err)
}
return nil
}
func (a *acmeState) ListOrderIds(ac *acmeContext, accountId string) ([]string, error) {
accountOrderPrefixPath := acmeAccountPrefix + accountId + "/orders/"
rawOrderIds, err := ac.sc.Storage.List(ac.sc.Context, accountOrderPrefixPath)
if err != nil {
return nil, fmt.Errorf("failed listing order ids for account %s: %w", accountId, err)
}
orderIds := []string{}
for _, order := range rawOrderIds {
if strings.HasSuffix(order, "/") {
// skip any folders we might have for some reason
continue
}
orderIds = append(orderIds, order)
}
return orderIds, nil
}
func getAuthorizationPath(accountId string, authId string) string {
return acmeAccountPrefix + accountId + "/authorizations/" + authId
}
func getOrderPath(accountId string, orderId string) string {
return acmeAccountPrefix + accountId + "/orders/" + orderId
}

View File

@ -241,6 +241,18 @@ func Backend(conf *logical.BackendConfig) *backend {
pathAcmeRoleChallenge(&b),
pathAcmeIssuerChallenge(&b),
pathAcmeIssuerAndRoleChallenge(&b),
pathAcmeRootNewOrder(&b),
pathAcmeRoleNewOrder(&b),
pathAcmeIssuerNewOrder(&b),
pathAcmeIssuerAndRoleNewOrder(&b),
pathAcmeRootListOrders(&b),
pathAcmeRoleListOrders(&b),
pathAcmeIssuerListOrders(&b),
pathAcmeIssuerAndRoleListOrders(&b),
pathAcmeRootGetOrder(&b),
pathAcmeRoleGetOrder(&b),
pathAcmeIssuerGetOrder(&b),
pathAcmeIssuerAndRoleGetOrder(&b),
},
Secrets: []*framework.Secret{
@ -264,6 +276,8 @@ func Backend(conf *logical.BackendConfig) *backend {
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/account/+")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/authorization/+")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/challenge/+/+")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/orders")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/order/+")
}
if constants.IsEnterprise {

View File

@ -6813,9 +6813,12 @@ func TestProperAuthing(t *testing.T) {
paths[acmePrefix+"acme/directory"] = shouldBeUnauthedReadList
paths[acmePrefix+"acme/new-nonce"] = shouldBeUnauthedReadList
paths[acmePrefix+"acme/new-account"] = shouldBeUnauthedWriteOnly
paths[acmePrefix+"acme/new-order"] = shouldBeUnauthedWriteOnly
paths[acmePrefix+"acme/orders"] = shouldBeUnauthedWriteOnly
paths[acmePrefix+"acme/account/hrKmDYTvicHoHGVN2-3uzZV_BPGdE0W_dNaqYTtYqeo="] = shouldBeUnauthedWriteOnly
paths[acmePrefix+"acme/authorization/29da8c38-7a09-465e-b9a6-3d76802b1afd"] = shouldBeUnauthedWriteOnly
paths[acmePrefix+"acme/challenge/29da8c38-7a09-465e-b9a6-3d76802b1afd/http-01"] = shouldBeUnauthedWriteOnly
paths[acmePrefix+"acme/order/13b80844-e60d-42d2-b7e9-152a8e834b90"] = shouldBeUnauthedWriteOnly
}
for path, checkerType := range paths {
@ -6870,6 +6873,9 @@ func TestProperAuthing(t *testing.T) {
if strings.Contains(raw_path, "acme/") && strings.Contains(raw_path, "{challenge_type}") {
raw_path = strings.ReplaceAll(raw_path, "{challenge_type}", "http-01")
}
if strings.Contains(raw_path, "acme/") && strings.Contains(raw_path, "{order_id}") {
raw_path = strings.ReplaceAll(raw_path, "{order_id}", "13b80844-e60d-42d2-b7e9-152a8e834b90")
}
handler, present := paths[raw_path]
if !present {

View File

@ -46,7 +46,7 @@ func patternAcmeAuthorization(b *backend, pattern string) *framework.Path {
Fields: fields,
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.acmeParsedWrapper(b.acmeAuthorizationHandler),
Callback: b.acmeAccountRequiredWrapper(b.acmeAuthorizationHandler),
ForwardPerformanceSecondary: false,
ForwardPerformanceStandby: true,
},
@ -57,7 +57,7 @@ func patternAcmeAuthorization(b *backend, pattern string) *framework.Path {
}
}
func (b *backend) acmeAuthorizationHandler(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}) (*logical.Response, error) {
func (b *backend) acmeAuthorizationHandler(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}, _ *acmeAccount) (*logical.Response, error) {
authId := fields.Get("auth_id").(string)
authz, err := b.acmeState.LoadAuthorization(acmeCtx, userCtx, authId)
if err != nil {

View File

@ -0,0 +1,469 @@
package pki
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
"golang.org/x/net/idna"
)
func pathAcmeRootListOrders(b *backend) *framework.Path {
return patternAcmeListOrders(b, "acme/orders")
}
func pathAcmeRoleListOrders(b *backend) *framework.Path {
return patternAcmeListOrders(b, "roles/"+framework.GenericNameRegex("role")+"/acme/orders")
}
func pathAcmeIssuerListOrders(b *backend) *framework.Path {
return patternAcmeListOrders(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/orders")
}
func pathAcmeIssuerAndRoleListOrders(b *backend) *framework.Path {
return patternAcmeListOrders(b,
"issuer/"+framework.GenericNameRegex(issuerRefParam)+
"/roles/"+framework.GenericNameRegex("role")+"/acme/orders")
}
func pathAcmeRootGetOrder(b *backend) *framework.Path {
return patternAcmeGetOrder(b, "acme/order/"+uuidNameRegex("order_id"))
}
func pathAcmeRoleGetOrder(b *backend) *framework.Path {
return patternAcmeGetOrder(b, "roles/"+framework.GenericNameRegex("role")+"/acme/order/"+uuidNameRegex("order_id"))
}
func pathAcmeIssuerGetOrder(b *backend) *framework.Path {
return patternAcmeGetOrder(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/order/"+uuidNameRegex("order_id"))
}
func pathAcmeIssuerAndRoleGetOrder(b *backend) *framework.Path {
return patternAcmeGetOrder(b,
"issuer/"+framework.GenericNameRegex(issuerRefParam)+
"/roles/"+framework.GenericNameRegex("role")+"/acme/order/"+uuidNameRegex("order_id"))
}
func pathAcmeRootNewOrder(b *backend) *framework.Path {
return patternAcmeNewOrder(b, "acme/new-order")
}
func pathAcmeRoleNewOrder(b *backend) *framework.Path {
return patternAcmeNewOrder(b, "roles/"+framework.GenericNameRegex("role")+"/acme/new-order")
}
func pathAcmeIssuerNewOrder(b *backend) *framework.Path {
return patternAcmeNewOrder(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/new-order")
}
func pathAcmeIssuerAndRoleNewOrder(b *backend) *framework.Path {
return patternAcmeNewOrder(b,
"issuer/"+framework.GenericNameRegex(issuerRefParam)+
"/roles/"+framework.GenericNameRegex("role")+"/acme/new-order")
}
func patternAcmeNewOrder(b *backend, pattern string) *framework.Path {
fields := map[string]*framework.FieldSchema{}
addFieldsForACMEPath(fields, pattern)
addFieldsForACMERequest(fields)
return &framework.Path{
Pattern: pattern,
Fields: fields,
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.acmeAccountRequiredWrapper(b.acmeNewOrderHandler),
ForwardPerformanceSecondary: false,
ForwardPerformanceStandby: true,
},
},
HelpSynopsis: "",
HelpDescription: "",
}
}
func patternAcmeListOrders(b *backend, pattern string) *framework.Path {
fields := map[string]*framework.FieldSchema{}
addFieldsForACMEPath(fields, pattern)
addFieldsForACMERequest(fields)
return &framework.Path{
Pattern: pattern,
Fields: fields,
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.acmeAccountRequiredWrapper(b.acmeListOrdersHandler),
ForwardPerformanceSecondary: false,
ForwardPerformanceStandby: true,
},
},
HelpSynopsis: "",
HelpDescription: "",
}
}
func patternAcmeGetOrder(b *backend, pattern string) *framework.Path {
fields := map[string]*framework.FieldSchema{}
addFieldsForACMEPath(fields, pattern)
addFieldsForACMERequest(fields)
fields["order_id"] = &framework.FieldSchema{
Type: framework.TypeString,
Description: `The ACME order identifier to fetch`,
Required: true,
}
return &framework.Path{
Pattern: pattern,
Fields: fields,
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.acmeAccountRequiredWrapper(b.acmeGetOrderHandler),
ForwardPerformanceSecondary: false,
ForwardPerformanceStandby: true,
},
},
HelpSynopsis: "",
HelpDescription: "",
}
}
type acmeAccountRequiredOperation func(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}, acct *acmeAccount) (*logical.Response, error)
func (b *backend) acmeAccountRequiredWrapper(op acmeAccountRequiredOperation) framework.OperationFunc {
return b.acmeParsedWrapper(func(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, uc *jwsCtx, data map[string]interface{}) (*logical.Response, error) {
if !uc.Existing {
return nil, fmt.Errorf("cannot process request without a 'kid': %w", ErrMalformed)
}
account, err := b.acmeState.LoadAccount(acmeCtx, uc.Kid)
if err != nil {
return nil, fmt.Errorf("error loading account: %w", err)
}
if account.Status != StatusValid {
// Treating "revoked" and "deactivated" as the same here.
return nil, fmt.Errorf("%w: account status is %s", ErrUnauthorized, account.Status)
}
return op(acmeCtx, r, fields, uc, data, account)
})
}
func (b *backend) acmeGetOrderHandler(ac *acmeContext, _ *logical.Request, fields *framework.FieldData, uc *jwsCtx, _ map[string]interface{}, acct *acmeAccount) (*logical.Response, error) {
orderId := fields.Get("order_id").(string)
order, err := b.acmeState.LoadOrder(ac, uc, orderId)
if err != nil {
return nil, err
}
// Per RFC 8555 -> 7.1.3. Order Objects
// For final orders (in the "valid" or "invalid" state), the authorizations that were completed.
//
// Otherwise, for "pending" orders we will return our list as it was originally saved.
requiresFiltering := order.Status == ACMEOrderValid || order.Status == ACMEOrderInvalid
if requiresFiltering {
filteredAuthorizationIds := []string{}
for _, authId := range order.AuthorizationIds {
authorization, err := b.acmeState.LoadAuthorization(ac, uc, authId)
if err != nil {
return nil, err
}
if (order.Status == ACMEOrderInvalid || order.Status == ACMEOrderValid) &&
authorization.Status == ACMEAuthorizationValid {
filteredAuthorizationIds = append(filteredAuthorizationIds, authId)
}
}
order.AuthorizationIds = filteredAuthorizationIds
}
return formatOrderResponse(ac, order), nil
}
func (b *backend) acmeListOrdersHandler(ac *acmeContext, _ *logical.Request, _ *framework.FieldData, uc *jwsCtx, _ map[string]interface{}, acct *acmeAccount) (*logical.Response, error) {
orderIds, err := b.acmeState.ListOrderIds(ac, acct.KeyId)
if err != nil {
return nil, err
}
orderUrls := []string{}
for _, orderId := range orderIds {
order, err := b.acmeState.LoadOrder(ac, uc, orderId)
if err != nil {
return nil, err
}
if order.Status == ACMEOrderInvalid {
// Per RFC8555 -> 7.1.2.1 - Orders List
// The server SHOULD include pending orders and SHOULD NOT
// include orders that are invalid in the array of URLs.
continue
}
orderUrls = append(orderUrls, buildOrderUrl(ac, orderId))
}
resp := &logical.Response{
Data: map[string]interface{}{
"orders": orderUrls,
},
}
return resp, nil
}
func (b *backend) acmeNewOrderHandler(ac *acmeContext, r *logical.Request, _ *framework.FieldData, _ *jwsCtx, data map[string]interface{}, account *acmeAccount) (*logical.Response, error) {
identifiers, err := parseOrderIdentifiers(data)
if err != nil {
return nil, err
}
notBefore, err := parseOptRFC3339Field(data, "notBefore")
if err != nil {
return nil, err
}
notAfter, err := parseOptRFC3339Field(data, "notAfter")
if err != nil {
return nil, err
}
err = validateAcmeProvidedOrderDates(notBefore, notAfter)
if err != nil {
return nil, err
}
// TODO: Implement checks against role here.
// Per RFC 8555 -> 7.1.3. Order Objects
// For pending orders, the authorizations that the client needs to complete before the
// requested certificate can be issued (see Section 7.5), including
// unexpired authorizations that the client has completed in the past
// for identifiers specified in the order.
//
// Since we are generating all authorizations here, there is no need to filter them out
// IF/WHEN we support pre-authz workflows and associate existing authorizations to this
// order they will need filtering.
var authorizations []*ACMEAuthorization
var authorizationIds []string
for _, identifier := range identifiers {
authz := generateAuthorization(account, identifier)
authorizations = append(authorizations, authz)
err = b.acmeState.SaveAuthorization(ac, authz)
if err != nil {
return nil, fmt.Errorf("failed storing authorization: %w", err)
}
authorizationIds = append(authorizationIds, authz.Id)
}
order := &acmeOrder{
OrderId: genUuid(),
AccountId: account.KeyId,
Status: ACMEOrderPending,
Expires: time.Now().Add(1 * time.Hour).Format(time.RFC3339),
Identifiers: identifiers,
AuthorizationIds: authorizationIds,
}
if !notBefore.IsZero() {
order.NotBefore = notBefore.Format(time.RFC3339)
}
if !notAfter.IsZero() {
order.NotAfter = notAfter.Format(time.RFC3339)
}
err = b.acmeState.SaveOrder(ac, order)
if err != nil {
return nil, fmt.Errorf("failed storing order: %w", err)
}
resp := formatOrderResponse(ac, order)
// Per RFC 8555 Section 7.4. Applying for Certificate Issuance:
//
// > If the server is willing to issue the requested certificate, it
// > responds with a 201 (Created) response.
resp.Data[logical.HTTPStatusCode] = http.StatusCreated
return resp, nil
}
func validateAcmeProvidedOrderDates(notBefore time.Time, notAfter time.Time) error {
if !notBefore.IsZero() && !notAfter.IsZero() {
if notBefore.Equal(notAfter) {
return fmt.Errorf("%w: provided notBefore and notAfter dates can not be equal", ErrMalformed)
}
if notBefore.After(notAfter) {
return fmt.Errorf("%w: provided notBefore can not be greater than notAfter", ErrMalformed)
}
}
if !notAfter.IsZero() {
if time.Now().After(notAfter) {
return fmt.Errorf("%w: provided notAfter can not be in the past", ErrMalformed)
}
}
return nil
}
func formatOrderResponse(acmeCtx *acmeContext, order *acmeOrder) *logical.Response {
baseOrderUrl := buildOrderUrl(acmeCtx, order.OrderId)
var authorizationUrls []string
for _, authId := range order.AuthorizationIds {
authorizationUrls = append(authorizationUrls, acmeCtx.baseUrl.String()+"authz/"+authId)
}
resp := &logical.Response{
Data: map[string]interface{}{
"status": ACMEOrderPending,
"expires": order.Expires,
"identifiers": order.Identifiers,
"authorizations": authorizationUrls,
"finalize": baseOrderUrl + "/finalize",
},
Headers: map[string][]string{
"Location": {baseOrderUrl},
},
}
// Only reply with the certificate URL if we are in a valid order state.
if order.Status == ACMEOrderValid {
resp.Data["certificate"] = baseOrderUrl + "/cert"
}
return resp
}
func buildOrderUrl(acmeCtx *acmeContext, orderId string) string {
return acmeCtx.baseUrl.String() + "order/" + orderId
}
func generateAuthorization(acct *acmeAccount, identifier *ACMEIdentifier) *ACMEAuthorization {
challenges := []*ACMEChallenge{
{
Type: ACMEHTTPChallenge,
URL: genUuid(),
Status: ACMEChallengePending,
ChallengeFields: map[string]interface{}{}, // TODO fill this in properly
},
}
return &ACMEAuthorization{
Id: genUuid(),
AccountId: acct.KeyId,
Identifier: identifier,
Status: ACMEAuthorizationPending,
Expires: "", // only populated when it switches to valid.
Challenges: challenges,
Wildcard: strings.HasPrefix(identifier.Value, "*."),
}
}
func parseOptRFC3339Field(data map[string]interface{}, keyName string) (time.Time, error) {
var timeVal time.Time
var err error
rawBefore, present := data[keyName]
if present {
beforeStr, ok := rawBefore.(string)
if !ok {
return timeVal, fmt.Errorf("invalid type (%T) for field '%s': %w", rawBefore, keyName, ErrMalformed)
}
timeVal, err = time.Parse(time.RFC3339, beforeStr)
if err != nil {
return timeVal, fmt.Errorf("failed parsing field '%s' (%s): %s: %w", keyName, rawBefore, err.Error(), ErrMalformed)
}
if timeVal.IsZero() {
return timeVal, fmt.Errorf("provided time value is invalid '%s' (%s): %w", keyName, rawBefore, ErrMalformed)
}
}
return timeVal, nil
}
func parseOrderIdentifiers(data map[string]interface{}) ([]*ACMEIdentifier, error) {
rawIdentifiers, present := data["identifiers"]
if !present {
return nil, fmt.Errorf("missing required identifiers argument: %w", ErrMalformed)
}
listIdentifiers, ok := rawIdentifiers.([]interface{})
if !ok {
return nil, fmt.Errorf("invalid type (%T) for field 'identifiers': %w", rawIdentifiers, ErrMalformed)
}
var identifiers []*ACMEIdentifier
for _, rawIdentifier := range listIdentifiers {
mapIdentifier, ok := rawIdentifier.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid type (%T) for value in 'identifiers': %w", rawIdentifier, ErrMalformed)
}
typeVal, present := mapIdentifier["type"]
if !present {
return nil, fmt.Errorf("missing type argument for value in 'identifiers': %w", ErrMalformed)
}
typeStr, ok := typeVal.(string)
if !ok {
return nil, fmt.Errorf("invalid type for type argument (%T) for value in 'identifiers': %w", typeStr, ErrMalformed)
}
var acmeIdentifierType ACMEIdentifierType
switch typeStr {
// TODO: No support for this yet.
// case string(ACMEIPIdentifier):
// acmeIdentifierType = ACMEIPIdentifier
case string(ACMEDNSIdentifier):
acmeIdentifierType = ACMEDNSIdentifier
default:
return nil, fmt.Errorf("unsupported identifier type %s: %w", typeStr, ErrUnsupportedIdentifier)
}
valueVal, present := mapIdentifier["value"]
if !present {
return nil, fmt.Errorf("missing value argument for value in 'identifiers': %w", ErrMalformed)
}
valueStr, ok := valueVal.(string)
if !ok {
return nil, fmt.Errorf("invalid type for value argument (%T) for value in 'identifiers': %w", valueStr, ErrMalformed)
}
if len(valueStr) == 0 {
return nil, fmt.Errorf("value argument for value in 'identifiers' can not be blank: %w", ErrMalformed)
}
p := idna.New(
idna.StrictDomainName(true),
idna.VerifyDNSLength(true),
)
converted, err := p.ToASCII(valueStr)
if err != nil {
return nil, fmt.Errorf("value argument (%s) failed validation: %s: %w", valueStr, err.Error(), ErrMalformed)
}
if !hostnameRegex.MatchString(converted) {
return nil, fmt.Errorf("value argument (%s) failed validation: %w", valueStr, ErrMalformed)
}
identifiers = append(identifiers, &ACMEIdentifier{
Type: acmeIdentifierType,
Value: converted,
})
}
return identifiers, nil
}

View File

@ -10,6 +10,9 @@ import (
"net/http"
"strings"
"testing"
"time"
"github.com/go-test/deep"
"github.com/hashicorp/go-cleanhttp"
"golang.org/x/crypto/acme"
@ -50,6 +53,7 @@ func TestAcmeBasicWorkflow(t *testing.T) {
acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, key)
t.Logf("Testing discover on %s", baseAcmeURL)
discovery, err := acmeClient.Discover(testCtx)
require.NoError(t, err, "failed acme discovery call")
@ -61,10 +65,12 @@ func TestAcmeBasicWorkflow(t *testing.T) {
require.Equal(t, discoveryBaseUrl+"key-change", discovery.KeyChangeURL)
// Attempt to update prior to creating an account
t.Logf("Testing updates with no proper account fail on %s", baseAcmeURL)
_, err = acmeClient.UpdateReg(testCtx, &acme.Account{Contact: []string{"mailto:shouldfail@example.com"}})
require.ErrorIs(t, err, acme.ErrNoAccount, "expected failure attempting to update prior to account registration")
// Create new account
t.Logf("Testing register on %s", baseAcmeURL)
acct, err := acmeClient.Register(testCtx, &acme.Account{
Contact: []string{"mailto:test@example.com", "mailto:test2@test.com"},
}, func(tosURL string) bool { return true })
@ -75,12 +81,14 @@ func TestAcmeBasicWorkflow(t *testing.T) {
require.Len(t, acct.Contact, 2)
// Call register again we should get existing account
t.Logf("Testing duplicate register returns existing account on %s", baseAcmeURL)
_, err = acmeClient.Register(testCtx, acct, func(tosURL string) bool { return true })
require.ErrorIs(t, err, acme.ErrAccountAlreadyExists,
"We should have returned a 200 status code which would have triggered an error in the golang acme"+
" library")
// Update contact
t.Logf("Testing Update account contacts on %s", baseAcmeURL)
acct.Contact = []string{"mailto:test3@example.com"}
acct2, err := acmeClient.UpdateReg(testCtx, acct)
require.NoError(t, err, "failed updating account")
@ -89,11 +97,30 @@ func TestAcmeBasicWorkflow(t *testing.T) {
require.Contains(t, acct2.Contact, "mailto:test3@example.com")
require.Len(t, acct2.Contact, 1)
// Create an order
t.Logf("Testing Authorize Order on %s", baseAcmeURL)
createOrder, err := acmeClient.AuthorizeOrder(testCtx, []acme.AuthzID{{Type: "dns", Value: "www.test.com"}},
acme.WithOrderNotBefore(time.Now().Add(10*time.Minute)),
acme.WithOrderNotAfter(time.Now().Add(7*24*time.Hour)))
require.NoError(t, err, "failed creating order")
require.Equal(t, acme.StatusPending, createOrder.Status)
// Get orders
t.Logf("Testing GetOrder on %s", baseAcmeURL)
getOrder, err := acmeClient.GetOrder(testCtx, createOrder.URI)
require.NoError(t, err, "failed fetching order")
require.Equal(t, acme.StatusPending, createOrder.Status)
if diffs := deep.Equal(createOrder, getOrder); diffs != nil {
t.Fatalf("Differences exist between create and get order: \n%v", strings.Join(diffs, "\n"))
}
// Deactivate account
t.Logf("Testing deactivate account on %s", baseAcmeURL)
err = acmeClient.DeactivateReg(testCtx)
require.NoError(t, err, "failed deactivating account")
// Make sure we get an unauthorized error trying to update the account again.
t.Logf("Testing update on deactivated account fails on %s", baseAcmeURL)
_, err = acmeClient.UpdateReg(testCtx, acct)
require.Error(t, err, "expected account to be deactivated")
require.IsType(t, &acme.Error{}, err, "expected acme error type")