<!-- Status: current · Kind: guide · Updated: 2026-10-16 · Governed by: public architecture · Review when: the public API or referenced standard changes -->

# Sign and verify HTTP messages

**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](https://www.rfc-editor.org/rfc/rfc9421.html) with [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) body integrity.

## 1. Generate an example key

RFC 9421 [Section 2.2.3](https://www.rfc-editor.org/rfc/rfc9421.html#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`.

```elixir
{_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](key-custody.md). Trust the public key through configuration, not a message-supplied key ID.

## 2. Build the request

```elixir
{: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:

```elixir
{: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

```elixir
{: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. `algorithms:` takes tokens from `RequestSeal.Crypto.algorithms/0` (`"ed25519"`, `"ecdsa-p256-sha256"`, `"ecdsa-p384-sha384"`, `"rsa-pss-sha512"`, `"rsa-v1_5-sha256"`, `"hmac-sha256"`) or an explicit `{:jws, name}` selection. `RequestSeal.Policy.new/1` documents every option. `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

```elixir
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.
Pass `clock:` (an arity-zero function returning integer Unix seconds from 0
through 253,402,300,799) to replace the default system clock. Req and Finch accept
the same defaulted signing spec; see [Req and Finch](req-and-finch.md).
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)`.
A handle whose algorithm differs from the spec's `algorithm` returns
`:signer_algorithm_mismatch` at `:input` (from 0.5.0; 0.4.1 returns `:invalid_options`). Both forms 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 sets `nonce: nil`; omitting `nonce` keeps the
default `:random`, which uses this option. Unknown or duplicate options,
callback faults, malformed results, and reused labels reject with bounded
`RequestSeal.Error` values.

```elixir
{: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

```elixir
{: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:

```elixir
{: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:

```elixir
{: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:

```elixir
{: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 function callback contract. It
also accepts a `RequestSeal.KeyHandle` directly, with `signing_timeout` from
1–300,000 ms (default 5,000). See [key custody](key-custody.md) for algorithm
binding, bounded errors, and supervised ownership. Use the spec path to generate
parameters.

## 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. `nonqualifying` lists the bounded label, slot, reason, and layer of each ignored label or failed slot attempt; read it to diagnose `:quorum_not_met`. `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. From 0.5.0, `verify_quorum/3` accepts a `:deadline` — an absolute `System.monotonic_time(:millisecond)` value bounding the assignment search's wall-clock time; on expiry a policy-satisfying best-so-far assignment still succeeds, otherwise the retryable `:deadline` reason returns, which also surfaces on the `[:request_seal, :verify_quorum, :stop]` telemetry event.

## Errors and next steps

| Reason | Meaning and action |
| --- | --- |
| `:invalid_policy` | Supply all six valid policy choices. |
| `:unknown_key` | Configure a trusted resolver entry for this key ID. |
| `:missing_required_component` | Require the sender to cover the needed identifier and parameters. |
| `:invalid_signature` | Reject: signed bytes or the selected key differ. |
| `:digest_mismatch` | Reject: body bytes differ from the signed digest. |
| `:expired` | Reject: the expiration is exclusive; check your clocks and validity window. |
| `:too_old` | Reject: created plus `max_age` has passed; check clocks or `max_age`. |
| `:created_in_future` | Reject: the sender's clock is ahead by more than `skew`. |
| `:missing_created`, `:missing_expires` | The sender must include the parameter your freshness policy requires. |
| `:unknown_label` | No `Signature-Input`/`Signature` member has this label. |
| `:digest_not_covered` | The sender must cover the exact digest field your content policy selects. |
| `:algorithm_not_permitted` | The algorithm is outside `algorithms:`; add it only if you trust it. |
| `:unexpected_component` | Coverage exceeds the policy under `extra_components: :reject`. |
| `:label_in_use` | Signing would reuse an existing label; choose another. |
| `:invalid_options` | Fix the signing specification or options. |
| `:signer_failed`, `:signing_failed` | The signer function or the key handle failed; `:signing_failed` carries a bounded custody error in `source`, for example `:key_not_found`. |
| `:quorum_not_met` | The required slots or distinct counting units are not satisfied. |

See [the public contract](../design/architecture.md) 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`. `RequestSeal.Error` lists every reason by layer, and the [errors guide](errors.md) shows how to diagnose them. [Testing](testing.md) distinguishes published vectors, local round trips, independent verifiers, and deployed-peer acceptance. Proposed contracts in design pages do not establish implemented capability.
