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:
parent
e7c0d5744b
commit
d324aa0d15
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue