feat(): finalize HTTP signature support

This commit is contained in:
Nicolas Carlier 2020-02-26 19:30:41 +00:00
parent c16ec83a5a
commit 43204677d3
11 changed files with 271 additions and 13 deletions

View File

@ -311,6 +311,8 @@ $ curl -X POST \
"http://loclahost:8080/echo?msg=hello"
```
You can find a small HTTP client in the ["tooling" directory](./tooling/httpsig/README.md) that is capable of forging HTTP signatures.
### TLS
You can activate TLS to secure communications:

View File

@ -12,8 +12,8 @@ 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()))
w.WriteHeader(400)
w.Write([]byte("invalid HTTP signature: " + err.Error()))
return
}
pubKeyID := verifier.KeyId()

View File

@ -11,6 +11,7 @@ import (
"path/filepath"
"github.com/go-fed/httpsig"
"github.com/ncarlier/webhookd/pkg/logger"
)
const defaultAlgorithm = httpsig.RSA_SHA256
@ -28,17 +29,17 @@ func (ks *directoryKeyStore) Get(keyID string) (crypto.PublicKey, httpsig.Algori
return key, defaultAlgorithm, nil
}
func newDirectoryKeyStore(root string) (store *directoryKeyStore, err error) {
store = &directoryKeyStore{
func newDirectoryKeyStore(root string) (*directoryKeyStore, error) {
store := &directoryKeyStore{
algorithm: "",
keys: make(map[string]crypto.PublicKey),
}
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
walkErr := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if filepath.Ext(path) == "pem" {
if filepath.Ext(path) == ".pem" {
data, err := ioutil.ReadFile(path)
if err != nil {
return err
@ -51,7 +52,7 @@ func newDirectoryKeyStore(root string) (store *directoryKeyStore, err error) {
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return err
return nil
}
rsaPublicKey, ok := pub.(*rsa.PublicKey)
if !ok {
@ -61,9 +62,10 @@ func newDirectoryKeyStore(root string) (store *directoryKeyStore, err error) {
keyID, ok := block.Headers["key_id"]
if ok {
store.keys[keyID] = rsaPublicKey
logger.Debug.Println("HTTP signature public key loaded: ", path)
}
}
return nil
})
return
return store, walkErr
}

View File

@ -3,8 +3,10 @@ package pubkey
import (
"crypto"
"fmt"
"github.com/go-fed/httpsig"
"net/url"
"strings"
"github.com/go-fed/httpsig"
)
// KeyStore is a generic interface to retrieve a public key
@ -19,14 +21,14 @@ func NewKeyStore(uri string) (store KeyStore, err error) {
}
u, err := url.Parse(uri)
if err != nil {
return nil, fmt.Errorf("invalid KeyStore URL: %s", uri)
return nil, fmt.Errorf("invalid KeyStore URI: %s", uri)
}
switch u.Scheme {
case "file":
store, err = newDirectoryKeyStore(u.RawPath)
store, err = newDirectoryKeyStore(strings.TrimPrefix(uri, "file://"))
default:
err = fmt.Errorf("non supported KeyStore URL: %s", uri)
err = fmt.Errorf("non supported KeyStore URI: %s", uri)
}
return store, nil
return
}

View File

@ -0,0 +1,26 @@
package test
import (
"testing"
"github.com/go-fed/httpsig"
"github.com/ncarlier/webhookd/pkg/assert"
"github.com/ncarlier/webhookd/pkg/logger"
"github.com/ncarlier/webhookd/pkg/pubkey"
)
func TestKeyStore(t *testing.T) {
logger.Init("warn")
ks, err := pubkey.NewKeyStore("file://.")
assert.Nil(t, err, "")
assert.NotNil(t, ks, "")
pk, algo, err := ks.Get("test")
assert.Nil(t, err, "")
assert.NotNil(t, pk, "")
assert.Equal(t, httpsig.RSA_SHA256, algo, "")
_, _, err = ks.Get("notfound")
assert.NotNil(t, err, "")
}

View File

@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
key_id: test
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwdCB5DZD0cFeJYUu1W3I
lNN9y+NZC/Jqktdkn8/WHlXec07nesyDyicveduuaaDBeUoR3imRUtTS+eFKvDR/
HMke8HmoleZvADJ0ppqbvj0YZOhWp2SqZvAKJbce8D+OSKuLGpxqq6FLl0cq+Fv+
mFpZyDcqPZtrwAz+AbJp6sxvVvNb0r0Z1LxEIVb0JHHLhsJkxJ06PcbT2tnvaPHT
8S80cvFO57zFpiX/M8gQNaRCqwD1/sHEGJc6Av+WUBhrE7pz0XGMLjU4n7W6Ooyz
g2QdBiLE3tW8HYL9iJ7EuLG5apYbFVVHJHl8HNWpmD0q8sVExvx5+AKQ3kEDp68m
ywIDAQAB
-----END PUBLIC KEY-----

40
tooling/httpsig/Makefile Normal file
View File

@ -0,0 +1,40 @@
.SILENT :
export GO111MODULE=on
# App name
APPNAME=httpsig
# Go configuration
GOOS?=linux
GOARCH?=amd64
# Add exe extension if windows target
is_windows:=$(filter windows,$(GOOS))
EXT:=$(if $(is_windows),".exe","")
# Archive name
ARCHIVE=$(APPNAME)-$(GOOS)-$(GOARCH).tgz
# Executable name
EXECUTABLE=$(APPNAME)$(EXT)
all: build
clean:
-rm -rf release
.PHONY: clean
## Build executable
build:
-mkdir -p release
echo "Building: $(EXECUTABLE) $(VERSION) for $(GOOS)-$(GOARCH) ..."
GOOS=$(GOOS) GOARCH=$(GOARCH) go build $(LDFLAGS) -o release/$(EXECUTABLE)
.PHONY: build
release/$(EXECUTABLE): build
key:
openssl genrsa -out key.pem 2048
openssl rsa -in key.pem -outform PEM -pubout -out key-pub.pem
.PHONY: key

33
tooling/httpsig/README.md Normal file
View File

@ -0,0 +1,33 @@
# httpsig
A simple HTTP client with HTTP signature support.
# Usage
- Generate an RSA key: `make key`
- Add `key_id` header to public key:
```pem
-----BEGIN PUBLIC KEY-----
key_id: my-key
MIIEowIBAAKCAQEAwdCB5DZD0cFeJYUu1W3IlNN9y+NZC/Jqktdkn8/WHlXec07n
...
-----END PUBLIC KEY-----
```
- Start Webhookd with HTTP signature support:
```bash
$ webhookd -key-store-uri file://.
```
- Make HTTP signed request:
```bash
$ ./release/httpsig \
--key-id my-key \
--key-file ./key.pem \
http://localhost:8080/echo`
```

8
tooling/httpsig/go.mod Normal file
View File

@ -0,0 +1,8 @@
module github.com/ncarlier/webhookd/tooling/httpsig
go 1.13
require (
github.com/go-fed/httpsig v0.1.0
github.com/ncarlier/webhookd v1.7.0
)

19
tooling/httpsig/go.sum Normal file
View File

@ -0,0 +1,19 @@
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=
github.com/ncarlier/webhookd v1.7.0 h1:9oJs7Ihe8T2jG04OTyNJv9pbaKRi1nk0k8qRdaoFYZY=
github.com/ncarlier/webhookd v1.7.0/go.mod h1:Y+KgOmrNoNpjWc3VazWaWQMvJGEm9dY+waVdhP/rxbg=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo=
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=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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 h1:PvnWIWTbA7gsEBkKjt0HV9hckYfcqYv8s/ju7ArZ0do=
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/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

115
tooling/httpsig/main.go Normal file
View File

@ -0,0 +1,115 @@
package main
import (
"bufio"
"bytes"
"crypto/x509"
"encoding/pem"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/go-fed/httpsig"
configflag "github.com/ncarlier/webhookd/pkg/config/flag"
)
type config struct {
KeyID string `flag:"key-id" desc:"Signature key ID"`
KeyFile string `flag:"key-file" desc:"Public key file (PEM format)" default:"./key.pem"`
JSON string `flag:"json" desc:"JSON payload"`
}
func main() {
conf := &config{}
configflag.Bind(conf, "HTTP_SIG")
flag.Parse()
if conf.KeyID == "" {
log.Fatal("missing key ID")
}
args := flag.Args()
if len(args) <= 0 {
log.Fatal("missing target URL")
}
targetURL := args[0]
if _, err := url.Parse(targetURL); err != nil {
log.Fatal("invalid target URL")
}
keyBytes, err := ioutil.ReadFile(conf.KeyFile)
if err != nil {
log.Fatal(err.Error())
}
pemBlock, _ := pem.Decode(keyBytes)
if pemBlock == nil {
log.Fatal("invalid PEM format")
}
privateKey, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
if err != nil {
log.Fatal(err.Error())
}
var payload io.Reader
if conf.JSON != "" {
jsonBytes, err := ioutil.ReadFile(conf.JSON)
if err != nil {
log.Fatal(err.Error())
}
payload = bytes.NewReader(jsonBytes)
}
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
headers := []string{httpsig.RequestTarget, "date"}
signer, _, err := httpsig.NewSigner(prefs, headers, httpsig.Signature)
if err != nil {
log.Fatal(err.Error())
}
req, err := http.NewRequest("POST", targetURL, payload)
if err != nil {
log.Fatal(err.Error())
}
if payload != nil {
req.Header.Add("content-type", "application/json")
}
req.Header.Add("date", time.Now().UTC().Format(http.TimeFormat))
if err = signer.SignRequest(privateKey, conf.KeyID, req); err != nil {
log.Fatal(err.Error())
}
dump, err := httputil.DumpRequest(req, true)
if err != nil {
log.Fatal(err.Error())
}
scanner := bufio.NewScanner(strings.NewReader(string(dump)))
for scanner.Scan() {
fmt.Println(">", scanner.Text())
}
client := &http.Client{Timeout: 10 * time.Second}
res, err := client.Do(req)
if err != nil {
log.Fatal(err.Error())
}
dump, err = httputil.DumpResponse(res, true)
if err != nil {
log.Fatal(err.Error())
}
scanner = bufio.NewScanner(strings.NewReader(string(dump)))
for scanner.Scan() {
fmt.Println("<", scanner.Text())
}
}