mirror of
https://github.com/zerotier/ztchooks.git
synced 2026-05-22 16:29:29 -07:00
add signature verification & usage example
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
[](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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user