Ankusa.Verifier behaviour (ankusa v0.7.0)

Copy Markdown View Source

Signature and timestamp verification. HMAC is microseconds, so it runs inline on the hot path, before the ack.

Adapters implement verify/2 over the raw envelope (the exact bytes matter). opts carry the per-source secret and any tolerance window (Ankusa.Verifier.check_timestamp/2).

The verifiers in core:

The edge puts request context some verifiers need into opts, for those verifiers only: :peer_cert (the DER client certificate, or nil) for ClientCert and :query (the raw query string) for a Token in the query.

Failure vocabulary

Adapters return {:error, reason}, which the source's on_verify_failure policy turns into :reject, :quarantine, or :accept_flag. The reasons are deliberately shared so telemetry and dashboards can count them without knowing which provider produced them:

  • :missing_signature — a required header, credential or certificate is absent
  • :malformed_signature — present, but not parseable
  • :no_match — nothing matched the expected digest, key, token or pin
  • :timestamp_out_of_tolerance — outside the replay window
  • :bad_secret — the configured secret can't be used as a key (Verifier.Hmac, Verifier.Token)
  • :bad_key — a configured public key can't be used (Verifier.Asymmetric)
  • :bad_pin — a certificate source pins nothing usable (Verifier.ClientCert)
  • :bad_scheme — an unknown or missing scheme name (Verifier.Hmac, Verifier.Asymmetric), or an unknown :location (Verifier.Token)
  • :bad_tolerance — :tolerance is not an integer from 0 to max_tolerance_seconds/0 (Verifier.Hmac, Verifier.Asymmetric)

Summary

Callbacks

Human name of the scheme for telemetry; nil when the verifier has no named scheme. Optional — verifiers that only implement verify/2 are attributed by their module name instead.

Check opts without a request: :ok, or the {:error, reason} verify/2 would return for every hook. Optional — validate_config!/1 runs it, at boot, for every source declared in config whose verifier implements it, and the image's loader runs it for every source an admin API write submits.

Verify an envelope. Return :ok to accept, or {:error, reason} to trigger the source's on_verify_failure policy (:reject | :quarantine | :accept_flag).

Functions

Whether source may run under config: {:error, message} when it is shared (tenant_id "default"), has no verifier (Ankusa.Verifier.None), does not set trust_url_tenant: true, and the edge takes the tenant from the request (any Ankusa.RouteResolver but Ankusa.RouteResolver.Path). Such a source would file hooks under whatever tenant the request names, and spend that tenant's rate limit, with nothing to say the sender may. The verdict depends on the config alone, never on the node's roles, so every node reading one config agrees.

Check a Unix-seconds timestamp from a signature header against a tolerance window.

Whether opts[:tolerance] is a usable replay window: absent (the default window), or an integer from 0 to max_tolerance_seconds/0. A window wider than an hour is not a window, it is the absence of one. Shared by every verifier that checks a timestamp.

The widest replay window, in seconds, a verifier accepts: an hour. Beyond that a signed hook stays replayable long enough that the window protects nothing.

The scheme name a verification is attributed to: mod.scheme_name(opts) when the verifier implements it, else the module name.

Constant-time compare of two binaries of equal length.

Refuse to boot with a source declared in config whose verifier would fail every hook, which on_verify_failure then quarantines, flags or rejects — silently, at runtime. Every verifier that implements validate_opts/1 is asked: an Ankusa.Verifier.Hmac or Ankusa.Verifier.Token secret that is missing, empty or undecodable, an Ankusa.Verifier.Asymmetric public key that is not a key, an Ankusa.Verifier.ClientCert that pins nothing, an unknown scheme, or a :tolerance outside 0..max_tolerance_seconds/0. A ClientCert source also needs listener.tls with verify: :verify_peer: no other listener ever receives a client certificate. Sources a writable store holds are checked when they are written. Raises ArgumentError naming the source. A source that verifies with SHA-1 (Ankusa.Verifier.Hmac.weak_hash?/1) boots, with one warning.

Refuse to boot with a source declared in config that check_shared/2 refuses. Sources a writable store holds are checked when they are written (and a stored one written before the check existed is named by warn_stored_shared/2). Raises ArgumentError naming the source.

Log one warning per stored (API-managed) source that check_shared/2 refuses. Such a source was written before writes were checked: it keeps serving as it did, and only a write that still lacks trust_url_tenant: true is refused.

Whether the verifier {mod, opts} checks signatures with a weak hash (SHA-1). Only Ankusa.Verifier.Hmac can; see Ankusa.Verifier.Hmac.weak_hash?/1.

Callbacks

scheme_name(opts)

(optional)
@callback scheme_name(opts :: keyword()) :: String.t() | nil

Human name of the scheme for telemetry; nil when the verifier has no named scheme. Optional — verifiers that only implement verify/2 are attributed by their module name instead.

validate_opts(opts)

(optional)
@callback validate_opts(opts :: keyword()) :: :ok | {:error, atom()}

Check opts without a request: :ok, or the {:error, reason} verify/2 would return for every hook. Optional — validate_config!/1 runs it, at boot, for every source declared in config whose verifier implements it, and the image's loader runs it for every source an admin API write submits.

verify(t, opts)

@callback verify(Ankusa.Envelope.t(), opts :: keyword()) :: :ok | {:error, term()}

Verify an envelope. Return :ok to accept, or {:error, reason} to trigger the source's on_verify_failure policy (:reject | :quarantine | :accept_flag).

Functions

check_shared(config, source)

@spec check_shared(Ankusa.Config.t(), Ankusa.Source.t()) :: :ok | {:error, String.t()}

Whether source may run under config: {:error, message} when it is shared (tenant_id "default"), has no verifier (Ankusa.Verifier.None), does not set trust_url_tenant: true, and the edge takes the tenant from the request (any Ankusa.RouteResolver but Ankusa.RouteResolver.Path). Such a source would file hooks under whatever tenant the request names, and spend that tenant's rate limit, with nothing to say the sender may. The verdict depends on the config alone, never on the node's roles, so every node reading one config agrees.

check_timestamp(ts, opts)

@spec check_timestamp(String.t(), keyword()) :: :ok | {:error, atom()}

Check a Unix-seconds timestamp from a signature header against a tolerance window.

Every verifier that carries a signed timestamp needs this, so the window semantics are defined once instead of per adapter. opts may set :tolerance in seconds (default 300, i.e. ±5 minutes), and :now (Unix seconds) to judge the window against a fixed instant instead of the clock: Ankusa.Dispatch.Replayer sets it to the hook's receive time when it re-verifies a quarantined hook, so a release hours later holds the hook to the window it arrived in, not one it can no longer meet.

check_tolerance(opts)

@spec check_tolerance(keyword()) :: :ok | {:error, :bad_tolerance}

Whether opts[:tolerance] is a usable replay window: absent (the default window), or an integer from 0 to max_tolerance_seconds/0. A window wider than an hour is not a window, it is the absence of one. Shared by every verifier that checks a timestamp.

max_tolerance_seconds()

@spec max_tolerance_seconds() :: pos_integer()

The widest replay window, in seconds, a verifier accepts: an hour. Beyond that a signed hook stays replayable long enough that the window protects nothing.

scheme_name(mod, opts)

@spec scheme_name(module(), keyword()) :: String.t() | nil

The scheme name a verification is attributed to: mod.scheme_name(opts) when the verifier implements it, else the module name.

secure_compare(a, b)

@spec secure_compare(binary(), binary()) :: boolean()

Constant-time compare of two binaries of equal length.

validate_config!(config)

@spec validate_config!(Ankusa.Config.t()) :: :ok

Refuse to boot with a source declared in config whose verifier would fail every hook, which on_verify_failure then quarantines, flags or rejects — silently, at runtime. Every verifier that implements validate_opts/1 is asked: an Ankusa.Verifier.Hmac or Ankusa.Verifier.Token secret that is missing, empty or undecodable, an Ankusa.Verifier.Asymmetric public key that is not a key, an Ankusa.Verifier.ClientCert that pins nothing, an unknown scheme, or a :tolerance outside 0..max_tolerance_seconds/0. A ClientCert source also needs listener.tls with verify: :verify_peer: no other listener ever receives a client certificate. Sources a writable store holds are checked when they are written. Raises ArgumentError naming the source. A source that verifies with SHA-1 (Ankusa.Verifier.Hmac.weak_hash?/1) boots, with one warning.

validate_shared!(config)

@spec validate_shared!(Ankusa.Config.t()) :: :ok

Refuse to boot with a source declared in config that check_shared/2 refuses. Sources a writable store holds are checked when they are written (and a stored one written before the check existed is named by warn_stored_shared/2). Raises ArgumentError naming the source.

warn_stored_shared(config, sources)

@spec warn_stored_shared(Ankusa.Config.t(), [Ankusa.Source.t()]) :: :ok

Log one warning per stored (API-managed) source that check_shared/2 refuses. Such a source was written before writes were checked: it keeps serving as it did, and only a write that still lacks trust_url_tenant: true is refused.

weak_hash?(arg1, opts)

@spec weak_hash?(module(), keyword()) :: boolean()

Whether the verifier {mod, opts} checks signatures with a weak hash (SHA-1). Only Ankusa.Verifier.Hmac can; see Ankusa.Verifier.Hmac.weak_hash?/1.