AshHooks.Signing (AshHooks v1.0.0)

Copy Markdown View Source

Standard Webhooks canon signing and verification — v1 (HMAC-SHA256) and v1a (ed25519) from day one (ADR-0006), over the canonical string msg_id.timestamp.payload.

Conformance anchors: the official Go reference library's own test vector (TestWebhookSign) is reproduced byte-for-byte by sign/4; verification semantics (space-delimited multi-signatures, unknown version identifiers skipped, timestamp tolerance, whsec_ prefix optional) match the official reference libraries read first-hand. ed25519 usage is anchored to RFC 8032 §7.1 known-answers.

Secret formats:

  • symmetric — whsec_ + base64 of 24–64 random bytes (prefix optional, matching the references). sign/4 enforces the 24–64 byte band (the spec's emitter guidance); verify/4 rejects only EMPTY secrets so a vendor with a shorter secret still interoperates.
  • 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 is the single seam the delivery runtime uses to emit :standard, :dual (SW + byte-identical legacy envelope, ADR-0002), or :legacy headers.

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 (both day one); :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 (both day one); :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, matching the official references.

headers is a map with EXACTLY the 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.