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:
parent
1ea85c56d7
commit
b4edc81cd5
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue