patchwork-rb 0.1.4 → 0.1.5
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.
- checksums.yaml +4 -4
- data/README.md +5 -1
- data/lib/patchwork/gateway.rb +1 -0
- data/lib/patchwork/rails.rb +1 -0
- data/lib/patchwork/signature.rb +63 -23
- data/lib/patchwork/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 374df53edc622840cc719b9bb341ae9ff6272fbb71cde1f71df2cb3a46819f09
|
|
4
|
+
data.tar.gz: 5d147937164ef2fdaf16d067f586c9bf83dc13fc8b8eed578bf5ce22a4495864
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dbf196787290106a3380d963f513f40ffba9327ce7c10b33ec307fa7eb4ff1b08182b7850b63b7d05be435fdeb3b427d385f5f2e23c69281350956a29ba40832
|
|
7
|
+
data.tar.gz: 95ee3747bab93b373d1a39ca115df1eab44a73fef3f0b49e7a2029a1d9df5418f4b7c0a23dc9197481dcf3333c9b928aeb65658639f390d74fd97a2177019521
|
data/README.md
CHANGED
|
@@ -232,7 +232,11 @@ Failure behaviour:
|
|
|
232
232
|
|
|
233
233
|
### What the signature does and doesn't cover
|
|
234
234
|
|
|
235
|
-
|
|
235
|
+
A signature carries a label. `v1` covers the method, the path and the raw body. `v2` covers the same with the query string, exactly as sent. The scheme is specified in the [Signing guide](https://docs.usepatchwork.co/guides/signing).
|
|
236
|
+
|
|
237
|
+
The gateway and the concern verify either label, and pass the query for you. If you verify with the primitives instead, pass `query:` — the raw query string, `request.query_string` in Rack and Rails. Leave it out and a `v2` value is checked against the bare path, which will not match.
|
|
238
|
+
|
|
239
|
+
**A GET tool's arguments are still not protected yet.** Patchwork sends both labels while consumers upgrade, and `v1` does not cover the query, so altering a GET tool's arguments in flight still passes. That closes when Patchwork stops sending `v1`. Until then, give any tool whose arguments matter — ids, amounts, anything that writes — a `POST` binding, where the arguments travel in the signed body.
|
|
236
240
|
|
|
237
241
|
The signed path is the one your app sees. If a proxy rewrites paths before your app does, mount the gateway where the original path is still intact.
|
|
238
242
|
|
data/lib/patchwork/gateway.rb
CHANGED
data/lib/patchwork/rails.rb
CHANGED
data/lib/patchwork/signature.rb
CHANGED
|
@@ -6,39 +6,58 @@ module Patchwork
|
|
|
6
6
|
SKEW = 300
|
|
7
7
|
HEADER = "Patchwork-Signature".freeze
|
|
8
8
|
|
|
9
|
-
# Patchwork sends one
|
|
10
|
-
# stops an unauthenticated header of thousands of
|
|
11
|
-
# verification into a CPU amplifier.
|
|
9
|
+
# Patchwork sends one value per live secret per label — two during a
|
|
10
|
+
# rotation. The cap stops an unauthenticated header of thousands of
|
|
11
|
+
# candidates from turning verification into a CPU amplifier.
|
|
12
12
|
MAX_SIGNATURES = 8
|
|
13
13
|
MAX_HEADER_BYTES = 1024
|
|
14
14
|
TIMESTAMP = /\A\d{1,12}\z/
|
|
15
15
|
HEX_SHA256 = /\A\h{64}\z/
|
|
16
16
|
|
|
17
|
+
# v1 covers the method, the path and the body. v2 covers the same with the
|
|
18
|
+
# query string, as sent. Its payload carries a "v2." prefix, so a v1
|
|
19
|
+
# signature can never read as a v2 one even where the two cover the same
|
|
20
|
+
# bytes — a request with no query.
|
|
21
|
+
V1 = "v1".freeze
|
|
22
|
+
V2 = "v2".freeze
|
|
23
|
+
LABELS = [ V1, V2 ].freeze
|
|
24
|
+
|
|
17
25
|
Parsed = Struct.new(:timestamp, :signatures, keyword_init: true)
|
|
18
26
|
|
|
19
|
-
def self.sign(secret:, timestamp:, method:, path:, body:)
|
|
27
|
+
def self.sign(secret:, timestamp:, method:, path:, body:, query: nil, label: V1)
|
|
20
28
|
secret = usable!([ secret ]).first
|
|
21
|
-
mac(secret, payload(timestamp, method, path, digest(body)))
|
|
29
|
+
mac(secret, payload(label, timestamp, method, path, query, digest(body)))
|
|
22
30
|
end
|
|
23
31
|
|
|
24
|
-
def self.header(secrets:, timestamp:, method:, path:, body:)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
def self.header(secrets:, timestamp:, method:, path:, body:, query: nil, labels: nil)
|
|
33
|
+
labels = (labels || (query.nil? ? [ V1 ] : LABELS)).map(&:to_s)
|
|
34
|
+
usable = usable!(secrets)
|
|
35
|
+
|
|
36
|
+
values = labels.flat_map do |label|
|
|
37
|
+
base = payload(label, timestamp, method, path, query, digest(body))
|
|
38
|
+
usable.map { |secret| "#{label}=#{mac(secret, base)}" }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
"t=#{timestamp},#{values.join(",")}"
|
|
28
42
|
end
|
|
29
43
|
|
|
30
44
|
# Structural parse, with no secret and no body. Returns nil for anything
|
|
31
45
|
# Patchwork would never send, so a malformed header is rejected before the
|
|
32
|
-
# body is read.
|
|
46
|
+
# body is read. Unknown labels are ignored rather than refused, so an older
|
|
47
|
+
# reader keeps working when a new one is added.
|
|
33
48
|
def self.parse(header)
|
|
34
49
|
value = header.to_s
|
|
35
50
|
return nil if value.empty? || value.bytesize > MAX_HEADER_BYTES
|
|
36
51
|
|
|
37
52
|
pairs = value.split(",").map { |pair| pair.strip.split("=", 2) }
|
|
38
53
|
timestamps = pairs.select { |key, _| key == "t" }.map(&:last)
|
|
39
|
-
signatures = pairs.select { |key, _| key == "v1" }.map(&:last)
|
|
40
54
|
return nil unless timestamps.size == 1 && TIMESTAMP.match?(timestamps.first.to_s)
|
|
41
|
-
|
|
55
|
+
|
|
56
|
+
signatures = LABELS.to_h do |label|
|
|
57
|
+
[ label, pairs.select { |key, _| key == label }.map(&:last) ]
|
|
58
|
+
end
|
|
59
|
+
return nil if signatures.values.all?(&:empty?)
|
|
60
|
+
return nil if signatures.values.any? { |values| values.size > MAX_SIGNATURES }
|
|
42
61
|
|
|
43
62
|
Parsed.new(timestamp: Integer(timestamps.first, 10), signatures: signatures)
|
|
44
63
|
end
|
|
@@ -47,24 +66,33 @@ module Patchwork
|
|
|
47
66
|
(now - parsed.timestamp).abs <= skew
|
|
48
67
|
end
|
|
49
68
|
|
|
50
|
-
|
|
69
|
+
# Accepts either label. During the migration Patchwork sends both, so a
|
|
70
|
+
# reader that understands v2 must still accept v1 from a platform that has
|
|
71
|
+
# not started sending it.
|
|
72
|
+
def self.verify(secrets:, header:, method:, path:, body:, query: nil, skew: SKEW, now: Time.now.to_i)
|
|
51
73
|
secrets = usable!(secrets)
|
|
52
74
|
parsed = parse(header)
|
|
53
75
|
return :bad if parsed.nil?
|
|
54
76
|
return :stale unless fresh?(parsed, skew: skew, now: now)
|
|
55
77
|
|
|
56
|
-
# The body is hashed once
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
78
|
+
# The body is hashed once however many candidates the header carries.
|
|
79
|
+
body_digest = digest(body)
|
|
80
|
+
|
|
81
|
+
matched = LABELS.any? do |label|
|
|
82
|
+
candidates = parsed.signatures.fetch(label, []).select { |value| HEX_SHA256.match?(value) }
|
|
83
|
+
next false if candidates.empty?
|
|
84
|
+
|
|
85
|
+
base = payload(label, parsed.timestamp, method, path, query, body_digest)
|
|
86
|
+
expected = secrets.map { |secret| mac(secret, base) }
|
|
87
|
+
expected.product(candidates).any? { |mine, theirs| secure_compare(mine, theirs) }
|
|
88
|
+
end
|
|
61
89
|
|
|
62
|
-
matched = expected.product(candidates).any? { |mine, theirs| secure_compare(mine, theirs) }
|
|
63
90
|
matched ? :ok : :bad
|
|
64
91
|
end
|
|
65
92
|
|
|
66
|
-
def self.verify!(secrets:, header:, method:, path:, body:, skew: SKEW, now: Time.now.to_i)
|
|
67
|
-
case verify(secrets: secrets, header: header, method: method, path: path, body: body,
|
|
93
|
+
def self.verify!(secrets:, header:, method:, path:, body:, query: nil, skew: SKEW, now: Time.now.to_i)
|
|
94
|
+
case verify(secrets: secrets, header: header, method: method, path: path, body: body,
|
|
95
|
+
query: query, skew: skew, now: now)
|
|
68
96
|
when :stale then raise StaleSignature, "signature timestamp outside the #{skew}s window"
|
|
69
97
|
when :bad then raise InvalidSignature, "signature did not match"
|
|
70
98
|
else true
|
|
@@ -76,8 +104,20 @@ module Patchwork
|
|
|
76
104
|
end
|
|
77
105
|
private_class_method :digest
|
|
78
106
|
|
|
79
|
-
|
|
80
|
-
|
|
107
|
+
# The request target: the path as signed by v1, and the path with the query
|
|
108
|
+
# exactly as sent by v2. No query means no "?", because that is what goes
|
|
109
|
+
# on the wire.
|
|
110
|
+
def self.target(label, path, query)
|
|
111
|
+
return path.to_s if label == V1
|
|
112
|
+
|
|
113
|
+
value = query.to_s
|
|
114
|
+
value.empty? ? path.to_s : "#{path}?#{value}"
|
|
115
|
+
end
|
|
116
|
+
private_class_method :target
|
|
117
|
+
|
|
118
|
+
def self.payload(label, timestamp, method, path, query, digest)
|
|
119
|
+
base = "#{timestamp}.#{method.to_s.upcase}.#{target(label, path, query)}.#{digest}"
|
|
120
|
+
label == V2 ? "#{V2}.#{base}" : base
|
|
81
121
|
end
|
|
82
122
|
private_class_method :payload
|
|
83
123
|
|
data/lib/patchwork/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: patchwork-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chromablue Labs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: jwt
|