usps-jwt_auth 1.1.0 → 1.2.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: be13d7639636f1abce28a82c032ea04883808bec8bb3925060f3f2071e1bf3ef
4
- data.tar.gz: 8486bf2584dbcc901f00e17ef059ec3125db036f76f7c81080d68125177f50b0
3
+ metadata.gz: a248993ec7c0ad7e97d4bfcb7e89302f3366f7c3fd95c122f71d116f9c541bcf
4
+ data.tar.gz: ef4e072d124dd0f398fbb80202523c3a99b5a25220c48dfeaf27c8a732800259
5
5
  SHA512:
6
- metadata.gz: 2a8d5e69bd26ed1a65da6f4c7beb907aa570e7ae1942c4f256fee3c42e8f39bdcbc938018b626825ac77e5d50a64d6b994534361acfac8fda025c2681f39d4a3
7
- data.tar.gz: 5fc08818ac1c3cd4b13b1005f294d6bfd0d3837fc03fd0609c74235cf9de706d5311fc01aaa44cb3cda34edcad22636f56807f6a0ac7d5e7e7b5c79a43048f5f
6
+ metadata.gz: f38759de429b850a32caa02b412fbbc644dca520f74422bef274fe30dc0f709b272057b81aaf411afce3a7c979bc550e1bb1531dbd228f0185fb6e12bff6ea73
7
+ data.tar.gz: 57c98a347480941fbf0f7c88a27252228eb0198d91ffb11f7259b5abf5dfb79f50b4c6db7113dbbe5fe5748ffa7a90a4fdc172268123741d6b27faec0023124c
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- usps-jwt_auth (1.1.0)
4
+ usps-jwt_auth (1.2.0)
5
5
  activesupport (~> 8.0)
6
6
  colorize (~> 1.1)
7
7
  fileutils (~> 1.7)
data/README.md CHANGED
@@ -39,11 +39,35 @@ Usps::JwtAuth.configure do |config|
39
39
  config.issuer_base = 'usps:1' # ENV['JWT_ISSUER_BASE'] # 'usps:1'
40
40
  config.issuers = ['admin:1'] # ENV['JWT_ISSUERS'] # []
41
41
 
42
+ # Base URL of the shared public-key store (see "Public key resolution").
43
+ config.public_keys_url = 'https://keys.aws.usps.org' # ENV['JWT_PUBLIC_KEYS_URL'] # nil
44
+
45
+ # Optional. Called as `publisher.call(fingerprint, pem)` when an issuer writes a
46
+ # new public key, so it can be pushed to the shared store. Defaults to nil (no-op).
47
+ config.publisher = ->(fingerprint, pem) { S3.put("#{fingerprint}.pub", pem) }
48
+
42
49
  config.is_admin = ->(user) { Pundit.policy(user, :admin).admin? }
43
50
  config.find_member = ->(certificate) { Members::Member.find(certificate) }
44
51
  end
45
52
  ```
46
53
 
54
+ ## Public key resolution
55
+
56
+ A token names its signing key by an SSH SHA256 fingerprint (the `key` claim),
57
+ which is content-addressed — a fingerprint maps to exactly one key, forever.
58
+
59
+ When decoding, the gem first looks for `<fingerprint>.pub` under `public_keys_path`
60
+ (the local cache). On a miss, if `public_keys_url` is set, it fetches
61
+ `<public_keys_url>/<fingerprint>.pub` once, **verifies the fetched key reproduces
62
+ the fingerprint** (so the store is an untrusted cache that cannot forge keys),
63
+ caches it under `public_keys_path`, and uses it. With no `public_keys_url` set,
64
+ only locally cached keys are used.
65
+
66
+ Issuers publish keys to that same store: set `publisher` and the gem pushes each
67
+ newly written public key on encode. Because keys are content-addressed, rotation
68
+ needs no consumer changes — a new key is published and consumers fetch it on first
69
+ sight.
70
+
47
71
  ## Usage
48
72
 
49
73
  ```ruby
@@ -7,7 +7,8 @@ module Usps
7
7
  class Config
8
8
  REQUIRED_OPTIONS = %i[audience is_admin find_member].freeze
9
9
 
10
- attr_accessor :key_size, :algorithm, :issuer_base, :issuers, :audience, :is_admin, :find_member
10
+ attr_accessor :key_size, :algorithm, :issuer_base, :issuers, :audience, :is_admin, :find_member,
11
+ :public_keys_url, :publisher
11
12
  attr_reader :environment
12
13
 
13
14
  def initialize
@@ -19,6 +20,11 @@ module Usps
19
20
  @issuer_base = ENV.fetch('JWT_ISSUER_BASE', 'usps:1')
20
21
  @issuers = ENV.fetch('JWT_ISSUERS', [])
21
22
  @audience = ENV.fetch('JWT_AUDIENCE', nil)
23
+ # Base URL of the shared public-key store (CloudFront). Consumers fetch
24
+ # "<public_keys_url>/<fingerprint>.pub" on a local cache miss. nil disables fetching.
25
+ # `publisher` (defaults to nil) is an optional callable invoked as
26
+ # publisher.call(fingerprint, pem) when an issuer writes a new public key.
27
+ @public_keys_url = ENV.fetch('JWT_PUBLIC_KEYS_URL', nil)
22
28
 
23
29
  yield self if block_given? # Also support setting options on initialize
24
30
  end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'fileutils'
6
+
3
7
  module Usps
4
8
  module JwtAuth
5
9
  # Decode and validate data from a JWT
@@ -32,12 +36,57 @@ module Usps
32
36
 
33
37
  private
34
38
 
39
+ # Resolve the public key the token names by its fingerprint. Fast path is the local
40
+ # cache; on a miss we fetch it once from the shared store and cache it permanently
41
+ # (a fingerprint maps to one key forever). The store is treated as untrusted — see
42
+ # cache_verified_key — so a tampered key can never be trusted.
35
43
  def public_key(token)
36
- OpenSSL::PKey::RSA.new(File.read("#{JwtAuth.config.public_keys_path}/#{fingerprint(token)}.pub"))
44
+ fingerprint = fingerprint(token)
45
+ OpenSSL::PKey::RSA.new(File.read(cache_path(fingerprint)))
37
46
  rescue Errno::ENOENT
38
- # No public key matches the token's fingerprint (e.g. a stale token after key rotation),
39
- # so the token cannot be verified and is treated as invalid.
40
- raise JWT::VerificationError, 'No public key for token fingerprint'
47
+ fetch_public_key(fingerprint)
48
+ end
49
+
50
+ def cache_path(fingerprint)
51
+ "#{JwtAuth.config.public_keys_path}/#{fingerprint}.pub"
52
+ end
53
+
54
+ # Fetch the named key from the shared store, verify it, and cache it locally.
55
+ def fetch_public_key(fingerprint)
56
+ url = JwtAuth.config.public_keys_url
57
+ # No store configured, so a missing local key cannot be resolved (e.g. a stale token
58
+ # after rotation): the token cannot be verified and is treated as invalid.
59
+ raise JWT::VerificationError, 'No public key for token fingerprint' unless url
60
+
61
+ pem = http_get("#{url}/#{fingerprint}.pub")
62
+ cache_verified_key(fingerprint, pem)
63
+ rescue JWT::VerificationError
64
+ raise
65
+ rescue StandardError => e
66
+ raise JWT::VerificationError, "Could not fetch public key for token fingerprint: #{e.message}"
67
+ end
68
+
69
+ # Trust nothing the store returns until the key reproduces the fingerprint the token
70
+ # names. Without this check a compromised store/CDN could serve an attacker key under a
71
+ # known fingerprint and forge valid tokens. Only verified keys are written to the cache.
72
+ def cache_verified_key(fingerprint, pem)
73
+ key = OpenSSL::PKey::RSA.new(pem)
74
+ unless Fingerprint.for(key.public_key) == fingerprint
75
+ raise JWT::VerificationError, 'Fetched public key does not match token fingerprint'
76
+ end
77
+
78
+ path = cache_path(fingerprint)
79
+ FileUtils.mkdir_p(File.dirname(path))
80
+ File.write(path, pem)
81
+ key
82
+ end
83
+
84
+ def http_get(url)
85
+ uri = URI.parse(url)
86
+ response = Net::HTTP.get_response(uri)
87
+ raise "HTTP #{response.code} for #{url}" unless response.is_a?(Net::HTTPSuccess)
88
+
89
+ response.body
41
90
  end
42
91
 
43
92
  def fingerprint(token)
@@ -30,34 +30,14 @@ module Usps
30
30
  @public_key ||= private_key.public_key
31
31
  end
32
32
 
33
- # OpenSSH-style SHA256 key fingerprint: base64url(SHA256(ssh-rsa wire blob)),
34
- # i.e. the value `ssh-keygen -lf` prints as `SHA256:...`. This matches the key
35
- # filenames and `key` claim that the consuming apps already recognize.
33
+ # The OpenSSH-style SHA256 fingerprint of this key see Fingerprint. Stamped onto
34
+ # the token's `key` claim so consumers can resolve and verify the matching public key.
36
35
  def fingerprint
37
- digest = OpenSSL::Digest::SHA256.digest(ssh_public_blob)
38
- [digest].pack('m0').tr('+/', '-_').delete('=')
36
+ Fingerprint.for(public_key)
39
37
  end
40
38
 
41
39
  private
42
40
 
43
- # Public key in the OpenSSH wire format: string('ssh-rsa') + mpint(e) + mpint(n).
44
- def ssh_public_blob
45
- ssh_string('ssh-rsa') + ssh_mpint(public_key.e) + ssh_mpint(public_key.n)
46
- end
47
-
48
- # SSH "string": 4-byte big-endian length prefix followed by the bytes.
49
- def ssh_string(bytes)
50
- [bytes.bytesize].pack('N') + bytes.b
51
- end
52
-
53
- # SSH "mpint": a length-prefixed big-endian integer; a leading zero byte is
54
- # prepended when the high bit is set so the value stays non-negative.
55
- def ssh_mpint(num)
56
- bytes = num.to_s(2)
57
- bytes = "\x00".b + bytes if bytes.bytes.first && bytes.bytes.first >= 0x80
58
- ssh_string(bytes)
59
- end
60
-
61
41
  def expires_at
62
42
  ::Time.now.to_i + @expiration_time
63
43
  end
@@ -94,6 +74,14 @@ module Usps
94
74
 
95
75
  File.open(path, 'w+') { |f| f.puts(public_key) }
96
76
  ::FileUtils.chmod(0o644, path)
77
+ publish_public_key
78
+ end
79
+
80
+ # Push the freshly written public key to the shared store, if a publisher is wired
81
+ # in, so consumers can resolve this fingerprint without a manual sync. Re-publishing
82
+ # an existing fingerprint is a harmless no-op for the store.
83
+ def publish_public_key
84
+ JwtAuth.config.publisher&.call(fingerprint, public_key.to_pem)
97
85
  end
98
86
 
99
87
  def generate_private_key
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Usps
6
+ module JwtAuth
7
+ # OpenSSH-style SHA256 key fingerprint: base64url(SHA256(ssh-rsa wire blob)),
8
+ # i.e. the value `ssh-keygen -lf` prints as `SHA256:...`. This is the identifier
9
+ # used for key filenames and the JWT `key` claim, and it is content-addressed: a
10
+ # given fingerprint maps to exactly one public key, forever. Encode stamps it onto
11
+ # tokens; Decode uses it to verify a fetched key really is the key the token names.
12
+ module Fingerprint
13
+ class << self
14
+ def for(public_key)
15
+ digest = OpenSSL::Digest::SHA256.digest(ssh_public_blob(public_key))
16
+ [digest].pack('m0').tr('+/', '-_').delete('=')
17
+ end
18
+
19
+ private
20
+
21
+ # Public key in the OpenSSH wire format: string('ssh-rsa') + mpint(e) + mpint(n).
22
+ def ssh_public_blob(public_key)
23
+ ssh_string('ssh-rsa') + ssh_mpint(public_key.e) + ssh_mpint(public_key.n)
24
+ end
25
+
26
+ # SSH "string": 4-byte big-endian length prefix followed by the bytes.
27
+ def ssh_string(bytes)
28
+ [bytes.bytesize].pack('N') + bytes.b
29
+ end
30
+
31
+ # SSH "mpint": a length-prefixed big-endian integer; a leading zero byte is
32
+ # prepended when the high bit is set so the value stays non-negative.
33
+ def ssh_mpint(num)
34
+ bytes = num.to_s(2)
35
+ bytes = "\x00".b + bytes if bytes.bytes.first && bytes.bytes.first >= 0x80
36
+ ssh_string(bytes)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Usps
4
4
  module JwtAuth
5
- VERSION = '1.1.0'
5
+ VERSION = '1.2.0'
6
6
  end
7
7
  end
data/lib/usps/jwt_auth.rb CHANGED
@@ -33,6 +33,7 @@ end
33
33
  # Internal requires
34
34
  require_relative 'jwt_auth/version'
35
35
  require_relative 'jwt_auth/config'
36
+ require_relative 'jwt_auth/fingerprint'
36
37
  require_relative 'jwt_auth/encode'
37
38
  require_relative 'jwt_auth/decode'
38
39
  require_relative 'jwt_auth/incorrect_login'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: usps-jwt_auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Fiander
@@ -86,6 +86,7 @@ files:
86
86
  - lib/usps/jwt_auth/config.rb
87
87
  - lib/usps/jwt_auth/decode.rb
88
88
  - lib/usps/jwt_auth/encode.rb
89
+ - lib/usps/jwt_auth/fingerprint.rb
89
90
  - lib/usps/jwt_auth/incorrect_login.rb
90
91
  - lib/usps/jwt_auth/railtie.rb
91
92
  - lib/usps/jwt_auth/version.rb