Sign and verify HTTP messages

Copy Markdown View Source

What you will build: A signed request, an explicit verification policy, and a quorum that requires your sender's signature. Prerequisite: RequestSeal installed; no framework or network is needed. These examples use locally generated keys and RFC 9421 with RFC 9530 body integrity.

1. Generate an example key

RFC 9421 Section 2.2.3 lowercases the authority host and omits default ports (HTTP 80 and HTTPS 443). Covering @authority without @scheme or @target-uri can accept the same signature across http://example.com, http://example.com:80, https://example.com, and https://example.com:443 when other covered values match. Cover @scheme (or @target-uri) whenever scheme matters; the examples below cover @scheme.

{_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)
signer = fn "ed25519", bytes -> RequestSeal.Custody.sign(handle, bytes) end

Reuse the handle in a long-lived owner in your application; see key custody. Trust the public key through configuration, not a message-supplied key ID.

2. Build the request

{:ok, message} =
  RequestSeal.Message.request(
    "POST",
    "https://api.example.com/webhooks",
    [],
    ~s({"event":"created"}),
    digest: ["sha-256"]
  )

The builder preserves header order, repeats, case, nondefault ports, percent escapes, and query bytes. It lowercases scheme and host and omits HTTP port 80 and HTTPS port 443, matching Req and Finch. nil and "" both retain empty content. Its optional digest: adds Content-Digest over those exact bytes. Transport declarations remain unknown and trailers unavailable; it does not establish a connection. Invalid values return RequestSeal.Message.Error through the same validation as Message.new/1. Bodies use Body.new/1's 1 MiB retention limit. Existing digest headers reject when generating a digest; omit digest: to preserve them.

For a response, retain its exact body and optionally link the request:

{:ok, response} =
  RequestSeal.Message.response(200, [{"content-type", "text/plain"}], "accepted",
    request: message,
    digest: ["sha-256"]
  )

The linked request supplies components selected with req; no request is inferred.

3. Choose acceptance rules

{:ok, policy} =
  RequestSeal.Policy.new(%{
    algorithms: ["ed25519"],
    components: ~s[("@method" "@scheme" "@authority" "@path" "content-digest")],
    key_resolver: fn
      %{keyid: "demo-key"} -> {: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
  })

All six choices are required: algorithms, components, key resolver, freshness, content, and replay. extra_components: :reject requires exactly your selected coverage; the default allows additional coverage. A representation digest needs an explicitly selected complete representation; a content body does not substitute. For streams, feed Digest.init/3 and update/2 through EOF, then pass digest_state:.

4. Sign and verify

signing = %{
  label: "sig",
  algorithm: "ed25519",
  components: ~s[("@method" "@scheme" "@authority" "@path" "content-digest")],
  expires_in: 60,
  keyid: "demo-key",
  digest: ["sha-256"]
}

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

Core signing requires label, algorithm, components, and a positive integer expires_in. Defaults are created true, nonce random, alg true (false for JWS algorithm tuples), keyid/tag/digest nil, and field_schemas an empty map. The signer's clock defaults to nonnegative system seconds. Req and Finch accept the same defaulted signing spec; see Req and Finch. The shared pipeline adds or checks Content-Digest and supplies covered Content-Length only over retained bytes. Existing conflicting digests reject. It refuses host and trailer components; cover @authority instead. Responses can cover related-request components.

Spec signing accepts a custody handle or an arity-two signer (algorithm, base). Both use monitored custody workers; signing_timeout: defaults to 5,000 ms and accepts 1–300,000 ms. Optional nonce: supplies caller-owned 32-byte entropy instead of generating it; callers must provide fresh entropy for each signing attempt. It is ignored when the spec omits nonce. Unknown or duplicate options, callback faults, malformed results, and reused labels reject with bounded RequestSeal.Error values.

{:ok, verification} = RequestSeal.verify(signed, policy, label: "sig")
verification.signature.crypto
# => :valid
verification.authorization
# => :not_evaluated

The label selects exactly one signature. Policy still requires all six acceptance choices; construction and signing do not select verification or authorization rules.

5. Require a signer with a quorum

{:ok, quorum} =
  RequestSeal.Quorum.new(%{
    mode: :all,
    unit: :key,
    unexpected: :reject,
    invalid: :reject,
    slots: [%{id: :sender, label: "sig", required: true, policy: policy}]
  })

{:ok, result} = RequestSeal.verify_quorum(signed, quorum, [])
result.count
# => 1
result.satisfied
# => [:sender]

Choose :all, :any, or {:threshold, n} and count distinct keys, caller-bound principals, or roles. Each assigned signature independently meets its complete policy; coverage is never pooled. Counting bindings establish no identity attribution. Required replay and representation digests reject in quorum policies; required content digests need retained bytes.

Build messages by hand

Use Message.new/1 for other target forms, separate trailers, body availability, retention bounds, provenance, or transport declarations. This path preserves every caller-supplied value and runs the same validation as the builders. Compute a content digest explicitly when needed:

{:ok, body} = RequestSeal.Body.new(%{state: :retained, bytes: ~s({"event":"created"})})
{:ok, digest} = RequestSeal.Digest.compute(body, ["sha-256"])
{:ok, digest_wire} = RequestSeal.Digest.serialize(digest)

{:ok, digest_field} =
  RequestSeal.FieldOccurrence.new(%{
    name: "content-digest",
    value: digest_wire,
    section: :headers
  })

Supply the fields and authoritative origin without inferring them from headers:

{:ok, transport} = RequestSeal.TransportFacts.new(%{})

{:ok, message} =
  RequestSeal.Message.new(%{
    kind: :request,
    method: "POST",
    raw_target: "/webhooks",
    target_form: :origin,
    scheme: "https",
    authority: "api.example.com",
    fields: [digest_field],
    trailers: :unavailable,
    body: body,
    transport: transport
  })

For exact signature metadata, the original sign/4 contract remains available. It accepts a serialized Inner List or StructuredFields.Value and appends the signature fields without generating parameters. This example intentionally omits freshness; the earlier freshness policy would reject it:

{:ok, low_level_signed} =
  RequestSeal.sign(
    message,
    %{
      label: "manual",
      algorithm: "ed25519",
      signature_input:
        ~s[("@method" "@scheme" "@authority" "@path" "content-digest");alg="ed25519";keyid="demo-key"]
    },
    signer,
    field_schemas: %{}
  )

The explicit-input path retains its synchronous callback contract; callers bound external work themselves or delegate to custody. Use the spec path for generated parameters and bounded signer execution.

Read the result

RequestSeal.Verification reports the selected label, profile, covered identifiers, authoritative algorithm, signature parameters, digest checks, freshness checks, and replay receipt. Generic results have principal: :unattributed and authorization: :not_evaluated. A cryptographically valid shared-secret signature also supplies no identity attribution. Do not log parameters or key IDs; default inspection hides them.

RequestSeal.Quorum.Verification.signatures maps assigned labels to those results. qualifying records assigned slots and caller counting bindings; count counts distinct configured units. required lists required slots and satisfied includes assigned optional slots. Negotiation checks the verified eligible pool: a valid Accept-Signature challenge can be fulfilled without being assigned or appearing in the result map. Nested bindings require both inner signature dictionary members; equal-parameter bindings require the same non-nil value. See RequestSeal.AcceptSignature and RequestSeal.Quorum for negotiation and assignment limits.

Errors and next steps

ReasonMeaning and action
:invalid_policySupply all six valid policy choices.
:unknown_keyConfigure a trusted resolver entry for this key ID.
:missing_required_componentRequire the sender to cover the needed identifier and parameters.
:invalid_signatureReject: signed bytes or the selected key differ.
:digest_mismatchReject: body bytes differ from the signed digest.
:expiredReject: the expiration is exclusive; check your clocks and validity window.
:quorum_not_metThe required slots or distinct counting units are not satisfied.

See the public contract for the separate representation, construction, cryptography, profile, trust, integrity, and replay layers. Named application profiles use RequestSeal.Profile in extension packages; this extension surface is unstable, so pin RequestSeal exactly. Extensions must enforce their source-specific rules and trusted attribution; a profile stamp alone is no proof.

Module docs: RequestSeal, RequestSeal.Message, RequestSeal.SignatureBase, RequestSeal.Policy, RequestSeal.Verification, RequestSeal.Quorum, RequestSeal.Quorum.Verification, RequestSeal.Digest, RequestSeal.Profile, RequestSeal.Error. Testing distinguishes published vectors, local round trips, independent verifiers, and deployed-peer acceptance. Proposed contracts in design pages do not establish implemented capability.