From 607740ecc0a33b508aa4053ca1f665014cb92a3b Mon Sep 17 00:00:00 2001 From: Vladimir Dementyev Date: Wed, 6 Mar 2024 12:28:09 -0800 Subject: [PATCH] refactor: http broadcasting secrets + broadcasting docs --- broadcast/http.go | 60 +++++++++--- broadcast/http_test.go | 38 +++++--- cli/options.go | 79 +++++++++++---- config/config.go | 1 + docs/broadcasting.md | 199 ++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 12 +-- features/sse.testfile | 12 ++- rpc/config.go | 2 + rpc/rpc.go | 17 ++++ server/server.go | 2 +- utils/message_verifier.go | 29 +++++- 11 files changed, 386 insertions(+), 65 deletions(-) create mode 100644 docs/broadcasting.md diff --git a/broadcast/http.go b/broadcast/http.go index a79ba0b1..151c5c2c 100644 --- a/broadcast/http.go +++ b/broadcast/http.go @@ -9,11 +9,13 @@ import ( "strconv" "github.com/anycable/anycable-go/server" + "github.com/anycable/anycable-go/utils" + "github.com/joomcode/errorx" ) const ( - defaultHTTPPort = 8090 - defaultHTTPPath = "/_broadcast" + defaultHTTPPath = "/_broadcast" + broadcastKeyPhrase = "broadcast-cable" ) // HTTPConfig contains HTTP pubsub adapter configuration @@ -24,20 +26,26 @@ type HTTPConfig struct { Path string // Secret token to authorize requests Secret string + // SecretBase is a secret used to generate a token if none provided + SecretBase string } // NewHTTPConfig builds a new config for HTTP pub/sub func NewHTTPConfig() HTTPConfig { return HTTPConfig{ - Port: defaultHTTPPort, Path: defaultHTTPPath, } } +func (c *HTTPConfig) IsSecured() bool { + return c.Secret != "" || c.SecretBase != "" +} + // HTTPBroadcaster represents HTTP broadcaster type HTTPBroadcaster struct { port int path string + conf *HTTPConfig authHeader string server *server.HTTPServer node Handler @@ -48,18 +56,12 @@ var _ Broadcaster = (*HTTPBroadcaster)(nil) // NewHTTPBroadcaster builds a new HTTPSubscriber struct func NewHTTPBroadcaster(node Handler, config *HTTPConfig, l *slog.Logger) *HTTPBroadcaster { - authHeader := "" - - if config.Secret != "" { - authHeader = fmt.Sprintf("Bearer %s", config.Secret) - } - return &HTTPBroadcaster{ - node: node, - log: l.With("context", "broadcast").With("provider", "http"), - port: config.Port, - path: config.Path, - authHeader: authHeader, + node: node, + log: l.With("context", "broadcast").With("provider", "http"), + port: config.Port, + path: config.Path, + conf: config, } } @@ -75,10 +77,38 @@ func (s *HTTPBroadcaster) Start(done chan (error)) error { return err } + authHeader := "" + + if s.conf.Secret == "" && s.conf.SecretBase != "" { + secret, err := utils.NewMessageVerifier(s.conf.SecretBase).Sign([]byte(broadcastKeyPhrase)) + + if err != nil { + err = errorx.Decorate(err, "failed to auto-generate authentication key for HTTP broadcaster") + return err + } + + s.log.Info("auto-generated authorization secret from the application secret") + s.conf.Secret = string(secret) + } + + if s.conf.Secret != "" { + authHeader = fmt.Sprintf("Bearer %s", s.conf.Secret) + } + + s.authHeader = authHeader + s.server = server s.server.SetupHandler(s.path, http.HandlerFunc(s.Handler)) - s.log.Info(fmt.Sprintf("Accept broadcast requests at %s%s", s.server.Address(), s.path)) + var verifiedVia string + + if s.authHeader != "" { + verifiedVia = "authorization required" + } else { + verifiedVia = "no authorization" + } + + s.log.Info(fmt.Sprintf("Accept broadcast requests at %s%s (%s)", s.server.Address(), s.path, verifiedVia)) go func() { if err := s.server.StartAndAnnounce("broadcasting HTTP server"); err != nil { diff --git a/broadcast/http_test.go b/broadcast/http_test.go index c321b05f..e3e4c249 100644 --- a/broadcast/http_test.go +++ b/broadcast/http_test.go @@ -1,6 +1,7 @@ package broadcast import ( + "context" "encoding/json" "log/slog" "net/http" @@ -10,15 +11,28 @@ import ( "github.com/anycable/anycable-go/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHttpHandler(t *testing.T) { handler := &mocks.Handler{} - config := HTTPConfig{} - secretConfig := HTTPConfig{Secret: "secret"} + config := NewHTTPConfig() + + secretConfig := NewHTTPConfig() + secretConfig.SecretBase = "qwerty" + broadcastKey := "42923a28b760e667fc92f7c6123bb07a282822b329dd2ef48e7aee7830d98485" + broadcaster := NewHTTPBroadcaster(handler, &config, slog.Default()) protectedBroadcaster := NewHTTPBroadcaster(handler, &secretConfig, slog.Default()) + done := make(chan (error)) + + require.NoError(t, broadcaster.Start(done)) + defer broadcaster.Shutdown(context.Background()) + + require.NoError(t, protectedBroadcaster.Start(done)) + defer protectedBroadcaster.Shutdown(context.Background()) + payload, err := json.Marshal(map[string]string{"stream": "any_test", "data": "123_test"}) if err != nil { t.Fatal(err) @@ -31,9 +45,7 @@ func TestHttpHandler(t *testing.T) { t.Run("Handles broadcasts", func(t *testing.T) { req, err := http.NewRequest("POST", "/", strings.NewReader(string(payload))) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) rr := httptest.NewRecorder() handler := http.HandlerFunc(broadcaster.Handler) @@ -44,9 +56,7 @@ func TestHttpHandler(t *testing.T) { t.Run("Rejects non-POST requests", func(t *testing.T) { req, err := http.NewRequest("GET", "/", strings.NewReader(string(payload))) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) rr := httptest.NewRecorder() handler := http.HandlerFunc(broadcaster.Handler) @@ -57,9 +67,7 @@ func TestHttpHandler(t *testing.T) { t.Run("Rejects when authorization header is missing", func(t *testing.T) { req, err := http.NewRequest("POST", "/", strings.NewReader(string(payload))) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) rr := httptest.NewRecorder() handler := http.HandlerFunc(protectedBroadcaster.Handler) @@ -68,13 +76,11 @@ func TestHttpHandler(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rr.Code) }) - t.Run("Rejects when authorization header is valid", func(t *testing.T) { + t.Run("Accepts when authorization header is valid", func(t *testing.T) { req, err := http.NewRequest("POST", "/", strings.NewReader(string(payload))) - req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("Authorization", "Bearer "+broadcastKey) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) rr := httptest.NewRecorder() handler := http.HandlerFunc(protectedBroadcaster.Handler) diff --git a/cli/options.go b/cli/options.go index 69c3214b..a59fa701 100644 --- a/cli/options.go +++ b/cli/options.go @@ -259,13 +259,55 @@ It has no effect anymore, use public streams instead.`) c.RPC.Implementation = "none" } - configureSecrets( - c.Secret, - &c.Streams.Secret, - &c.JWT.Secret, - &c.HTTPBroadcast.Secret, - &c.RPC.Secret, - ) + // Legacy HTTP authentication stuff + if c.HTTPBroadcast.Secret == "" { + c.HTTPBroadcast.Secret = c.BroadcastKey + } + + // Fallback secrets + if c.Secret != "" { + if c.Streams.Secret == "" { + c.Streams.Secret = c.Secret + } + + if c.JWT.Secret == "" { + c.JWT.Secret = c.Secret + } + + if c.HTTPBroadcast.Secret == "" { + c.HTTPBroadcast.SecretBase = c.Secret + } + + if c.RPC.Secret == "" { + c.RPC.SecretBase = c.Secret + } + } + + // Nullify none secrets + if c.Streams.Secret == "none" { + c.Streams.Secret = "" + } + + if c.JWT.Secret == "none" { + c.JWT.Secret = "" + } + + if c.RPC.Secret == "none" { + c.RPC.Secret = "" + } + + if c.HTTPBroadcast.Secret == "none" { + c.HTTPBroadcast.Secret = "" + } + + // Configure default HTTP port + if c.HTTPBroadcast.Port == 0 { + if c.HTTPBroadcast.IsSecured() { + c.HTTPBroadcast.Port = c.Port + } else { + c.HTTPBroadcast.Port = 8090 + } + } // Configure public mode and other insecure features if isPublic { @@ -273,6 +315,7 @@ It has no effect anymore, use public streams instead.`) c.Streams.Public = true // Ensure broadcasting is also public c.HTTPBroadcast.Secret = "" + c.HTTPBroadcast.SecretBase = "" } return &c, nil, false @@ -344,6 +387,13 @@ func serverCLIFlags(c *config.Config, path *string, isPublic *bool) []cli.Flag { Destination: &c.Secret, }, + &cli.StringFlag{ + Name: "broadcast_key", + Usage: "An authentication key for broadcast requests", + Value: c.BroadcastKey, + Destination: &c.BroadcastKey, + }, + &cli.BoolFlag{ Name: "public", Usage: "[DANGER ZONE] Run server in the public mode allowing all connections and stream subscriptions", @@ -542,8 +592,9 @@ func httpBroadcastCLIFlags(c *config.Config) []cli.Flag { &cli.StringFlag{ Name: "http_broadcast_secret", - Usage: "HTTP pub/sub authorization secret", + Usage: "[Deprecated] HTTP pub/sub authorization secret", Destination: &c.HTTPBroadcast.Secret, + Hidden: true, }, }) } @@ -1218,15 +1269,3 @@ func parseTags(str string) map[string]string { return res } - -func configureSecrets(source string, targets ...*string) { - for _, t := range targets { - if (*t) == "" { - (*t) = source - } - - if (*t) == "none" { - (*t) = "" - } - } -} diff --git a/config/config.go b/config/config.go index 12c2601f..c6cb3cd7 100644 --- a/config/config.go +++ b/config/config.go @@ -22,6 +22,7 @@ import ( type Config struct { ID string Secret string + BroadcastKey string SkipAuth bool App node.Config RPC rpc.Config diff --git a/docs/broadcasting.md b/docs/broadcasting.md new file mode 100644 index 00000000..ab80ff26 --- /dev/null +++ b/docs/broadcasting.md @@ -0,0 +1,199 @@ +# Broadcasting + +Publishing messages from your application to connected clients (aka _broadcasting_) is an essential component of any real-time application. + +AnyCable comes with multiple options on how to broadcast messages. We call them _broadcasters_. Currently, we support HTTP, Redis, and NATS-based broadcasters. + +**NOTE:** The default broadcaster is Redis Pub/Sub for backward-compatibility reasons. This is going to change in v2. + +## HTTP + +> Enable via `--broadcast_adapter=http` (or `ANYCABLE_BROADCAST_ADAPTER=http`). + +HTTP broadcaster has zero-dependencies and, thus, allows you to quickly start using AnyCable, and it's good enough to keep using it at scale. + +By default, HTTP broadcaster accepts publications as POST requests to the `/_broadcast` path of your server\*. The request body MUST contain the publication payload (see below to learn about [the format](#publication-format)). + +Here is a basic cURL example: + +```bash +curl -X POST -H "Content-Type: application/json" -d '{"stream":"my_stream","data":"{\"text\":\"Hello, world!\"}"}' http://localhost:8090/_broadcast +``` + +\* If neither the broadcast key nor the application secret is specified, we configure HTTP broadcaster to use a different port by default (`:8090`) for security reasons. You can handle broadcast requests at the main AnyCable port by specifying it explicitly (via the `http_broadcast_port` option). If the broadcast key is specified or explicitly set to "none" or auto-generated from the application secret (see below), we run it on the main port. You will see the notice in the startup logs telling you how the HTTP broadcaster endpoint was configured: + +```sh +2024-03-06 10:35:39.297 INF Accept broadcast requests at http://localhost:8090/_broadcast (no authorization) nodeid=uE3mZ7 context=broadcast provider=http + +# OR +2024-03-06 10:35:39.297 INF Accept broadcast requests at http://localhost:8080/_broadcast (authorization required) nodeid=uE3mZ7 context=broadcast provider=http +``` + +### Securing HTTP endpoint + +We automatically secure the HTTP broadcaster endpoint if the application broadcast key (`--broadcast_key`) is specified or inferred\* from the application secret (`--secret`) and the server is not running in the public mode (`--public`). + +Every request MUST include an "Authorization" header with the `Bearer ` value: + +```sh +# Run AnyCable +$ anycable-go --broadcast_key=my-secret-key + +2024-03-06 10:35:39.296 INF Starting AnyCable 1.5.0-a7aa9b4 (with mruby 1.2.0 (2015-11-17)) (pid: 57260, open file limit: 122880, gomaxprocs: 8) nodeid=uE3mZ7 +... +2024-03-06 10:35:39.297 INF Accept broadcast requests at http://localhost:8080/_broadcast (authorization required) nodeid=uE3mZ7 context=broadcast provider=http + +# Broadcast a message +$ curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer my-secret-key" -d '{"stream":"my_stream","data":"{\"text\":\"Hello, world!\"}"}' http://localhost:8080/_broadcast -w "%{http_code}" + +201 +``` + +\* When the broadcast key is missing but the application secret is present, we automatically generate a broadcast key using the following formula (in Ruby): + +```ruby +broadcast_key = OpenSSL::HMAC.hexdigest("SHA256", "", "broadcast-cable") +``` + +When using AnyCable SDKs, you don't need to calculate it yourself. But if you want to publish broadcasts using a custom implementation, you can generate a broadcast key for your secret key as follows: + +```sh +echo -n 'broadcast-cable' | openssl dgst -sha256 -hmac '' | awk '{print $2}' +``` + +## Redis Pub/Sub + +> Enable via `--broadcast_adapter=redis` (or `ANYCABLE_BROADCAST_ADAPTER=redis`). + +This broadcaster uses Redis [Pub/Sub](https://redis.io/topics/pubsub) feature under the hood, and, thus, publications are delivered to all subscribed AnyCable servers simulteneously. + +All broadcast messages are published to a single channel (configured via the `--redis_channel`, defaults to `__anycable__`) as follows: + +```sh +$ redis-cli PUBLISH __anycable__ '{"stream":"my_stream","data":"{\"text\":\"Hello, world!\"}"}' + +(integer) 1 +``` + +Note that since all AnyCable server receive each publication, we cannot use [broker](./broker.md) to provide stream history support when using Redis Pub/Sub. + +See [configuration](./configuration.md#redis-configuration) for available Redis options. + +## Redis X + +> Enable via `--broadcast_adapter=redisx` (or `ANYCABLE_BROADCAST_ADAPTER=redisx`). + +**IMPORTANT:** Redis v6.2+ is required. + +Redis X broadcaster uses [Redis Streams][redis-streams] instead of Publish/Subscribe to _consume_ publications from your application. That gives us the following benefits: + +- **Broker compatibility**. This broadcaster uses a [broker](/anycable-go/broker.md) to store messages in a cache and distribute them within a cluster. This is possible due to the usage of Redis Streams consumer groups. + +- **Better delivery guarantees**. Even if there is no AnyCable server available at the broadcast time, the message will be stored in Redis and delivered to an AnyCable server once it is available. In combination with the [broker feature](./broker.md), you can achieve at-least-once delivery guarantees (compared to at-most-once provided by Redis Pub/Sub). + +To broadcast a message, you publish it to a dedicated Redis stream (configured via the `--redis_channel` option, defaults to `__anycable__`) with the publication JSON provided as the `payload` field value: + +```sh +$ redis-cli XADD __anycable__ "*" payload '{"stream":"my_stream","data":"{\"text\":\"Hello, world!\"}"}' + +"1709754437079-0" +``` + +See [configuration](./configuration.md#redis-configuration) for available Redis options. + +## NATS Pub/Sub + +> Enable via `--broadcast_adapter=nats` (or `ANYCABLE_BROADCAST_ADAPTER=nats`). + +NATS broadcaster uses [NATS publish/subscribe](https://docs.nats.io/nats-concepts/core-nats/pubsub) functionality and supports cluster features out-of-the-box. It works to Redis Pub/Sub: distribute publications to all subscribed AnyCable servers. Thus, it's incompatible with [broker](./broker.md) (stream history support), too. + +To broadcast a message, you publish it to a NATS stream (configured via the `--nats_channel` option, defaults to `__anycable__`) as follows: + +```sh +$ nats pub __anycable__ '{"stream":"my_stream","data":"{\"text\":\"Hello, world!\"}"}' + +12:03:39 Published 60 bytes to "__anycable__" +``` + +NATS Pub/Sub is useful when you want to set up an AnyCable cluster using our [embedded NATS](./embedded_nats.md) feature, so you can avoid having additional infrastructure components. + +See [configuration](./configuration.md#nats-configuration) for available NATS options. + +## Publication format + +AnyCable accepts broadcast messages encoded as JSON and having the following properties: + +```js +{ + "stream": "", // string + "data": "", // string, usually a JSON-encoded object, but not necessarily + "meta": "{}" // object, publication metadata, optional +} +``` + +It's also possible to publish multiple messages at once. For that, you just send them as an array of publications: + +```js +[ + { + "stream": "...", + "data": "...", + }, + { + "stream": "...", + "data": "..." + } +] +``` + +The `meta` field MAY contain additional instructions for servers on how to deliver the publication. Currently, the following fields are supported: + +- `exclude_socket`: you can specify a unique client identifier (returned by the server in the `welcome` message as `sid`) to remove this client from the list of recipients. + +All other meta fields are ignored for now. + +Here is a JSON Schema describing this format: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema", + "definitions": { + "publication": { + "type": "object", + "properties": { + "stream": { + "type": "string", + "description": "Publication stream name" + }, + "data": { + "type": "string", + "description": "Payload, usually a JSON-encoded object, but not necessarily" + }, + "meta": { + "type": "object", + "description": "Publication metadata, optional", + "properties": { + "exclude_socket": { + "type": "string", + "description": "Unique client identifier to remove this client from the list of recipients" + } + }, + "additionalProperties": true + } + }, + "required": ["stream", "data"] + } + }, + "anyOf": [ + { + "$ref": "#/definitions/publication" + }, + { + "type": "array", + "items":{"$ref": "#/definitions/publication"} + } + ] +} +``` + +[redis-streams]: https://redis.io/docs/data-types/streams-tutorial/ diff --git a/docs/configuration.md b/docs/configuration.md index 82015c9c..287c3ce1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,7 +81,11 @@ Comma-separated list of cookies to proxy to RPC (default: all cookies). **--secret** (`ANYCABLE_SECRET`) -A common secret key used by all AnyCable features by default (i.e., unless a specific key is specified): [JWT authentication](./jwt_identification.md), [signed streams](./signed_streams.md), etc. +A common secret key used by the following components (unless a specific key is specified): [JWT authentication](./jwt_identification.md), [signed streams](./signed_streams.md). + +**--broadcast_key** (`ANYCABLE_BROADCAST_KEY`) + +A secret key used to authenticate broadcast requests. See [broadcasting docs](./broadcasting.md). You can use the special "none" value to disable broadcasting authentication. **--noauth** (`ANYCABLE_NOAUTH=true`) @@ -93,7 +97,7 @@ Setting this value allows direct subscribing to streams using unsigned names (se **--public** (`ANYCABLE_PUBLIC=true`) -This is a shortcut to specify both `--noauth` and `--public_streams` and remove the protection from HTTP broadcasting endpoint (`--http_broadcast_secret=""`), so you can use AnyCable without any protection. **Do not do this in production**. +This is a shortcut to specify both `--noauth`, `--public_streams` and `--broadcast_key=none`, so you can use AnyCable without any protection. **Do not do this in production**. ## HTTP API @@ -101,10 +105,6 @@ This is a shortcut to specify both `--noauth` and `--public_streams` and remove You can specify on which port to receive broadcasting requests (NOTE: it could be the same port as the main HTTP server listens to). -**--http_broadcast_secret** (`ANYCABLE_HTTP_BROADCAST_SECRET`) - -Authorization secret to protect the broadcasting endpoint (see [Ruby docs](../ruby/broadcast_adapters.md#securing-http-endpoint)). If this value is not set, the `--secret` setting is used. - ## Redis configuration **--redis_url** (`ANYCABLE_REDIS_URL` or `REDIS_URL`) diff --git a/features/sse.testfile b/features/sse.testfile index 56f880f7..917c699f 100644 --- a/features/sse.testfile +++ b/features/sse.testfile @@ -1,5 +1,5 @@ launch :anycable, - "./dist/anycable-go --sse --public_streams --secret=qwerty --broadcast_adapter=http --presets=broker --http_broadcast_secret=none" + "./dist/anycable-go --sse --public_streams --secret=qwerty --broadcast_adapter=http --presets=broker" wait_tcp 8080 @@ -18,9 +18,15 @@ url = "http://localhost:8080/events?jid=#{token}&identifier=#{identifier}" Event = Struct.new(:type, :data, :id, :retry) +# echo -n 'broadcast-cable' | openssl dgst -sha256 -hmac 'qwerty' | awk '{print $2}' +BROADCAST_KEY = "42923a28b760e667fc92f7c6123bb07a282822b329dd2ef48e7aee7830d98485" + def broadcast(stream, data) - uri = URI.parse("http://localhost:8090/_broadcast") - header = {"Content-Type": "application/json"} + uri = URI.parse("http://localhost:8080/_broadcast") + header = { + "Content-Type": "application/json", + "Authorization": "Bearer #{BROADCAST_KEY}" + } data = {stream: stream, data: data.to_json} http = Net::HTTP.new(uri.host, uri.port) request = Net::HTTP::Post.new(uri.request_uri, header) diff --git a/rpc/config.go b/rpc/config.go index 78afb5f6..ee33f1b9 100644 --- a/rpc/config.go +++ b/rpc/config.go @@ -54,6 +54,8 @@ type Config struct { Secret string // Timeout for HTTP RPC requests (in ms) RequestTimeout int + // SecretBase is a secret used to generate authentication token + SecretBase string } // NewConfig builds a new config diff --git a/rpc/rpc.go b/rpc/rpc.go index 7a9d4d13..6d5e1212 100644 --- a/rpc/rpc.go +++ b/rpc/rpc.go @@ -13,6 +13,8 @@ import ( "github.com/anycable/anycable-go/common" "github.com/anycable/anycable-go/metrics" "github.com/anycable/anycable-go/protocol" + "github.com/anycable/anycable-go/utils" + "github.com/joomcode/errorx" pb "github.com/anycable/anycable-go/protos" "google.golang.org/grpc" @@ -44,6 +46,8 @@ const ( metricsRPCPending = "rpc_pending_num" metricsRPCCapacity = "rpc_capacity_num" metricsGRPCActiveConns = "grpc_active_conn_num" + + secretKeyPhrase = "rpc-cable" ) type grpcClientHelper struct { @@ -198,6 +202,19 @@ func (c *Controller) Start() error { switch impl { case "http": var err error + + if c.config.Secret == "" && c.config.SecretBase != "" { + secret, err := utils.NewMessageVerifier(c.config.SecretBase).Sign([]byte(secretKeyPhrase)) + + if err != nil { + err = errorx.Decorate(err, "failed to auto-generate authentication key for HTTP RPC") + return err + } + + c.log.Info("auto-generated authorization secret from the application secret") + c.config.Secret = string(secret) + } + dialer, err = NewHTTPDialer(c.config) if err != nil { return err diff --git a/server/server.go b/server/server.go index f41b0f8e..1b583dbd 100644 --- a/server/server.go +++ b/server/server.go @@ -42,7 +42,7 @@ var ( // MaxConn is a default configuration for maximum connections MaxConn int // Default logger - Logger *slog.Logger + Logger *slog.Logger = slog.Default() ) // ForPort creates new or returns the existing server for the specified port diff --git a/utils/message_verifier.go b/utils/message_verifier.go index cb9c2366..d62e2125 100644 --- a/utils/message_verifier.go +++ b/utils/message_verifier.go @@ -9,6 +9,8 @@ import ( "errors" "fmt" "strings" + + "github.com/joomcode/errorx" ) type MessageVerifier struct { @@ -27,9 +29,13 @@ func (m *MessageVerifier) Generate(payload interface{}) (string, error) { } encoded := base64.StdEncoding.EncodeToString(payloadJson) - digest := hmac.New(sha256.New, m.key) - digest.Write([]byte(encoded)) - signature := []byte(fmt.Sprintf("%x", digest.Sum(nil))) + + signature, err := m.Sign([]byte(encoded)) + + if err != nil { + return "", err + } + signed := encoded + "--" + string(signature) return signed, nil } @@ -72,8 +78,23 @@ func (m *MessageVerifier) isValid(msg string) bool { data := []byte(parts[0]) digest := []byte(parts[1]) + return m.VerifySignature(data, digest) +} + +func (m *MessageVerifier) Sign(payload []byte) ([]byte, error) { + digest := hmac.New(sha256.New, m.key) + _, err := digest.Write(payload) + + if err != nil { + return nil, errorx.Decorate(err, "failed to sign payload") + } + + return []byte(fmt.Sprintf("%x", digest.Sum(nil))), nil +} + +func (m *MessageVerifier) VerifySignature(payload []byte, digest []byte) bool { h := hmac.New(sha256.New, m.key) - h.Write(data) + h.Write(payload) actual := []byte(fmt.Sprintf("%x", h.Sum(nil)))