Repository: adnanh/webhook
Pull request #388 (2020-01-07 00:28:49)

Feature/multi signature
Resubmitting @wyattjoh's work from #355 but against the `development` branch this time.


Patch:
diff --git a/docs/Hook-Rules.md b/docs/Hook-Rules.md
index 68b83d3..e1f92f6 100644
--- a/docs/Hook-Rules.md
+++ b/docs/Hook-Rules.md
@@ -186,6 +186,13 @@ For the regex syntax, check out <http://golang.org/pkg/regexp/syntax/>
 }
 ```
 
+Note that if multiple signatures were passed via a comma separated string, each
+will be tried unless a match is found. For example:
+
+```
+X-Hub-Signature: sha1=the-first-signature,sha1=the-second-signature
+```
+
 ### 4. Match payload-hash-sha256
 ```json
 {
@@ -202,6 +209,13 @@ For the regex syntax, check out <http://golang.org/pkg/regexp/syntax/>
 }
 ```
 
+Note that if multiple signatures were passed via a comma separated string, each
+will be tried unless a match is found. For example:
+
+```
+X-Hub-Signature: sha256=the-first-signature,sha256=the-second-signature
+```
+
 ### 5. Match payload-hash-sha512
 ```json
 {
@@ -218,6 +232,13 @@ For the regex syntax, check out <http://golang.org/pkg/regexp/syntax/>
 }
 ```
 
+Note that if multiple signatures were passed via a comma separated string, each
+will be tried unless a match is found. For example:
+
+```
+X-Hub-Signature: sha512=the-first-signature,sha512=the-second-signature
+```
+
 ### 6. Match Whitelisted IP range
 
 The IP can be IPv4- or IPv6-formatted, using [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_blocks).  To match a single IP address only, use `/32`.
diff --git a/internal/hook/hook.go b/internal/hook/hook.go
index 1131eb4..69df88e 100644
--- a/internal/hook/hook.go
+++ b/internal/hook/hook.go
@@ -12,6 +12,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"hash"
 	"io/ioutil"
 	"log"
 	"math"
@@ -48,13 +49,19 @@ const (
 
 // SignatureError describes an invalid payload signature passed to Hook.
 type SignatureError struct {
-	Signature string
+	Signature  string
+	Signatures []string
 }
 
 func (e *SignatureError) Error() string {
 	if e == nil {
 		return "<nil>"
 	}
+
+	if e.Signatures != nil {
+		return fmt.Sprintf("invalid payload signatures %s", e.Signatures)
+	}
+
 	return fmt.Sprintf("invalid payload signature %s", e.Signature)
 }
 
@@ -94,46 +101,80 @@ func (e *ParseError) Error() string {
 	return e.Err.Error()
 }
 
-// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload
-func CheckPayloadSignature(payload []byte, secret string, signature string) (string, error) {
-	if secret == "" {
-		return "", errors.New("signature validation secret can not be empty")
+// ExtractCommaSeparatedValues will extract the values matching the key.
+func ExtractCommaSeparatedValues(source, prefix string) []string {
+	parts := strings.Split(source, ",")
+	values := make([]string, 0)
+	for _, part := range parts {
+		if strings.HasPrefix(part, prefix) {
+			values = append(values, strings.TrimPrefix(part, prefix))
+		}
 	}
 
-	signature = strings.TrimPrefix(signature, "sha1=")
+	return values
+}
+
+// ExtractSignatures will extract all the signatures from the source.
+func ExtractSignatures(source, prefix string) []string {
+	// If there are multiple possible matches, let the comma seperated extractor
+	// do it's work.
+	if strings.Contains(source, ",") {
+		return ExtractCommaSeparatedValues(source, prefix)
+	}
 
-	mac := hmac.New(sha1.New, []byte(secret))
+	// There were no commas, so just trim the prefix (if it even exists) and
+	// pass it back.
+	return []string{
+		strings.TrimPrefix(source, prefix),
+	}
+}
+
+// ValidateMAC will verify that the expected mac for the given hash will match
+// the one provided.
+func ValidateMAC(payload []byte, mac hash.Hash, signatures []string) (string, error) {
+	// Write the payload to the provided hash.
 	_, err := mac.Write(payload)
 	if err != nil {
 		return "", err
 	}
+
 	expectedMAC := hex.EncodeToString(mac.Sum(nil))
 
-	if !hmac.Equal([]byte(signature), []byte(expectedMAC)) {
-		return expectedMAC, &SignatureError{signature}
+	for _, signature := range signatures {
+		if hmac.Equal([]byte(signature), []byte(expectedMAC)) {
+			return expectedMAC, err
+		}
+	}
+
+	return expectedMAC, &SignatureError{
+		Signatures: signatures,
 	}
-	return expectedMAC, err
 }
 
-// CheckPayloadSignature256 calculates and verifies SHA256 signature of the given payload
-func CheckPayloadSignature256(payload []byte, secret string, signature string) (string, error) {
+// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload
+func CheckPayloadSignature(payload []byte, secret string, signature string) (string, error) {
 	if secret == "" {
 		return "", errors.New("signature validation secret can not be empty")
 	}
 
-	signature = strings.TrimPrefix(signature, "sha256=")
+	// Extract the signatures.
+	signatures := ExtractSignatures(signature, "sha1=")
 
-	mac := hmac.New(sha256.New, []byte(secret))
-	_, err := mac.Write(payload)
-	if err != nil {
-		return "", err
-	}
-	expectedMAC := hex.EncodeToString(mac.Sum(nil))
+	// Validate the MAC.
+	return ValidateMAC(payload, hmac.New(sha1.New, []byte(secret)), signatures)
+}
 
-	if !hmac.Equal([]byte(signature), []byte(expectedMAC)) {
-		return expectedMAC, &SignatureError{signature}
+// CheckPayloadSignature256 calculates and verifies SHA256 signature of the given payload
+func CheckPayloadSignature256(payload []byte, secret string, signature string) (string, error) {
+	if secret == "" {
+		return "", errors.New("signature validation secret can not be empty")
 	}
-	return expectedMAC, err
+
+	// Extract the signatures.
+	signatures := ExtractSignatures(signature, "sha256=")
+
+	// Validate the MAC.
+	return ValidateMAC(payload, hmac.New(sha256.New, []byte(secret)), signatures)
 }
 
 // CheckPayloadSignature512 calculates and verifies SHA512 signature of the given payload
@@ -142,19 +183,11 @@ func CheckPayloadSignature512(payload []byte, secret string, signature string) (
 		return "", errors.New("signature validation secret can not be empty")
 	}
 
-	signature = strings.TrimPrefix(signature, "sha512=")
-
-	mac := hmac.New(sha512.New, []byte(secret))
-	_, err := mac.Write(payload)
-	if err != nil {
-		return "", err
-	}
-	expectedMAC := hex.EncodeToString(mac.Sum(nil))
+	// Extract the signatures.
+	signatures := ExtractSignatures(signature, "sha512=")
 
-	if !hmac.Equal([]byte(signature), []byte(expectedMAC)) {
-		return expectedMAC, &SignatureError{signature}
-	}
-	return expectedMAC, err
+	// Validate the MAC.
+	return ValidateMAC(payload, hmac.New(sha512.New, []byte(secret)), signatures)
 }
 
 func CheckScalrSignature(headers map[string]interface{}, body []byte, signingKey string, checkDate bool) (bool, error) {
@@ -177,7 +210,7 @@ func CheckScalrSignature(headers map[string]interface{}, body []byte, signingKey
 	expectedSignature := hex.EncodeToString(mac.Sum(nil))
 
 	if !hmac.Equal([]byte(providedSignature), []byte(expectedSignature)) {
-		return false, &SignatureError{providedSignature}
+		return false, &SignatureError{Signature: providedSignature}
 	}
 
 	if !checkDate {
@@ -192,7 +225,7 @@ func CheckScalrSignature(headers map[string]interface{}, body []byte, signingKey
 	delta := math.Abs(now.Sub(date).Seconds())
 
 	if delta > 300 {
-		return false, &SignatureError{"outdated"}
+		return false, &SignatureError{Signature: "outdated"}
 	}
 	return true, nil
 }


Test patch:
diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go
index dfb4874..cccf4de 100644
--- a/internal/hook/hook_test.go
+++ b/internal/hook/hook_test.go
@@ -48,8 +48,11 @@ var checkPayloadSignatureTests = []struct {
 }{
 	{[]byte(`{"a": "z"}`), "secret", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true},
 	{[]byte(`{"a": "z"}`), "secret", "sha1=b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true},
+	{[]byte(`{"a": "z"}`), "secret", "sha1=XXXe04cbb22afa8ffbff8796fc1894ed27badd9e,sha1=b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true},
 	// failures
 	{[]byte(`{"a": "z"}`), "secret", "XXXe04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", false},
+	{[]byte(`{"a": "z"}`), "secret", "sha1=XXXe04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", false},
+	{[]byte(`{"a": "z"}`), "secret", "sha1=XXXe04cbb22afa8ffbff8796fc1894ed27badd9e,sha1=XXXe04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", false},
 	{[]byte(`{"a": "z"}`), "secreX", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "900225703e9342328db7307692736e2f7cc7b36e", false},
 	{[]byte(`{"a": "z"}`), "", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "", false},
 }
@@ -76,8 +79,11 @@ var checkPayloadSignature256Tests = []struct {
 }{
 	{[]byte(`{"a": "z"}`), "secret", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true},
 	{[]byte(`{"a": "z"}`), "secret", "sha256=f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true},
+	{[]byte(`{"a": "z"}`), "secret", "sha256=XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89,sha256=f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true},
 	// failures
 	{[]byte(`{"a": "z"}`), "secret", "XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", false},
+	{[]byte(`{"a": "z"}`), "secret", "sha256=XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", false},
+	{[]byte(`{"a": "z"}`), "secret", "sha256=XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89,sha256=XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", false},
 	{[]byte(`{"a": "z"}`), "", "XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "", false},
 }