Module: Hook0

Defined in:
lib/hook0.rb,
lib/hook0/client.rb,
lib/hook0/errors.rb,
lib/hook0/runtime.rb,
lib/hook0/version.rb,
lib/hook0/signature.rb,
lib/hook0/transport.rb,
lib/hook0/generated/api.rb,
lib/hook0/generated/errors.rb,
lib/hook0/generated/models.rb

Overview

Verifying that a webhook came from Hook0, and that nothing in it changed on the way.

Defined Under Namespace

Modules: Generated, Runtime Classes: Client, ClientError, Event, EventType, Options, RetryPolicy, Signature, Transport, TransportError

Constant Summary collapse

VERSION =

What this gem is released as, which the gemspec reads rather than repeats.

"2.0.2"

Class Method Summary collapse

Class Method Details

.generate_event_idString

A UUIDv7, the shape of identifier Hook0 mints when it is the one choosing.

Its leading 48 bits are the current time in milliseconds, so identifiers generated in sequence are ordered, which is what keeps the index they end up in from being written all over. Written here rather than taken from the standard library, which has had SecureRandom.uuid_v7 only since Ruby 3.3 and this gem supports older.

Returns:

  • (String)


346
347
348
349
350
351
352
353
354
355
356
# File 'lib/hook0/client.rb', line 346

def self.generate_event_id
  drawn = SecureRandom.random_bytes(16).unpack("C*")

  milliseconds = (Time.now.to_f * 1000).floor
  6.times { |index| drawn[index] = (milliseconds >> (8 * (5 - index))) & 0xFF }
  drawn[6] = (drawn[6] & 0x0F) | 0x70
  drawn[8] = (drawn[8] & 0x3F) | 0x80

  written = drawn.pack("C*").unpack1("H*")
  [written[0, 8], written[8, 4], written[12, 4], written[16, 4], written[20, 12]].join("-")
end

.verify_webhook_signature(signature, payload, headers, subscription_secret, tolerance) ⇒ void

This method returns an undefined value.

Verifies a webhook against the current moment.

See verify_webhook_signature_with_current_time for what each argument is.

Raises:



269
270
271
272
273
# File 'lib/hook0/signature.rb', line 269

def self.verify_webhook_signature(signature, payload, headers, subscription_secret, tolerance)
  verify_webhook_signature_with_current_time(
    signature, payload, headers, subscription_secret, tolerance, Time.now
  )
end

.verify_webhook_signature_with_current_time(signature, payload, headers, subscription_secret, tolerance, current_time) ⇒ void

This method returns an undefined value.

Verifies a webhook against a moment the caller names.

The clock window is bilateral. A moment too far in the future is refused exactly like one too far in the past, so the window a given delivery is accepted in stays the width the caller asked for, whichever way a clock drifted.

Parameters:

  • signature (String)

    the value of the X-Hook0-Signature header

  • payload (String)

    the raw body of the webhook request

  • headers (Hash, Array<Array>)

    the headers of the webhook request

  • subscription_secret (String)

    the signing secret of the subscription it was delivered for

  • tolerance (Numeric)

    how far, in seconds and in either direction, the moment the signature names may sit from current_time. Five minutes is a reasonable trade-off between tolerating clock drift and bounding how long a captured delivery can be replayed.

  • current_time (Time)

    what to hold the signature's moment against

Raises:

  • (ClientError)

    for every reason a webhook may be refused



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/hook0/signature.rb', line 238

def self.verify_webhook_signature_with_current_time(
  signature, payload, headers, subscription_secret, tolerance, current_time
)
  parsed = Signature.parse(signature)

  delivered = delivered_headers(headers)
  covered_values = parsed.covered_headers.map do |name|
    raise ClientError, "the `#{name}` header the signature covers was not delivered" unless delivered.key?(name)

    delivered[name]
  end

  unless parsed.matches?(payload.to_s, covered_values, subscription_secret)
    raise ClientError, "the signature does not match what the subscription secret produces"
  end

  drift = current_time.to_f - parsed.timestamp
  if drift.abs > tolerance
    raise ClientError,
          "the signature was made #{format("%.0f", drift)} seconds from now, outside the #{tolerance} accepted"
  end

  nil
end