patchwork-rb 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +348 -0
- data/lib/patchwork/bridge_assertion.rb +64 -0
- data/lib/patchwork/configuration.rb +63 -0
- data/lib/patchwork/errors.rb +9 -0
- data/lib/patchwork/gateway.rb +180 -0
- data/lib/patchwork/health_check.rb +36 -0
- data/lib/patchwork/mint.rb +38 -0
- data/lib/patchwork/presented.rb +53 -0
- data/lib/patchwork/rails.rb +70 -0
- data/lib/patchwork/secure_compare.rb +13 -0
- data/lib/patchwork/session_token.rb +65 -0
- data/lib/patchwork/signature.rb +105 -0
- data/lib/patchwork/signing_key.rb +69 -0
- data/lib/patchwork/subject.rb +162 -0
- data/lib/patchwork/version.rb +3 -0
- data/lib/patchwork/webhook.rb +77 -0
- data/lib/patchwork-rb.rb +1 -0
- data/lib/patchwork.rb +41 -0
- metadata +109 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require "rack"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module Patchwork
|
|
7
|
+
# Rack middleware for the calls Patchwork makes to your backend.
|
|
8
|
+
#
|
|
9
|
+
# use Patchwork::Gateway, resolve: ->(subject, claims) { ... }
|
|
10
|
+
#
|
|
11
|
+
# A request carrying a Patchwork-Signature is verified — signature first, then
|
|
12
|
+
# the session token you minted — and the token's subject is handed to your
|
|
13
|
+
# `resolve`. Whatever it returns is what the request acts as; nil, false or an
|
|
14
|
+
# empty collection is a 401. Requests without the header fall through to your
|
|
15
|
+
# own auth untouched.
|
|
16
|
+
#
|
|
17
|
+
# The gateway never interprets the subject. Pass `subject:` with a format from
|
|
18
|
+
# Patchwork::Subject.define and it decodes for you first, 401ing anything that
|
|
19
|
+
# is not in that format.
|
|
20
|
+
#
|
|
21
|
+
# Pass `mint_path:` and the gateway also answers Patchwork's server-to-server
|
|
22
|
+
# (relay) mint at that path; your own frontend's unsigned mint request at the
|
|
23
|
+
# same path still falls through to your app.
|
|
24
|
+
class Gateway
|
|
25
|
+
SUBJECT_KEY = "patchwork.subject".freeze
|
|
26
|
+
CLAIMS_KEY = "patchwork.claims".freeze
|
|
27
|
+
PRINCIPAL_KEY = "patchwork.principal".freeze
|
|
28
|
+
SIGNATURE_HEADER = "HTTP_PATCHWORK_SIGNATURE".freeze
|
|
29
|
+
MAX_BODY_BYTES = 1024 * 1024
|
|
30
|
+
MAX_CONTROL_BODY_BYTES = 16 * 1024
|
|
31
|
+
|
|
32
|
+
TooLarge = Class.new(Error)
|
|
33
|
+
|
|
34
|
+
def initialize(app, resolve:, subject: nil, mint_path: nil, health_check: true,
|
|
35
|
+
max_body_bytes: MAX_BODY_BYTES, replay_guard: nil)
|
|
36
|
+
raise ArgumentError, "resolve must respond to #call" unless resolve.respond_to?(:call)
|
|
37
|
+
raise ArgumentError, "subject must respond to #decode" if subject && !subject.respond_to?(:decode)
|
|
38
|
+
raise ArgumentError, "replay_guard must respond to #call" if replay_guard && !replay_guard.respond_to?(:call)
|
|
39
|
+
raise ArgumentError, "mint_path must be an absolute path" if mint_path && !mint_path.to_s.start_with?("/")
|
|
40
|
+
|
|
41
|
+
@app = app
|
|
42
|
+
@resolve = resolve
|
|
43
|
+
@subject = subject
|
|
44
|
+
@mint_path = mint_path&.to_s
|
|
45
|
+
@health_check = health_check
|
|
46
|
+
@max_body_bytes = Integer(max_body_bytes)
|
|
47
|
+
@replay_guard = replay_guard
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def call(env)
|
|
51
|
+
# Only this middleware may say a request is Patchwork's. Anything already
|
|
52
|
+
# in these keys came from somewhere else and is dropped, on every path.
|
|
53
|
+
[ SUBJECT_KEY, CLAIMS_KEY, PRINCIPAL_KEY ].each { |key| env.delete(key) }
|
|
54
|
+
|
|
55
|
+
request = Rack::Request.new(env)
|
|
56
|
+
signature = env[SIGNATURE_HEADER].to_s
|
|
57
|
+
|
|
58
|
+
return health(request, signature) if @health_check && at?(request, HealthCheck::PATH) && request.post?
|
|
59
|
+
return @app.call(env) if signature.empty?
|
|
60
|
+
return relay_mint(request, signature) if @mint_path && at?(request, @mint_path) && request.post?
|
|
61
|
+
|
|
62
|
+
authenticate!(env, request, signature)
|
|
63
|
+
@app.call(env)
|
|
64
|
+
rescue TooLarge
|
|
65
|
+
json(413, { error: "request body too large" })
|
|
66
|
+
rescue InvalidSignature
|
|
67
|
+
unauthorized("invalid signature")
|
|
68
|
+
rescue InvalidToken
|
|
69
|
+
unauthorized("invalid token")
|
|
70
|
+
rescue UnknownSubject
|
|
71
|
+
unauthorized("subject not authorized")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def authenticate!(env, request, signature)
|
|
77
|
+
verify_signature!(request, signature, limit: @max_body_bytes)
|
|
78
|
+
|
|
79
|
+
guard_replay!(request, signature)
|
|
80
|
+
|
|
81
|
+
result = Presented.authenticate!(
|
|
82
|
+
authorization: env["HTTP_AUTHORIZATION"],
|
|
83
|
+
resolve: @resolve,
|
|
84
|
+
subject: @subject
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
env[SUBJECT_KEY] = result.subject
|
|
88
|
+
env[CLAIMS_KEY] = result.claims
|
|
89
|
+
env[PRINCIPAL_KEY] = result.principal
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Patchwork's server-to-server mint (the mint-endpoint guide, relay branch):
|
|
93
|
+
# signed, no bearer, the subject in the body. Mint for exactly that subject.
|
|
94
|
+
def relay_mint(request, signature)
|
|
95
|
+
body = verify_signature!(request, signature, limit: MAX_CONTROL_BODY_BYTES)
|
|
96
|
+
subject = Mint.relay_subject(body)
|
|
97
|
+
json(201, { token: SessionToken.issue(subject: subject), expires_in: Patchwork.config.token_ttl })
|
|
98
|
+
rescue Mint::BadRequest => e
|
|
99
|
+
json(400, { error: e.message })
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def health(request, signature)
|
|
103
|
+
body = if signature.empty?
|
|
104
|
+
read_body(request, MAX_CONTROL_BODY_BYTES)
|
|
105
|
+
else
|
|
106
|
+
verify_signature!(request, signature, limit: MAX_CONTROL_BODY_BYTES)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
json(200, HealthCheck.respond(body: body))
|
|
110
|
+
rescue InvalidSignature
|
|
111
|
+
unauthorized("invalid signature")
|
|
112
|
+
rescue HealthCheck::BadRequest
|
|
113
|
+
json(400, { error: "invalid health check" })
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Header shape and freshness are checked before the body is read, so a
|
|
117
|
+
# garbage or stale header costs nothing but the header.
|
|
118
|
+
def verify_signature!(request, signature, limit:)
|
|
119
|
+
parsed = Signature.parse(signature)
|
|
120
|
+
raise InvalidSignature, "malformed signature" if parsed.nil?
|
|
121
|
+
raise StaleSignature, "stale signature" unless Signature.fresh?(parsed)
|
|
122
|
+
|
|
123
|
+
method = signed_method!(request)
|
|
124
|
+
body = read_body(request, limit)
|
|
125
|
+
Signature.verify!(
|
|
126
|
+
secrets: Patchwork.config.request_secrets,
|
|
127
|
+
header: signature,
|
|
128
|
+
method: method,
|
|
129
|
+
path: request.path,
|
|
130
|
+
body: body
|
|
131
|
+
)
|
|
132
|
+
body
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Patchwork signs the method it sent and never asks for an override. An
|
|
136
|
+
# override header is not covered by the signature, so a Rack::MethodOverride
|
|
137
|
+
# mounted after the gateway could turn a verified POST into a DELETE; one
|
|
138
|
+
# mounted before it has already rewritten REQUEST_METHOD. Refuse both.
|
|
139
|
+
def signed_method!(request)
|
|
140
|
+
Presented.signed_method!(request.env, request.request_method)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def guard_replay!(request, signature)
|
|
144
|
+
return unless @replay_guard
|
|
145
|
+
|
|
146
|
+
key = "patchwork:replay:#{Digest::SHA256.hexdigest("#{request.request_method} #{request.path} #{signature}")}"
|
|
147
|
+
raise InvalidSignature, "replayed request" unless @replay_guard.call(key, Signature::SKEW * 2)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Reads at most `limit` bytes. The verified bytes are handed back to the app
|
|
151
|
+
# as a fresh StringIO, so it sees exactly what was verified whether or not
|
|
152
|
+
# the original input could rewind.
|
|
153
|
+
def read_body(request, limit)
|
|
154
|
+
length = request.content_length.to_i
|
|
155
|
+
raise TooLarge, "declared body exceeds #{limit} bytes" if length > limit
|
|
156
|
+
|
|
157
|
+
input = request.env["rack.input"]
|
|
158
|
+
body = input ? input.read(limit + 1).to_s : +""
|
|
159
|
+
raise TooLarge, "body exceeds #{limit} bytes" if body.bytesize > limit
|
|
160
|
+
|
|
161
|
+
request.env["rack.input"] = StringIO.new(body)
|
|
162
|
+
body
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The path Patchwork signed is SCRIPT_NAME + PATH_INFO; the route is
|
|
166
|
+
# matched on either, so the gateway also works mounted under a prefix.
|
|
167
|
+
def at?(request, path)
|
|
168
|
+
request.path == path || request.path_info == path
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def unauthorized(message)
|
|
172
|
+
json(401, { error: message })
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def json(status, payload)
|
|
176
|
+
body = JSON.generate(payload)
|
|
177
|
+
[ status, { "content-type" => "application/json", "content-length" => body.bytesize.to_s }, [ body ] ]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
module Patchwork
|
|
5
|
+
module HealthCheck
|
|
6
|
+
PATH = "/patchwork/up".freeze
|
|
7
|
+
PROOF_CONTEXT = "patchwork-up.v1:".freeze
|
|
8
|
+
MAX_NONCE_BYTES = 256
|
|
9
|
+
|
|
10
|
+
BadRequest = Class.new(Error)
|
|
11
|
+
|
|
12
|
+
# The context prefix keeps this proof from ever equalling a request
|
|
13
|
+
# signature, whose payload starts with a numeric timestamp — so answering
|
|
14
|
+
# unsigned probes is not a signing oracle.
|
|
15
|
+
def self.proof(secret:, nonce:)
|
|
16
|
+
key = secret.to_s
|
|
17
|
+
raise ConfigurationError, "request_secret is not set" if key.strip.empty?
|
|
18
|
+
|
|
19
|
+
OpenSSL::HMAC.hexdigest("SHA256", key, "#{PROOF_CONTEXT}#{nonce}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.respond(body:, secret: nil)
|
|
23
|
+
parsed = JSON.parse(body.to_s)
|
|
24
|
+
raise BadRequest, "health check body must be a JSON object" unless parsed.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
nonce = parsed["nonce"]
|
|
27
|
+
unless nonce.is_a?(String) && !nonce.empty? && nonce.bytesize <= MAX_NONCE_BYTES
|
|
28
|
+
raise BadRequest, "health check nonce must be a string of 1-#{MAX_NONCE_BYTES} bytes"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
{ nonce: nonce, proof: proof(secret: secret || Patchwork.config.request_secret, nonce: nonce) }
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
raise BadRequest, "health check body is not JSON"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Patchwork
|
|
4
|
+
# The relay half of your mint endpoint: Patchwork asking, server to server,
|
|
5
|
+
# for a token for a subject it already acts for. The Gateway answers it when
|
|
6
|
+
# given `mint_path:`; outside Rack, call Mint.relay from your own route.
|
|
7
|
+
#
|
|
8
|
+
# result = Patchwork::Mint.relay(body: request.raw_post,
|
|
9
|
+
# signature: request.headers["Patchwork-Signature"],
|
|
10
|
+
# path: request.path)
|
|
11
|
+
# render json: result, status: :created # bare { token:, expires_in: }
|
|
12
|
+
module Mint
|
|
13
|
+
BadRequest = Class.new(Error)
|
|
14
|
+
MAX_BODY_BYTES = 16 * 1024
|
|
15
|
+
|
|
16
|
+
# Verifies the presenter signature, then mints for exactly the subject in
|
|
17
|
+
# the body — never a default, never a widened one. Raises InvalidSignature,
|
|
18
|
+
# StaleSignature or BadRequest.
|
|
19
|
+
def self.relay(body:, signature:, path:, secrets: Patchwork.config.request_secrets)
|
|
20
|
+
raise BadRequest, "mint body too large" if body.to_s.bytesize > MAX_BODY_BYTES
|
|
21
|
+
|
|
22
|
+
Signature.verify!(secrets: secrets, header: signature, method: "POST", path: path, body: body)
|
|
23
|
+
{ token: SessionToken.issue(subject: relay_subject(body)), expires_in: Patchwork.config.token_ttl }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.relay_subject(body)
|
|
27
|
+
payload = JSON.parse(body.to_s)
|
|
28
|
+
raise BadRequest, "mint body must be a JSON object" unless payload.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
subject = payload["subject"]
|
|
31
|
+
Subject.validate!(subject)
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
raise BadRequest, "mint body is not JSON"
|
|
34
|
+
rescue ArgumentError
|
|
35
|
+
raise BadRequest, "mint body has no usable subject"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Patchwork
|
|
2
|
+
module Presented
|
|
3
|
+
Result = Struct.new(:subject, :claims, :principal, keyword_init: true)
|
|
4
|
+
|
|
5
|
+
def self.authenticate!(authorization:, resolve:, subject: nil)
|
|
6
|
+
claims = SessionToken.verify(bearer(authorization))
|
|
7
|
+
presented = claims["sub"]
|
|
8
|
+
principal = resolve_principal(resolve, subject, presented, claims)
|
|
9
|
+
raise UnknownSubject, "subject was not resolved" if refused?(principal)
|
|
10
|
+
|
|
11
|
+
Result.new(subject: presented, claims: claims, principal: principal)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.signed_method!(env, request_method)
|
|
15
|
+
raise InvalidSignature, "method override on a signed request" if env.key?("HTTP_X_HTTP_METHOD_OVERRIDE")
|
|
16
|
+
|
|
17
|
+
original = env["rack.methodoverride.original_method"]
|
|
18
|
+
raise InvalidSignature, "method override on a signed request" if original && original != request_method
|
|
19
|
+
|
|
20
|
+
request_method
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.bearer(authorization)
|
|
24
|
+
token = authorization.to_s[/\ABearer ([A-Za-z0-9_\-.=]+)\z/, 1]
|
|
25
|
+
raise InvalidToken, "no bearer token" if token.nil?
|
|
26
|
+
|
|
27
|
+
token
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.resolve_principal(resolve, format, presented, claims)
|
|
31
|
+
argument = presented
|
|
32
|
+
if format
|
|
33
|
+
argument = format.decode(presented)
|
|
34
|
+
raise UnknownSubject, "subject is not in the expected format" if argument.nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
takes_claims?(resolve) ? resolve.call(argument, claims) : resolve.call(argument)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.refused?(principal)
|
|
41
|
+
return true unless principal
|
|
42
|
+
return principal.empty? if principal.respond_to?(:empty?) && !principal.is_a?(String)
|
|
43
|
+
|
|
44
|
+
principal.is_a?(String) && principal.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.takes_claims?(callable)
|
|
48
|
+
parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
49
|
+
positional = parameters.count { |type, _| type == :req || type == :opt }
|
|
50
|
+
parameters.any? { |type, _| type == :rest } || positional >= 2
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "patchwork"
|
|
3
|
+
|
|
4
|
+
module Patchwork
|
|
5
|
+
module Rails
|
|
6
|
+
module Presented
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
before_action :verify_patchwork_call!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class_methods do
|
|
14
|
+
def patchwork_subject_format(format = nil)
|
|
15
|
+
@patchwork_subject_format = format if format
|
|
16
|
+
return @patchwork_subject_format if defined?(@patchwork_subject_format) && @patchwork_subject_format
|
|
17
|
+
return unless superclass.respond_to?(:patchwork_subject_format)
|
|
18
|
+
|
|
19
|
+
superclass.patchwork_subject_format
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def verify_patchwork_call!
|
|
26
|
+
signature = request.headers[::Patchwork::Signature::HEADER].to_s
|
|
27
|
+
raise ::Patchwork::InvalidSignature, "missing signature" if signature.empty?
|
|
28
|
+
|
|
29
|
+
signed = ::Patchwork::Presented.signed_method!(request.env, request.request_method)
|
|
30
|
+
::Patchwork::Signature.verify!(
|
|
31
|
+
secrets: ::Patchwork.config.request_secrets,
|
|
32
|
+
header: signature,
|
|
33
|
+
method: signed,
|
|
34
|
+
path: request.path,
|
|
35
|
+
body: request.raw_post
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
result = ::Patchwork::Presented.authenticate!(
|
|
39
|
+
authorization: request.headers["Authorization"],
|
|
40
|
+
resolve: method(:patchwork_resolve),
|
|
41
|
+
subject: self.class.patchwork_subject_format
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@patchwork_subject = result.subject
|
|
45
|
+
@patchwork_claims = result.claims
|
|
46
|
+
@patchwork_principal = result.principal
|
|
47
|
+
rescue ::Patchwork::StaleSignature
|
|
48
|
+
deny_patchwork_call("stale signature")
|
|
49
|
+
rescue ::Patchwork::InvalidSignature
|
|
50
|
+
deny_patchwork_call("invalid signature")
|
|
51
|
+
rescue ::Patchwork::InvalidToken
|
|
52
|
+
deny_patchwork_call("invalid token")
|
|
53
|
+
rescue ::Patchwork::UnknownSubject
|
|
54
|
+
deny_patchwork_call("subject not authorized")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def patchwork_resolve(_subject)
|
|
58
|
+
raise ::Patchwork::ConfigurationError,
|
|
59
|
+
"#{self.class.name} must define patchwork_resolve(subject) — return the principal " \
|
|
60
|
+
"the call acts as, or nil to refuse it"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def deny_patchwork_call(message)
|
|
64
|
+
render json: { error: message }, status: :unauthorized
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :patchwork_subject, :patchwork_claims, :patchwork_principal
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module Patchwork
|
|
4
|
+
module SecureCompare
|
|
5
|
+
def self.call(expected, candidate)
|
|
6
|
+
expected = expected.to_s
|
|
7
|
+
candidate = candidate.to_s
|
|
8
|
+
return false unless expected.bytesize == candidate.bytesize
|
|
9
|
+
|
|
10
|
+
OpenSSL.fixed_length_secure_compare(expected, candidate)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require "jwt"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module Patchwork
|
|
5
|
+
module SessionToken
|
|
6
|
+
PLATFORM_AUDIENCE = "patchwork".freeze
|
|
7
|
+
|
|
8
|
+
# Mints the token your frontend hands to Patchwork. `subject` is any string
|
|
9
|
+
# you choose — compose it with Patchwork::Subject.define if it has parts.
|
|
10
|
+
def self.issue(subject:, ttl: nil, connection_id: nil)
|
|
11
|
+
Subject.validate!(subject)
|
|
12
|
+
|
|
13
|
+
connection = connection_id.to_s
|
|
14
|
+
raise ArgumentError, "connection_id must be a connection id, not a wildcard" if connection == "*"
|
|
15
|
+
|
|
16
|
+
now = Time.now.to_i
|
|
17
|
+
claims = {
|
|
18
|
+
iss: Patchwork.config.issuer!,
|
|
19
|
+
aud: [ PLATFORM_AUDIENCE, Patchwork.config.audience! ],
|
|
20
|
+
sub: subject,
|
|
21
|
+
iat: now,
|
|
22
|
+
exp: now + (ttl || Patchwork.config.token_ttl),
|
|
23
|
+
jti: SecureRandom.uuid,
|
|
24
|
+
act: PLATFORM_AUDIENCE
|
|
25
|
+
}
|
|
26
|
+
claims[:conn] = connection unless connection.empty?
|
|
27
|
+
|
|
28
|
+
JWT.encode(claims, SigningKey.private_key, SigningKey::ALG, { kid: SigningKey.kid })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Verifies a token you minted, as it comes back to you on a hosted tool
|
|
32
|
+
# call. The algorithm is pinned to RS256 against your own public key, so an
|
|
33
|
+
# HS256 token "signed" with that public key is refused.
|
|
34
|
+
def self.verify(token)
|
|
35
|
+
JWT.decode(
|
|
36
|
+
token.to_s,
|
|
37
|
+
SigningKey.public_key,
|
|
38
|
+
true,
|
|
39
|
+
algorithms: [ SigningKey::ALG ],
|
|
40
|
+
aud: Patchwork.config.audience!,
|
|
41
|
+
verify_aud: true,
|
|
42
|
+
iss: Patchwork.config.issuer!,
|
|
43
|
+
verify_iss: true,
|
|
44
|
+
verify_expiration: true,
|
|
45
|
+
required_claims: %w[sub aud exp iat iss]
|
|
46
|
+
).first.tap do |claims|
|
|
47
|
+
Subject.validate!(claims["sub"])
|
|
48
|
+
enforce_lifetime!(claims)
|
|
49
|
+
end
|
|
50
|
+
rescue JWT::DecodeError, ArgumentError => e
|
|
51
|
+
raise InvalidToken, e.message
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.enforce_lifetime!(claims)
|
|
55
|
+
bound = Patchwork.config.max_token_lifetime
|
|
56
|
+
return if bound.nil?
|
|
57
|
+
|
|
58
|
+
lifetime = claims["exp"].to_i - claims["iat"].to_i
|
|
59
|
+
return if lifetime <= bound
|
|
60
|
+
|
|
61
|
+
raise LifetimeExceeded, "token lifetime of #{lifetime}s exceeds the #{bound}s bound"
|
|
62
|
+
end
|
|
63
|
+
private_class_method :enforce_lifetime!
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
module Patchwork
|
|
5
|
+
module Signature
|
|
6
|
+
SKEW = 300
|
|
7
|
+
HEADER = "Patchwork-Signature".freeze
|
|
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.
|
|
12
|
+
MAX_SIGNATURES = 8
|
|
13
|
+
MAX_HEADER_BYTES = 1024
|
|
14
|
+
TIMESTAMP = /\A\d{1,12}\z/
|
|
15
|
+
HEX_SHA256 = /\A\h{64}\z/
|
|
16
|
+
|
|
17
|
+
Parsed = Struct.new(:timestamp, :signatures, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def self.sign(secret:, timestamp:, method:, path:, body:)
|
|
20
|
+
secret = usable!([ secret ]).first
|
|
21
|
+
mac(secret, payload(timestamp, method, path, digest(body)))
|
|
22
|
+
end
|
|
23
|
+
|
|
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(",")}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Structural parse, with no secret and no body. Returns nil for anything
|
|
31
|
+
# Patchwork would never send, so a malformed header is rejected before the
|
|
32
|
+
# body is read.
|
|
33
|
+
def self.parse(header)
|
|
34
|
+
value = header.to_s
|
|
35
|
+
return nil if value.empty? || value.bytesize > MAX_HEADER_BYTES
|
|
36
|
+
|
|
37
|
+
pairs = value.split(",").map { |pair| pair.strip.split("=", 2) }
|
|
38
|
+
timestamps = pairs.select { |key, _| key == "t" }.map(&:last)
|
|
39
|
+
signatures = pairs.select { |key, _| key == "v1" }.map(&:last)
|
|
40
|
+
return nil unless timestamps.size == 1 && TIMESTAMP.match?(timestamps.first.to_s)
|
|
41
|
+
return nil if signatures.empty? || signatures.size > MAX_SIGNATURES
|
|
42
|
+
|
|
43
|
+
Parsed.new(timestamp: Integer(timestamps.first, 10), signatures: signatures)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.fresh?(parsed, skew: SKEW, now: Time.now.to_i)
|
|
47
|
+
(now - parsed.timestamp).abs <= skew
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.verify(secrets:, header:, method:, path:, body:, skew: SKEW, now: Time.now.to_i)
|
|
51
|
+
secrets = usable!(secrets)
|
|
52
|
+
parsed = parse(header)
|
|
53
|
+
return :bad if parsed.nil?
|
|
54
|
+
return :stale unless fresh?(parsed, skew: skew, now: now)
|
|
55
|
+
|
|
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) }
|
|
61
|
+
|
|
62
|
+
matched = expected.product(candidates).any? { |mine, theirs| secure_compare(mine, theirs) }
|
|
63
|
+
matched ? :ok : :bad
|
|
64
|
+
end
|
|
65
|
+
|
|
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)
|
|
68
|
+
when :stale then raise StaleSignature, "signature timestamp outside the #{skew}s window"
|
|
69
|
+
when :bad then raise InvalidSignature, "signature did not match"
|
|
70
|
+
else true
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.digest(body)
|
|
75
|
+
Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(body.to_s))
|
|
76
|
+
end
|
|
77
|
+
private_class_method :digest
|
|
78
|
+
|
|
79
|
+
def self.payload(timestamp, method, path, digest)
|
|
80
|
+
"#{timestamp}.#{method.to_s.upcase}.#{path}.#{digest}"
|
|
81
|
+
end
|
|
82
|
+
private_class_method :payload
|
|
83
|
+
|
|
84
|
+
def self.mac(secret, payload)
|
|
85
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
|
|
86
|
+
end
|
|
87
|
+
private_class_method :mac
|
|
88
|
+
|
|
89
|
+
# An empty string is a valid HMAC key, so a secret read from an env var that
|
|
90
|
+
# is set but blank — or only whitespace, or a newline from a mounted file —
|
|
91
|
+
# would make every signature forgeable. Refuse it loudly.
|
|
92
|
+
def self.usable!(secrets)
|
|
93
|
+
usable = Array(secrets).map(&:to_s).reject { |secret| secret.strip.empty? }
|
|
94
|
+
raise ConfigurationError, "no signing secret — pass a non-empty secret" if usable.empty?
|
|
95
|
+
|
|
96
|
+
usable
|
|
97
|
+
end
|
|
98
|
+
private_class_method :usable!
|
|
99
|
+
|
|
100
|
+
def self.secure_compare(expected, candidate)
|
|
101
|
+
SecureCompare.call(expected, candidate)
|
|
102
|
+
end
|
|
103
|
+
private_class_method :secure_compare
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module Patchwork
|
|
6
|
+
module SigningKey
|
|
7
|
+
ALG = "RS256".freeze
|
|
8
|
+
MIN_BITS = 2048
|
|
9
|
+
|
|
10
|
+
def self.configured?
|
|
11
|
+
Patchwork.config.signing_key?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.private_key
|
|
15
|
+
@private_key ||= begin
|
|
16
|
+
material = Patchwork.config.signing_key
|
|
17
|
+
raise ConfigurationError, "signing_key is not set" if material.to_s.empty?
|
|
18
|
+
|
|
19
|
+
key = parse(decode(material))
|
|
20
|
+
raise ConfigurationError, "signing_key must be an RSA private key, not a public key" unless key.private?
|
|
21
|
+
raise ConfigurationError, "signing_key must be at least #{MIN_BITS} bits" if key.n.num_bits < MIN_BITS
|
|
22
|
+
|
|
23
|
+
key
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.public_key
|
|
28
|
+
private_key.public_key
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.kid
|
|
32
|
+
configured = Patchwork.config.signing_kid.to_s
|
|
33
|
+
return configured unless configured.empty?
|
|
34
|
+
|
|
35
|
+
@thumbprint ||= JWT::JWK.new(public_key, kid_generator: JWT::JWK::Thumbprint).kid
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.public_jwk
|
|
39
|
+
JWT::JWK.new(public_key, kid: kid).export
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.jwks
|
|
43
|
+
{ keys: [ public_jwk ] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.reset!
|
|
47
|
+
@private_key = nil
|
|
48
|
+
@thumbprint = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.parse(pem)
|
|
52
|
+
# An explicit empty passphrase: an encrypted key fails here instead of
|
|
53
|
+
# blocking on an interactive prompt at boot.
|
|
54
|
+
OpenSSL::PKey::RSA.new(pem, "")
|
|
55
|
+
rescue OpenSSL::PKey::PKeyError
|
|
56
|
+
raise ConfigurationError, "signing_key is not an RSA private key (an EC or encrypted key is not supported)"
|
|
57
|
+
end
|
|
58
|
+
private_class_method :parse
|
|
59
|
+
|
|
60
|
+
def self.decode(material)
|
|
61
|
+
return material if material.include?("-----BEGIN")
|
|
62
|
+
|
|
63
|
+
Base64.strict_decode64(material)
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
raise ConfigurationError, "signing_key is neither PEM nor base64-encoded PEM"
|
|
66
|
+
end
|
|
67
|
+
private_class_method :decode
|
|
68
|
+
end
|
|
69
|
+
end
|