Skip to content

Commit

Permalink
Add Support for Signature Validation via HTTP Header
Browse files Browse the repository at this point in the history
* Implement HTTP PKI annotator
* Implement HTTP request parser
* Implement related unit tests
* Made VerifySignature and DeriveHash public and updated their uses

Fix project-alvarium#25

Signed-off-by: husseinfakharany <fakharany.hussein@gmail.com>
  • Loading branch information
husseinfakharany committed Feb 10, 2022
1 parent 4119b48 commit 8e92339
Show file tree
Hide file tree
Showing 17 changed files with 579 additions and 24 deletions.
6 changes: 3 additions & 3 deletions internal/annotators/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import (
"io/ioutil"
)

func deriveHash(hash contracts.HashType, data []byte) string {
func DeriveHash(hash contracts.HashType, data []byte) string {
var h hashprovider.Provider
switch hash {
case contracts.MD5Hash:
Expand All @@ -41,7 +41,7 @@ func deriveHash(hash contracts.HashType, data []byte) string {
return h.Derive(data)
}

func signAnnotation(key config.KeyInfo, a contracts.Annotation) (string, error) {
func SignAnnotation(key config.KeyInfo, a contracts.Annotation) (string, error) {
var s signprovider.Provider
switch key.Type {
case contracts.KeyEd25519:
Expand All @@ -64,7 +64,7 @@ func signAnnotation(key config.KeyInfo, a contracts.Annotation) (string, error)
return signed, nil
}

func verifySignature(key config.KeyInfo, src contracts.Annotation) (bool, error) {
func VerifySignature(key config.KeyInfo, src contracts.Annotation) (bool, error) {
var s signprovider.Provider
switch key.Type {
case contracts.KeyEd25519:
Expand Down
4 changes: 2 additions & 2 deletions internal/annotators/base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func TestDeriveHash(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := deriveHash(tt.hashType, tt.input)
result := DeriveHash(tt.hashType, tt.input)
assert.Equal(t, tt.output, result)
})
}
Expand Down Expand Up @@ -80,7 +80,7 @@ func TestSignAnnotation(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := signAnnotation(tt.cfg, tt.annotation)
result, err := SignAnnotation(tt.cfg, tt.annotation)
test.CheckError(err, tt.expectError, tt.name, t)

if err == nil {
Expand Down
40 changes: 40 additions & 0 deletions internal/annotators/http/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*******************************************************************************
* Copyright 2022 Dell Inc.
*
* 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.
*******************************************************************************/

package http

type specialtyComponent string

const (
method specialtyComponent = "@method"
authority specialtyComponent = "@authority"
scheme specialtyComponent = "@scheme"
requestTarget specialtyComponent = "@request-target"
path specialtyComponent = "@path"
query specialtyComponent = "@query"
queryParams specialtyComponent = "@query-params"
)

const (
contentLength string = "Content-Length"
contentType string = "Content-Type"
testRequest string = "testRequest"
)

func (s specialtyComponent) Validate() bool {
if s == method || s == authority || s == scheme || s == requestTarget || s == path || s == query || s == queryParams {
return true
}
return false
}
109 changes: 109 additions & 0 deletions internal/annotators/http/parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*******************************************************************************
* Copyright 2022 Dell Inc.
*
* 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.
*******************************************************************************/

package http

import (
"bytes"
"fmt"
"net/http"
"strings"
)

func RemoveExtraSpaces(s string) string {
return strings.Join(strings.Fields(s), " ")
}

func requestParser(r *http.Request) (string, error) {
//Signature Inputs extraction
signatureInput := r.Header.Get("Signature-Input")
signatureInputList := strings.Fields(signatureInput)

signatureInputFields := make(map[string][]string)

parsedSignatureInput := ""

for _, field := range signatureInputList {
//remove double quotes from the field to access it directly in the header map
key := field[1 : len(field)-1]
if key[0:1] == "@" {
switch specialtyComponent(key) {
case method:
signatureInputFields[key] = []string{r.Method}
case authority:
signatureInputFields[key] = []string{r.Host}
case scheme:
protool := r.Proto
scheme := strings.ToLower(strings.Split(protool, "/")[0])
signatureInputFields[key] = []string{scheme}
case requestTarget:
signatureInputFields[key] = []string{r.RequestURI}
case path:
signatureInputFields[key] = []string{r.URL.Path}
case query:
var query string = "?"
query += r.URL.RawQuery
signatureInputFields[key] = []string{query}
case queryParams:
rawQueryParams := strings.Split(r.URL.RawQuery, "&")
var queryParams []string
for _, rawQueryParam := range rawQueryParams {
if rawQueryParam != "" {
parameter := strings.Split(rawQueryParam, "=")
name := parameter[0]
value := parameter[1]
b := new(bytes.Buffer)
fmt.Fprintf(b, ";name=\"%s\": %s", name, value)
queryParams = append(queryParams, b.String())
}
}
signatureInputFields[key] = queryParams
default:
return "", fmt.Errorf("Unhandled Specialty Component %s", key)
}
} else {
fieldValues := r.Header.Values(key)

if len(fieldValues) == 0 {
return "", fmt.Errorf("Unhandled Specialty Component %s", key)
} else if len(fieldValues) == 1 {
value := RemoveExtraSpaces(r.Header.Get(key))
signatureInputFields[key] = []string{value}

} else {

value := ""
for i := 0; i < len(fieldValues); i++ {
value += fieldValues[i]
if i != (len(fieldValues) - 1) {
value += ", "
}
}
value = RemoveExtraSpaces(value)
signatureInputFields[key] = []string{value}
}
}
// Construct final output string
keyValues := signatureInputFields[key]
if len(keyValues) == 1 {
parsedSignatureInput += ("\"" + key + "\" " + keyValues[0] + "\n")
} else {
for _, v := range keyValues {
parsedSignatureInput += ("\"" + key + "\"" + v + "\n")
}
}
}

return parsedSignatureInput, nil
}
89 changes: 89 additions & 0 deletions internal/annotators/http/parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*******************************************************************************
* Copyright 2022 Dell Inc.
*
* 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.
*******************************************************************************/

package http

import (
"encoding/json"
"io/ioutil"
"net/http/httptest"
"testing"

"github.com/project-alvarium/alvarium-sdk-go/pkg/config"
"github.com/stretchr/testify/assert"
)

func TestHttpPkiAnnotator_RequestParser(t *testing.T) {
b, err := ioutil.ReadFile("./test/config.json")
if err != nil {
t.Fatalf(err.Error())
}

var cfg config.SdkInfo
err = json.Unmarshal(b, &cfg)
if err != nil {
t.Fatalf(err.Error())
}

base := httptest.NewRequest("POST", "/foo?var1=&var2=2", nil)

base.Header.Set("Host", "example.com")
base.Header.Set("Date", "Tue, 20 Apr 2021 02:07:55 GMT")
base.Header.Set("Content-Type", "application/json")
base.Header.Set("Content-Length", "18")
base.Header.Set("Signature", "whatever")

tests := []struct {
name string
signatureInput string
expectedSeed string
expectError bool
}{
{"testing integeration of all Signature-Input fields",
"\"date\" \"@method\" \"@path\" \"@authority\" \"content-type\" \"content-length\" \"@query-params\" \"@query\" ",
"\"date\" Tue, 20 Apr 2021 02:07:55 GMT\n\"@method\" POST\n\"@path\" /foo\n\"@authority\" example.com\n\"content-type\" application/json\n\"content-length\" 18\n\"@query-params\";name=\"var1\": \n\"@query-params\";name=\"var2\": 2\n\"@query\" ?var1=&var2=2\n", false},

{"testing @method ", "\"@method\"", "\"@method\" POST\n", false},
{"testing @authority", "\"@authority\"", "\"@authority\" example.com\n", false},

{"testing @scheme", "\"@scheme\"", "\"@scheme\" http\n", false},
{"testing @request-target", "\"@request-target\"", "\"@request-target\" /foo?var1=&var2=2\n", false},

{"testing @path", "\"@path\"", "\"@path\" /foo\n", false},

{"testing @query", "\"@query\"", "\"@query\" ?var1=&var2=2\n", false},
{"testing @query-params", "\"@query-params\"", "\"@query-params\";name=\"var1\": \n\"@query-params\";name=\"var2\": 2\n", false},

{"testing non-existant derived component", "\"@x-test\"", "", true},
{"testing non-existant header field", "\"x-test\"", "", true},
}

var seed string
for _, tt := range tests {

req := base.Clone(base.Context())
req.Header.Set("Signature-Input", tt.signatureInput)

t.Run(tt.name, func(t *testing.T) {
seed, err = requestParser(req)
if tt.expectError {
assert.Error(t, err)
} else {
assert.Equal(t, tt.expectedSeed, seed)
}
})

}

}
101 changes: 101 additions & 0 deletions internal/annotators/http/pki.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*******************************************************************************
* Copyright 2022 Dell Inc.
*
* 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.
*******************************************************************************/

package http

import (
"context"
"fmt"
"io/ioutil"
"net/http"
"os"

"github.com/project-alvarium/alvarium-sdk-go/internal/annotators"
"github.com/project-alvarium/alvarium-sdk-go/internal/signprovider"
"github.com/project-alvarium/alvarium-sdk-go/internal/signprovider/ed25519"
"github.com/project-alvarium/alvarium-sdk-go/pkg/config"
"github.com/project-alvarium/alvarium-sdk-go/pkg/contracts"
"github.com/project-alvarium/alvarium-sdk-go/pkg/interfaces"
)

// HttpPkiAnnotator is used to validate whether the signature on a given piece of data is valid, both sent in the HTTP message
type HttpPkiAnnotator struct {
hash contracts.HashType
kind contracts.AnnotationType
sign config.SignatureInfo
}

func NewHttpPkiAnnotator(cfg config.SdkInfo) interfaces.Annotator {
a := HttpPkiAnnotator{}
a.hash = cfg.Hash.Type
a.kind = contracts.AnnotationPKIHttp
a.sign = cfg.Signature
return &a
}

func (a *HttpPkiAnnotator) Do(ctx context.Context, data []byte) (contracts.Annotation, error) {
key := annotators.DeriveHash(a.hash, data)
hostname, _ := os.Hostname()

//Call parser on request
req := ctx.Value(testRequest)
parsed, err := requestParser(req.(*http.Request))

if err != nil {
return contracts.Annotation{}, err
}
var sig signable
sig.Seed = parsed
sig.Signature = req.(*http.Request).Header.Get("Signature")

ok, err := sig.verifySignature(a.sign.PublicKey)
if err != nil {
return contracts.Annotation{}, err
}

annotation := contracts.NewAnnotation(string(key), a.hash, hostname, a.kind, ok)
signed, err := annotators.SignAnnotation(a.sign.PrivateKey, annotation)
if err != nil {
return contracts.Annotation{}, err
}
annotation.Signature = string(signed)
return annotation, nil
}

type signable struct {
Seed string
Signature string
}

func (s *signable) verifySignature(key config.KeyInfo) (bool, error) {
if len(s.Signature) == 0 { // no signature detected
return false, nil
}
var p signprovider.Provider
switch contracts.KeyAlgorithm(key.Type) {
case contracts.KeyEd25519:
p = ed25519.New()

default:
return false, fmt.Errorf("unrecognized key type %s", key.Type)
}
// Path can change from one enviroment to another
// When using Kubernetes, we can search for the keyid directly in the secrets folder, as all keys can be stored there
pub, err := ioutil.ReadFile(key.Path)
if err != nil {
return false, err
}
ok := p.Verify(pub, []byte(s.Seed), []byte(s.Signature))
return ok, nil
}
Loading

0 comments on commit 8e92339

Please sign in to comment.