AshHooks.Signing (AshHooks v2.0.2)

Copy Markdown View Source

Standard Webhooks signing and verification for v1 (HMAC-SHA256) and v1a (ed25519), over msg_id.timestamp.payload.

Pass the exact payload bytes. headers/4 assembles the webhook ID, timestamp, and signatures; verify/4 validates those headers against the received bytes and enforces a timestamp tolerance by default.

Secret formats:

  • symmetric — whsec_ + base64 of 24–64 random bytes (prefix optional, matching the references). sign/4 enforces the 24–64 byte band (the emitter's length bound); verification accepts nonempty decoded symmetric secrets, including shorter keys, and rejects malformed base64.
  • asymmetric — whsk_ + base64 of the 32-byte ed25519 seed (signing), whpk_ + base64 of the 32-byte public key (verification). Prefixes are required — they are what verify/4 dispatches on.

headers_for_mode/5 emits :standard, :dual (Standard Webhooks and legacy), or :legacy headers for the delivery runtime. Rotation options include the previous key's signature alongside the current signature.

Summary

Functions

A fresh msg_-prefixed webhook id (URL-safe, never contains .).

A fresh whsec_-prefixed secret (24–64 decoded bytes, spec band).

A fresh ed25519 keypair as {whsk, whpk}.

The three Standard Webhooks headers. :whsec and/or :whsk select the signature schemes; :previous_whsec / :previous_whsk append the rotated-out key's signature (space-delimited, per the spec's zero-downtime rotation — symmetric and asymmetric alike).

The full header set for a subscription's signing_mode (ADR-0002)

Signs the canonical string with HMAC-SHA256 under the decoded symmetric secret: "v1," <> base64(mac).

Signs the canonical string with ed25519 under the seed decoded from a whsk_ secret: "v1a," <> base64(signature).

Verifies a webhook's webhook-id / webhook-timestamp / webhook-signature headers against the raw payload.

Types

verify_reason()

@type verify_reason() ::
  :missing_header
  | :invalid_timestamp
  | :timestamp_out_of_tolerance
  | :invalid_signature
  | :invalid_secret

Functions

generate_msg_id()

@spec generate_msg_id() :: String.t()

A fresh msg_-prefixed webhook id (URL-safe, never contains .).

generate_secret(bytes \\ 32)

@spec generate_secret(pos_integer()) :: String.t()

A fresh whsec_-prefixed secret (24–64 decoded bytes, spec band).

generate_signing_keypair()

@spec generate_signing_keypair() :: {String.t(), String.t()}

A fresh ed25519 keypair as {whsk, whpk}.

headers(msg_id, unix_ts, payload, opts \\ [])

@spec headers(String.t(), integer(), binary(), keyword()) :: %{
  required(String.t()) => String.t()
}

The three Standard Webhooks headers. :whsec and/or :whsk select the signature schemes; :previous_whsec / :previous_whsk append the rotated-out key's signature (space-delimited, per the spec's zero-downtime rotation — symmetric and asymmetric alike).

headers_for_mode(mode, msg_id, unix_ts, body, opts)

@spec headers_for_mode(
  :legacy | :dual | :standard,
  String.t(),
  integer(),
  binary(),
  keyword()
) :: %{
  required(String.t()) => String.t()
}

The full header set for a subscription's signing_mode (ADR-0002):

  • :standard — the three SW headers.
  • :dual — SW headers PLUS the byte-identical legacy envelope (x-webhook-signature, via AshHooks.Legacy); requires :legacy_secret (dual MEANS both envelopes).
  • :legacy — the legacy envelope only.

Legacy options: :legacy_secret, :legacy_previous_secret. SW options: :whsec, :whsk, :previous_whsec.

sign(msg_id, unix_ts, payload, whsec)

@spec sign(String.t(), integer(), binary(), String.t()) :: String.t()

Signs the canonical string with HMAC-SHA256 under the decoded symmetric secret: "v1," <> base64(mac).

Raises ArgumentError on invalid inputs — the send path fails loud on config or programming errors.

sign_ed25519(msg_id, unix_ts, payload, whsk)

@spec sign_ed25519(String.t(), integer(), binary(), String.t()) :: String.t()

Signs the canonical string with ed25519 under the seed decoded from a whsk_ secret: "v1a," <> base64(signature).

verify(payload, headers, secret, opts \\ [])

@spec verify(binary(), map(), String.t(), keyword()) ::
  {:ok, %{id: String.t(), timestamp: integer()}} | {:error, verify_reason()}

Verifies a webhook's webhook-id / webhook-timestamp / webhook-signature headers against the raw payload.

Dispatches on the secret's prefix: whpk_ verifies v1a (ed25519) entries; anything else verifies v1 (HMAC) entries and skips all other version identifiers.

headers is a map with lowercased header names as string keys and single binary values ("webhook-id", "webhook-timestamp", "webhook-signature") — the shape Plug and the inbound seam supply after normalization. Mixed-case keys or multi-value header lists are {:error, :missing_header} (fail closed); callers normalize before this boundary.

Options:

  • :now — integer unix seconds (default System.system_time(:second)).
  • :tolerance — allowed |now - timestamp| in seconds (default 300).
  • :ignore_timestamp — skip the tolerance check (dead-letter re-drives, offline verification); the timestamp must still parse.

Returns {:ok, %{id: id, timestamp: ts}} or {:error, reason}. Never raises on hostile input.