Skip to content

Commit

Permalink
feat: New endpoint auth type to create http message signatures for ou…
Browse files Browse the repository at this point in the history
…tbound requests according to RFC 9421 (#1507)
  • Loading branch information
dadrus authored Sep 16, 2024
1 parent 8c6b9c3 commit 672988d
Show file tree
Hide file tree
Showing 30 changed files with 1,515 additions and 94 deletions.
39 changes: 39 additions & 0 deletions docs/content/docs/configuration/types.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,45 @@ config:
----
====

=== HTTP Message Signatures

This strategy implements HTTP message signatures according to https://datatracker.ietf.org/doc/html/rfc9421[RFC 9421] to sign outbound requests.

`type` must be set to `http_message_signatures`. `config` supports the following properties:

* *`ttl`*: _link:{{< relref "#_duration" >}}[Duration]_ (optional)
+
The TTL of the resulting signature. Defaults to 1m. Responsible for setting `created` and `expires` parameters in the resulting signature.

* *`label`*: _string_ (optional)
+
The label to use. Defaults to `sig`.

* *`components`*: _string array_ (mandatory)
+
The components to be covered by the signature. While the RFC allows for signatures that do not cover any components, this is considered a security risk. When using the `"content-digest"` component, Heimdall will compute hash values of the request body using `sha-256` and `sha-512` algorithms. It will then add a `Content-Digest` header with these hash values to the request, and this header will be included in the signature calculation.

* *`signer`*: _link:{{< relref "/docs/configuration/types.adoc#_signer" >}}[Signer]_ (mandatory)
+
The configuration of the key material used for signature creation purposes, as well as the name used for the `tag` parameter in the resulting signature.

.Strategy configuration
====
[source, yaml]
----
type: http_message_signatures
config:
ttl: 2m
label: foo
components: ["@method", "content-digest", "@authority", "x-my-fancy-header"]
signer:
name: bar
key-store:
path: /path/to/key.pem
----
====

=== OAuth2 Client Credentials Grant Flow Strategy

This strategy implements the https://datatracker.ietf.org/doc/html/rfc6749#section-4.4[OAuth2 Client Credentials Grant Flow] to obtain an access token expected by the endpoint. Heimdall caches the received access token.
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.23.0
require (
github.com/Masterminds/sprig/v3 v3.2.3
github.com/alicebob/miniredis/v2 v2.33.0
github.com/dadrus/httpsig v0.0.0-20240814203911-f6539fdef42a
github.com/dlclark/regexp2 v1.11.4
github.com/drone/envsubst/v2 v2.0.0-20210730161058-179042472c46
github.com/elnormous/contenttype v1.0.4
Expand Down Expand Up @@ -130,6 +131,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dunglas/httpsfv v1.0.2 // indirect
github.com/envoyproxy/protoc-gen-validate v1.0.4 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b h1:ga8SEFjZ60pxLcmhnTh
github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/dadrus/httpsig v0.0.0-20240814203911-f6539fdef42a h1:eXhsbwb2ROng8D7DcMgMinL5FfY4Ao6N2K9DTwOXFfs=
github.com/dadrus/httpsig v0.0.0-20240814203911-f6539fdef42a/go.mod h1:P31eM5Rh3dqq9FLr1QASaZsk8/8qIiKKUYFKjBC/yYc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand All @@ -104,6 +106,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/drone/envsubst/v2 v2.0.0-20210730161058-179042472c46 h1:7QPwrLT79GlD5sizHf27aoY2RTvw62mO6x7mxkScNk0=
github.com/drone/envsubst/v2 v2.0.0-20210730161058-179042472c46/go.mod h1:esf2rsHFNlZlxsqsZDojNBcnNs5REqIvRrWRHqX0vEU=
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/elnormous/contenttype v1.0.4 h1:FjmVNkvQOGqSX70yvocph7keC8DtmJaLzTTq6ZOQCI8=
github.com/elnormous/contenttype v1.0.4/go.mod h1:5KTOW8m1kdX1dLMiUJeN9szzR2xkngiv2K+RVZwWBbI=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
Expand Down
248 changes: 248 additions & 0 deletions internal/rules/endpoint/authstrategy/http_message_signatures.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// Copyright 2024 Dimitrij Drus <dadrus@gmx.de>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package authstrategy

import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/binary"
"fmt"
"net/http"
"sync"
"time"

"github.com/dadrus/httpsig"
"github.com/go-jose/go-jose/v4"
"github.com/rs/zerolog"

"github.com/dadrus/heimdall/internal/heimdall"
"github.com/dadrus/heimdall/internal/keystore"
"github.com/dadrus/heimdall/internal/x"
"github.com/dadrus/heimdall/internal/x/errorchain"
"github.com/dadrus/heimdall/internal/x/pkix"
"github.com/dadrus/heimdall/internal/x/stringx"
)

type KeyStore struct {
Path string `mapstructure:"path" validate:"required"`
Password string `mapstructure:"password"`
}

type SignerConfig struct {
Name string `mapstructure:"name"`
KeyStore KeyStore `mapstructure:"key_store" validate:"required"`
KeyID string `mapstructure:"key_id"`
}

type HTTPMessageSignatures struct {
Signer SignerConfig `mapstructure:"signer" validate:"required"`
Components []string `mapstructure:"components" validate:"gt=0,dive,required"`
TTL *time.Duration `mapstructure:"ttl"`
Label string `mapstructure:"label"`

mut sync.RWMutex
// used to allow downloading the keys for signature verification purposes
// since the http message signatures rfc does not define a format for key transport
// JWK is used here.
pubKeys []jose.JSONWebKey
// used to monitor the expiration of configured certificates
certChain []*x509.Certificate
signer httpsig.Signer
}

func (s *HTTPMessageSignatures) OnChanged(logger zerolog.Logger) {
err := s.init()
if err != nil {
logger.Warn().Err(err).
Str("_file", s.Signer.KeyStore.Path).
Msg("Signer key store reload failed")
} else {
logger.Info().
Str("_file", s.Signer.KeyStore.Path).
Msg("Signer key store reloaded")
}
}

func (s *HTTPMessageSignatures) init() error {
ks, err := keystore.NewKeyStoreFromPEMFile(s.Signer.KeyStore.Path, s.Signer.KeyStore.Password)
if err != nil {
return errorchain.NewWithMessage(heimdall.ErrConfiguration,
"failed loading keystore for http_message_signatures strategy").CausedBy(err)
}

var kse *keystore.Entry

if len(s.Signer.KeyID) == 0 {
kse, err = ks.Entries()[0], nil
} else {
kse, err = ks.GetKey(s.Signer.KeyID)
}

if err != nil {
return errorchain.NewWithMessage(heimdall.ErrConfiguration,
"failed retrieving key from key store for http_message_signatures strategy").CausedBy(err)
}

if len(kse.CertChain) != 0 {
opts := []pkix.ValidationOption{
pkix.WithKeyUsage(x509.KeyUsageDigitalSignature),
pkix.WithRootCACertificates([]*x509.Certificate{kse.CertChain[len(kse.CertChain)-1]}),
pkix.WithCurrentTime(time.Now()),
}

if len(kse.CertChain) > 2 { //nolint: mnd
opts = append(opts, pkix.WithIntermediateCACertificates(kse.CertChain[1:len(kse.CertChain)-1]))
}

if err = pkix.ValidateCertificate(kse.CertChain[0], opts...); err != nil {
return errorchain.NewWithMessage(heimdall.ErrConfiguration,
"certificate for http_message_signatures strategy cannot be used for signing purposes").
CausedBy(err)
}
}

keys := make([]jose.JSONWebKey, len(ks.Entries()))
for idx, entry := range ks.Entries() {
keys[idx] = entry.JWK()
}

signer, err := httpsig.NewSigner(
toHTTPSigKey(kse),
httpsig.WithComponents(s.Components...),
httpsig.WithTag(x.IfThenElse(len(s.Signer.Name) != 0, s.Signer.Name, "heimdall")),
httpsig.WithLabel(s.Label),
httpsig.WithTTL(x.IfThenElseExec(s.TTL != nil,
func() time.Duration { return *s.TTL },
func() time.Duration { return 1 * time.Minute },
)),
)
if err != nil {
return errorchain.NewWithMessage(heimdall.ErrConfiguration,
"failed to configure http_message_signatures strategy").CausedBy(err)
}

s.mut.Lock()
defer s.mut.Unlock()

s.signer = signer
s.pubKeys = keys
s.certChain = kse.CertChain

return nil
}

func (s *HTTPMessageSignatures) Apply(ctx context.Context, req *http.Request) error {
logger := zerolog.Ctx(ctx)
logger.Debug().Msg("Applying http_message_signatures strategy to authenticate request")

s.mut.RLock()
defer s.mut.RUnlock()

header, err := s.signer.Sign(httpsig.MessageFromRequest(req))
if err != nil {
return err
}

// set the updated headers
req.Header = header

return nil
}

func (s *HTTPMessageSignatures) Keys() []jose.JSONWebKey {
s.mut.RLock()
defer s.mut.RUnlock()

return s.pubKeys
}

func (s *HTTPMessageSignatures) Hash() []byte {
const int64BytesCount = 8

hash := sha256.New()
hash.Write(stringx.ToBytes(s.Label))

for _, component := range s.Components {
hash.Write(stringx.ToBytes(component))
}

if s.TTL != nil {
ttlBytes := make([]byte, int64BytesCount)
binary.LittleEndian.PutUint64(ttlBytes, uint64(*s.TTL))

hash.Write(ttlBytes)
}

hash.Write(stringx.ToBytes(s.Signer.Name))
hash.Write(stringx.ToBytes(s.Signer.KeyID))

return hash.Sum(nil)
}

func (s *HTTPMessageSignatures) Name() string { return "http message signer" }
func (s *HTTPMessageSignatures) Certificates() []*x509.Certificate {
s.mut.RLock()
defer s.mut.RUnlock()

return s.certChain
}

func toHTTPSigKey(entry *keystore.Entry) httpsig.Key {
var httpSigAlg httpsig.SignatureAlgorithm

switch entry.Alg {
case keystore.AlgRSA:
httpSigAlg = getRSAAlgorithm(entry.KeySize)
case keystore.AlgECDSA:
httpSigAlg = getECDSAAlgorithm(entry.KeySize)
default:
panic("unsupported key algorithm: " + entry.Alg)
}

return httpsig.Key{
Algorithm: httpSigAlg,
KeyID: entry.KeyID,
Key: entry.PrivateKey,
}
}

func getECDSAAlgorithm(keySize int) httpsig.SignatureAlgorithm {
switch keySize {
case 256: //nolint: mnd
return httpsig.EcdsaP256Sha256
case 384: //nolint: mnd
return httpsig.EcdsaP384Sha384
case 512: //nolint: mnd
return httpsig.EcdsaP521Sha512
default:
panic(fmt.Sprintf("unsupported ECDSA key size: %d", keySize))
}
}

func getRSAAlgorithm(keySize int) httpsig.SignatureAlgorithm {
switch keySize {
case 2048: //nolint: mnd
return httpsig.RsaPssSha256
case 3072: //nolint: mnd
return httpsig.RsaPssSha384
case 4096: //nolint: mnd
return httpsig.RsaPssSha512
default:
panic(fmt.Sprintf("unsupported RSA key size: %d", keySize))
}
}
Loading

0 comments on commit 672988d

Please sign in to comment.