Powered by AppSignal & Oban Pro

Sign and verify HTTP messages

livebooks/sign-and-verify.livemd

Sign and verify HTTP messages

This notebook walks the core RequestSeal journey with locally generated keys: sign a request with a covered body digest, verify it under an explicit policy, sign a response bound to that request, accept a request only once, require two signers, and read the errors each layer returns. It uses only the core library: no framework, server, or network access beyond the Hex install. Every cell asserts its result, so a changed outcome stops the notebook.

Mix.install([{:request_seal, "~> 0.4.1"}])

Hold a key behind a handle

RequestSeal.Custody.Local.new/3 keeps the private seed in its own process and returns an opaque handle. Only the public key leaves it.

{_public_bytes, seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
{:ok, key} = RequestSeal.Custody.public_key(handle)
{:ok, thumbprint} = RequestSeal.PublicKey.thumbprint(key)

43 = byte_size(thumbprint)
IO.puts("Handle ready; public key thumbprint is #{byte_size(thumbprint)} characters")

Sign a request and its body

The request reports a finished build. digest: ["sha-256"] adds a Content-Digest header over the exact body bytes, and the signing specification covers it, so the signature protects the body too.

build_event = ~s({"build":"4821","status":"passed"})

{:ok, request} =
  RequestSeal.Message.request(
    "POST",
    "https://ci.example.com/hooks/builds",
    [{"content-type", "application/json"}],
    build_event,
    digest: ["sha-256"]
  )

components = ~s[("@method" "@scheme" "@authority" "@path" "content-digest")]

signing = %{
  label: "sig",
  algorithm: "ed25519",
  components: components,
  expires_in: 60,
  keyid: "ci-sender"
}

{:ok, signed} = RequestSeal.sign(request, signing, handle)

["content-type", "content-digest", "Signature-Input", "Signature"] =
  Enum.map(signed.fields, & &1.name)

[{_, signature_input}] =
  for field <- signed.fields, field.name == "Signature-Input", do: {field.name, field.value}

IO.puts(signature_input)

Signing appends Signature-Input and Signature after your headers. The generated Signature-Input carries created, expires, a random nonce, alg, and keyid parameters.

Verify under an explicit policy

A policy names all six acceptance choices: algorithms, required components, key resolver, freshness, body integrity, and replay. The resolver trusts the key through configuration; the sender's key ID only selects among keys you already trust.

policy_fields = %{
  algorithms: ["ed25519"],
  components: components,
  key_resolver: fn
    %{keyid: "ci-sender"} -> {:ok, %{algorithm: "ed25519", key: key}}
    _ -> :error
  end,
  freshness: %{
    clock: fn -> System.system_time(:second) end,
    max_age: 60,
    skew: 5,
    require_expires: true
  },
  content: %{kind: :content, algorithms: ["sha-256"], section: :headers},
  replay: :not_required
}

{:ok, policy} = RequestSeal.Policy.new(policy_fields)
{:ok, verification} = RequestSeal.verify(signed, policy, label: "sig")

:valid = verification.signature.crypto
%{kind: :content, checked: ["sha-256"]} = verification.content
:unattributed = verification.principal
:not_evaluated = verification.authorization
IO.puts("Request verified with body integrity")

The result states facts about the signature. authorization: :not_evaluated is a reminder that your application decides what the caller may do.

Reject a changed body

The sender's headers and signature stay the same; only the body differs.

{:ok, altered} =
  RequestSeal.Message.request(
    "POST",
    "https://ci.example.com/hooks/builds",
    Enum.map(signed.fields, &{&1.name, &1.value}),
    ~s({"build":"4821","status":"failed"})
  )

{:error, error} = RequestSeal.verify(altered, policy, label: "sig")
:digest_mismatch = error.reason
:content = error.layer
false = error.retryable
16 = byte_size(error.correlation)
IO.puts("Changed body rejected: #{error.reason} at the #{error.layer} layer")

Errors carry a closed reason, the layer that rejected the message, retryable: false, and a random correlation token to log. They never carry request bytes or key IDs. RequestSeal.Error lists every reason by layer.

Sign a response bound to its request

A response can cover components of the request it answers with the req parameter. A verifier then accepts the response only for that request.

{:ok, response} =
  RequestSeal.Message.response(
    202,
    [{"content-type", "application/json"}],
    ~s({"queued":true}),
    request: signed,
    digest: ["sha-256"]
  )

response_components = ~s[("@status" "content-digest" "@method";req "@path";req)]

{:ok, signed_response} =
  RequestSeal.sign(
    response,
    %{
      label: "reply",
      algorithm: "ed25519",
      components: response_components,
      expires_in: 60,
      keyid: "ci-sender"
    },
    handle
  )

{:ok, response_policy} =
  RequestSeal.Policy.new(%{policy_fields | components: response_components})

{:ok, response_verification} =
  RequestSeal.verify(signed_response, response_policy, label: "reply")

:valid = response_verification.signature.crypto

# The same response presented for a different request path fails.
{:ok, other_request} =
  RequestSeal.Message.request("POST", "https://ci.example.com/hooks/deploys", [], nil)

{:error, %{reason: :invalid_signature}} =
  RequestSeal.verify(%{signed_response | related_request: other_request}, response_policy,
    label: "reply"
  )

IO.puts("Response verified for its own request and rejected for another")

Accept a request only once

A replay policy claims each request's authenticated nonce in a store. RequestSeal.Replay.ETS is a local, in-memory store for one node; the commitment function decides what a claim identifies within your namespace.

{:ok, replay_store} = RequestSeal.Replay.ETS.start_link(max_entries: 1_000)

replay = %{
  identifier: :nonce,
  namespace: "ci-hooks",
  commitment: fn facts -> {:ok, facts.identifier} end,
  store: RequestSeal.Replay.ETS.store(replay_store),
  timeout: 5_000
}

{:ok, once_policy} = RequestSeal.Policy.new(%{policy_fields | replay: replay})
{:ok, first} = RequestSeal.verify(signed, once_policy, label: "sig")
RequestSeal.Replay.Receipt = first.replay.__struct__

{:error, %{reason: :replayed, layer: :replay}} =
  RequestSeal.verify(signed, once_policy, label: "sig")

GenServer.stop(replay_store)
IO.puts("First delivery accepted; the repeat rejected as replayed")

ETS loses its claims when the owner stops. For several nodes or restarts, use durable storage; see replay protection.

Require two signers

A quorum assigns labels to slots and counts distinct keys. Here a release request must carry signatures from both the build system and a reviewer.

{_, reviewer_seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, reviewer} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, reviewer_seed})
{:ok, reviewer_key} = RequestSeal.Custody.public_key(reviewer)

release_components = ~s[("@method" "@scheme" "@authority" "@path")]

{:ok, release} =
  RequestSeal.Message.request("POST", "https://ci.example.com/releases/4821", [], nil)

slot_policy = fn keyid, slot_key ->
  {:ok, slot} =
    RequestSeal.Policy.new(%{
      policy_fields
      | components: release_components,
        content: :not_required,
        key_resolver: fn
          %{keyid: ^keyid} -> {:ok, %{algorithm: "ed25519", key: slot_key}}
          _ -> :error
        end
    })

  slot
end

{:ok, quorum} =
  RequestSeal.Quorum.new(%{
    mode: :all,
    unit: :key,
    unexpected: :reject,
    invalid: :reject,
    slots: [
      %{id: :build, label: "build", required: true, policy: slot_policy.("ci-sender", key)},
      %{id: :review, label: "review", required: true, policy: slot_policy.("reviewer", reviewer_key)}
    ]
  })

release_spec = fn label, keyid ->
  %{label: label, algorithm: "ed25519", components: release_components, expires_in: 60, keyid: keyid}
end

{:ok, build_signed} = RequestSeal.sign(release, release_spec.("build", "ci-sender"), handle)
{:ok, both_signed} = RequestSeal.sign(build_signed, release_spec.("review", "reviewer"), reviewer)

{:ok, result} = RequestSeal.verify_quorum(both_signed, quorum, [])
2 = result.count
[:build, :review] = Enum.sort(result.satisfied)

{:error, %{reason: :quorum_not_met, layer: :quorum}} =
  RequestSeal.verify_quorum(build_signed, quorum, [])

IO.puts("Both signers present: accepted; reviewer missing: rejected")

Each slot's signature must satisfy that slot's complete policy on its own; coverage is never pooled across signatures.

Release the keys

:ok = RequestSeal.Custody.Local.release(handle)
:ok = RequestSeal.Custody.Local.release(reviewer)

{:error, %{reason: :key_not_found}} = RequestSeal.Custody.sign(handle, "after release")
IO.puts("Handles released; signing with a released handle fails")

In an application, a supervised owner holds keys for the life of the service; see key custody. The signing and verifying guide covers hand-built messages, exact signature metadata, and every policy option.