keypairs 0.1.0.alpha.1 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6674e2a0a6b9e2a995950c220252b13402d1410d58d2102819cf10811275a50
4
- data.tar.gz: dfd6fa3397cbce88a13b405fcf5c4f6fea0dfa20fcd94daba6d7247f0c33aca9
3
+ metadata.gz: b27173f10b5fa527f4bc1c1ea863ee154735fa91afd45b0b050fc07e30d3d4c1
4
+ data.tar.gz: 471a038be596fec5dcac92e16923fde534348288abd000c9a235178244dff4ee
5
5
  SHA512:
6
- metadata.gz: c92758e1f8552b5cc461790e7779a2526d21580a962c8de2126ed441f19a548273fe049924dbf1e80e92edb08becb84fdb00a7877142a9cfcd600bc474df1977
7
- data.tar.gz: d689d8a570e11ff872174a5163411427f9cd189fff972976741bae5c89b01624096f3646bb6194257e8698d6c3e7fe554faf20726b6992bc011e37a6094b7353
6
+ metadata.gz: 7da952c440fb88a52457fa801ac78ea318cdccd7514b44187f5fd7701c36d72e83dbbc6c436789b9fb029680b6c1b4f9197c69bcca7449b74bdc09b00ded9d94
7
+ data.tar.gz: 8bb70edb0ea21824d99841414f3b02bfa467f5e420c3fb7ae1ca61ee593a491123ea7826f455f549beb42f8408399d78afb15a227a399f298f5e8d41ebae15b3
data/README.md CHANGED
@@ -4,13 +4,28 @@ Applications often need to have a public/private keypair so sign messages. This
4
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
5
 
6
6
  ## Installation
7
- Add this line to your application's Gemfile:
7
+ 1. **Add gem**
8
8
 
9
- ```ruby
10
- gem 'keypairs'
11
- ```
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'keypairs'
13
+ ```
14
+
15
+ The of course run `bundle install`.
16
+
17
+ 2. **Copy migration**
18
+
19
+ The default migration file can be copied to your app with:
20
+ ```bash
21
+ bundle exec rails keypairs:install:migrations
22
+ ```
23
+
24
+ Then of course run `bundle exec rails db:migrate`
25
+
26
+ 3. **Setup encryption key**
12
27
 
13
- The of course run `bundle install` and run the migrations `bundle exec rake db:migrate`. The migrations from the gem run automatically.
28
+ The private keys are encrypted with the [lockbox](https://github.com/ankane/lockbox) gem. In order for this to work, you need to set the master key as described in [the readme](https://github.com/ankane/lockbox#key-generation), but the easiest thing is to set the environment variable `LOCKBOX_MASTER_KEY` to a sufficient long string (you can generate one with `Lockbox.generate_key`).
14
29
 
15
30
  ## Usage
16
31
  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.
@@ -4,8 +4,7 @@ class CreateKeypairs < ActiveRecord::Migration[6.0]
4
4
  def change
5
5
  create_table :keypairs do |t|
6
6
  t.string :jwk_kid, null: false
7
- t.string :encrypted__keypair, null: false
8
- t.string :encrypted__keypair_iv, null: false
7
+ t.text :_keypair_ciphertext, null: false
9
8
  t.timestamps precision: 6
10
9
  # Since we are ordering on created_at, let's create an index
11
10
  t.index :created_at
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddValidityToKeypairs < ActiveRecord::Migration[6.0]
4
+ class Keypair < ActiveRecord::Base; end
5
+
6
+ def change # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
7
+ change_table :keypairs, bulk: true do |t|
8
+ t.datetime :not_before, precision: 6
9
+ t.datetime :not_after, precision: 6
10
+ t.datetime :expires_at, precision: 6
11
+ end
12
+
13
+ reversible do |dir|
14
+ dir.up do
15
+ Keypair.find_each do |keypair|
16
+ keypair.update!(
17
+ not_before: keypair.created_at,
18
+ not_after: keypair.created_at + 1.month,
19
+ expires_at: keypair.created_at + 2.months
20
+ )
21
+ end
22
+ end
23
+ end
24
+
25
+ change_column_null :keypairs, :not_before, false
26
+ change_column_null :keypairs, :not_after, false
27
+ change_column_null :keypairs, :expires_at, false
28
+ end
29
+ end
@@ -1,13 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'attr_encrypted'
3
+ require 'lockbox'
4
4
  require 'jwt'
5
5
 
6
6
  # This class contains functionality needed for signing messages
7
7
  # and publishing JWK[s].
8
8
  #
9
- # The last three created keypairs are considered valid, so creating a new Keypair
10
- # will invalidate the second to last created Keypair.
9
+ # Keypairs are considered valid based on their {#not_before}, {#not_after} and {#expires_at} attributes.
10
+ #
11
+ # A keypair can be used for signing if:
12
+ # - The current time is greater than or equal to {#not_before}
13
+ # - The current time is less than or equal to {#not_after}
14
+ #
15
+ # A keypair can be used for validation if:
16
+ # - The current time is less than {#expires_at}.
17
+ #
18
+ # By default, this means that when a key is created, it can be used for signing for 1 month and can still be used
19
+ # for signature validation 1 month after it is not used for signing (i.e. for 2 months since it started being used
20
+ # for signing).
11
21
  #
12
22
  # If you need to sign messages, use the {Keypair.current} keypair for this. This method
13
23
  # performs the rotation of the keypairs if required.
@@ -21,25 +31,36 @@ require 'jwt'
21
31
  # decoded = Keypair.jwt_decode(id_token)
22
32
  #
23
33
  # @attr [String] jwk_kid The public external id of the key used to find the associated key on decoding.
34
+ # @attr [Time] not_before The time before which no payloads may be signed using the keypair.
35
+ # @attr [Time] not_after The time after which no payloads may be signed using the keypair.
36
+ # @attr [Time] expires_at The time after which the keypair may not be used for signature validation.
24
37
  class Keypair < ActiveRecord::Base
25
38
  ALGORITHM = 'RS256'
39
+ ROTATION_INTERVAL = 1.month
26
40
 
27
- attr_encrypted :_keypair, key: Rails.application.secrets.secret_key_base[0, 32]
41
+ encrypts :_keypair
28
42
 
29
43
  validates :_keypair, presence: true
30
44
  validates :jwk_kid, presence: true
45
+ validates :not_before, :expires_at, presence: true
46
+
47
+ validate :not_after_after_not_before
48
+ validate :expires_at_after_not_after
31
49
 
32
50
  after_initialize :set_keypair
51
+ after_initialize :set_validity
33
52
 
34
53
  # @!method valid
35
54
  # @!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)) }
55
+ # Non-expired keypairs are considered valid and can be used to validate signatures and export public jwks.
56
+ scope :valid, -> { where(arel_table[:expires_at].gt(Time.zone.now)) }
39
57
 
40
- # @return [Keypair] the keypair used to sign messages and autorotates if it is older than 1 month.
58
+ # @return [Keypair] the keypair used to sign messages and autorotates if it has expired.
41
59
  def self.current
42
- order(:created_at).where(arel_table[:created_at].gt(1.month.ago)).last || create!
60
+ order(not_before: :asc)
61
+ .where(arel_table[:not_before].lteq(Time.zone.now))
62
+ .where(arel_table[:not_after].gteq(Time.zone.now))
63
+ .last || create!
43
64
  end
44
65
 
45
66
  # The JWK Set of our valid keypairs.
@@ -65,11 +86,26 @@ class Keypair < ActiveRecord::Base
65
86
  #
66
87
  # @see https://www.imsglobal.org/spec/security/v1p0/#h_key-set-url
67
88
  def self.keyset
89
+ valid_keys = valid.order(not_before: :asc).to_a
90
+ # If we don't have any keys or if we don't have a future key (i.e. the last key is the current key)
91
+ while valid_keys.last.nil? || valid_keys.last.not_before <= Time.zone.now
92
+ # There is an automatic fallback to Time.zone.now if not_before is not set
93
+ valid_keys << create!(not_before: valid_keys.last&.not_after)
94
+ end
95
+
68
96
  {
69
- keys: valid.order(created_at: :desc).map(&:public_jwk_export)
97
+ keys: valid_keys.map(&:public_jwk_export)
70
98
  }
71
99
  end
72
100
 
101
+ # @return [Hash] a cached version of the keyset
102
+ # @see #keyset
103
+ def self.cached_keyset
104
+ Rails.cache.fetch('keypairs/Keypair/keyset', expires_in: 12.hours) do
105
+ keyset
106
+ end
107
+ end
108
+
73
109
  # Encodes the payload with the current keypair.
74
110
  # It forewards the call to the instance method {Keypair#jwt_encode}.
75
111
  # @return [String] Encoded JWT token with security credentials.
@@ -172,4 +208,25 @@ class Keypair < ActiveRecord::Base
172
208
  self._keypair ||= OpenSSL::PKey::RSA.new(2048).to_pem
173
209
  self.jwk_kid = public_jwk.kid
174
210
  end
211
+
212
+ # Set the validity timestamps based on the rotation interval.
213
+ def set_validity
214
+ self.not_before ||= created_at || Time.zone.now
215
+ self.not_after ||= not_before + ROTATION_INTERVAL
216
+ self.expires_at ||= not_after + ROTATION_INTERVAL
217
+ end
218
+
219
+ def not_after_after_not_before
220
+ return if not_before.nil? || not_after.nil?
221
+ return if not_after > not_before
222
+
223
+ errors.add(:not_after, 'must be after not before')
224
+ end
225
+
226
+ def expires_at_after_not_after
227
+ return if not_after.nil? || expires_at.nil?
228
+ return if expires_at > not_after
229
+
230
+ errors.add(:expires_at, 'must be after not after')
231
+ end
175
232
  end
@@ -1,18 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Keypairs
4
- # Rails engine for this gem.
5
- # It ensures that the migrations are automatically ran in the applications.
4
+ # This engine is only needed to add the migration installation rake task.
6
5
  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
6
+ engine_name 'keypairs'
17
7
  end
18
8
  end
@@ -18,7 +18,9 @@ module Keypairs
18
18
  # }
19
19
  class PublicKeysController < ActionController::API
20
20
  def index
21
- render json: Keypair.keyset
21
+ # Always cache for 1 week, our rotation interval is much more than a week
22
+ expires_in 1.week, public: true
23
+ render json: Keypair.cached_keyset
22
24
  end
23
25
  end
24
26
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Keypairs
4
- VERSION = '0.1.0.alpha.1'
4
+ VERSION = '1.1.0'
5
5
  end
data/lib/keypairs.rb CHANGED
@@ -1,3 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'keypairs/engine'
3
+ require 'lockbox'
4
+
5
+ autoload :Keypair, 'keypair.rb'
6
+
7
+ # The Keypairs module contains common functionality in support of the {Keypair} model.
8
+ module Keypairs
9
+ autoload :PublicKeysController, 'keypairs/public_keys_controller'
10
+ end
11
+
12
+ require 'keypairs/engine' if defined?(Rails)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keypairs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.alpha.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stef Schenkelaars
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-10-24 00:00:00.000000000 Z
11
+ date: 2021-12-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -39,33 +39,33 @@ dependencies:
39
39
  - !ruby/object:Gem::Version
40
40
  version: '6.0'
41
41
  - !ruby/object:Gem::Dependency
42
- name: attr_encrypted
42
+ name: jwt
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '3.1'
47
+ version: '2.1'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '3.1'
54
+ version: '2.1'
55
55
  - !ruby/object:Gem::Dependency
56
- name: jwt
56
+ name: lockbox
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '2.1'
61
+ version: '0.4'
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '2.1'
68
+ version: '0.4'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: brakeman
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -206,6 +206,20 @@ dependencies:
206
206
  - - ">="
207
207
  - !ruby/object:Gem::Version
208
208
  version: '0'
209
+ - !ruby/object:Gem::Dependency
210
+ name: timecop
211
+ requirement: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - ">="
214
+ - !ruby/object:Gem::Version
215
+ version: '0'
216
+ type: :development
217
+ prerelease: false
218
+ version_requirements: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
209
223
  description: Manage application level keypairs with automatic rotation and JWT support
210
224
  email:
211
225
  - stef.schenkelaars@gmail.com
@@ -215,11 +229,12 @@ extra_rdoc_files: []
215
229
  files:
216
230
  - LICENSE
217
231
  - README.md
218
- - app/controllers/keypairs/public_keys_controller.rb
219
- - app/models/keypair.rb
220
232
  - db/migrate/20201024100500_create_keypairs.rb
233
+ - db/migrate/20201116144600_add_validity_to_keypairs.rb
234
+ - lib/keypair.rb
221
235
  - lib/keypairs.rb
222
236
  - lib/keypairs/engine.rb
237
+ - lib/keypairs/public_keys_controller.rb
223
238
  - lib/keypairs/version.rb
224
239
  homepage: https://drieam.github.io/keypairs
225
240
  licenses:
@@ -235,12 +250,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
235
250
  requirements:
236
251
  - - ">="
237
252
  - !ruby/object:Gem::Version
238
- version: 2.5.0
253
+ version: 2.6.0
239
254
  required_rubygems_version: !ruby/object:Gem::Requirement
240
255
  requirements:
241
- - - ">"
256
+ - - ">="
242
257
  - !ruby/object:Gem::Version
243
- version: 1.3.1
258
+ version: '0'
244
259
  requirements: []
245
260
  rubygems_version: 3.1.4
246
261
  signing_key: