Class: Hook0::Signature

Inherits:
Object
  • Object
show all
Defined in:
lib/hook0/signature.rb

Overview

A signature header, read into the pieces a verification needs.

A signature names the moment it was signed and one or two message authentication codes over the body. The v1 scheme also covers a list of request headers, so a receiver can tell apart two deliveries that carry the same body but not the same context; v0 covers the body alone and is what an older sender still produces. When both are offered, v1 is the one verified: accepting the weaker of two schemes on the strength of the sender offering it is how a downgrade works.

Two things are refused before any code is computed. A header the signature says it covers but the request did not carry is refused outright, because signing over an absent value would let a sender drop a header and keep the signature valid. And a signature whose codes are not whole hexadecimal is refused rather than decoded as far as it goes: a decoder that stops at the first bad character compares a prefix, and a prefix of the right code is not the right code.

Constant Summary collapse

MAX_SIGNATURE_BYTES =

Longest signature header read. The header is written by whoever reached the endpoint, so its size is bounded before any of it is split, decoded or compared.

8 * 1024
MAX_SIGNATURE_PARTS =

Most key=value parts one signature header is split into.

32
MAX_COVERED_HEADERS =

Most header names one signature covers.

64
MAX_TIMESTAMP =

Furthest from the epoch, in either direction, a signature's moment may sit. Ruby's integers grow without bound, so a header carrying thousands of digits would otherwise reach the arithmetic that holds it against the current time and cost more than reading it did.

10**12
PART_SEPARATOR =

What separates one part of the signature header from the next.

","
PART_ASSIGNATOR =

What separates the name of a part from its value. Only the first one counts: a value may hold further ones, and splitting on all of them would silently drop everything past the second.

"="
HEADER_NAME_SEPARATOR =

What separates two header names inside the h part, and what they are joined back with.

" "
MESSAGE_SEPARATOR =

What separates the pieces of the message a code is computed over.

"."
TIMESTAMP_PART =

Part naming the moment the delivery was signed, in whole seconds since the Unix epoch.

"t"
BODY_SCHEME_PART =

Part carrying the code covering the body alone.

"v0"
HEADERS_SCHEME_PART =

Part carrying the code covering the covered headers and the body.

"v1"
COVERED_HEADERS_PART =

Part listing the headers the v1 code covers, in the order it covers them.

"h"
WHOLE_SECONDS =

What a whole number of seconds reads as. Integer() would accept 1_0 as ten, which is a spelling no sender produces and no receiver should invent a meaning for.

/\A-?\d+\z/
WHOLE_HEXADECIMAL =

What a code reads as: whole pairs of hexadecimal digits, and nothing else.

/\A(?:\h\h)+\z/
HEADER_NAME =

What a header name is written with, as RFC 9110 spells a token.

/\A[A-Za-z0-9!\#$%&'*+\-.^_`|~]+\z/
DIGEST =

What the codes are computed with.

"SHA256"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(timestamp, covered_headers, body_code, headers_code) ⇒ Signature

Returns a new instance of Signature.

Parameters:

  • timestamp (Integer)
  • covered_headers (Array<String>)
  • body_code (String, nil)
  • headers_code (String, nil)


92
93
94
95
96
97
98
# File 'lib/hook0/signature.rb', line 92

def initialize(timestamp, covered_headers, body_code, headers_code)
  @timestamp = timestamp
  @covered_headers = covered_headers
  @body_code = body_code
  @headers_code = headers_code
  freeze
end

Instance Attribute Details

#body_codeString? (readonly)

Returns the v0 code, decoded.

Returns:

  • (String, nil)

    the v0 code, decoded



83
84
85
# File 'lib/hook0/signature.rb', line 83

def body_code
  @body_code
end

#covered_headersArray<String> (readonly)

Returns the headers the stronger scheme covers, lowercased and in order.

Returns:

  • (Array<String>)

    the headers the stronger scheme covers, lowercased and in order



80
81
82
# File 'lib/hook0/signature.rb', line 80

def covered_headers
  @covered_headers
end

#headers_codeString? (readonly)

Returns the v1 code, decoded.

Returns:

  • (String, nil)

    the v1 code, decoded



86
87
88
# File 'lib/hook0/signature.rb', line 86

def headers_code
  @headers_code
end

#timestampInteger (readonly)

Returns the moment the delivery was signed, in whole seconds since the epoch.

Returns:

  • (Integer)

    the moment the delivery was signed, in whole seconds since the epoch



77
78
79
# File 'lib/hook0/signature.rb', line 77

def timestamp
  @timestamp
end

Class Method Details

.parse(signature) ⇒ Signature

Reads a signature header, refusing anything it cannot read whole.

Parameters:

  • signature (String)

    the value of the X-Hook0-Signature header

Returns:

Raises:

  • (ClientError)

    for every way a header can fail to be one



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/hook0/signature.rb', line 105

def self.parse(signature)
  raise ClientError, "the signature is #{signature.class}, not a header value" unless signature.is_a?(String)

  if signature.length > MAX_SIGNATURE_BYTES
    raise ClientError,
          "the signature is #{signature.length} characters long, above the #{MAX_SIGNATURE_BYTES} accepted"
  end

  read = parts_of(signature)
  raise ClientError, "the signature carries neither a timestamp nor a code" if read.size < 2

  body_code = code_of(read, BODY_SCHEME_PART)
  headers_code = code_of(read, HEADERS_SCHEME_PART)
  if body_code.nil? && headers_code.nil?
    raise ClientError, "the signature carries neither a `#{BODY_SCHEME_PART}` nor a `#{HEADERS_SCHEME_PART}` code"
  end

  new(timestamp_of(read), covered_headers_of(read), body_code, headers_code)
end

.same_code?(left, right) ⇒ Boolean

Whether two codes are the same, without saying by how long it took how much of one was right.

Parameters:

  • left (String)
  • right (String)

Returns:

  • (Boolean)


217
218
219
# File 'lib/hook0/signature.rb', line 217

def self.same_code?(left, right)
  left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
end

Instance Method Details

#matches?(payload, covered_values, subscription_secret) ⇒ Boolean

Whether the code this signature carries is the one the secret produces.

The stronger scheme wins when both are offered, and the comparison is made in constant time: one that gave up at the first differing byte would say, by how long it took, how much of a guess was right.

Parameters:

  • payload (String)

    the raw body of the webhook request

  • covered_values (Array<String>)

    the values of the covered headers, in order

  • subscription_secret (String)

Returns:

  • (Boolean)


192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/hook0/signature.rb', line 192

def matches?(payload, covered_values, subscription_secret)
  code = OpenSSL::HMAC.new(subscription_secret.to_s, DIGEST)
  code << @timestamp.to_s
  code << MESSAGE_SEPARATOR

  unless @headers_code.nil?
    code << @covered_headers.join(HEADER_NAME_SEPARATOR)
    code << MESSAGE_SEPARATOR
    code << covered_values.join(MESSAGE_SEPARATOR)
    code << MESSAGE_SEPARATOR
    code << payload
    return Signature.same_code?(code.digest, @headers_code)
  end

  # A signature carrying neither code is refused while it is being read, so what is left here
  # is the body-only scheme.
  code << payload
  Signature.same_code?(code.digest, @body_code)
end