app_identity 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "validation"
5
+ require_relative "versions"
6
+
7
+ class AppIdentity::Internal # :nodoc:
8
+ include AppIdentity::Validation
9
+
10
+ class << self
11
+ private :new
12
+
13
+ def instance
14
+ @instance ||= new
15
+ end
16
+
17
+ def generate_proof!(app, **kwargs)
18
+ instance.generate_proof!(app, **kwargs)
19
+ end
20
+
21
+ def parse_proof!(proof)
22
+ instance.parse_proof!(proof)
23
+ end
24
+
25
+ def verify_proof!(proof, app, **kwargs)
26
+ instance.verify_proof!(proof, app, **kwargs)
27
+ end
28
+ end
29
+
30
+ def generate_proof!(app, nonce: nil, version: nil, disallowed: nil)
31
+ app = AppIdentity::App.new(app)
32
+ version ||= app.version
33
+ nonce ||= app.generate_nonce(version)
34
+
35
+ __generate_proof(app, nonce, version, disallowed: disallowed)
36
+ end
37
+
38
+ def parse_proof!(proof)
39
+ return proof if proof.is_a?(Hash)
40
+
41
+ raise AppIdentity::Error, "proof must be a string or a map" unless proof.is_a?(String)
42
+
43
+ parts = Base64.decode64(proof).split(":", -1)
44
+
45
+ case parts.length
46
+ when 4
47
+ version, id, nonce, padlock = parts
48
+
49
+ version = validate_version(version)
50
+ AppIdentity::Versions.allowed!(version)
51
+
52
+ {version: version, id: id, nonce: nonce, padlock: padlock}
53
+ when 3
54
+ id, nonce, padlock = parts
55
+
56
+ {version: 1, id: id, nonce: nonce, padlock: padlock}
57
+ else
58
+ raise AppIdentity::Error, "proof must have 3 parts (version 1) or 4 parts (any version)"
59
+ end
60
+ end
61
+
62
+ def verify_proof!(proof, app, disallowed: nil)
63
+ proof = parse_proof!(proof)
64
+
65
+ app = app.call(proof) if app.respond_to?(:call)
66
+ app = AppIdentity::App.new(app)
67
+
68
+ raise AppIdentity::Error, "proof and app do not match" unless app.id == proof[:id]
69
+ raise AppIdentity::Error, "proof and app version mismatch" if app.version > proof[:version]
70
+ AppIdentity::Versions.allowed!(proof[:version], disallowed)
71
+
72
+ valid_nonce!(proof[:nonce], proof[:version], app.config)
73
+ validate_padlock(proof[:padlock])
74
+
75
+ padlock = __generate_padlock(app, proof[:nonce], proof[:version])
76
+
77
+ compare_padlocks(padlock, proof[:padlock]) ? app.verify : nil
78
+ end
79
+
80
+ private
81
+
82
+ def __generate_proof(app, nonce, version, disallowed: nil)
83
+ AppIdentity::Versions.allowed!(version, disallowed)
84
+
85
+ padlock = __generate_padlock(app, nonce, version)
86
+
87
+ return unless padlock
88
+
89
+ parts =
90
+ case version
91
+ when 1
92
+ [app.id, nonce, padlock]
93
+ else
94
+ [version, app.id, nonce, padlock]
95
+ end
96
+
97
+ Base64.urlsafe_encode64(parts.join(":"))
98
+ end
99
+
100
+ def __generate_padlock(app, nonce, version)
101
+ versions_compatible!(app.version, version)
102
+ valid_nonce!(nonce, version, app.config)
103
+ AppIdentity::Versions[version].make_digest([app.id, nonce, app.secret.call].join(":"))
104
+ end
105
+
106
+ def versions_compatible!(app_version, version)
107
+ return if app_version <= version
108
+ raise AppIdentity::Error, "app version #{app_version} is not compatible with proof version #{version}"
109
+ end
110
+
111
+ def valid_nonce!(nonce, version, config)
112
+ AppIdentity::Versions[version].check_nonce!(nonce, config)
113
+ end
114
+
115
+ def compare_padlocks(generated, provided)
116
+ return false if generated.nil? || generated.empty?
117
+ return false if provided.nil? || provided.empty?
118
+ return false if generated.length != provided.length
119
+
120
+ generated = generated.upcase.unpack("C*")
121
+ provided = provided.upcase.unpack("C*")
122
+
123
+ generated.zip(provided).map { |a, b| a ^ b }.reduce(:+) == 0
124
+ end
125
+
126
+ def generate_version_nonce(version)
127
+ version = validate_version(version)
128
+ AppIdentity[validate_version(version)][:nonce].call
129
+ end
130
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "app_identity"
5
+ require "app_identity/error"
6
+ require "set"
7
+
8
+ # A Rack middleware that verifies App Identity proofs provided via one or more
9
+ # HTTP headers.
10
+ #
11
+ # When multiple proof values are provided in the request, all must be
12
+ # successfully verified. If any of the proof values cannot be verified, request
13
+ # processing halts with `403 Forbidden`. Should no proof headers are included,
14
+ # the request is considered invalid.
15
+ #
16
+ # All of the above behaviours can be modified through configuration (see below).
17
+ #
18
+ # The results of completed proof validations can be found at
19
+ # env["app_identity"], regardless of success or failure.
20
+ #
21
+ # ### Configuration
22
+ #
23
+ # The Rack middlware can be configured with the following options:
24
+ #
25
+ # - `apps`: A list of AppIdentity::App objects or objects that can be converted
26
+ # into AppIdentity::App objects to be used for proof validation. Duplicate
27
+ # values will be ignored.
28
+ #
29
+ # - `disallowed`: A list of algorithm versions that are not allowed when
30
+ # processing received identity proofs. See AppIdentity::Versions.allowed?.
31
+ #
32
+ # - `finder`: A 1-arity callable function that will load an input to
33
+ # AppIdentity::App.new from an external source given a parsed proof.
34
+ #
35
+ # - `headers`: A list of HTTP header names.
36
+ #
37
+ # - `on_failure`: The behaviour of the Rack middleware when proof validation
38
+ # fails. Must be one of the following values:
39
+ #
40
+ # - `:forbidden`: Halt request processing and respond with a `403`
41
+ # (forbidden) status. This is the same as `[:halt, :forbidden]`. This is
42
+ # the default `on_failure` behaviour.
43
+ #
44
+ # - `[:halt, status]`: Halt request processing and return the specified
45
+ # status code. An empty body is emitted.
46
+ #
47
+ # - `[:halt, status, body]`: Halt request processing and return the
48
+ # specified status code. The body value is included in the response.
49
+ #
50
+ # - `:continue`: Continue processing, ensuring that failure states are
51
+ # recorded for the application to act on at a later point. This could be
52
+ # used to implement a distinction between *validating* a proof and
53
+ # *requiring* that the proof is valid.
54
+ #
55
+ # - A 1-arity callable accepting the Rack `env` value and returns one of the
56
+ # above values.
57
+ #
58
+ # At least one of `apps` or `finder` **must** be supplied. If both are present,
59
+ # apps are looked up in the `apps` list first.
60
+ #
61
+ # ```ruby
62
+ # use AppIdentity::RackMiddleware, header: "application-identity",
63
+ # finder: ->(proof) { ApplicationModel.find(proof[:id]) }
64
+ # ```
65
+ class AppIdentity::RackMiddleware
66
+ def initialize(app, options = {}) # :nodoc:
67
+ @app = app
68
+
69
+ if !options.has_key?(:apps) && !options.has_key?(:finder)
70
+ raise AppIdentity::Error, :plug_missing_apps_or_finder
71
+ end
72
+
73
+ @apps = get_apps(options)
74
+ @finder = options[:finder]
75
+
76
+ if @apps.empty? && @finder.nil?
77
+ raise "One of `apps` or `finder` options is required."
78
+ end
79
+
80
+ @disallowed = get_disallowed(options)
81
+ @headers = get_headers(options)
82
+ @on_failure = get_on_failure(options)
83
+ end
84
+
85
+ def call(env) # :nodoc:
86
+ headers = verify_headers(Hash[*@headers.flat_map { |h| [h, env[h]] }])
87
+
88
+ env["app_identity"] = headers
89
+
90
+ if has_errors?(headers)
91
+ dispatch_on_failure(@on_failure, env)
92
+ else
93
+ @app.call(env)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def dispatch_on_failure(on_failure, env)
100
+ if on_failure.respond_to?(:call)
101
+ dispatch_on_failure(on_failure.call(env), env)
102
+ elsif on_failure == :forbidden
103
+ halt
104
+ elsif on_failure == :continue
105
+ @app.call(env)
106
+ elsif on_failure.is_a?(Array) && on_failure.first == :halt
107
+ _, status, body = on_failure
108
+ halt(status, body)
109
+ end
110
+ end
111
+
112
+ def has_errors?(headers)
113
+ headers.empty? || headers.any? { |_k, v| v.nil? || v.empty? || v.any? { |vv| vv.nil? || !vv.verified } }
114
+ end
115
+
116
+ def halt(status = nil, body = nil)
117
+ status ||= :forbidden
118
+ status = Rack::Utils.status_code(status) if status.is_a?(Symbol)
119
+
120
+ body = Array(body)
121
+
122
+ length = body.empty? ? "0" : body.length.to_s
123
+
124
+ [status, {"Content-Type" => "text/plain", "Content-Length" => length}, body]
125
+ end
126
+
127
+ def verify_headers(headers)
128
+ headers.each_with_object({}) { |(header, values), result|
129
+ next if values.nil? || values.empty?
130
+
131
+ # If Rack can start returning arrays, we are ready.
132
+
133
+ result[header] = Array(values).each_with_object([]) { |value, list|
134
+ break list if verify_header_value(value, list) == :halt
135
+ }
136
+ }
137
+ end
138
+
139
+ def verify_header_value(value, list)
140
+ proof = AppIdentity.parse_proof(value)
141
+ return handle_proof_error(list, nil) unless proof
142
+
143
+ app = @apps[proof[:id]]
144
+
145
+ if app.nil? && @finder
146
+ app = AppIdentity::App.new(finder.call(proof))
147
+ @apps[app.id] = app
148
+ end
149
+
150
+ return handle_proof_error(list, nil) unless app
151
+
152
+ verified = AppIdentity.verify_proof(proof, app, disallowed: @disallowed)
153
+
154
+ if verified
155
+ list << verified
156
+ :cont
157
+ else
158
+ handle_proof_error(list, verified)
159
+ end
160
+ end
161
+
162
+ def handle_proof_error(list, value)
163
+ case @on_failure
164
+ when :continue
165
+ list << value
166
+ :cont
167
+ when :forbidden, Array
168
+ :halt
169
+ else
170
+ list << value
171
+ :cont
172
+ end
173
+ end
174
+
175
+ def get_request_headers(env)
176
+ Hash[*@headers.lazy.flat_map { |header| [header, env[header]] }]
177
+ end
178
+
179
+ def get_apps(options)
180
+ options.fetch(:apps, []).each_with_object({}) { |input, map|
181
+ app = AppIdentity::App.new(input)
182
+ map[app.id] = app unless map.key?(app.id)
183
+ }
184
+ end
185
+
186
+ def get_disallowed(options)
187
+ disallowed = options[:disallowed] || []
188
+
189
+ if disallowed.is_a?(Set)
190
+ disallowed
191
+ elsif disallowed.is_a?(Array)
192
+ Set.new(disallowed)
193
+ else
194
+ raise AppIdentity::Error, :plug_disallowed_invalid
195
+ end
196
+ end
197
+
198
+ def get_headers(options)
199
+ headers = options[:headers] || []
200
+
201
+ raise AppIdentity::Error, :plug_headers_required if !headers.is_a?(Array) || headers.empty?
202
+
203
+ headers.map { |header| parse_option_header(header) }
204
+ end
205
+
206
+ def get_on_failure(options)
207
+ resolve_on_failure_option(options[:on_failure] || :forbidden)
208
+ end
209
+
210
+ def parse_option_header(header)
211
+ if !header.is_a?(String) || header.empty?
212
+ raise AppIdentity::Error, :plug_header_invalid
213
+ end
214
+
215
+ if /\AHTTP_[A-Z_0-9]+\z/.match?(header)
216
+ header
217
+ else
218
+ -"HTTP_#{header.to_s.tr("-", "_").upcase}"
219
+ end
220
+ end
221
+
222
+ def resolve_on_failure_option(value)
223
+ valid =
224
+ case value
225
+ when :forbidden, :continue
226
+ true
227
+ when Array
228
+ case [value.first, value.length, value[1].class]
229
+ when [:halt, 2, Symbol], [:halt, 2, Integer], [:halt, 3, Symbol], [:halt, 3, Integer]
230
+ true
231
+ end
232
+ else
233
+ value.respond_to?(:call)
234
+ end
235
+
236
+ if valid
237
+ value
238
+ else
239
+ raise AppIdentity::Error, :plug_on_failure_invalid
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ class AppIdentity
6
+ module Validation # :nodoc:
7
+ def validate_id(id) # :nodoc:
8
+ id.tap {
9
+ validate_not_nil(:id, id)
10
+ validate_not_empty(:id, id.to_s)
11
+ validate_no_colons(:id, id.to_s)
12
+ }.to_s
13
+ end
14
+
15
+ def validate_secret(secret) # :nodoc:
16
+ secret.tap {
17
+ secret = secret.call if secret.respond_to?(:call)
18
+
19
+ validate_not_nil(:secret, secret)
20
+ raise AppIdentity::Error, "secret must be a binary string value" unless secret.is_a?(String)
21
+ validate_not_empty(:secret, secret)
22
+ }
23
+ end
24
+
25
+ def validate_version(version) # :nodoc:
26
+ version.tap {
27
+ validate_not_nil(:version, version)
28
+
29
+ begin
30
+ version = Integer(version) if version.is_a?(String)
31
+ rescue
32
+ raise AppIdentity::Error, "version cannot be converted to an integer"
33
+ end
34
+
35
+ if !version.is_a?(Integer) || version <= 0
36
+ raise AppIdentity::Error, "version must be a positive integer"
37
+ end
38
+ }.to_i
39
+ end
40
+
41
+ def validate_config(config) # :nodoc:
42
+ config.tap {
43
+ raise AppIdentity::Error, "config must be nil or a map" unless config.nil? || config.is_a?(Hash)
44
+
45
+ if config.is_a?(Hash)
46
+ fuzz = config[:fuzz] || config["fuzz"]
47
+
48
+ case fuzz
49
+ when nil
50
+ nil
51
+ when Integer
52
+ raise AppIdentity::Error, "config.fuzz must be a positive integer or nil" unless fuzz > 0
53
+ else
54
+ raise AppIdentity::Error, "config.fuzz must be a positive integer or nil"
55
+ end
56
+ end
57
+ }
58
+ end
59
+
60
+ def validate_padlock(padlock) # :nodoc:
61
+ padlock.tap {
62
+ validate_not_nil(:padlock, padlock)
63
+ raise AppIdentity::Error, "padlock must be a string" unless padlock.is_a?(String)
64
+ validate_not_empty(:padlock, padlock)
65
+ validate_no_colons(:padlock, padlock)
66
+ }
67
+ end
68
+
69
+ private
70
+
71
+ def validate_not_nil(type, value)
72
+ raise AppIdentity::Error, "#{type} must not be nil" if value.nil?
73
+ end
74
+
75
+ def validate_not_empty(type, value)
76
+ raise AppIdentity::Error, "#{type} must not be an empty string" if value.empty?
77
+ end
78
+
79
+ def validate_no_colons(type, value)
80
+ raise AppIdentity::Error, "#{type} must not contain colons" if /:/.match?(value)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "digest/sha2"
5
+ require "securerandom"
6
+ require "set"
7
+
8
+ require_relative "validation"
9
+ require_relative "error"
10
+
11
+ class AppIdentity
12
+ module Versions # :nodoc:
13
+ class Base # :nodoc:
14
+ class << self
15
+ def defined # :nodoc:
16
+ @defined ||= {}
17
+ end
18
+
19
+ def instance # :nodoc:
20
+ @_instance = new
21
+ end
22
+
23
+ def inherited(subclass) # :nodoc:
24
+ match = /V(?<version>\d+)\z/.match(subclass.name)
25
+ return unless match
26
+ version = match[:version].to_i
27
+
28
+ raise AppIdentity::Error, "version is not unique" if Base.defined.has_key?(version)
29
+
30
+ subclass.define_method(:version) { version }
31
+
32
+ Base.defined[version] = subclass.instance
33
+ end
34
+
35
+ private :new
36
+ end
37
+
38
+ def make_digest(raw) # :nodoc:
39
+ digest_algorithm.hexdigest(raw).upcase
40
+ end
41
+
42
+ def inspect # :nodoc:
43
+ parts = [self.class]
44
+ parts << "version=#{version}" if respond_to?(:version)
45
+ parts << "nonce=#{nonce_type}" if respond_to?(:nonce_type)
46
+ parts << "digest=#{digest_algorithm}" if respond_to?(:digest_algorithm)
47
+ "#<#{parts.join(" ")}>"
48
+ end
49
+
50
+ def check_nonce!(nonce, _config) # :nodoc:
51
+ raise AppIdentity::Error, "nonce must not be nil" if nonce.nil?
52
+ raise AppIdentity::Error, "nonce must be a string" unless nonce.is_a?(String)
53
+ raise AppIdentity::Error, "nonce must not be blank" if nonce.empty?
54
+ raise AppIdentity::Error, "nonce must not contain colon characters" if /:/.match?(nonce)
55
+ end
56
+ end
57
+
58
+ class RandomNonce < Base # :nodoc:
59
+ def nonce_type # :nodoc:
60
+ :random
61
+ end
62
+
63
+ def generate_nonce # :nodoc:
64
+ SecureRandom.urlsafe_base64(32)
65
+ end
66
+ end
67
+
68
+ class TimestampNonce < Base # :nodoc:
69
+ def nonce_type # :nodoc:
70
+ :timestamp
71
+ end
72
+
73
+ def generate_nonce # :nodoc:
74
+ Time.now.utc.strftime("%Y%m%dT%H%M%S.%6NZ")
75
+ end
76
+
77
+ def check_nonce!(nonce, config) # :nodoc:
78
+ super(nonce, config)
79
+
80
+ timestamp = parse_timestamp!(nonce)
81
+ config ||= default_config
82
+ fuzz = config[:fuzz] || config["fuzz"] || 600
83
+
84
+ diff = (Time.now.utc - timestamp).abs.to_i
85
+
86
+ raise AppIdentity::Error, "nonce is invalid" unless diff <= fuzz
87
+ end
88
+
89
+ private
90
+
91
+ def default_config
92
+ {fuzz: 600}
93
+ end
94
+
95
+ def parse_timestamp!(nonce)
96
+ Time.strptime(nonce, "%Y%m%dT%H%M%S.%N%Z").tap { |value| raise if value.nil? }.utc
97
+ rescue
98
+ raise AppIdentity::Error, "nonce does not look like a timestamp"
99
+ end
100
+ end
101
+
102
+ # V1 is the original algorithm, using a permanent nonce value and SHA256
103
+ # digests. The use of this version is strongly discouraged for new clients.
104
+ class V1 < RandomNonce # :nodoc:
105
+ def digest_algorithm # :nodoc:
106
+ Digest::SHA256
107
+ end
108
+ end
109
+
110
+ # V2 uses a timestamp-based nonce value with SHA256 digests.
111
+ #
112
+ # The nonce values will be verified to be within plus or minus a configured
113
+ # number of seconds.
114
+ class V2 < TimestampNonce # :nodoc:
115
+ def digest_algorithm # :nodoc:
116
+ Digest::SHA256
117
+ end
118
+ end
119
+
120
+ # V3 uses a timestamp-based nonce value with SHA384 digests.
121
+ #
122
+ # The nonce values will be verified to be within plus or minus a configured
123
+ # number of seconds.
124
+ class V3 < TimestampNonce # :nodoc:
125
+ def digest_algorithm # :nodoc:
126
+ Digest::SHA384
127
+ end
128
+ end
129
+
130
+ # V4 uses a timestamp-based nonce value with SHA512 digests.
131
+ #
132
+ # The nonce values will be verified to be within plus or minus a configured
133
+ # number of seconds.
134
+ class V4 < TimestampNonce # :nodoc:
135
+ def digest_algorithm # :nodoc:
136
+ Digest::SHA512
137
+ end
138
+ end
139
+
140
+ class << self
141
+ include AppIdentity::Validation
142
+
143
+ # Looks up the version instance by version.
144
+ def [](version) # :nodoc:
145
+ AppIdentity::Versions::Base.defined.fetch(version)
146
+ end
147
+
148
+ # Checks to see if the version has been defined.
149
+ def valid?(version) # :nodoc:
150
+ AppIdentity::Versions::Base.defined.has_key?(version)
151
+ end
152
+
153
+ # Tests that the version is valid or raises an exception.
154
+ def valid!(version) # :nodoc:
155
+ return true if valid?(version)
156
+ raise AppIdentity::Error, "version must be one of #{AppIdentity::Versions::Base.defined.keys}"
157
+ end
158
+
159
+ # Checks to see if the version is valid and not explicitly disallowed,
160
+ # either in the provided list or in global list.
161
+ def allowed?(version, provided = nil)
162
+ valid?(version) && !disallowed.member?(version) &&
163
+ !Set.new(provided).member?(version)
164
+ end
165
+
166
+ # Tests that the version is valid and not explicitly disallowed or raises
167
+ # an exception.
168
+ def allowed!(version, provided = nil)
169
+ return true if valid!(version) && allowed?(version, provided)
170
+ raise AppIdentity::Error, "version #{version} has been disallowed"
171
+ end
172
+
173
+ # Globally disallow the listed versions.
174
+ def disallow(*versions)
175
+ disallowed.merge(coerce_versions(versions))
176
+ end
177
+
178
+ # Globally allow the listed versions.
179
+ def allow(*versions)
180
+ disallowed.subtract(coerce_versions(versions))
181
+ end
182
+
183
+ private
184
+
185
+ def coerce_versions(versions)
186
+ versions.select { |v| v.is_a?(Integer) }
187
+ end
188
+
189
+ def disallowed
190
+ @disalllowed ||= Set.new
191
+ end
192
+ end
193
+ end
194
+ end