diff --git a/mcs-s3-webhook-server-example/.gitignore b/mcs-s3-webhook-server-example/.gitignore new file mode 100644 index 0000000..889a723 --- /dev/null +++ b/mcs-s3-webhook-server-example/.gitignore @@ -0,0 +1 @@ +/mcs-s3-webhook-server-example diff --git a/mcs-s3-webhook-server-example/README.md b/mcs-s3-webhook-server-example/README.md new file mode 100644 index 0000000..7f1e069 --- /dev/null +++ b/mcs-s3-webhook-server-example/README.md @@ -0,0 +1,23 @@ +# Example S3 webhook server for MCS + +## Building +``` +go build +``` + +You'll get the binary named `mcs-s3-webhook-server-example`. + +## Running + +``` +./mcs-s3-webhook-server-example +``` + +It will listen for HTTP requests on TCP port 33345. It assumes https forwarding from nginx. + +## Testing + +To test signature correctness: +``` +go test +``` diff --git a/mcs-s3-webhook-server-example/helpers.go b/mcs-s3-webhook-server-example/helpers.go new file mode 100644 index 0000000..bbc4007 --- /dev/null +++ b/mcs-s3-webhook-server-example/helpers.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + "log" + "net/http" +) + +func httpError(w http.ResponseWriter, code int, format string, args ...interface{}) { + text := fmt.Sprintf(format, args...) + log.Println(text) + http.Error(w, text, code) +} diff --git a/mcs-s3-webhook-server-example/hookserver.go b/mcs-s3-webhook-server-example/hookserver.go new file mode 100644 index 0000000..173def2 --- /dev/null +++ b/mcs-s3-webhook-server-example/hookserver.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httputil" + "strings" +) + +func main() { + http.HandleFunc("/", ServeHTTP) + log.Fatal(http.ListenAndServe(":33345", nil)) +} + +// general handler for all http requests +func ServeHTTP(w http.ResponseWriter, r *http.Request) { + // dump all requests to stdout + dump, err := httputil.DumpRequest(r, true) + if err != nil { + httpError(w, http.StatusInternalServerError, "Failed to dump request") + return + } + fmt.Printf("%s\n", dump) + + // X-Amz-Sns-Message-Type: SubscriptionConfirmation + snsType := strings.ToLower(r.Header.Get("X-Amz-Sns-Message-Type")) + switch snsType { + case "subscriptionconfirmation": + handleSubscriptionConfirmation(w, r) + case "notification": + handleNotification(w, r) + default: + httpError(w, http.StatusBadRequest, "Invalid SNS message type") + } +} + +// handle all subscription confirmations +func handleSubscriptionConfirmation(w http.ResponseWriter, r *http.Request) { + decoded := SubscriptionConfirmation{} + err := json.NewDecoder(r.Body).Decode(&decoded) + if err != nil { + httpError(w, http.StatusBadRequest, "Failed to decode JSON in the body: %s", err) + return + } + + // assume https + fullURL := fmt.Sprintf("https://%s%s", r.Host, r.URL.String()) // r.URL doesn't contain protocol or host + + response := struct { + Signature string `json:"signature"` // base64-encoded signature + }{ + Signature: signSubscriptionHex(decoded, fullURL), + } + w.Header().Set("Content-Type", "application/json") + jsonBody, err := json.Marshal(&response) + if err != nil { + httpError(w, http.StatusInternalServerError, "Failed to marshal json: %s", err) + return + } + + log.Printf("Responding with body: %s", jsonBody) + w.Write(jsonBody) +} + +func handleNotification(w http.ResponseWriter, r *http.Request) { + // do nothing, just 200, we dump the request body in main handler already +} diff --git a/mcs-s3-webhook-server-example/signatures.go b/mcs-s3-webhook-server-example/signatures.go new file mode 100644 index 0000000..4b862ba --- /dev/null +++ b/mcs-s3-webhook-server-example/signatures.go @@ -0,0 +1,38 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +type SubscriptionConfirmation struct { + Timestamp string // we need to give it to hmac byte-for-byte, "2019-12-26T19:29:12+03:00", + Type string // always "SubscriptionConfirmation", + Message string // always "You have chosen to subscribe to the topic $topic. To confirm the subscription you need to response with calculated signature", + TopicArn string // for feeding into signature, "mcs2883541269|bucketA|s3:ObjectCreated:Put", + SignatureVersion int64 // always 1, + Token string // for feeding into signature, "RPE5UuG94rGgBH6kHXN9FUPugFxj1hs2aUQc99btJp3E49tA" +} + +// signSubscriptionHex returns hex-encoded signature for SNS webhook subscription confirmation +func signSubscriptionHex(decoded SubscriptionConfirmation, fullURL string) string { + return hex.EncodeToString(signSubscription(decoded, fullURL)) +} + +// signSubscription returns array with a signature for SNS webhook subscription confirmation +func signSubscription(decoded SubscriptionConfirmation, fullURL string) []byte { + // get timestamp+token hash first + firstMAC := hmac.New(sha256.New, []byte(decoded.Token)) + firstMAC.Write([]byte(decoded.Timestamp)) + firstHash := firstMAC.Sum(nil) + // then combine that with TopicArn + secondMAC := hmac.New(sha256.New, firstHash) + secondMAC.Write([]byte(decoded.TopicArn)) + secondHash := secondMAC.Sum(nil) + // then combine that with full URL + thirdMAC := hmac.New(sha256.New, secondHash) + thirdMAC.Write([]byte(fullURL)) + thirdHash := thirdMAC.Sum(nil) + return thirdHash +} diff --git a/mcs-s3-webhook-server-example/signatures_test.go b/mcs-s3-webhook-server-example/signatures_test.go new file mode 100644 index 0000000..5af35b2 --- /dev/null +++ b/mcs-s3-webhook-server-example/signatures_test.go @@ -0,0 +1,46 @@ +package main + +import "testing" + +// signature = hmac_sha256_hex(“http://test.com”, +// hmac_sha256(“mcs2883541269|bucketA|s3:ObjectCreated:Put”, +// hmac_sha256(“2019-12-26T19:29:12+03:00”, “RPE5UuG94rGgBH6kHXN9FUPugFxj1hs2aUQc99btJp3E49tA”))) +// results in ea3fce4bb15c6de4fec365d36bcebbc34ccddf54616d5ca12e1972f82b6d37af +func TestSubscriptionConfirmationSignature(t *testing.T) { + fullURL := "http://test.com" + confirmation := SubscriptionConfirmation{ + Timestamp: "2019-12-26T19:29:12+03:00", + Type: "", + Message: "", + TopicArn: "mcs2883541269|bucketA|s3:ObjectCreated:Put", + SignatureVersion: 1, + Token: "RPE5UuG94rGgBH6kHXN9FUPugFxj1hs2aUQc99btJp3E49tA", + } + signature := signSubscriptionHex(confirmation, fullURL) + expected := "ea3fce4bb15c6de4fec365d36bcebbc34ccddf54616d5ca12e1972f82b6d37af" + if signature != expected { + t.Errorf("Wrong signature, expected %s, got %s", expected, signature) + } +} + +func BenchmarkSubscriptionConfirmationSignature(b *testing.B) { + fullURL := "http://test.com" + confirmation := SubscriptionConfirmation{ + Timestamp: "2019-12-26T19:29:12+03:00", + Type: "", + Message: "", + TopicArn: "mcs2883541269|bucketA|s3:ObjectCreated:Put", + SignatureVersion: 1, + Token: "RPE5UuG94rGgBH6kHXN9FUPugFxj1hs2aUQc99btJp3E49tA", + } + expected := "ea3fce4bb15c6de4fec365d36bcebbc34ccddf54616d5ca12e1972f82b6d37af" + bytes := len(confirmation.Timestamp) + len(confirmation.TopicArn) + len(confirmation.Token) + len(fullURL) + len(expected) + b.SetBytes(int64(bytes)) + b.ResetTimer() + for i := 0; i < b.N; i++ { + signature := signSubscriptionHex(confirmation, fullURL) + if signature != expected { + b.Errorf("Wrong signature, expected %s, got %s", expected, signature) + } + } +}