findxpand 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Is this manifest push signed by the holder of the token, and is it recent?
4
+ #
5
+ # A bearer token proves who sent a push and says nothing about what was in it.
6
+ # Anything holding the token — a logging proxy, a mirrored request, an old CI
7
+ # secret — could replay a captured manifest forever, or swap the body inside one,
8
+ # and a manifest is production content. So every push carries an HMAC-SHA256 over
9
+ # `timestamp.body`, with the timestamp inside the digest so a captured request
10
+ # expires rather than working indefinitely.
11
+ #
12
+ # The scheme is `engine.publish.notify`'s, unchanged and deliberately so: we
13
+ # already ship three verified implementations of it, and a second scheme here
14
+ # would be a second thing to get right. The secret is the middleware token rather
15
+ # than a new credential — whoever holds the token can already push, so a separate
16
+ # secret would add distribution and rotation for no gain.
17
+ #
18
+ # §3 rule 7: this is a write, so every branch here fails closed. The only
19
+ # question the module answers is *why* a push is unacceptable.
20
+ #
21
+ # Frozen string literals: on. The one accumulator is `String#+`, never `<<`.
22
+
23
+ require 'openssl'
24
+
25
+ module Findxpand
26
+ module Signature
27
+ # Mirrors `engine.fix.origin.SIGNATURE_HEADER` and `TIMESTAMP_HEADER`.
28
+ SIGNATURE_HEADER = 'x-findxpand-signature'
29
+ TIMESTAMP_HEADER = 'x-findxpand-timestamp'
30
+
31
+ # The same names as Rack presents them. A Rack server upcases a header,
32
+ # replaces the hyphens and prefixes `HTTP_`, so these are the keys actually
33
+ # read out of `env` — written out rather than computed at each call site so
34
+ # that a rename cannot leave one of the two spellings behind.
35
+ SIGNATURE_ENV = 'HTTP_X_FINDXPAND_SIGNATURE'
36
+ TIMESTAMP_ENV = 'HTTP_X_FINDXPAND_TIMESTAMP'
37
+
38
+ # How far out of date a push may be, in seconds. Mirrors
39
+ # `engine.fix.origin.TOLERANCE_S` and `sdk/next/src/webhook.ts`.
40
+ TOLERANCE_S = 300
41
+
42
+ # The reason this push is not acceptable, or `''` when it is.
43
+ #
44
+ # A reason rather than a boolean: "the signature does not match" and "the
45
+ # timestamp is forty minutes old" send an operator to completely different
46
+ # places — a wrong token versus a clock that has drifted — and a bare `false`
47
+ # makes them guess.
48
+ def self.problem(signature, timestamp, body, token, now: nil)
49
+ signature = signature.to_s
50
+ timestamp = timestamp.to_s
51
+ return 'unsigned manifest push' if signature.empty? || timestamp.empty?
52
+
53
+ begin
54
+ # `Float()`, never `String#to_f`. `'nonsense'.to_f` is `0.0` and raises
55
+ # nothing, so a garbled header would be read as the epoch and rejected
56
+ # as 1.7 billion seconds stale — a true refusal reported with a reason
57
+ # that sends an operator to look at a clock that is perfectly correct.
58
+ # `Float()` raises on anything that is not a number, which is the
59
+ # distinction this method exists to make.
60
+ age = ((now || Time.now.to_f) - Float(timestamp)).abs
61
+ rescue ArgumentError, TypeError
62
+ return 'manifest push carries an unreadable timestamp'
63
+ end
64
+ if age > TOLERANCE_S
65
+ return "manifest push is #{age.round}s out of date - replay, or a clock that has drifted"
66
+ end
67
+
68
+ unless secure_compare(signature, expected(timestamp, body, token))
69
+ return 'manifest signature does not match'
70
+ end
71
+
72
+ ''
73
+ end
74
+
75
+ # The digest we should have been sent, over the raw bytes.
76
+ #
77
+ # **Binary throughout, and that is not a detail.** `"#{timestamp}.#{body}"`
78
+ # is the obvious line and it raises `Encoding::CompatibilityError` the first
79
+ # time a manifest carries a non-ASCII byte: `rack.input` hands back an
80
+ # ASCII-8BIT String, the literal in the interpolation is UTF-8, and Ruby
81
+ # refuses to join the two once either holds a byte above 0x7F. Our market is
82
+ # Arabic (§14), so the first manifest with an Arabic title in it is the first
83
+ # push this would have rejected — with a 500 out of the middleware rather
84
+ # than a reason, since the raise happens before any verdict is reached.
85
+ # `String#b` gives a binary copy of each half and the concatenation is then
86
+ # always legal.
87
+ #
88
+ # Over the raw bytes, never over a re-serialised parse: re-encoding the JSON
89
+ # to check it would reorder keys and reject every legitimate push.
90
+ def self.expected(timestamp, body, token)
91
+ signed = "#{timestamp}.".b + body.to_s.b
92
+ OpenSSL::HMAC.hexdigest('SHA256', token.to_s, signed)
93
+ end
94
+
95
+ # Constant-time comparison of two digests.
96
+ #
97
+ # `==` on Strings returns as soon as two bytes differ, so the time it takes
98
+ # is a function of how many leading characters an attacker guessed right —
99
+ # which is enough, over enough requests, to recover a signature byte by byte
100
+ # without ever knowing the token. The endpoint being compared against is a
101
+ # manifest push, so the prize is arbitrary content on the customer's pages.
102
+ #
103
+ # `OpenSSL.secure_compare` is preferred because it is the audited C
104
+ # implementation, and it hashes both inputs before comparing so the lengths
105
+ # cannot leak either. The fallback is the same construction in Ruby, for a
106
+ # build whose bundled openssl predates it: SHA-256 both sides so the loop
107
+ # length is fixed, then OR the XOR of every byte pair and test once at the
108
+ # end. `Rack::Utils.secure_compare` would do as well and is not used, because
109
+ # this gem declares no runtime dependency on Rack — it is a Rack middleware
110
+ # that needs nothing of Rack but the calling convention.
111
+ def self.secure_compare(given, expected)
112
+ given = given.to_s
113
+ expected = expected.to_s
114
+ return OpenSSL.secure_compare(given, expected) if OpenSSL.respond_to?(:secure_compare)
115
+
116
+ # `OpenSSL::Digest.digest(name, data)` rather than the
117
+ # `OpenSSL::Digest::SHA256` constant, which successive openssl releases
118
+ # have deprecated and un-deprecated. This branch exists for an old build;
119
+ # writing it against the newest spelling of the API would defeat it.
120
+ left = OpenSSL::Digest.digest('SHA256', given)
121
+ right = OpenSSL::Digest.digest('SHA256', expected)
122
+ difference = 0
123
+ left.bytes.each_with_index { |byte, index| difference |= byte ^ right.getbyte(index).to_i }
124
+ difference.zero?
125
+ end
126
+ end
127
+ end