diff --git a/README.md b/README.md index a26fa1a..a52c8f1 100644 --- a/README.md +++ b/README.md @@ -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: ' \ + -H 'Signature: keyId=,algorithm="rsa-sha256",headers="(request-target) date",signature=' \ + -H 'Accept: application/json' \ + "http://loclahost:8080/echo?msg=hello" +``` + ### TLS You can activate TLS to secure communications: diff --git a/etc/default/webhookd.env b/etc/default/webhookd.env index d3e5dfc..bec59e2 100644 --- a/etc/default/webhookd.env +++ b/etc/default/webhookd.env @@ -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" diff --git a/go.mod b/go.mod index 1ee4398..dc3dd28 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 34f5842..25df4c3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/api/router.go b/pkg/api/router.go index 4305ce2..4ce6a02 100644 --- a/pkg/api/router.go +++ b/pkg/api/router.go @@ -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) } diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go deleted file mode 100644 index b6582be..0000000 --- a/pkg/auth/auth.go +++ /dev/null @@ -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 -} diff --git a/pkg/auth/authenticator.go b/pkg/auth/authenticator.go new file mode 100644 index 0000000..98b0df3 --- /dev/null +++ b/pkg/auth/authenticator.go @@ -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 +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a8b2968..a894343 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"` } diff --git a/pkg/middleware/signature.go b/pkg/middleware/signature.go new file mode 100644 index 0000000..e025298 --- /dev/null +++ b/pkg/middleware/signature.go @@ -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) + }) +} diff --git a/pkg/pubkey/directory_keystore.go b/pkg/pubkey/directory_keystore.go new file mode 100644 index 0000000..fd2c331 --- /dev/null +++ b/pkg/pubkey/directory_keystore.go @@ -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 +} diff --git a/pkg/pubkey/keystore.go b/pkg/pubkey/keystore.go new file mode 100644 index 0000000..be585bf --- /dev/null +++ b/pkg/pubkey/keystore.go @@ -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 +}