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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5b4e037d427bdd50e672f5f08ff93f8f4e129c42c998224162f7a6296988d2c
4
- data.tar.gz: 59c03fbcbbfff9f6b6c27a006f3b37da9f8d995c3bb1f29d1168af2c619d6f17
3
+ metadata.gz: 374df53edc622840cc719b9bb341ae9ff6272fbb71cde1f71df2cb3a46819f09
4
+ data.tar.gz: 5d147937164ef2fdaf16d067f586c9bf83dc13fc8b8eed578bf5ce22a4495864
5
5
  SHA512:
6
- metadata.gz: ee481c8625a8556c87f7924a4766e77588c0dbcd9a3529cb1041b74cafcc4610cdb1063f00ff607daac78eb1ef93861086926be81b2a897d029b47bfbc472a08
7
- data.tar.gz: b0392464c771cfd3323ac2384cce4982f9bf75f62fbe79f7c03f578480b9ed1be0817a6f5e4ad89a7dd0c23161e5306d693fa878c5098121e366d9d56191ecb3
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
- The signature covers the method, the path and the raw body, as specified in the [Signing guide](https://docs.usepatchwork.co/guides/signing). **It does not cover the query string**, and Patchwork sends a GET tool's arguments as query parameters. So a GET tool's arguments are not integrity-protected. Anyone who can alter a request in flight (a TLS-terminating proxy, for example) can change them without breaking the signature. Give any tool whose arguments matter, such as ids, amounts or anything that writes, a `POST` binding, where the arguments travel in the signed body.
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
 
@@ -131,6 +131,7 @@ module Patchwork
131
131
  header: signature,
132
132
  method: method,
133
133
  path: request.path,
134
+ query: request.query_string,
134
135
  body: body
135
136
  )
136
137
  body
@@ -32,6 +32,7 @@ module Patchwork
32
32
  header: signature,
33
33
  method: signed,
34
34
  path: request.path,
35
+ query: request.query_string,
35
36
  body: request.raw_post
36
37
  )
37
38
 
@@ -6,39 +6,58 @@ module Patchwork
6
6
  SKEW = 300
7
7
  HEADER = "Patchwork-Signature".freeze
8
8
 
9
- # Patchwork sends one v1 per live secret — two during a rotation. The cap
10
- # stops an unauthenticated header of thousands of candidates from turning
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
- base = payload(timestamp, method, path, digest(body))
26
- signatures = usable!(secrets).map { |secret| mac(secret, base) }
27
- "t=#{timestamp},#{signatures.map { |signature| "v1=#{signature}" }.join(",")}"
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
- return nil if signatures.empty? || signatures.size > MAX_SIGNATURES
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
- def self.verify(secrets:, header:, method:, path:, body:, skew: SKEW, now: Time.now.to_i)
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 and each secret MACs once, however many
57
- # candidates the header carries.
58
- base = payload(parsed.timestamp, method, path, digest(body))
59
- expected = secrets.map { |secret| mac(secret, base) }
60
- candidates = parsed.signatures.select { |candidate| HEX_SHA256.match?(candidate) }
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, skew: skew, now: now)
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
- def self.payload(timestamp, method, path, digest)
80
- "#{timestamp}.#{method.to_s.upcase}.#{path}.#{digest}"
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
 
@@ -1,3 +1,3 @@
1
1
  module Patchwork
2
- VERSION = "0.1.4".freeze
2
+ VERSION = "0.1.5".freeze
3
3
  end
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
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-05 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jwt