feat(signature): refactore the trust store system

This commit is contained in:
Nicolas Carlier 2020-03-15 20:50:51 +00:00
parent a5fe96d2e8
commit d91e84d1be
11 changed files with 104 additions and 126 deletions

View File

@ -293,16 +293,15 @@ $ curl -u api:test -XPOST "http://localhost:8080/echo?msg=hello"
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:
To activate HTTP signature verification, you have to configure the trust store:
```bash
$ export WHD_KEY_STORE_URI=file:///etc/webhookd/keys
$ export WHD_TRUST_STORE_FILE=/etc/webhookd/pubkey.pem
$ # or
$ webhookd --key-store-uri file:///etc/webhookd/keys
$ webhookd --trust-store-file /etc/webhookd/pubkey.pem
```
Note that only `file://` URI s currently supported.
All public keys stored in PEM format in the targeted directory will be loaded.
Public key is stored in PEM format.
Once configured, you must call webhooks using a valid HTTP signature:

View File

@ -5,10 +5,8 @@
# 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=
# Maximum hook execution time in second, default is 10
#WHD_HOOK_TIMEOUT=10
# HTTP listen address, default is ":8080"
# Example: `localhost:8080` or `:8080` for all interfaces
@ -30,8 +28,10 @@
# Scripts location, default is "scripts"
#WHD_SCRIPTS="scripts"
# Maximum hook execution time in second, default is 10
#WHD_HOOK_TIMEOUT=10
# Trust store URI, disabled by default
# Enable HTTP signature verification if set.
# Example: `/etc/webhookd/pubkey.pem`
#WHD_TRUST_STORE_FILE=
# TLS listend address, disabled by default
# Example: `localhost:8443` or `:8443` for all interfaces

View File

@ -26,9 +26,9 @@ func NewRouter(conf *config.Config) *http.ServeMux {
}
// Load key store...
keystore, err := pubkey.NewKeyStore(conf.KeyStoreURI)
keystore, err := pubkey.NewTrustStore(conf.TrustStoreFile)
if err != nil {
logger.Warning.Printf("unable to load key store (\"%s\"): %s\n", conf.KeyStoreURI, err)
logger.Warning.Printf("unable to load trust store (\"%s\"): %s\n", conf.TrustStoreFile, err)
}
if keystore != nil {
middlewares = append(middlewares, middleware.HTTPSignature(keystore))

View File

@ -14,5 +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"`
TrustStoreFile string `flag:"trust-store-file" desc:"Trust store used by HTTP signature verifier (.pem or .p12)"`
}

View File

@ -8,7 +8,7 @@ import (
)
// HTTPSignature is a middleware to checks HTTP request signature
func HTTPSignature(keyStore pubkey.KeyStore) Middleware {
func HTTPSignature(trustStore pubkey.TrustStore) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
verifier, err := httpsig.NewVerifier(r)
@ -18,7 +18,7 @@ func HTTPSignature(keyStore pubkey.KeyStore) Middleware {
return
}
pubKeyID := verifier.KeyId()
pubKey, algo, err := keyStore.Get(pubKeyID)
pubKey, algo, err := trustStore.Get(pubKeyID)
if err != nil {
w.WriteHeader(400)
w.Write([]byte("invalid HTTP signature: " + err.Error()))

View File

@ -1,71 +0,0 @@
package pubkey
import (
"crypto"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"github.com/go-fed/httpsig"
"github.com/ncarlier/webhookd/pkg/logger"
)
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) (*directoryKeyStore, error) {
store := &directoryKeyStore{
algorithm: "",
keys: make(map[string]crypto.PublicKey),
}
walkErr := 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 nil
}
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
logger.Debug.Println("HTTP signature public key loaded: ", path)
}
}
return nil
})
return store, walkErr
}

View File

@ -1,34 +0,0 @@
package pubkey
import (
"crypto"
"fmt"
"net/url"
"strings"
"github.com/go-fed/httpsig"
)
// 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 URI: %s", uri)
}
switch u.Scheme {
case "file":
store, err = newDirectoryKeyStore(strings.TrimPrefix(uri, "file://"))
default:
err = fmt.Errorf("non supported KeyStore URI: %s", uri)
}
return
}

View File

@ -0,0 +1,55 @@
package pubkey
import (
"crypto"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"github.com/go-fed/httpsig"
)
type pemTrustStore struct {
key crypto.PublicKey
}
func (ts *pemTrustStore) Get(keyID string) (crypto.PublicKey, httpsig.Algorithm, error) {
return ts.key, defaultAlgorithm, nil
}
func newPEMTrustStore(filename string) (*pemTrustStore, error) {
data, err := ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("invalid PEM file: %s", filename)
}
var rsaPublicKey *rsa.PublicKey
switch block.Type {
case "PUBLIC KEY":
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
rsaPublicKey, _ = pub.(*rsa.PublicKey)
case "CERTIFICATE":
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
rsaPublicKey, _ = cert.PublicKey.(*rsa.PublicKey)
}
if rsaPublicKey == nil {
return nil, fmt.Errorf("no RSA public key found: %s", filename)
}
return &pemTrustStore{
key: rsaPublicKey,
}, nil
}

View File

@ -12,7 +12,7 @@ import (
func TestKeyStore(t *testing.T) {
logger.Init("warn")
ks, err := pubkey.NewKeyStore("file://.")
ks, err := pubkey.NewTrustStore("test-key.pem")
assert.Nil(t, err, "")
assert.NotNil(t, ks, "")
@ -20,7 +20,4 @@ func TestKeyStore(t *testing.T) {
assert.Nil(t, err, "")
assert.NotNil(t, pk, "")
assert.Equal(t, httpsig.RSA_SHA256, algo, "")
_, _, err = ks.Get("notfound")
assert.NotNil(t, err, "")
}

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

@ -0,0 +1,32 @@
package pubkey
import (
"crypto"
"fmt"
"path/filepath"
"github.com/go-fed/httpsig"
)
const defaultAlgorithm = httpsig.RSA_SHA256
// TrustStore is a generic interface to retrieve a public key
type TrustStore interface {
Get(keyID string) (crypto.PublicKey, httpsig.Algorithm, error)
}
// NewTrustStore creates new Key Store from URI
func NewTrustStore(filename string) (store TrustStore, err error) {
if filename == "" {
return nil, nil
}
switch filepath.Ext(filename) {
case ".pem":
store, err = newPEMTrustStore(filename)
default:
err = fmt.Errorf("unsupported TrustStore file format: %s", filename)
}
return
}

View File

@ -20,7 +20,7 @@ MIIEowIBAAKCAQEAwdCB5DZD0cFeJYUu1W3IlNN9y+NZC/Jqktdkn8/WHlXec07n
- Start Webhookd with HTTP signature support:
```bash
$ webhookd -key-store-uri file://.
$ webhookd --trust-store-file ./key-pub.pem
```
- Make HTTP signed request: