feat(): HTTP signature support

This commit is contained in:
Nicolas Carlier 2020-02-25 20:51:07 +00:00
parent 67f56528d1
commit c16ec83a5a
11 changed files with 202 additions and 26 deletions

View File

@ -286,6 +286,31 @@ Once configured, you must call webhooks using basic authentication:
$ curl -u api:test -XPOST "http://localhost:8080/echo?msg=hello"
```
### Signature
You can ensure message integrity (and authenticity) with [HTTP Signatures](https://www.ietf.org/archive/id/draft-cavage-http-signatures-12.txt).
To activate HTTP signature verification, you have to configure the key store:
```bash
$ export WHD_KEY_STORE_URI=file:///etc/webhookd/keys
$ # or
$ webhookd --key-store-uri file:///etc/webhookd/keys
```
Note that only `file://` URI s currently supported.
All public keys stored in PEM format in the targeted directory will be loaded.
Once configured, you must call webhooks using a valid HTTP signature:
```bash
$ curl -X POST \
-H 'Date: <req-date>' \
-H 'Signature: keyId=<key-id>,algorithm="rsa-sha256",headers="(request-target) date",signature=<signature-string>' \
-H 'Accept: application/json' \
"http://loclahost:8080/echo?msg=hello"
```
### TLS
You can activate TLS to secure communications:

View File

@ -5,6 +5,11 @@
# Output debug logs, default is false
#WHD_DEBUG=false
# Key store URI, disabled by default
# Enable HTTP signature verification if set.
# Example: `file:///etc/webhookd/keys`
#KEY_STORE_URI=
# HTTP listen address, default is ":8080"
# Example: `localhost:8080` or `:8080` for all interfaces
#WHD_LISTEN_ADDR=":8080"

1
go.mod
View File

@ -1,6 +1,7 @@
module github.com/ncarlier/webhookd
require (
github.com/go-fed/httpsig v0.1.0
golang.org/x/crypto v0.0.0-20200204104054-c9f3fb736b72
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect
golang.org/x/text v0.3.2 // indirect

5
go.sum
View File

@ -1,3 +1,6 @@
github.com/go-fed/httpsig v0.1.0 h1:6F2OxRVnNTN4OPN+Mc2jxs2WEay9/qiHT/jphlvAwIY=
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200204104054-c9f3fb736b72 h1:+ELyKg6m8UBf0nPFSqD0mi7zUfwPyXo23HNjMnXPz7w=
golang.org/x/crypto v0.0.0-20200204104054-c9f3fb736b72/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@ -5,7 +8,9 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2eP
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -7,18 +7,32 @@ import (
"github.com/ncarlier/webhookd/pkg/auth"
"github.com/ncarlier/webhookd/pkg/config"
"github.com/ncarlier/webhookd/pkg/logger"
"github.com/ncarlier/webhookd/pkg/middleware"
"github.com/ncarlier/webhookd/pkg/pubkey"
)
func nextRequestID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// NewRouter creates router with declared routes
func NewRouter(conf *config.Config) *http.ServeMux {
router := http.NewServeMux()
authenticator := auth.NewAuthenticator(conf)
nextRequestID := func() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
// Load authenticator...
authenticator, err := auth.NewHtpasswdFromFile(conf.PasswdFile)
if err != nil {
logger.Debug.Printf("unable to load htpasswd file (\"%s\"): %s\n", conf.PasswdFile, err)
}
// Load key store...
keystore, err := pubkey.NewKeyStore(conf.KeyStoreURI)
if err != nil {
logger.Warning.Printf("unable to load key store (\"%s\"): %s\n", conf.KeyStoreURI, err)
}
// Register HTTP routes...
for _, route := range routes {
var handler http.Handler
@ -31,6 +45,9 @@ func NewRouter(conf *config.Config) *http.ServeMux {
handler = middleware.Logger(handler)
handler = middleware.Tracing(nextRequestID)(handler)
if keystore != nil {
handler = middleware.HTTPSignature(handler, keystore)
}
if authenticator != nil {
handler = middleware.Auth(handler, authenticator)
}

View File

@ -1,23 +0,0 @@
package auth
import (
"net/http"
"github.com/ncarlier/webhookd/pkg/config"
"github.com/ncarlier/webhookd/pkg/logger"
)
// Authenticator is a generic interface to validate an HTTP request
type Authenticator interface {
Validate(r *http.Request) bool
}
// NewAuthenticator creates new authenticator form the configuration
func NewAuthenticator(conf *config.Config) Authenticator {
authenticator, err := NewHtpasswdFromFile(conf.PasswdFile)
if err != nil {
logger.Debug.Printf("unable to load htpasswd file: \"%s\" (%s)\n", conf.PasswdFile, err)
return nil
}
return authenticator
}

10
pkg/auth/authenticator.go Normal file
View File

@ -0,0 +1,10 @@
package auth
import (
"net/http"
)
// Authenticator is a generic interface to validate an HTTP request
type Authenticator interface {
Validate(r *http.Request) bool
}

View File

@ -14,4 +14,5 @@ type Config struct {
PasswdFile string `flag:"passwd-file" desc:"Password file for basic HTTP authentication" default:".htpasswd"`
LogDir string `flag:"log-dir" desc:"Hook execution logs location" default:""`
NotificationURI string `flag:"notification-uri" desc:"Notification URI"`
KeyStoreURI string `flag:"key-store-uri" desc:"Key store URI used by HTTP signature verifier"`
}

View File

@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"github.com/go-fed/httpsig"
"github.com/ncarlier/webhookd/pkg/pubkey"
)
// HTTPSignature is a middleware to checks HTTP request signature
func HTTPSignature(inner http.Handler, keyStore pubkey.KeyStore) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
verifier, err := httpsig.NewVerifier(r)
if err != nil {
w.WriteHeader(500)
w.Write([]byte("unable to initialize HTTP signature verifier: " + err.Error()))
return
}
pubKeyID := verifier.KeyId()
pubKey, algo, err := keyStore.Get(pubKeyID)
if err != nil {
w.WriteHeader(400)
w.Write([]byte("invalid HTTP signature: " + err.Error()))
return
}
err = verifier.Verify(pubKey, algo)
if err != nil {
w.WriteHeader(400)
w.Write([]byte("invalid HTTP signature: " + err.Error()))
return
}
inner.ServeHTTP(w, r)
})
}

View File

@ -0,0 +1,69 @@
package pubkey
import (
"crypto"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"github.com/go-fed/httpsig"
)
const defaultAlgorithm = httpsig.RSA_SHA256
type directoryKeyStore struct {
algorithm string
keys map[string]crypto.PublicKey
}
func (ks *directoryKeyStore) Get(keyID string) (crypto.PublicKey, httpsig.Algorithm, error) {
key, ok := ks.keys[keyID]
if !ok {
return nil, defaultAlgorithm, fmt.Errorf("public key not found: %s", keyID)
}
return key, defaultAlgorithm, nil
}
func newDirectoryKeyStore(root string) (store *directoryKeyStore, err error) {
store = &directoryKeyStore{
algorithm: "",
keys: make(map[string]crypto.PublicKey),
}
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if filepath.Ext(path) == "pem" {
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
block, _ := pem.Decode(data)
if block == nil {
return fmt.Errorf("invalid PEM file: %s", path)
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return err
}
rsaPublicKey, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("unable to cast public key to RSA public key")
}
keyID, ok := block.Headers["key_id"]
if ok {
store.keys[keyID] = rsaPublicKey
}
}
return nil
})
return
}

32
pkg/pubkey/keystore.go Normal file
View File

@ -0,0 +1,32 @@
package pubkey
import (
"crypto"
"fmt"
"github.com/go-fed/httpsig"
"net/url"
)
// KeyStore is a generic interface to retrieve a public key
type KeyStore interface {
Get(keyID string) (crypto.PublicKey, httpsig.Algorithm, error)
}
// NewKeyStore creates new Key Store from URI
func NewKeyStore(uri string) (store KeyStore, err error) {
if uri == "" {
return nil, nil
}
u, err := url.Parse(uri)
if err != nil {
return nil, fmt.Errorf("invalid KeyStore URL: %s", uri)
}
switch u.Scheme {
case "file":
store, err = newDirectoryKeyStore(u.RawPath)
default:
err = fmt.Errorf("non supported KeyStore URL: %s", uri)
}
return store, nil
}