Class: Hook0::Signature
- Inherits:
-
Object
- Object
- Hook0::Signature
- 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=valueparts 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
hpart, 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
v1code covers, in the order it covers them. "h"- WHOLE_SECONDS =
What a whole number of seconds reads as.
Integer()would accept1_0as 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
-
#body_code ⇒ String?
readonly
The
v0code, decoded. -
#covered_headers ⇒ Array<String>
readonly
The headers the stronger scheme covers, lowercased and in order.
-
#headers_code ⇒ String?
readonly
The
v1code, decoded. -
#timestamp ⇒ Integer
readonly
The moment the delivery was signed, in whole seconds since the epoch.
Class Method Summary collapse
-
.parse(signature) ⇒ Signature
Reads a signature header, refusing anything it cannot read whole.
-
.same_code?(left, right) ⇒ Boolean
Whether two codes are the same, without saying by how long it took how much of one was right.
Instance Method Summary collapse
-
#initialize(timestamp, covered_headers, body_code, headers_code) ⇒ Signature
constructor
A new instance of Signature.
-
#matches?(payload, covered_values, subscription_secret) ⇒ Boolean
Whether the code this signature carries is the one the secret produces.
Constructor Details
#initialize(timestamp, covered_headers, body_code, headers_code) ⇒ Signature
Returns a new instance of Signature.
92 93 94 95 96 97 98 |
# File 'lib/hook0/signature.rb', line 92 def initialize(, covered_headers, body_code, headers_code) @timestamp = @covered_headers = covered_headers @body_code = body_code @headers_code = headers_code freeze end |
Instance Attribute Details
#body_code ⇒ String? (readonly)
Returns the v0 code, decoded.
83 84 85 |
# File 'lib/hook0/signature.rb', line 83 def body_code @body_code end |
#covered_headers ⇒ Array<String> (readonly)
Returns 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_code ⇒ String? (readonly)
Returns the v1 code, decoded.
86 87 88 |
# File 'lib/hook0/signature.rb', line 86 def headers_code @headers_code end |
#timestamp ⇒ Integer (readonly)
Returns the moment the delivery was signed, in whole seconds since the epoch.
77 78 79 |
# File 'lib/hook0/signature.rb', line 77 def @timestamp end |
Class Method Details
.parse(signature) ⇒ Signature
Reads a signature header, refusing anything it cannot read whole.
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((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.
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.
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 |