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.
data/spec.md ADDED
@@ -0,0 +1,409 @@
1
+ # App Identity Specification
2
+
3
+ App Identity provides a fast, lightweight, cryptographically secure app
4
+ authentication mechanism as an improvement over just using API keys or app IDs.
5
+ It does this by computing a proof with an application identifier, a nonce, an
6
+ application secret key, and a hashing algorithm. The secret key is embedded in
7
+ client applications and stored securely on the server, so it is never passed
8
+ over the wire.
9
+
10
+ ## Terminology Notes
11
+
12
+ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD",
13
+ "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be
14
+ interpreted as described in [RFC2199][rfc2119].
15
+
16
+ Only the text of this specification is _normative_. Code examples in this
17
+ document are _informative_ and may have bugs.
18
+
19
+ ## Version and Versioning
20
+
21
+ This specification is versioned with a modified [Semantic Versioning][] scheme.
22
+ The major version of the specification will always be the highest algorithms
23
+ version defined. Minor versions may adjust the text of the specification.
24
+
25
+ As the current specification defines four algorithm versions, the current
26
+ specification is `4.0`.
27
+
28
+ ## Application
29
+
30
+ For the purposes of this specification, an `application` requires the following
31
+ attributes:
32
+
33
+ - `id`: The unique identifier of the application. It is recommended that this
34
+ value is a `UUID`. When using an integer identifier, it is recommended that
35
+ this value be extended, such as that provided by Rails [global ID][global id].
36
+ Such representations are _also_ recommended if the ID is a compound value.
37
+ Non-string identifiers must be converted to string values.
38
+
39
+ - `secret`: The random value used as the secret key. This value _must_ be used
40
+ as presented (if presented as a base-64 value, the secret _is_ the base-64
41
+ value, not a decoded version of it).
42
+
43
+ - `version`: The minimum algorithm version supported by the application, an
44
+ integer value. The reference implementations of App Identity do not currently
45
+ restrict the `version` for compatibility purposes, but new applications
46
+ **should not** use version 1 applications.
47
+
48
+ - `config`: A configuration object. As of this writing, only one key for this
49
+ object is defined when `version` 2 or higher. The `config` only affects proof
50
+ verification.
51
+
52
+ - `fuzz`: The fuzziness of time stamp comparison, in seconds, for version `2`
53
+ or higher algorithms. If not present, defaults to `600` seconds, or ±600
54
+ seconds (±10 minutes). Depending on the nature of the app being verified and
55
+ the expected network conditions, a shorter time period than 600 seconds is
56
+ recommended.
57
+
58
+ ### Suggested Extra Fields
59
+
60
+ The following fields are recommended for use on by proof verifiers.
61
+
62
+ - `code`: A unique text identifier for the application. Not used in the proof
63
+ algorithm, but used to manage the application on the servers.
64
+
65
+ - `name`: A displayable name for the application. Not used in the proof
66
+ algorithm, but used for human consumption.
67
+
68
+ - `type`: The type of the application. Because the server deals with many types
69
+ of applications, this value may be used to _limit_ access to APIs to only type
70
+ of applications.
71
+
72
+ ## Algorithm Versions
73
+
74
+ The algorithm version controls both the shape of the nonce and the specific
75
+ algorithm chosen for hashing. Versions are _strictly upgradeable_. That is,
76
+ a version 1 app can verify version 1, 2, 3, or 4 proofs. However, a version
77
+ 2 app will _never_ validate a version 1 proof.
78
+
79
+ <table>
80
+ <thead>
81
+ <tr>
82
+ <th rowspan=2>Version</th>
83
+ <th rowspan=2>Nonce</th>
84
+ <th rowspan=2>Digest Algorithm</th>
85
+ <th colspan=4>Can Verify</th>
86
+ </tr>
87
+ <tr><th>1</th><th>2</th><th>3</th><th>4</th></tr>
88
+ </thead>
89
+ <tbody>
90
+ <tr><th>1</th><td>random</td><td>SHA 256</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td></tr>
91
+ <tr><th>2</th><td>timestamp ± fuzz</td><td>SHA 256</td><td>⛔️</td><td>✅</td><td>✅</td><td>✅</td></tr>
92
+ <tr><th>3</th><td>timestamp ± fuzz</td><td>SHA 384</td><td>⛔️</td><td>⛔️</td><td>✅</td><td>✅</td></tr>
93
+ <tr><th>4</th><td>timestamp ± fuzz</td><td>SHA 512</td><td>⛔️</td><td>⛔️</td><td>⛔️</td><td>✅</td></tr>
94
+ </tbody>
95
+ </table>
96
+
97
+ ## Identity Proof
98
+
99
+ The client identity proof is a short signed value, composed from the _id_,
100
+ a _nonce_, and an intermediary _padlock_ generated using the _application
101
+ secret_. The application id and secret will be provided securely for
102
+ compile-time inclusion; all care should be taken to ensure that the secret is
103
+ not easily extractable from the application or shared in the clear.
104
+
105
+ The generation of a proof looks like this:
106
+
107
+ padlock = Padlock(Version, Identity, Nonce, Secret)
108
+ proof = Proof(Version, Identity, Nonce, padlock)
109
+
110
+ The verification of a proof looks like this:
111
+
112
+ (Version, Identity, Nonce, padlock) = Parse(proof)
113
+ app = FindApplication(Identity)
114
+ padlockʹ = Padlock(Version, app.Identity, Nonce, app.Secret)
115
+ proofʹ = Proof(Version, Identity, Nonce, padlockʹ)
116
+
117
+ ConstantTimeEqualityComparison(proof, proofʹ)
118
+
119
+ ### Nonce
120
+
121
+ Depending on the version of the application algorithm, the _nonce_ may contain
122
+ any byte sequences _except_ ASCII colon (`:`), but it is recommended that the
123
+ value be UTF-8 safe.
124
+
125
+ #### Random Nonces
126
+
127
+ Version 1 nonces should be cryptographically secure and non-sequential, but
128
+ sufficiently fine-grained timestamps (those including microseconds, as
129
+ `yyyymmddHHMMSS.sss`) _may_ be used. Version 1 proofs verify that the nonce is
130
+ at least one byte long and do not contain a colon (`:`).
131
+
132
+ **Ruby**:
133
+
134
+ ```ruby
135
+ require 'securerandom'
136
+ nonce1 = SecureRandom.urlsafe_base64(32)
137
+ nonce2 = SecureRandom.hex(32)
138
+ ```
139
+
140
+ **Elixir**:
141
+
142
+ ```elixir
143
+ # Elixir
144
+ Base.url_encode64(:crypto.strong_rand_bytes(32), padding: true)
145
+ Base.encode16(:crypto.strong_rand_bytes(32))
146
+ ```
147
+
148
+ **Typescript (Node)**:
149
+
150
+ ```typescript
151
+ import { randomBytes } from 'crypto'
152
+ import base64url from 'base64-url' // https://www.npmjs.com/package/base64-url
153
+
154
+ base64url.encode(randomBytes(32).toString())
155
+ ```
156
+
157
+ **Swift**:
158
+
159
+ ```swift
160
+ func secure_random_base64_bytes(count: Int32 = 16) -> String? {
161
+ var data = Data(count: count)
162
+ let result = data.withUnsafeMutableBytes {
163
+ SecRandomCopyBytes(kSecRandomDefaults, data.count, $0)
164
+ }
165
+ if result == errSecSuccess {
166
+ return data.base64EncodedString()
167
+ } else {
168
+ return nil
169
+ }
170
+ }
171
+
172
+ secure_random_base64_bytes(32)
173
+ ```
174
+
175
+ #### Timestamp Nonces
176
+
177
+ Version 2, 3, and 4 nonces **must** be a UTC timestamp formatted using ISO
178
+ 8601 basic formatting. The timestamp _should_ be generated on a clock synced with
179
+ NTP and _should_ be verified using a clock synced with NTP.
180
+
181
+ For the purposes of this document, ISO 8601 basic formatting uses the
182
+ following [ABNF][] format, adapted from [RFC3339][]:
183
+
184
+ ```abnf
185
+ date-fullyear = 4DIGIT
186
+ date-month = 2DIGIT ; 01-12
187
+ date-mday = 2DIGIT ; 01-28, 01-29, 01-30, 01-31 based on
188
+ ; month/year
189
+ time-hour = 2DIGIT ; 00-23
190
+ time-minute = 2DIGIT ; 00-59
191
+ time-second = 2DIGIT ; 00-58, 00-59, 00-60 based on leap second
192
+ ; rules
193
+ time-secfrac = "." 1\*DIGIT
194
+ time-offset = "Z"
195
+
196
+ partial-time = time-hour time-minute time-second [time-secfrac]
197
+ full-date = date-fullyear date-month date-mday
198
+ full-time = partial-time time-offset
199
+
200
+ date-time = full-date "T" full-time
201
+ ```
202
+
203
+ The timestamp must be an ASCII 7-bit or UTF-8 string using only ASCII
204
+ characters, and the special characters `T` and `Z` **must** be specified
205
+ uppercase.
206
+
207
+ This format differs from [RFC3339][] [§5.6][§5.6] timestamp format in the
208
+ following ways:
209
+
210
+ 1. It **must** be UTC and the timezone character must be `Z`. No other timezone
211
+ specifier is permitted, and it must not be omitted.
212
+ 2. It **must** only have the characters `[.0-9TZ]`. It **may** have the point
213
+ character (`.`) only preceding the _optional_ fractional seconds digits.
214
+
215
+ Therefore, a timestamp of `2020-02-25T23:20:03.321423-04:00` must be presented
216
+ as `20200225T192003.321423Z`.
217
+
218
+ C-style `strftime` formatting for this format would be `'%Y%m%dT%H%M%S.%6NZ'`,
219
+ and a PostgreSQL format for `TO_CHAR()` would `'YYYYMMDD"T"HH24MISS.FF6Z'`.
220
+
221
+ **Ruby**:
222
+
223
+ ```ruby
224
+ require 'time'
225
+ Time.now.utc.strftime('%Y%m%dT%H%M%S.%6NZ')
226
+ ```
227
+
228
+ **Elixir**:
229
+
230
+ ```elixir
231
+ case DateTime.now("Etc/UTC") do
232
+ {:ok, stamp} ->
233
+ {:ok, DateTime.to_iso8601(stamp, :basic)}
234
+
235
+ {:error, reason} ->
236
+ {:error, String.replace(Kernel.to_string(reason), "_", " ")}
237
+ end
238
+ ```
239
+
240
+ **Typescript (Node)**:
241
+
242
+ ```typescript
243
+ new Date().toISOString().replace(/[-:]/g, '')
244
+ ```
245
+
246
+ **Swift**:
247
+
248
+ ```swift
249
+ func secure_random_base64_bytes(count: Int32 = 16) -> String? {
250
+ var data = Data(count: count)
251
+ let result = data.withUnsafeMutableBytes {
252
+ SecRandomCopyBytes(kSecRandomDefaults, data.count, $0)
253
+ }
254
+ if result == errSecSuccess {
255
+ return data.base64EncodedString()
256
+ } else {
257
+ return nil
258
+ }
259
+ }
260
+
261
+ secure_random_base64_bytes(32)
262
+ ```
263
+
264
+ ### Padlock Calculation
265
+
266
+ To compute the padlock value, concatenate the application id, the nonce, and the
267
+ application secret using colons, then calculate the digest of the value and
268
+ convert to a base 16 string representation. As noted previously, the digest
269
+ algorithm used varies based on the application version.
270
+
271
+ **Ruby**:
272
+
273
+ ```ruby
274
+ require 'digest/sha2'
275
+ Digest::SHA256.hexdigest([version, id, nonce, secret].join(':')).upcase
276
+ ```
277
+
278
+ **Elixir**:
279
+
280
+ ```elixir
281
+ [id, nonce, secret]
282
+ |> Enum.join(":")
283
+ |> then(&:crypto.hash(:sha256, &1))
284
+ |> Base.encode16(case: :upper)
285
+ ```
286
+
287
+ **Typescript (Node)**:
288
+
289
+ ```typescript
290
+ import { createHash } from 'crypto'
291
+ const hash = createHash('sha384')
292
+ hash.update(raw, 'utf-8')
293
+ hash.digest('hex').toUpperCase()
294
+ ```
295
+
296
+ **Swift**:
297
+
298
+ ```swift
299
+ // Swift
300
+ extension Data {
301
+ func hexString() -> String {
302
+ return self.map {
303
+ Int($0).hexString()
304
+ }.joined()
305
+ }
306
+
307
+ func SHA256() -> Data {
308
+ var result = Data(count: Int(CC_SHA256_DIGEST_LENGTH))
309
+ _ = result.withUnsafeMutableBytes {
310
+ resultPtr in self.withUnsafeBytes {
311
+ CC_SHA256($0, CC_LONG(count), resultPtr)
312
+ }
313
+ }
314
+ return result
315
+ }
316
+ }
317
+
318
+ extension String {
319
+ func hexString() -> String {
320
+ return self.data(using: .utf8)!.hexString()
321
+ }
322
+
323
+ func SHA256() -> String {
324
+ return self.data(using: .utf8)!.SHA256().hexString()
325
+ }
326
+ }
327
+
328
+ let value = "\(id):\(nonce):\(secret)"
329
+ let padlock = value.SHA256()
330
+ ```
331
+
332
+ **Java**:
333
+
334
+ ```java
335
+ // Java (Android)
336
+ String value = id + ":" + nonce + ":" + secret;
337
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
338
+ byte[] hash = digest.digest(msg.getBytes(StandardCharsets.UTF_8));
339
+ StringBuffer padlock = new StringBuffer();
340
+
341
+ for (int i = 0; i < hash.length; i++) {
342
+ String hex = Integer.toHexString(0xFF & hash[i]);
343
+ if (hex.length() == 1) {
344
+ padlock.append('0');
345
+ }
346
+
347
+ padlock.append(hex);
348
+ }
349
+ ```
350
+
351
+ Validation of the padlock will convert this digest to uppercase, so the values
352
+ `c0ffee` and `C0FFEE` are identical. It is recommended that padlocks be passed
353
+ as uppercase hex values.
354
+
355
+ ### Padlock Presentation
356
+
357
+ The padlock cannot be presented by itself, because the digest used are one-way
358
+ cryptographic hashes. Therefore, the client must supply the id (used to find the
359
+ client definition on the server) and the nonce (because the nonce is generated
360
+ by the client application and not known by the server). This will typically be
361
+ provided as a concatenated, base-64-encoded string:
362
+
363
+ **Ruby**:
364
+
365
+ ```ruby
366
+ require 'base64'
367
+ # Version 1
368
+ Base64.urlsafe_encode64([id, nonce, padlock].join(':'))
369
+ # Version 2+
370
+ Base64.urlsafe_encode64([version, id, nonce, padlock].join(':'))
371
+ ```
372
+
373
+ **Elixir**:
374
+
375
+ ```elixir
376
+ [version, id, nonce, padlock]
377
+ |> Enum.join(":")
378
+ |> Base.urlsafe_encode64(padding: false)
379
+ ```
380
+
381
+ **Swift**:
382
+
383
+ ```swift
384
+ let value = "\(version):\(id):\(nonce):\(padlock)"
385
+ let proof = Data(value.utf8).base64EncodedString()
386
+ ```
387
+
388
+ **Typescript**:
389
+
390
+ ```typescript
391
+ import base64url from 'base64-url' // https://www.npmjs.com/package/base64-url
392
+ const parts = version === 1 ? [id, nonce, padlock] : [version, id, nonce, padlock]
393
+ base64url.encode(parts.join(':'))
394
+ ```
395
+
396
+ **Java**:
397
+
398
+ ```java
399
+ String value = version + ":" + id + ":" + nonce + ":" + padlock.toString();
400
+ byte[] encodedHash = Base64.getEncoder().encode(encodeValue.getBytes());
401
+ String proof = new String(encodedHash, "UTF-8");
402
+ ```
403
+
404
+ [global id]: https://github.com/rails/globalid
405
+ [rfc2119]: https://datatracker.ietf.org/doc/html/rfc2119
406
+ [rfc3339]: https://datatracker.ietf.org/doc/html/rfc3339
407
+ [§5.6]: https://tools.ietf.org/html/rfc3339#section-5.6
408
+ [semantic versioning]: http://semver.org/
409
+ [abnf]: https://www.rfc-editor.org/rfc/rfc2234.txt
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "app_identity/support"
5
+
6
+ class AppIdentity::Suite::Generator # :nodoc:
7
+ include AppIdentity::Validation
8
+ include AppIdentity::Support
9
+
10
+ class << self
11
+ private :new
12
+
13
+ def run(name, options)
14
+ new(name, options).run
15
+ end
16
+ end
17
+
18
+ def initialize(name, options)
19
+ @name = name.first || options[:default_suite]
20
+ @options = options
21
+
22
+ if !options[:stdout] && !@name.end_with?(".json")
23
+ @name =
24
+ if File.directory?(@name)
25
+ File.join(@name, options[:default_suite])
26
+ else
27
+ "#{@name}.json"
28
+ end
29
+ end
30
+ end
31
+
32
+ def run
33
+ suite = generate_suite
34
+
35
+ if !options[:stdout] && !options[:quiet]
36
+ puts "Generated #{suite[:tests].length} tests for #{suite[:name]} #{suite[:version]}."
37
+ end
38
+
39
+ if options[:stdout]
40
+ puts JSON.pretty_generate(suite)
41
+ else
42
+ File.write(name, JSON.generate(suite))
43
+ puts "Saved as #{name}" if !options[:quiet]
44
+ end
45
+
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ def generate_suite
52
+ {
53
+ name: AppIdentity::NAME,
54
+ version: AppIdentity::VERSION,
55
+ spec_version: AppIdentity::SPEC_VERSION,
56
+ tests: [
57
+ *generate_tests("required", load_json(required_tests)),
58
+ *generate_tests("optional", load_json(optional_tests))
59
+ ]
60
+ }
61
+ end
62
+
63
+ def load_json(name)
64
+ JSON.load_file!(name)
65
+ end
66
+
67
+ def required_tests
68
+ File.expand_path("../required.json", __FILE__)
69
+ end
70
+
71
+ def optional_tests
72
+ File.expand_path("../optional.json", __FILE__)
73
+ end
74
+
75
+ def generate_tests(type, input)
76
+ input.map.with_index { |entry, index| generate_test(type, entry, index) }
77
+ end
78
+
79
+ def generate_test(type, input, index)
80
+ normalized = normalize_test(type, input, index)
81
+
82
+ app =
83
+ if normalized[:app]
84
+ make_app(normalized.dig(:app, :version), normalized.dig(:app, :config, :fuzz))
85
+ else
86
+ make_app(1)
87
+ end
88
+
89
+ {
90
+ description: normalized.fetch(:description),
91
+ expect: normalized.fetch(:expect),
92
+ app: app.as_json,
93
+ proof: make_proof(type, normalized, index, app),
94
+ required: type == "required",
95
+ spec_version: normalized.fetch(:spec_version)
96
+ }
97
+ end
98
+
99
+ def normalize_test(type, input, index)
100
+ must_have!(type, input, index, "description")
101
+ must_have!(type, input, index, "expect")
102
+ must_have!(type, input, index, "proof")
103
+ must_have!(type, input, index, "spec_version")
104
+
105
+ if !%w[pass fail].include?(input["expect"])
106
+ fail!(type, input, index, "Invalid expect value '#{input["expect"]}'")
107
+ end
108
+
109
+ test = {
110
+ description: input["description"],
111
+ expect: input["expect"],
112
+ spec_version: input["spec_version"]
113
+ }
114
+
115
+ if input.key?("app")
116
+ tmp = input["app"]
117
+
118
+ must_have!(type, tmp, index, "version", input: input, name: "app.version")
119
+
120
+ app = {version: tmp["version"]}
121
+
122
+ if tmp.key?("config")
123
+ must_have!(type, tmp["config"], index, "fuzz", input: input, name: "app.config.fuzz")
124
+
125
+ app[:config] = {fuzz: tmp.dig("config", "fuzz")}
126
+ end
127
+
128
+ test[:app] = app
129
+ end
130
+
131
+ if input.key?("nonce")
132
+ nonce = {}
133
+ tmp = input["nonce"]
134
+
135
+ if tmp.key?("empty")
136
+ if tmp.key?("offset_minutes") || tmp.key?("value")
137
+ fail!(type, input, index, "nonce must only have one sub-key")
138
+ end
139
+
140
+ nonce[:empty] = !!tmp["empty"]
141
+ elsif tmp.key?("offset_minutes")
142
+ if tmp.key?("value")
143
+ fail!(type, input, index, "nonce must only have one sub-key")
144
+ end
145
+
146
+ nonce[:offset_minutes] = tmp["offset_minutes"]
147
+ elsif tmp.key?("value")
148
+ nonce[:value] = tmp["value"]
149
+ else
150
+ fail!(type, input, index, "nonce requires exactly one sub-key")
151
+ end
152
+
153
+ test[:nonce] = nonce
154
+ end
155
+
156
+ if input.key?("padlock")
157
+ tmp = input["padlock"]
158
+ must_have!(type, tmp, index, "nonce", input: input, name: "padlock.nonce")
159
+ test[:padlock] = {nonce: tmp["nonce"]}
160
+ end
161
+
162
+ tmp = input["proof"]
163
+ must_have!(type, tmp, index, "version", input: input, name: "proof.version")
164
+
165
+ proof = {version: tmp["version"]}
166
+ proof[:id] = tmp["id"] if tmp.key?("id")
167
+ proof[:secret] = tmp["secret"] if tmp.key?("secret")
168
+
169
+ test[:proof] = proof
170
+
171
+ test
172
+ end
173
+
174
+ def make_proof(type, input, index, app)
175
+ version = validate_version(input.fetch(:proof).fetch(:version))
176
+
177
+ nonce =
178
+ if input.dig(:nonce, :empty)
179
+ ""
180
+ elsif (value = input.dig(:nonce, :offset_minutes))
181
+ timestamp_nonce(value)
182
+ elsif (value = input.dig(:nonce, :value))
183
+ value
184
+ else
185
+ app.generate_nonce(version)
186
+ end
187
+
188
+ if input[:padlock]
189
+ padlock = build_padlock(app, {
190
+ id: input.dig(:proof, :id),
191
+ nonce: input.dig(:padlock, :nonce),
192
+ secret: input.dig(:proof, :secret),
193
+ version: input.dig(:proof, :version)
194
+ })
195
+
196
+ return build_proof(app, padlock, {
197
+ id: input.dig(:proof, :id),
198
+ nonce: nonce,
199
+ secret: input.dig(:proof, :secret),
200
+ version: input.dig(:proof, :version)
201
+ })
202
+ end
203
+
204
+ if input.dig(:proof, :id) || input.dig(:proof, :secret) || input[:nonce]
205
+ padlock = build_padlock(app,
206
+ id: input.dig(:proof, :id),
207
+ nonce: nonce,
208
+ secret: input.dig(:proof, :secret),
209
+ version: input.dig(:proof, :version))
210
+
211
+ return build_proof(app, padlock,
212
+ id: input.dig(:proof, :id),
213
+ nonce: nonce,
214
+ secret: input.dig(:proof, :secret),
215
+ version: input.dig(:proof, :version))
216
+ end
217
+
218
+ AppIdentity::Internal.generate_proof!(app, nonce: nonce, version: version)
219
+ rescue => ex
220
+ fail!(type, input, index, ex.message)
221
+ end
222
+
223
+ def fail!(type, input, index, message)
224
+ extra = ""
225
+
226
+ if message.is_a?(Exception)
227
+ extra = "\n#{message.backtrace.join("\n")}"
228
+ message = message.message
229
+ end
230
+
231
+ raise "Error in #{type} item #{index + 1}: #{message}\n" +
232
+ JSON.pretty_generate(input) +
233
+ extra
234
+ end
235
+
236
+ def must_have!(type, input, index, key, options = {})
237
+ return if input.key?(key)
238
+ fail!(type, options[:input] || input, index, "missing #{options[:name] || key}")
239
+ end
240
+
241
+ attr_reader :name, :options
242
+ end