Linzer Latest Version License: MIT CI Status RubyDoc

Linzer is a Ruby library for HTTP Message Signatures (RFC 9421), allowing you to sign and verify HTTP requests and responses with standard-compliant cryptographic signatures.

Useful for APIs, webhooks, and services that need to verify request authenticity or prevent tampering.

Install

Add the following line to your Gemfile:

gem "linzer"

Or just gem install linzer.

Usage

Quick start

Add the middleware to your Rack application:

# config.ru
use Rack::Auth::Signature,
  except: "/login",
  default_key: {
    material: Base64.strict_decode64(ENV["MYAPP_KEY"]),
    alg: "hmac-sha256"
  }
  # or using a public/private key pair:
  # default_key: { material: IO.read("app/config/pubkey.pem"), alg: "ed25519" }

In this example, the middleware requires a valid HTTP Message Signature for all endpoints except /login.

To learn how to sign requests, see the Signing Requests section.

Using a configuration file

For more complex setups, you can load configuration from a file, e.g.:

# config.ru
use Rack::Auth::Signature,
  except: "/login",
  config_path: "app/configuration/http-signatures.yml"

Rails

In a Rails application, add the middleware in your configuration:

# config/application.rb
config.middleware.use Rack::Auth::Signature,
  except: "/login",
  config_path: "http-signatures.yml"

What this does?

Once enabled, all protected routes will require a valid signature generated by a client using the corresponding private key. Requests without a valid signature will be rejected.

Next steps

Signing HTTP requests and responses

Linzer signs HTTP requests by adding the required Signature and Signature-Input headers based on selected request components (e.g. method, path, headers, etc).

Choose your client:

  • Use the http gem → recommended (simplest)
  • Use Net::HTTP → lower-level control
  • Use Linzer::HTTP → quick experiments / debugging

Using http gem

# first require http signatures feature class ready to be used with http gem:
require "linzer/http/signature_feature"

key = Linzer.generate_ed25519_key # generate a new key pair
# => #<Linzer::Ed25519::Key:0x00000fe13e9bd208
# or load an existing key with:
# key = Linzer.new_ed25519_key(IO.read("key"), "mykeyid")

# then send the request:
url = "https://example.org/api"
response = HTTP.headers(date: Time.now.to_s, foo: "bar")
               .use(http_signature: {key: key} # <--- covered components
               .get(url) # and signature params can also be customized on the client
=> #<HTTP::Response/1.1 200 OK {"Content-Type" => ...
response.body.to_s
=> "protected content..."

Using Net::HTTP (manual control)

key = Linzer.generate_ed25519_key
# => #<Linzer::Ed25519::Key:0x00000fe13e9bd208

uri = URI("https://example.org/api/task")
request = Net::HTTP::Get.new(uri)
request["date"] = Time.now.to_s

Linzer.sign!(
  request,
  key: key,
  components: %w[@method @request-target date],
  label: "sig1",
  params: {
    created: Time.now.to_i
  }
)

request["signature"]
# => "sig1=:Cv1TUCxUpX+5SVa7pH0Xh..."
request["signature-input"]
# => "sig1=(\"@method\" \"@request-target\" \"date\" ..."}

Then send the request:

require "net/http"

http = Net::HTTP.new(uri.host, uri.port)
http.set_debug_output($stderr)
response = http.request(request)
# opening connection to localhost:9292...
# opened
# <- "POST /some_uri HTTP/1.1\r\n
# <- Date: Fri, 23 Feb 2024 17:57:23 GMT\r\n
# <- X-Custom-Header: foo\r\n
# <- Signature: sig1=:Cv1TUCxUpX+5SVa7pH0X...
# <- Signature-Input: sig1=(\"date\" \"x-custom-header\" \"@method\"...
# <- Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\n
# <- Accept: */*\r\n
# <- User-Agent: Ruby\r\n
# <- Connection: close\r\n
# <- Host: localhost:9292
# <- Content-Length: 4\r\n
# <- Content-Type: application/x-www-form-urlencoded\r\n\r\n"
# <- "data"
#
# -> "HTTP/1.1 200 OK\r\n"
# -> "Content-Type: text/html;charset=utf-8\r\n"
# -> "Content-Length: 0\r\n"
# -> "X-Xss-Protection: 1; mode=block\r\n"
# -> "X-Content-Type-Options: nosniff\r\n"
# -> "X-Frame-Options: SAMEORIGIN\r\n"
# -> "Server: WEBrick/1.8.1 (Ruby/3.2.0/2022-12-25)\r\n"
# -> "Date: Thu, 28 Mar 2024 17:19:21 GMT\r\n"
# -> "Connection: close\r\n"
# -> "\r\n"
# reading 0 bytes...
# -> ""
# read 0 bytes
# Conn close
# => #<Net::HTTPOK 200 OK readbody=true>

Using the built-in client

key = Linzer.generate_rsa_pss_sha512_key(4096)
uri = URI("https://example.org/api/task")
headers = {"date" => Time.now.to_s}
response =
  Linzer::HTTP
    .post("http://httpbin.org/headers",
          data: "foo",
          debug: true,
          key: key,
          headers: headers)
...
=> #<Net::HTTPOK 200 OK readbody=true>

(This client is intended for testing and exploration. For production use, prefer a full-featured HTTP client).

Signing HTTP responses (server-side)

You can sign responses using the same API as for requests, e.g.:

put "/baz" do
  ...
  response
  # => #<Sinatra::Response:0x0000000109ac40b8 ...
  response.headers["x-custom-app-header"] = "..."
  Linzer.sign!(response,
    key: my_key,
    components: %w[@status content-type content-digest x-custom-app-header],
    label: "sig1",
    params: {
      created: Time.now.to_i
    }
  )
  response["signature"]
  # => "sig1=:2TPCzD4l48bg6LMcVXdV9u..."
  response["signature-input"]
  # => "sig1=(\"@status\" \"content-type\" \"content-digest\"..."
  ...
end

Verifying HTTP signatures

Linzer verifies incoming requests (or responses) by checking:

  • the signature is valid for the given key
  • the signed components match the actual request
  • any signature parameters (e.g. created, expires) are valid

If verification fails, an exception is raised explaining the reason.

The easiest way to verify incoming requests is via middleware:

use Rack::Auth::Signature, except: "/login"

This automatically:

  • verifies all incoming requests
  • rejects invalid or unsigned requests
  • integrates cleanly with Rack-based frameworks (Rails, Sinatra, etc.)

Manual verification (controller / route level)

If you need more control, you can verify incoming requests manually:

post "/foo" do
  request
  # =>
  # #<Sinatra::Request:0x000000011e5a5d60
  #  @env=
  #   {"GATEWAY_INTERFACE" => "CGI/1.1",
  #   "PATH_INFO" => "/api",
  # ...

  result = Linzer.verify!(request, key: some_client_key) rescue false
  # => true
  ...
  # proceed with trusted request
end

If the signature is missing or invalid, verify! will raise an exception.

head "/bar" do
  begin
    Linzer.verify!(request, key: key)
  rescue Linzer::VerifyError => e
    halt 401, e.message
  end
end

Dynamic key lookup

In many cases, the verification key depends on the keyid parameter provided in the signature.

You can supply a block to resolve keys dynamically:

get "/bar" do
  ...
  result = Linzer.verify!(request) do |keyid|
    retrieve_pubkey_from_db(db_client, keyid)
  end
  # => true
  ...
  # request is now verified
end

This is useful when:

  • you have multiple clients
  • keys are stored in a database or external service
  • keys rotate over time

Verifying responses (client-side)

As expected, signed responses are verified using the same API shown previously:

...
response
# => #<Net::HTTPOK 200 OK readbody=true>
response.body
# => "protected"
pubkey = Linzer.new_ed25519_key(IO.read("pubkey.pem"))
result = Linzer.verify!(response, key: pubkey, no_older_than: 600)
# => true

Using a custom HTTP library

If you’re using an HTTP library or framework other than Rack, http gem or Net::HTTP, you can plug in your own adapter with very little effort.

In most cases, implementing an adapter just means mapping your library’s request/response objects to the small interface Linzer expects, then registering it.

To do this:

  • implement a simple adapter for your request/response objects
  • register it with Linzer::Message

You can use the existing adapters as references:

Example of how to register an adapter before using a custom HTTP library:

Linzer::Message.register_adapter(HTTP::Response, Linzer::Message::Adapter::HTTPGem::Response)
# Linzer::Message.register_adapter(HTTP::Response, MyOwnResponseAdapter) # or use your own adapter
response = HTTP.get("http://www.example.com/api/service/task")
# => #<HTTP::Response/1.1 200 OK ...
response["signature"]
=> "sig1=:oqzDlQmfejfT..."
response["signature-input"]
=> "sig1=(\"@status\" \"foo\");created=1746480237"
result = Linzer.verify!(response, key: my_key)
# => true

Advanced verification

For low-level control over signing and verification, Linzer exposes internal message and signature objects. This allows you to work directly with Linzer::Message and Linzer::Signature, or integrate custom HTTP adapters if needed.

Verifying a signature manually

test_ed25519_key_pub = key.material.public_to_pem
# => "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAK1ZrC4JqC356pRs..."

pubkey = Linzer.new_ed25519_public_key(test_ed25519_key_pub, "some-key-ed25519")
# => #<Linzer::Ed25519::Key:0x00000fe19b9384b0

message = Linzer::Message.new(request)

signature = Linzer::Signature.build(message.headers)

Linzer.verify(pubkey, message, signature)
# => true

Preventing replay attacks

To reduce the risk of replay attacks (e.g. reusing a captured valid request), you can validate the created timestamp in the signature.

Linzer supports this via the no_older_than option:

Linzer.verify(pubkey, message, signature, no_older_than: 500)

no_older_than expects a number of seconds, but you can pass anything that to responds to #to_i, including an ActiveSupport::Duration.

If the signature is older than the allowed window, verification fails with an error.

Supported algorithms

Linzer currently supports the following signature algorithms:

  • RSASSA-PSS (SHA-512)
  • RSASSA-PKCS1-v1_5 (SHA-256)
  • HMAC-SHA256
  • Ed25519
  • ECDSA (P-256 and P-384 curves).

Of the JSON Web Signature (JWS) algorithms mentioned in RFC 9421, only Ed25519 is currently supported. Support for additional algorithms is planned and should be straightforward to add.

The goal is to support as much of the RFC as possible before the 1.0 release.

Documentation

The codebase is well-documented, and the Ruby API documentation is available on Rubydoc.

For deeper details or edge cases, the source code and unit tests are also a good reference.

Ruby version compatibility

linzer is built in Continuous Integration on Ruby 3.0+.

Security

This gem is provided “as is” without any warranties. It has not been independently audited for security vulnerabilities. Users are advised to review the code and assess its suitability for their use case, particularly in production environments.

Despite this, Linzer is already used in production by other projects with security-sensitive requirements, including Mastodon (since version 4.5.0). This does not constitute a security guarantee or endorsement, but it may be useful context when evaluating adoption.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/nomadium/linzer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Linzer project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.