Skip to content

kemalcr/kemal-hmac

Repository files navigation

kemal-hmac

test build lint acceptance docs coverage

HMAC middleware for Crystal's kemal framework

About

Why should I use HMAC in a client/server system with kemal? Here are some of the benefits:

  • Data Integrity: HMAC ensures that the data hasn't been tampered with during transit
  • Authentication: Verifies the identity of the sender, providing a level of trust in the communication
  • Keyed Security: Uses a secret key for hashing, making it more secure than simple hash functions
  • Protection Against Replay Attacks: By incorporating timestamps, HMAC helps prevent the replay of old messages

This readme will be broken up into two parts. The first part will cover how to use the server middleware in a kemal application. The second part will cover how to use the client to communicate with a server that uses the middleware.

Quick Start ⭐

Installation

Simply add the shard to your shard.yml file:

dependencies:
  kemal-hmac:
    github: kemalcr/kemal-hmac

Basic Example

The most basic example possible enabling HMAC authentication for all routes in a kemal application:

require "kemal"
require "kemal-hmac"

hmac_auth({"my_client" => ["my_secret"]})

get "/" do |env|
  "Hi, %s! You passed HMAC auth" % env.kemal_authorized_client?
end

Kemal.run

Server Usage

First, you must require the kemal-hmac shard in your kemal application and call it:

# file: hmac_server.cr
require "kemal"
require "kemal-hmac"

# Initialize the HMAC middleware with the client name that will be sending requests to this server and a secret
# Note: You can use more than one client name and secret pair. You can also use multiple secrets for the same client name (helps with key rotation)
hmac_auth({"my_client" => ["my_secret"]})

# Now all endpoints are protected with HMAC authentication
# env.kemal_authorized_client? will return the client name that was used to authenticate the request
get "/" do |env|
  "Hi, %s! You sent a request that was successfully verified with HMAC auth" % env.kemal_authorized_client?
end

# The `hmac_auth` method also protects websocket routes
ws "/websocket" do |socket|
  socket.send "HMAC protected websocket route, hooray!"
  socket.close
end

Kemal.run

# $ crystal run hmac_server.cr
# [development] Kemal is ready to lead at http://0.0.0.0:3000

In a new terminal, you can send a request into the kemal server and verify that HMAC authentication is working:

# file: client_test.cr
require "kemal-hmac"  # <-- import the kemal-hmac shard
require "http/client" # <-- here we will just use the crystal standard library

# Initialize the HMAC client
client = Kemal::Hmac::Client.new("my_client", "my_secret")

# Generate the HMAC headers for the desired path
path = "/"
headers = HTTP::Headers.new
client.generate_headers(path).each do |key, value|
  headers.add(key, value)
end

# Make the HTTP request with the generated headers to the server that uses `kemal-hmac` for authentication
response = HTTP::Client.get("http://localhost:3000#{path}", headers)

# Handle the response
if response.status_code == 200
  puts "Success: #{response.body}"
else
  puts "Error: #{response.status_code}"
end

# $ crystal run client_test.cr
# Success: Hi, my_client! You sent a request that was successfully verified with HMAC auth

Authentication for specific routes

The Kemal::Hmac::Handler inherits from Kemal::Handler and it is therefore easy to create a custom handler that adds HMAC authentication to specific routes instead of all routes.

# file: hmac_server.cr
require "kemal"
require "kemal-hmac"

class CustomAuthHandler < Kemal::Hmac::Handler
  only ["/admin", "/api"] # <-- only protect the /admin and /api routes
  
  def call(context)
    return call_next(context) unless only_match?(context)
    super
  end
end

# Initialize the HMAC middleware with the custom handler
Kemal.config.hmac_handler = CustomAuthHandler
add_handler CustomAuthHandler.new({"my_client" => ["my_secret"]})

# The root (/) endpoint is not protected by HMAC authentication in this example
get "/" do |env|
  "hello world"
end

# The /admin endpoint is protected by HMAC authentication in this example
get "/admin" do |env|
  "Hi, %s! You sent a request that was successfully verified with HMAC auth to the /admin endpoint" % env.kemal_authorized_client?
end

Kemal.run

# $ crystal run hmac_server.cr
# [development] Kemal is ready to lead at http://0.0.0.0:3000

kemal_authorized_client?

When a request is made to a protected route, the kemal_authorized_client? method is available on the env object. This method returns the client name that was used to authenticate the request if the request was successfully verified with HMAC authentication. Otherwise, it returns nil.

get "/admin" do |env|
  "Hi, %s! You sent a request that was successfully verified with HMAC auth" % env.kemal_authorized_client?
end

Environment Variable Configuration

The kemal-hmac server middleware can be configured completely through environment variables. For example, if you had the following environment variables set:

export MY_CLIENT_HMAC_SECRET_BLUE="my_secret_1"
export MY_CLIENT_HMAC_SECRET_GREEN="my_secret_2"

Then simply calling hmac_auth(enable_env_lookup: true) in your kemal application will automatically configure the middleware with the client names and secrets from the environment variables. Here is how it works:

  1. When the hmac_auth() method is called with the enable_env_lookup: true argument, the middleware will look for environment variables that start with the client name in all caps and end with HMAC_SECRET_BLUE or HMAC_SECRET_GREEN (these are called the HMAC_KEY_SUFFIX_LIST and can be further configured with environment variables as well). For example, if the client name is my_client, the middleware will look for an environment variable called MY_CLIENT_HMAC_SECRET_BLUE or MY_CLIENT_HMAC_SECRET_GREEN.
  2. If one or more matching secrets are found for the client name, the middleware will be configured with the client name and the secrets.
  3. The client name and secrets will be used to generate the HMAC token for incoming requests.
  4. The first matching secret for the client that successfully generates a valid HMAC token will be used to authenticate the request.

Here is an example:

# file: hmac_server.cr
require "kemal"
require "kemal-hmac"

# Initialize the HMAC middleware with the 'enable_env_lookup: true' param so it can self-hydrate from the environment variables
hmac_auth(enable_env_lookup: true)

# Now all endpoints are protected with HMAC authentication
get "/" do |env|
  "Hi, %s! You sent a request that was successfully verified with HMAC auth using environment variables" % env.kemal_authorized_client?
end

Note: The enable_env_lookup: true argument is optional and defaults to false. If you do not pass this argument, you will need to pass the hmac_secrets argument to the hmac_auth method to configure the middleware. This is the desired way to configure the middleware in production as it is more explicit, less error-prone, and performs significantly better than using environment variables.

Configuration

This section goes into detail on the configuration options available for the kemal-hmac middleware and the client utility.

Global Environment Variables

These environment variables can be set globally for the kemal-hmac middleware and the client utility to change the default behavior.

Environment Variable Default Value Description
HMAC_CLIENT_HEADER hmac-client The name of the header that contains the client name
HMAC_TIMESTAMP_HEADER hmac-timestamp The name of the header that contains the iso8601 timestamp
HMAC_TOKEN_HEADER hmac-token The name of the header that contains the HMAC token
HMAC_TIMESTAMP_SECOND_WINDOW 30 The number of seconds before and after the current time that a timestamp is considered valid - helps with clock drift
HMAC_REJECTED_CODE 401 The status code to return when a request is rejected
HMAC_REJECTED_MESSAGE_PREFIX Unauthorized: The prefix to add to the response body when a request is rejected
HMAC_KEY_SUFFIX_LIST HMAC_SECRET_BLUE,HMAC_SECRET_GREEN A comma-separated list of key suffixes to use for looking up secrets in the environment. Using a blue/green pattern is best for key rotation
HMAC_KEY_DELIMITER _ The delimiter to use for separating the client name from the key suffix in the environment variable name
HMAC_ALGORITHM SHA256 The algorithm to use for generating the HMAC token. See here for all supported algorithms

Direct Middleware Configuration

Passing in configuration options directly to the hmac_auth method is the most explicit way to configure the kemal-hmac middleware and these options take precedence over the environment variables.

# A very verbose example of how to configure the middleware
# file: hmac_server.cr

require "kemal"
require "kemal-hmac"

hmac_auth(
  hmac_secrets: {"my_client" => ["my_secret_blue", "my_secret_green"], "my_other_client" => ["my_other_secret"]},
  hmac_client_header: "hmac-client",
  hmac_timestamp_header: "hmac-timestamp",
  hmac_token_header: "hmac-token",
  timestamp_second_window: 30,
  rejected_code: 401,
  rejected_message_prefix: "Unauthorized:",
  hmac_key_suffix_list: ["HMAC_SECRET_BLUE", "HMAC_SECRET_GREEN"],
  hmac_key_delimiter: "_",
  hmac_algorithm: "SHA256",
  enable_env_lookup: false
)

# ... kemal logic here

Client Usage

The Kemal::Hmac::Client class is designed to facilitate making HTTP requests to a remote server that uses HMAC (Hash-based Message Authentication Code) authentication implemented by this same shard. This class helps generate the necessary HMAC headers required for authenticating requests.

Here are some examples of the relevant headers that are generated by the Kemal::Hmac::Client class:

hmac-client = "client-name-sending-request-to-the-server"
hmac-timestamp = "2024-10-15T05:10:36Z"
hmac-token = "LongHashHere

Initialization

To initialize the Kemal::Hmac::Client class, you need to provide the client name, secret, and optionally, the algorithm used to generate the HMAC token. The default algorithm is SHA256.

require "kemal-hmac"

client = Kemal::Hmac::Client.new("my_client", "my_secret")

You can also specify a different algorithm:

require "kemal-hmac"

client = Kemal::Hmac::Client.new("my_client", "my_secret", "SHA512")

Generating HMAC Headers

The generate_headers method generates the necessary HMAC headers for a given HTTP path. These headers can then be included in your HTTP request to the server.

require "kemal-hmac"

client = Kemal::Hmac::Client.new("my_client", "my_secret")
hmac_headers = client.generate_headers("/api/path")

Example: Making an HTTP Request

Here is a complete example of how to use the Kemal::Hmac::Client class to make an HTTP request to a remote server that uses kemal-hmac for authentication.

# Example using crystal's standard library for making HTTP requests with "http/client"

require "kemal-hmac" # <-- import the kemal-hmac shard
require "http/client" # <-- here we will just use the crystal standard library

# Initialize the HMAC client
client = Kemal::Hmac::Client.new("my_client", "my_secret")

# Generate the HMAC headers for the desired path
path = "/" # <-- can be any request path you like
headers = HTTP::Headers.new
# loop over the generated headers and add them to the HTTP headers
client.generate_headers(path).each do |key, value|
  headers.add(key, value)
end

# Make the HTTP request with the generated headers to the server that uses `kemal-hmac` for authentication
response = HTTP::Client.get("https://example.com#{path}", headers: headers)

# Handle the response
if response.status_code == 200
  puts "Success: #{response.body}"
else
  puts "Error: #{response.status_code}"
end

Example: Making an HTTP Request with the crest shard

Here is a complete example of how to use the Kemal::Hmac::Client class to make an HTTP request to a remote server that uses kemal-hmac for authentication. This example uses the popular crest library for making HTTP requests.

# Example using the popular `crest` library for making HTTP requests

require "kemal-hmac" # <-- import the kemal-hmac shard
require "crest"      # <-- here we will use the popular `crest` library

# Initialize the HMAC client
client = Kemal::Hmac::Client.new("my_client", "my_secret")

path = "/"

# Make the HTTP request with the generated headers to the server that uses `kemal-hmac` for authentication (using the `crest` library)
response = Crest.get(
  "http://localhost:3000#{path}",
  headers: client.generate_headers(path)
)

# Handle the response
if response.status_code == 200
  puts "Success: #{response.body}"
else
  puts "Error: #{response.status_code}"
end

Generating an HMAC secret

To generate an HMAC secret, you can use the following command for convenience:

openssl rand -hex 32

Benchmarks ⚡

TL;DR: The kemal-hmac middleware has a minimal impact on the performance of a kemal application.

Running kemal with the kemal-hmac middleware results in an extra 0.14ms of latency per request on average.

Whereas running Ruby + Sinatra + Puma results in an extra 118ms of latency per request on average.

rps

kemal + kemal-hmac

$ wrk -c 100 -d 40 -H "hmac-client: my_client" -H "hmac-timestamp: 2024-10-15T22:01:46Z" -H "hmac-token: 5b1d59098a2cccfb6e68bfea32dee4c19ae6bbd816d79285fbce3add5f2590d1" http://localhost:3000/applications/123/tokens/123
Running 40s test @ http://localhost:3000/applications/123/tokens/123
  2 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.14ms  426.66us  15.60ms   98.16%
    Req/Sec    44.71k     3.15k   55.55k    67.75%
  3559413 requests in 40.01s, 492.21MB read
Requests/sec:  88965.26
Transfer/sec:     12.30MB

kemal without kemal-hmac

$ wrk -c 100 -d 40 http://localhost:3000/applications/123/tokens/123
Running 40s test @ http://localhost:3000/applications/123/tokens/123
  2 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.00ms  409.37us  10.66ms   97.56%
    Req/Sec    51.30k     4.63k   66.11k    72.62%
  4084149 requests in 40.01s, 564.77MB read
Requests/sec: 102080.95
Transfer/sec:     14.12MB

Ruby with Sinatra + Puma

$ wrk -c 100 -d 40 http://localhost:3000/applications/123/tokens/123
Running 40s test @ http://localhost:3000/applications/123/tokens/123
  2 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   119.23ms  152.42ms 582.52ms   78.86%
    Req/Sec     3.53k     1.00k    5.73k    75.50%
  280940 requests in 40.07s, 46.24MB read
Requests/sec:   7010.87
Transfer/sec:      1.15MB