Add ACME authorizations & challenges (#20113)

* Distinguish POST-as-GET from POST-with-empty-body

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add ACME authorization, identifier, and challenge types

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add ability to load and save authorizations

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add ACME authorizations path handling

This supports two methods: a fetch handler over the authorization, to
expose the underlying challenges, and a deactivate handler to revoke
the authorization and mark its challenges invalid.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add ACME challenge path handling

These paths kick off processing and validation of the challenge by the
ACME client.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

---------

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
This commit is contained in:
Alexander Scheel 2023-04-12 12:55:25 -04:00 committed by GitHub
parent 1ea85c56d7
commit b4edc81cd5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 420 additions and 3 deletions

View File

@ -0,0 +1,123 @@
package pki
import (
"time"
)
type ACMEIdentifierType string
const (
ACMEDNSIdentifier ACMEIdentifierType = "dns"
ACMEIPIdentifier ACMEIdentifierType = "ip"
)
type ACMEIdentifier struct {
Type ACMEIdentifierType `json:"type"`
Value string `json:"value"`
}
type ACMEAuthorizationStatusType string
const (
ACMEAuthorizationPending ACMEAuthorizationStatusType = "pending"
ACMEAuthorizationValid ACMEAuthorizationStatusType = "valid"
ACMEAuthorizationInvalid ACMEAuthorizationStatusType = "invalid"
ACMEAuthorizationDeactivated ACMEAuthorizationStatusType = "deactivated"
ACMEAuthorizationExpired ACMEAuthorizationStatusType = "expired"
ACMEAuthorizationRevoked ACMEAuthorizationStatusType = "revoked"
)
type ACMEChallengeType string
const (
ACMEHTTPChallenge ACMEChallengeType = "http-01"
ACMEDNSChallenge ACMEChallengeType = "dns-01"
ACMEALPNChallenge ACMEChallengeType = "tls-alpn-01"
)
type ACMEChallengeStatusType string
const (
ACMEChallengePending ACMEChallengeStatusType = "pending"
ACMEChallengeProcessing ACMEChallengeStatusType = "processing"
ACMEChallengeValid ACMEChallengeStatusType = "valid"
ACMEChallengeInvalid ACMEChallengeStatusType = "invalid"
)
type ACMEChallenge struct {
Type ACMEChallengeType `json:"type"`
URL string `json:"url"`
Status ACMEChallengeStatusType `json:"status"`
Validated string `json:"validated,optional"`
Error map[string]interface{} `json:"error,optional"`
ChallengeFields map[string]interface{} `json:"challenge_fields"`
}
func (ac *ACMEChallenge) NetworkMarshal() map[string]interface{} {
resp := map[string]interface{}{
"type": ac.Type,
"url": ac.URL,
"status": ac.Status,
}
if ac.Validated != "" {
resp["validated"] = ac.Validated
}
if len(ac.Error) > 0 {
resp["error"] = ac.Error
}
for field, value := range ac.ChallengeFields {
resp[field] = value
}
return resp
}
type ACMEAuthorization struct {
Id string `json:"id"`
AccountId string `json:"account_id"`
Identifier *ACMEIdentifier `json:"identifier"`
Status ACMEAuthorizationStatusType `json:"status"`
// Per RFC 8555 Section 7.1.4. Authorization Objects:
//
// > This field is REQUIRED for objects with "valid" in the "status"
// > field.
Expires string `json:"expires,optional"`
Challenges []*ACMEChallenge `json:"challenges"`
Wildcard bool `json:"wildcard"`
}
func (aa *ACMEAuthorization) GetExpires() (time.Time, error) {
if aa.Expires == "" {
return time.Time{}, nil
}
return time.Parse(time.RFC3339, aa.Expires)
}
func (aa *ACMEAuthorization) NetworkMarshal() map[string]interface{} {
resp := map[string]interface{}{
"identifier": aa.Identifier,
"status": aa.Status,
"wildcard": aa.Wildcard,
}
if aa.Expires != "" {
resp["expires"] = aa.Expires
}
if len(aa.Challenges) > 0 {
challenges := []map[string]interface{}{}
for _, challenge := range aa.Challenges {
challenges = append(challenges, challenge.NetworkMarshal())
}
resp["challenges"] = challenges
}
return resp
}

View File

@ -143,6 +143,11 @@ func (c *jwsCtx) VerifyJWS(signature string) (map[string]interface{}, error) {
return nil, err
}
if len(payload) == 0 {
// Distinguish POST-AS-GET from POST-with-an-empty-body.
return nil, nil
}
var m map[string]interface{}
if err := json.Unmarshal(payload, &m); err != nil {
return nil, fmt.Errorf("failed to json unmarshal 'payload': %s: %w", err, ErrMalformed)

View File

@ -22,6 +22,7 @@ const (
acmePathPrefix = "acme/"
acmeAccountPrefix = acmePathPrefix + "accounts/"
acmeThumbprintPrefix = acmePathPrefix + "account-thumbprints/"
acmeAuthroizationPrefix = acmePathPrefix + "authz/"
)
type acmeState struct {
@ -261,6 +262,50 @@ func (a *acmeState) LoadJWK(ac *acmeContext, keyId string) ([]byte, error) {
return key.Jwk, nil
}
func (a *acmeState) LoadAuthorization(ac *acmeContext, userCtx *jwsCtx, authId string) (*ACMEAuthorization, error) {
if authId == "" {
return nil, fmt.Errorf("malformed authorization identifier")
}
entry, err := ac.sc.Storage.Get(ac.sc.Context, acmeAuthroizationPrefix+authId)
if err != nil {
return nil, fmt.Errorf("error loading authorization: %w", err)
}
if entry == nil {
return nil, fmt.Errorf("authorization does not exist: %w", ErrMalformed)
}
var authz ACMEAuthorization
err = entry.DecodeJSON(&authz)
if err != nil {
return nil, fmt.Errorf("error decoding authorization: %w", err)
}
if userCtx.Kid != authz.AccountId {
return nil, ErrUnauthorized
}
return &authz, nil
}
func (a *acmeState) SaveAuthorization(ac *acmeContext, authz *ACMEAuthorization) error {
if authz.Id == "" {
return fmt.Errorf("invalid authorization, missing id")
}
json, err := logical.StorageEntryJSON(acmeAuthroizationPrefix+authz.Id, 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 {
return fmt.Errorf("error writing authorization entry: %w", err)
}
return nil
}
func (a *acmeState) ParseRequestParams(ac *acmeContext, data *framework.FieldData) (*jwsCtx, map[string]interface{}, error) {
var c jwsCtx
var m map[string]interface{}

View File

@ -233,6 +233,14 @@ func Backend(conf *logical.BackendConfig) *backend {
pathAcmeRoleUpdateAccount(&b),
pathAcmeIssuerUpdateAccount(&b),
pathAcmeIssuerAndRoleUpdateAccount(&b),
pathAcmeRootAuthorization(&b),
pathAcmeRoleAuthorization(&b),
pathAcmeIssuerAuthorization(&b),
pathAcmeIssuerAndRoleAuthorization(&b),
pathAcmeRootChallenge(&b),
pathAcmeRoleChallenge(&b),
pathAcmeIssuerChallenge(&b),
pathAcmeIssuerAndRoleChallenge(&b),
},
Secrets: []*framework.Secret{
@ -254,6 +262,8 @@ func Backend(conf *logical.BackendConfig) *backend {
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/revoke-cert")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/key-change")
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/+/+")
}
if constants.IsEnterprise {

View File

@ -6814,6 +6814,8 @@ func TestProperAuthing(t *testing.T) {
paths[acmePrefix+"acme/new-nonce"] = shouldBeUnauthedReadList
paths[acmePrefix+"acme/new-account"] = 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
}
for path, checkerType := range paths {
@ -6862,6 +6864,12 @@ func TestProperAuthing(t *testing.T) {
if strings.Contains(raw_path, "acme/account/") && strings.Contains(raw_path, "{kid}") {
raw_path = strings.ReplaceAll(raw_path, "{kid}", "hrKmDYTvicHoHGVN2-3uzZV_BPGdE0W_dNaqYTtYqeo=")
}
if strings.Contains(raw_path, "acme/") && strings.Contains(raw_path, "{auth_id}") {
raw_path = strings.ReplaceAll(raw_path, "{auth_id}", "29da8c38-7a09-465e-b9a6-3d76802b1afd")
}
if strings.Contains(raw_path, "acme/") && strings.Contains(raw_path, "{challenge_type}") {
raw_path = strings.ReplaceAll(raw_path, "{challenge_type}", "http-01")
}
handler, present := paths[raw_path]
if !present {

View File

@ -0,0 +1,111 @@
package pki
import (
"fmt"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
func pathAcmeRootAuthorization(b *backend) *framework.Path {
return patternAcmeAuthorization(b, "acme/authorization/"+framework.MatchAllRegex("auth_id"))
}
func pathAcmeRoleAuthorization(b *backend) *framework.Path {
return patternAcmeAuthorization(b, "roles/"+framework.GenericNameRegex("role")+"/acme/authorization/"+framework.MatchAllRegex("auth_id"))
}
func pathAcmeIssuerAuthorization(b *backend) *framework.Path {
return patternAcmeAuthorization(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/authorization/"+framework.MatchAllRegex("auth_id"))
}
func pathAcmeIssuerAndRoleAuthorization(b *backend) *framework.Path {
return patternAcmeAuthorization(b,
"issuer/"+framework.GenericNameRegex(issuerRefParam)+
"/roles/"+framework.GenericNameRegex("role")+"/acme/authorization/"+framework.MatchAllRegex("auth_id"))
}
func addFieldsForACMEAuthorization(fields map[string]*framework.FieldSchema) map[string]*framework.FieldSchema {
fields["auth_id"] = &framework.FieldSchema{
Type: framework.TypeString,
Description: "ACME authorization identifier value",
Required: true,
}
return fields
}
func patternAcmeAuthorization(b *backend, pattern string) *framework.Path {
fields := map[string]*framework.FieldSchema{}
addFieldsForACMEPath(fields, pattern)
addFieldsForACMERequest(fields)
addFieldsForACMEAuthorization(fields)
return &framework.Path{
Pattern: pattern,
Fields: fields,
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.acmeParsedWrapper(b.acmeAuthorizationHandler),
ForwardPerformanceSecondary: false,
ForwardPerformanceStandby: true,
},
},
HelpSynopsis: "",
HelpDescription: "",
}
}
func (b *backend) acmeAuthorizationHandler(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}) (*logical.Response, error) {
authId := fields.Get("auth_id").(string)
authz, err := b.acmeState.LoadAuthorization(acmeCtx, userCtx, authId)
if err != nil {
return nil, fmt.Errorf("failed to load authorization: %w", err)
}
var status string
rawStatus, haveStatus := data["status"]
if haveStatus {
var ok bool
status, ok = rawStatus.(string)
if !ok {
return nil, fmt.Errorf("bad type (%T) for value 'status': %w", rawStatus, ErrMalformed)
}
}
if len(data) == 0 {
return b.acmeAuthorizationFetchHandler(acmeCtx, r, fields, userCtx, data, authz)
}
if haveStatus && status == "deactivated" {
return b.acmeAuthorizationDeactivateHandler(acmeCtx, r, fields, userCtx, data, authz)
}
return nil, ErrMalformed
}
func (b *backend) acmeAuthorizationFetchHandler(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}, authz *ACMEAuthorization) (*logical.Response, error) {
return &logical.Response{
Data: authz.NetworkMarshal(),
}, nil
}
func (b *backend) acmeAuthorizationDeactivateHandler(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}, authz *ACMEAuthorization) (*logical.Response, error) {
if authz.Status != ACMEAuthorizationPending && authz.Status != ACMEAuthorizationValid {
return nil, fmt.Errorf("unable to deactivate authorization in '%v' status: %w", authz.Status, ErrMalformed)
}
authz.Status = ACMEAuthorizationDeactivated
for _, challenge := range authz.Challenges {
challenge.Status = ACMEChallengeInvalid
}
if err := b.acmeState.SaveAuthorization(acmeCtx, authz); err != nil {
return nil, fmt.Errorf("error saving deactivated authorization: %w", err)
}
return &logical.Response{
Data: authz.NetworkMarshal(),
}, nil
}

View File

@ -0,0 +1,115 @@
package pki
import (
"fmt"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
func pathAcmeRootChallenge(b *backend) *framework.Path {
return patternAcmeChallenge(b,
"acme/challenge/"+framework.MatchAllRegex("auth_id")+"/"+
framework.MatchAllRegex("challenge_type"))
}
func pathAcmeRoleChallenge(b *backend) *framework.Path {
return patternAcmeChallenge(b,
"roles/"+framework.GenericNameRegex("role")+"/acme/challenge/"+
framework.MatchAllRegex("auth_id")+"/"+
framework.MatchAllRegex("challenge_type"))
}
func pathAcmeIssuerChallenge(b *backend) *framework.Path {
return patternAcmeChallenge(b,
"issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/challenge/"+
framework.MatchAllRegex("auth_id")+"/"+
framework.MatchAllRegex("challenge_type"))
}
func pathAcmeIssuerAndRoleChallenge(b *backend) *framework.Path {
return patternAcmeChallenge(b,
"issuer/"+framework.GenericNameRegex(issuerRefParam)+
"/roles/"+framework.GenericNameRegex("role")+"/acme/challenge/"+
framework.MatchAllRegex("auth_id")+"/"+
framework.MatchAllRegex("challenge_type"))
}
func addFieldsForACMEChallenge(fields map[string]*framework.FieldSchema) map[string]*framework.FieldSchema {
fields["auth_id"] = &framework.FieldSchema{
Type: framework.TypeString,
Description: "ACME authorization identifier value",
Required: true,
}
fields["challenge_type"] = &framework.FieldSchema{
Type: framework.TypeString,
Description: "ACME challenge type",
Required: true,
}
return fields
}
func patternAcmeChallenge(b *backend, pattern string) *framework.Path {
fields := map[string]*framework.FieldSchema{}
addFieldsForACMEPath(fields, pattern)
addFieldsForACMERequest(fields)
addFieldsForACMEChallenge(fields)
return &framework.Path{
Pattern: pattern,
Fields: fields,
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.acmeParsedWrapper(b.acmeChallengeHandler),
ForwardPerformanceSecondary: false,
ForwardPerformanceStandby: true,
},
},
HelpSynopsis: "",
HelpDescription: "",
}
}
func (b *backend) acmeChallengeHandler(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}) (*logical.Response, error) {
authId := fields.Get("auth_id").(string)
challengeType := fields.Get("challenge_type").(string)
authz, err := b.acmeState.LoadAuthorization(acmeCtx, userCtx, authId)
if err != nil {
return nil, fmt.Errorf("failed to load authorization: %w", err)
}
return b.acmeChallengeFetchHandler(acmeCtx, r, fields, userCtx, data, authz, challengeType)
}
func (b *backend) acmeChallengeFetchHandler(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}, authz *ACMEAuthorization, challengeType string) (*logical.Response, error) {
var challenge *ACMEChallenge
for _, c := range authz.Challenges {
if string(c.Type) == challengeType {
challenge = c
break
}
}
if challenge == nil {
return nil, fmt.Errorf("unknown challenge of type '%v' in authorization: %w", challengeType, ErrMalformed)
}
// Per RFC 8555 Section 7.5.1. Responding to Challenges:
//
// > The client indicates to the server that it is ready for the challenge
// > validation by sending an empty JSON body ("{}") carried in a POST
// > request to the challenge URL (not the authorization URL).
if len(data) > 0 {
return nil, fmt.Errorf("unexpected request parameters: %w", ErrMalformed)
}
// XXX: Prompt for challenge to be tried by the server.
return &logical.Response{
Data: challenge.NetworkMarshal(),
}, nil
}