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.
@@ -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