HMAC middleware for Crystal's kemal framework
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.
Simply add the shard to your shard.yml
file:
dependencies:
kemal-hmac:
github: kemalcr/kemal-hmac
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
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
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
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
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:
- When the
hmac_auth()
method is called with theenable_env_lookup: true
argument, the middleware will look for environment variables that start with the client name in all caps and end withHMAC_SECRET_BLUE
orHMAC_SECRET_GREEN
(these are called theHMAC_KEY_SUFFIX_LIST
and can be further configured with environment variables as well). For example, if the client name ismy_client
, the middleware will look for an environment variable calledMY_CLIENT_HMAC_SECRET_BLUE
orMY_CLIENT_HMAC_SECRET_GREEN
. - If one or more matching secrets are found for the client name, the middleware will be configured with the client name and the secrets.
- The client name and secrets will be used to generate the HMAC token for incoming requests.
- 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 tofalse
. If you do not pass this argument, you will need to pass thehmac_secrets
argument to thehmac_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.
This section goes into detail on the configuration options available for the kemal-hmac
middleware and the client utility.
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 |
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
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
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")
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")
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
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
To generate an HMAC secret, you can use the following command for convenience:
openssl rand -hex 32
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.
$ 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
$ 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
$ 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