keypairs 0.1.0.alpha.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []