add signature verification & usage example

This commit is contained in:
Grant Limberg
2023-09-06 14:32:36 -07:00
parent e270716ff3
commit 6a04e1b616
4 changed files with 282 additions and 0 deletions
+6
View File
@@ -1,9 +1,15 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/zerotier/ztchooks.svg)](https://pkg.go.dev/github.com/zerotier/ztchooks)
# ZTC Hooks
`ztchooks` provides primitives for serializing and verifying hooks fired from [ZeroTier Central](https://my.zerotier.com)
As of publishing time, this package is still a work in progress and the webhooks from ZeroTier Central are not publicly available yet.
# Example
A partial example of how to use this library can be found in the `example` directory
# License
Copyright 2023 ZeroTier, Inc. All rights reserved. Licensed under the Mozilla Public License Version 2.0. See the `LICENSE` file for the full license text.
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/zerotier/ztchooks"
)
// Users will need to replace the following value with the pre-shared key for their org
// at https://my.zerotier.com
var psk = "YOUR-PRE-SHARED-KEY"
var ErrUnhandledHook = errors.New("unhandled hook type")
var ErrUnknownHookType = errors.New("unknown hook type")
func hookCatcher(w http.ResponseWriter, req *http.Request) {
// read post body
body, err := io.ReadAll(req.Body)
if err != nil {
panic(err.Error())
}
// get signature header from request. If signature is empty, signature verification
// is skipped
signature := req.Header.Get("X-ZTC-Signature")
if signature != "" {
if err := ztchooks.VerifyHookSignature(psk, signature, body, ztchooks.DefaultTolerance); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("500 - Signature Verification Failed"))
return
}
}
if err := processPayload(body); err != nil && err != ErrUnhandledHook {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("500 - payload processing failed"))
return
}
}
func processPayload(payload []byte) error {
hType, err := ztchooks.GetHookType(payload)
if err != nil {
return err
}
//
switch hType {
case ztchooks.NETWORK_JOIN:
println(ztchooks.NETWORK_JOIN)
var nmj ztchooks.NewMemberJoined
if err := json.Unmarshal(payload, &nmj); err != nil {
return err
}
// ... do something with NewMemberJoined data
case ztchooks.NETWORK_AUTH:
println(ztchooks.NETWORK_AUTH)
var na ztchooks.NetworkMemberAuth
if err := json.Unmarshal(payload, &na); err != nil {
return err
}
// ... do something with NetworkMemberAuth data
case ztchooks.NETWORK_DEAUTH:
println(ztchooks.NETWORK_DEAUTH)
var nd ztchooks.NetworkMemberDeauth
if err := json.Unmarshal(payload, &nd); err != nil {
return err
}
// ... do something with NetworkMemberDeauth data
case ztchooks.NETWORK_CREATED:
println(ztchooks.NETWORK_CREATED)
var nc ztchooks.NetworkCreated
if err := json.Unmarshal(payload, &nc); err != nil {
return err
}
// ... do something with NetworkCreated data
//
// Continue with cases you wish to handle as needed
//
case ztchooks.HOOK_TYPE_UNKNOWN:
return ErrUnknownHookType
default:
return ErrUnhandledHook
}
return nil
}
func main() {
http.HandleFunc("/", hookCatcher)
http.ListenAndServe(":9999", nil)
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright 2023 ZeroTier, Inc. All rights reserved.
// Use of this source code is governed by the Mozilla Public License Version 2.0
// license that can be found in the LICENSE file.
package ztchooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
var (
ErrInvalidSignatureHeader = errors.New("webhook has no signature header")
ErrInvalidPreSharedKey = errors.New("invalid pre shared key")
ErrInvalidHeader = errors.New("webhook has invalid header")
ErrInvalidSignature = errors.New("webhook has no valid signature")
ErrTimestampExpired = errors.New("timestamp has expired")
)
var (
DefaultTolerance = 5 * time.Minute
)
type signedHeader struct {
timestamp time.Time
signatures [][]byte
}
// GetHookType decodes the `HookBase` portion of the data to determine and return
// the `HookType`
func GetHookType(data []byte) (HookType, error) {
var base HookBase
if err := json.Unmarshal(data, &base); err != nil {
return HOOK_TYPE_UNKNOWN, err
}
return base.HookType, nil
}
// VerifyHookSignature takes your pre-shared key, the value of the signature header, and the JSON payload
// and verifies the signature. tolerance determines how large of a time difference to tolerate in order
// to prevent a replay attack
func VerifyHookSignature(preSharedKey, sigHeader string, payload []byte, tolerance time.Duration) error {
header, err := parseHeader(sigHeader, tolerance)
if err != nil {
return err
}
expectedSig, err := generateExpectedSignature(header, preSharedKey, payload)
if err != nil {
return err
}
for _, sig := range header.signatures {
if hmac.Equal(expectedSig, sig) {
return nil
}
}
return ErrInvalidSignature
}
func generateExpectedSignature(sh *signedHeader, preSharedKey string, payload []byte) ([]byte, error) {
psk, err := hex.DecodeString(preSharedKey)
if err != nil {
return nil, ErrInvalidPreSharedKey
}
h := hmac.New(sha256.New, psk)
h.Write([]byte(fmt.Sprintf("%d", sh.timestamp.Unix())))
h.Write([]byte(","))
h.Write(payload)
return h.Sum(nil), nil
}
func parseHeader(sigHeader string, tolerance time.Duration) (*signedHeader, error) {
var err error
sh := &signedHeader{}
if sigHeader == "" {
return sh, ErrInvalidSignatureHeader
}
pairs := strings.Split(sigHeader, ",")
sh, err = decode(sh, pairs, tolerance)
if err != nil {
return sh, err
}
if len(sh.signatures) == 0 {
return sh, ErrInvalidSignature
}
return sh, nil
}
func decode(sh *signedHeader, pairs []string, tolerance time.Duration) (*signedHeader, error) {
for _, pair := range pairs {
parts := strings.SplitN(pair, "=", 2)
if len(parts) != 2 {
return sh, ErrInvalidHeader
}
item := parts[0]
if item == "t" {
timestamp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return sh, ErrInvalidHeader
}
sh.timestamp = time.Unix(timestamp, 0)
continue
}
if strings.Contains(item, "v") {
sig, err := hex.DecodeString(parts[1])
if err != nil {
continue
}
sh.signatures = append(sh.signatures, sig)
}
}
expiredTimestamp := time.Since(sh.timestamp) > tolerance
if expiredTimestamp {
return nil, ErrTimestampExpired
}
return sh, nil
}
+38
View File
File diff suppressed because one or more lines are too long