keypairs 0.1.0.alpha.1

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,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c6674e2a0a6b9e2a995950c220252b13402d1410d58d2102819cf10811275a50
4
+ data.tar.gz: dfd6fa3397cbce88a13b405fcf5c4f6fea0dfa20fcd94daba6d7247f0c33aca9
5
+ SHA512:
6
+ metadata.gz: c92758e1f8552b5cc461790e7779a2526d21580a962c8de2126ed441f19a548273fe049924dbf1e80e92edb08becb84fdb00a7877142a9cfcd600bc474df1977
7
+ data.tar.gz: d689d8a570e11ff872174a5163411427f9cd189fff972976741bae5c89b01624096f3646bb6194257e8698d6c3e7fe554faf20726b6992bc011e37a6094b7353
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Stef Schenkelaars
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,57 @@
1
+ # Keypairs
2
+ Applications often need to have a public/private keypair so sign messages. This gem manages your application level key pairs with automatic rotation and support for encoding and decoding [JWTs](https://jwt.io/).
3
+
4
+ Note: This gem is intended to work within Rails applications. It can probably be adjusted easily to also work for non-rails / sinatra project but that's out of scope for now.
5
+
6
+ ## Installation
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'keypairs'
11
+ ```
12
+
13
+ The of course run `bundle install` and run the migrations `bundle exec rake db:migrate`. The migrations from the gem run automatically.
14
+
15
+ ## Usage
16
+ The central point of this gem is the `Keypair` model which is backed by the `keypairs` table. If you need to sign messages, you can get the current keypair with the `Keypair.current` method. This method performs the rotation of the keypairs if required.
17
+
18
+ You can access the private an public key of the keypair (`OpenSSL::PKey::RSA`) and encrypt and decrypt messages with them:
19
+
20
+ ```ruby
21
+ encoded_message = Keypair.current.private_key.private_decrypt('foobar')
22
+ Keypair.current.public_key.public_decrypt(encoded_message)
23
+ # => 'foobar'
24
+ ```
25
+
26
+ ### JWT support
27
+ You can encode and decode JWTs directly on the class:
28
+ ```ruby
29
+ payload = { foo: 'bar' }
30
+ id_token = Keypair.jwt_encode(payload)
31
+ decoded = Keypair.jwt_decode(id_token)
32
+ ```
33
+
34
+ It's almost always a good idea to add a subject to your payload and pass the same subject during decoding. That way you know that users don't use a key for other purposes (for example a key intended for an OAuth2 flow used as a session key). So for example:
35
+
36
+ ```ruby
37
+ subject = 'MyAppSession'
38
+ payload = { foo: 'bar', subject: subject }
39
+ id_token = Keypair.jwt_encode(payload)
40
+ decoded = Keypair.jwt_decode(id_token, subject: subject)
41
+ ```
42
+
43
+ ### Exposing public keys
44
+ If you want others to validate your messages based on the public keys, you can share the JWK version of you current keys by adding them to your `config/routes.rb`:
45
+
46
+ ```ruby
47
+ get :jwks, to: Keypairs::PublicKeysController.action(:index)
48
+ ```
49
+
50
+ ## Releasing new version
51
+ Publishing a new version is handled by the publish workflow. This workflow publishes a GitHub release to rubygems and GitHub package registry with the version defined in the release.
52
+
53
+ ## Contributing
54
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Drieam/keypairs.
55
+
56
+ ## License
57
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keypairs
4
+ # Endpoint to fetch the current valid keypairs.
5
+ #
6
+ # @example
7
+ # {
8
+ # "keys": [
9
+ # {
10
+ # "kty": "RSA",
11
+ # "n": "wmi......1Gw",
12
+ # "e": "AQAB",
13
+ # "kid": "d8d1d4265d6c34acadce8a42fbbec167db1beaeb6ebbbf7fd555f6eb00bda76e",
14
+ # "alg": "RS256",
15
+ # "use": "sig"
16
+ # }
17
+ # ]
18
+ # }
19
+ class PublicKeysController < ActionController::API
20
+ def index
21
+ render json: Keypair.keyset
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'attr_encrypted'
4
+ require 'jwt'
5
+
6
+ # This class contains functionality needed for signing messages
7
+ # and publishing JWK[s].
8
+ #
9
+ # The last three created keypairs are considered valid, so creating a new Keypair
10
+ # will invalidate the second to last created Keypair.
11
+ #
12
+ # If you need to sign messages, use the {Keypair.current} keypair for this. This method
13
+ # performs the rotation of the keypairs if required.
14
+ #
15
+ # You can also use the +jwt_encode+ and +jwt_decode+ methods directly to encode and
16
+ # securely decode your payloads
17
+ #
18
+ # @example
19
+ # payload = { foo: 'bar' }
20
+ # id_token = Keypair.jwt_encode(payload)
21
+ # decoded = Keypair.jwt_decode(id_token)
22
+ #
23
+ # @attr [String] jwk_kid The public external id of the key used to find the associated key on decoding.
24
+ class Keypair < ActiveRecord::Base
25
+ ALGORITHM = 'RS256'
26
+
27
+ attr_encrypted :_keypair, key: Rails.application.secrets.secret_key_base[0, 32]
28
+
29
+ validates :_keypair, presence: true
30
+ validates :jwk_kid, presence: true
31
+
32
+ after_initialize :set_keypair
33
+
34
+ # @!method valid
35
+ # @!scope class
36
+ # The last 3 keypairs are considered valid and can be used to validate signatures and export public jwks.
37
+ # It uses a subquery to make sure a +find_by+ actually searches only the valid 3 ones.
38
+ scope :valid, -> { where(id: unscoped.order(created_at: :desc).limit(3)) }
39
+
40
+ # @return [Keypair] the keypair used to sign messages and autorotates if it is older than 1 month.
41
+ def self.current
42
+ order(:created_at).where(arel_table[:created_at].gt(1.month.ago)).last || create!
43
+ end
44
+
45
+ # The JWK Set of our valid keypairs.
46
+ # @return [Hash]
47
+ # @example
48
+ # {
49
+ # keys: [{
50
+ # e: "AQAB",
51
+ # use: "sig",
52
+ # alg: "RS256",
53
+ # kty: "RSA",
54
+ # n: "oNqXxxWuX7LlovO5reRNauF6TEFa-RRRl8Dw==...",
55
+ # kid: "1516918956_0"
56
+ # }, {
57
+ # e: "AQAB",
58
+ # use: "sig",
59
+ # alg: "RS256",
60
+ # kty: "RSA",
61
+ # n: "kMfHwTp2dIYybtvU-xzF2E3dRJBNm6g5kTQi8itw==...",
62
+ # kid: "1516918956_1"
63
+ # }]
64
+ # }
65
+ #
66
+ # @see https://www.imsglobal.org/spec/security/v1p0/#h_key-set-url
67
+ def self.keyset
68
+ {
69
+ keys: valid.order(created_at: :desc).map(&:public_jwk_export)
70
+ }
71
+ end
72
+
73
+ # Encodes the payload with the current keypair.
74
+ # It forewards the call to the instance method {Keypair#jwt_encode}.
75
+ # @return [String] Encoded JWT token with security credentials.
76
+ # @param payload [Hash] Hash which should be encoded.
77
+ def self.jwt_encode(payload)
78
+ current.jwt_encode(payload)
79
+ end
80
+
81
+ # Decodes the payload and verifies the signature against the current valid keypairs.
82
+ # @param id_token [String] A JWT that should be decoded.
83
+ # @param options [Hash] options for decoding, passed to {JWT::Decode}.
84
+ # @raise [JWT::DecodeError] or any of it's subclasses if the decoding / validation fails.
85
+ # @return [Hash] Decoded payload hash with indifferent access.
86
+ def self.jwt_decode(id_token, options = {})
87
+ # Add default decoding options
88
+ options.reverse_merge!(
89
+ # Change the default algorithm to match the encoding algorithm
90
+ algorithm: ALGORITHM,
91
+ # Load our own keyset as valid keys
92
+ jwks: keyset,
93
+ # If the `sub` is provided, validate that it matches the payload `sub`
94
+ verify_sub: true
95
+ )
96
+ JWT.decode(id_token, nil, true, options).first.with_indifferent_access
97
+ end
98
+
99
+ # JWT encodes the payload with this keypair.
100
+ # It automatically adds the security attributes +iat+, +exp+ and +nonce+ to the payload.
101
+ # It automatically sets the +kid+ in the header.
102
+ # @param payload [Hash] you have to provide a hash since the security attributes have to be added.
103
+ # @param headers [Hash] you can optionally add additional headers to the JWT.
104
+ def jwt_encode(payload, headers = {})
105
+ # Add security claims to payload
106
+ payload.reverse_merge!(
107
+ # Time at which the Issuer generated the JWT (epoch).
108
+ iat: Time.now.to_i,
109
+
110
+ # Expiration time on or after which the tool MUST NOT accept the ID Token for
111
+ # processing (epoch). This is mostly used to allow some clock skew.
112
+ exp: Time.now.to_i + 5.minutes.to_i,
113
+
114
+ # String value used to associate a tool session with an ID Token, and to mitigate replay
115
+ # attacks. The nonce value is a case-sensitive string.
116
+ nonce: SecureRandom.uuid
117
+ )
118
+
119
+ # Add additional info into the headers
120
+ headers.reverse_merge!(
121
+ # Set the id of they key
122
+ kid: jwk_kid
123
+ )
124
+
125
+ JWT.encode(payload, private_key, ALGORITHM, headers)
126
+ end
127
+
128
+ # Public representation of the keypair in the JWK format.
129
+ # We append the +alg+, and +use+ parameters to our JWK to indicate
130
+ # that our intended use is to generate signatures using +RS256+.
131
+ #
132
+ # +alg+::
133
+ # This (algorithm) parameter identifies the algorithm intended for use with the key.
134
+ # It is based in the {Keypair::ALGORITHM}.
135
+ # The IMS Security framework specifies that the +alg+ value SHOULD be the default of +RS256+.
136
+ # Use of this member is OPTIONAL.
137
+ # +use+::
138
+ # This (public key use) parameter identifies the intended use of the public key.
139
+ # Use of this member is OPTIONAL, unless the application requires its presence.
140
+ #
141
+ # @see https://tools.ietf.org/html/rfc7517#section-4.4
142
+ # @see https://www.imsglobal.org/spec/security/v1p0#authentication-response-validation
143
+ def public_jwk_export
144
+ public_jwk.export.merge(
145
+ alg: ALGORITHM,
146
+ use: 'sig'
147
+ )
148
+ end
149
+
150
+ # @return [OpenSSL::PKey::RSA] {OpenSSL::PKey::RSA} instance loaded with our keypair.
151
+ def private_key
152
+ OpenSSL::PKey::RSA.new(_keypair)
153
+ end
154
+
155
+ # @return [OpenSSL::PKey::RSA] {OpenSSL::PKey::RSA} instance loaded with the public part our keypair.
156
+ delegate :public_key, to: :private_key
157
+
158
+ private
159
+
160
+ # @return [JWT::JWK] {JWT::JWK} instance with the public part of our keypair.
161
+ def public_jwk
162
+ JWT::JWK.create_from(public_key)
163
+ end
164
+
165
+ # Generate a new keypair with a key_size of 2048. Keys less than 1024 bits should be
166
+ # considered insecure.
167
+ #
168
+ # See:
169
+ # https://ruby-doc.org/stdlib-2.6.5/libdoc/openssl/rdoc/OpenSSL/PKey/RSA.html#method-c-new
170
+ def set_keypair
171
+ # The generated keypair is stored in PEM encoding.
172
+ self._keypair ||= OpenSSL::PKey::RSA.new(2048).to_pem
173
+ self.jwk_kid = public_jwk.kid
174
+ end
175
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateKeypairs < ActiveRecord::Migration[6.0]
4
+ def change
5
+ create_table :keypairs do |t|
6
+ t.string :jwk_kid, null: false
7
+ t.string :encrypted__keypair, null: false
8
+ t.string :encrypted__keypair_iv, null: false
9
+ t.timestamps precision: 6
10
+ # Since we are ordering on created_at, let's create an index
11
+ t.index :created_at
12
+ t.index :jwk_kid, unique: true
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'keypairs/engine'
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keypairs
4
+ # Rails engine for this gem.
5
+ # It ensures that the migrations are automatically ran in the applications.
6
+ class Engine < ::Rails::Engine
7
+ initializer :append_migrations do |app|
8
+ unless app.root.to_s.match? "#{root}/"
9
+ config.paths['db/migrate'].expanded.each do |expanded_path|
10
+ app.config.paths['db/migrate'] << expanded_path
11
+ end
12
+ # Apartment will modify this, but it doesn't fully support engine migrations,
13
+ # so we'll reset it here
14
+ ActiveRecord::Migrator.migrations_paths = app.paths['db/migrate'].to_a
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keypairs
4
+ VERSION = '0.1.0.alpha.1'
5
+ end
metadata ADDED
@@ -0,0 +1,249 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: keypairs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.alpha.1
5
+ platform: ruby
6
+ authors:
7
+ - Stef Schenkelaars
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-10-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actionpack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: attr_encrypted
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: jwt
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.1'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.1'
69
+ - !ruby/object:Gem::Dependency
70
+ name: brakeman
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: combustion
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: database_cleaner-active_record
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec-github
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rspec-rails
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rubocop
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rubocop-performance
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: rubocop-rails
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ - !ruby/object:Gem::Dependency
182
+ name: shoulda-matchers
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ - !ruby/object:Gem::Dependency
196
+ name: sqlite3
197
+ requirement: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - ">="
200
+ - !ruby/object:Gem::Version
201
+ version: '0'
202
+ type: :development
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ version: '0'
209
+ description: Manage application level keypairs with automatic rotation and JWT support
210
+ email:
211
+ - stef.schenkelaars@gmail.com
212
+ executables: []
213
+ extensions: []
214
+ extra_rdoc_files: []
215
+ files:
216
+ - LICENSE
217
+ - README.md
218
+ - app/controllers/keypairs/public_keys_controller.rb
219
+ - app/models/keypair.rb
220
+ - db/migrate/20201024100500_create_keypairs.rb
221
+ - lib/keypairs.rb
222
+ - lib/keypairs/engine.rb
223
+ - lib/keypairs/version.rb
224
+ homepage: https://drieam.github.io/keypairs
225
+ licenses:
226
+ - MIT
227
+ metadata:
228
+ homepage_uri: https://drieam.github.io/keypairs
229
+ source_code_uri: https://github.com/Drieam/keypairs
230
+ post_install_message:
231
+ rdoc_options: []
232
+ require_paths:
233
+ - lib
234
+ required_ruby_version: !ruby/object:Gem::Requirement
235
+ requirements:
236
+ - - ">="
237
+ - !ruby/object:Gem::Version
238
+ version: 2.5.0
239
+ required_rubygems_version: !ruby/object:Gem::Requirement
240
+ requirements:
241
+ - - ">"
242
+ - !ruby/object:Gem::Version
243
+ version: 1.3.1
244
+ requirements: []
245
+ rubygems_version: 3.1.4
246
+ signing_key:
247
+ specification_version: 4
248
+ summary: Manage application level keypairs with automatic rotation and JWT support
249
+ test_files: []