app_identity 1.0.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,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