light_jwt 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2207fd0a72489515719e1fab29e59ea706bacf0c065a733109af119cd451299b
4
+ data.tar.gz: cc74a9a843825601b075417c6041b4ace87704d2ee0a76c1e35ff1e45a4513a1
5
+ SHA512:
6
+ metadata.gz: 91ce83435619aa592edbc37bdb2343c11224ec84fdb617e416ef99a6df793de25bbf458ecaaf5666246176b7ae822cf0d2e1d28165702a75b88d2ab603d2866f
7
+ data.tar.gz: 47fb6fc58fd01f4d7207acd39a7bb83cccef29e25e8e004508552cd4c488a254e2948f8151f5af25baa3066905f1e268fd46df9f6021792bcbd0ab8bf2c95e06
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Nguyen Ngoc Hai
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.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # LightJWT
2
+
3
+ **LightJWT** is a Ruby implementation of JWT (JSON Web Token) and its related specifications, compliant with RFC 7515 (JWS), RFC 7516 (JWE), RFC 7517 (JWK), RFC 7518 (JWA), and RFC 7519 (JWT) as much as possible.
4
+
5
+ ## Installation
6
+
7
+ Install the gem by running:
8
+
9
+ ```bash
10
+ gem install light_jwt
11
+ ```
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem 'light_jwt'
17
+ ```
18
+
19
+ Then, execute:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ ## Features
26
+
27
+ ### Signing and Verification
28
+ - Supports **HMAC**, **RSA**, and **ECDSA** with SHA-256, SHA-384, and SHA-512.
29
+ - Includes full support for **JWK-based key management**.
30
+
31
+ ### Encryption and Decryption
32
+ - Supported algorithms include **RSA1_5**, **RSA-OAEP**, and **AES-GCM** (128-bit and 256-bit keys).
33
+
34
+ ### JWK and JWKS
35
+ - Fetch and use keys from a **JWKS URI**.
36
+
37
+ ## Supported Algorithms
38
+
39
+ | Purpose | Algorithms |
40
+ |---------------|---------------------------------------------|
41
+ | **Signing** | `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512` |
42
+ | **Encryption**| `RSA1_5`, `RSA-OAEP`, `A128GCM`, `A256GCM` |
43
+ | **None** | Not supported (planned for future updates). |
44
+
45
+ ---
46
+
47
+ ## Usage
48
+
49
+ ### **Signing**
50
+
51
+ Sign a payload using a private key:
52
+
53
+ ```ruby
54
+ require 'light_jwt'
55
+
56
+ claims = { sub: '1234567890', name: 'John Doe' }
57
+
58
+ # Signing
59
+ jws = LightJWT::JWT.new(claims).sign('RS256', private_key)
60
+ jwt_token = jws.to_s # Outputs: header.payload.signature
61
+ ```
62
+ ### **Verification**
63
+
64
+ Verify a signed JWT using a public key:
65
+
66
+ ```ruby
67
+ # Verification
68
+ jws = LightJWT::JWT.decode(jwt_token, public_key)
69
+ payload = jws.payload # Decoded claims: { sub: '1234567890', name: 'John Doe' }
70
+ ```
71
+
72
+ Bypass verification (use only for debugging purposes):
73
+
74
+ ```ruby
75
+ jws = LightJWT::JWT.decode(jwt_token, skip_verification: true)
76
+ payload = jws.payload
77
+ ```
78
+
79
+ ### **Using JWK**
80
+
81
+ Fetch and verify using a JWKS URI:
82
+
83
+ ```ruby
84
+ jwk = LightJWT::JWK.new(jwks_uri) # JWKS URI
85
+ key = jwk.get(kid) # Retrieve key by `kid`
86
+ jws = LightJWT::JWT.decode(jwt_token, key)
87
+ payload = jws.payload
88
+ ```
89
+
90
+ ### **Encryption**
91
+
92
+ Encrypt a payload using a public key:
93
+
94
+ ```ruby
95
+ alg = 'RSA-OAEP'
96
+ enc = 'A256GCM'
97
+ jwe = LightJWT::JWT.new(claims).encrypt(alg, enc, public_key)
98
+ encrypted_token = jwe.to_s # Outputs: header.encrypted_key.iv.ciphertext.auth_tag
99
+ ```
100
+
101
+ ### **Decryption**
102
+
103
+ Decrypt an encrypted JWT using a private key:
104
+
105
+ ```ruby
106
+ jwe = LightJWT::JWT.decode(encrypted_token, private_key)
107
+ payload = jwe.payload # Decrypted claims: { sub: '1234567890', name: 'John Doe' }
108
+ ```
109
+
110
+ ## License
111
+
112
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ task default: %i[]
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightJWT
4
+ module Error
5
+ class Error < StandardError; end
6
+
7
+ class UnsupportedAlgorithm < Error; end
8
+ class InvalidKey < Error; end
9
+ class VerificationError < Error; end
10
+ class JWKKeyTypeError < Error; end
11
+ class JWKKeyIDError < Error; end
12
+ end
13
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module LightJWT
6
+ module JWA
7
+ class JWE
8
+ RSA_KEY_MANAGEMENT_ALGORITHMS = %w[RSA1_5 RSA-OAEP].freeze
9
+ CONTENT_ENCRYPTION_ALGORITHMS = {
10
+ 'A128GCM' => { key_length: 16, cipher: 'aes-128-gcm', iv_length: 12 },
11
+ 'A256GCM' => { key_length: 32, cipher: 'aes-256-gcm', iv_length: 12 }
12
+ }.freeze
13
+
14
+ SUPPORTED_ALGORITHMS = RSA_KEY_MANAGEMENT_ALGORITHMS.product(CONTENT_ENCRYPTION_ALGORITHMS.keys)
15
+
16
+ class << self
17
+ def encrypt(alg, enc, plaintext, public_key)
18
+ validate_algorithms(alg, enc)
19
+
20
+ cek = generate_cek(enc)
21
+ encrypted_key = rsa_encrypt_key(alg, cek, public_key)
22
+ iv, ciphertext, auth_tag = aes_gcm_encrypt(enc, cek, plaintext)
23
+
24
+ { encrypted_key:, iv:, ciphertext:, auth_tag: }
25
+ end
26
+
27
+ def decrypt(alg, enc, encrypted_key, iv, ciphertext, auth_tag, private_key)
28
+ validate_algorithms(alg, enc)
29
+
30
+ cek = rsa_decrypt_key(alg, encrypted_key, private_key)
31
+ aes_gcm_decrypt(enc, cek, iv, ciphertext, auth_tag)
32
+ end
33
+
34
+ def supported_algorithms
35
+ SUPPORTED_ALGORITHMS.map { |alg, enc| { alg: alg, enc: enc } }
36
+ end
37
+
38
+ private
39
+
40
+ def validate_algorithms(alg, enc)
41
+ unless SUPPORTED_ALGORITHMS.include?([alg, enc])
42
+ raise LightJWT::Error::UnsupportedAlgorithm,
43
+ "Unsupported combination: #{alg} + #{enc}"
44
+ end
45
+ end
46
+
47
+ def generate_cek(enc)
48
+ OpenSSL::Random.random_bytes(CONTENT_ENCRYPTION_ALGORITHMS[enc][:key_length])
49
+ end
50
+
51
+ def rsa_encrypt_key(alg, cek, public_key)
52
+ rsa = OpenSSL::PKey::RSA.new(public_key)
53
+ validate_rsa_key_size(rsa)
54
+
55
+ case alg
56
+ when 'RSA1_5'
57
+ rsa.public_encrypt(cek, OpenSSL::PKey::RSA::PKCS1_PADDING)
58
+ when 'RSA-OAEP'
59
+ rsa.public_encrypt(cek, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
60
+ else
61
+ raise LightJWT::Error::UnsupportedAlgorithm, "Unsupported RSA algorithm: #{alg}"
62
+ end
63
+ end
64
+
65
+ def rsa_decrypt_key(alg, encrypted_key, private_key)
66
+ rsa = OpenSSL::PKey::RSA.new(private_key)
67
+ validate_rsa_key_size(rsa)
68
+
69
+ case alg
70
+ when 'RSA1_5'
71
+ rsa.private_decrypt(encrypted_key, OpenSSL::PKey::RSA::PKCS1_PADDING)
72
+ when 'RSA-OAEP'
73
+ rsa.private_decrypt(encrypted_key, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
74
+ else
75
+ raise LightJWT::Error::UnsupportedAlgorithm, "Unsupported RSA algorithm: #{alg}"
76
+ end
77
+ end
78
+
79
+ def validate_rsa_key_size(rsa)
80
+ raise LightJWT::Error::InvalidKey, 'RSA key must be at least 2048 bits' if rsa.n.num_bits < 2048
81
+ end
82
+
83
+ def aes_gcm_encrypt(enc, cek, plaintext)
84
+ params = CONTENT_ENCRYPTION_ALGORITHMS[enc]
85
+ cipher = OpenSSL::Cipher.new(params[:cipher])
86
+ cipher.encrypt
87
+ iv = OpenSSL::Random.random_bytes(params[:iv_length])
88
+ cipher.key = cek
89
+ cipher.iv = iv
90
+
91
+ ciphertext = cipher.update(plaintext) + cipher.final
92
+ auth_tag = cipher.auth_tag
93
+
94
+ [iv, ciphertext, auth_tag]
95
+ end
96
+
97
+ def aes_gcm_decrypt(enc, cek, iv, ciphertext, auth_tag)
98
+ params = CONTENT_ENCRYPTION_ALGORITHMS[enc]
99
+ decipher = OpenSSL::Cipher.new(params[:cipher])
100
+ decipher.decrypt
101
+ decipher.key = cek
102
+ decipher.iv = iv
103
+ decipher.auth_tag = auth_tag
104
+
105
+ decipher.update(ciphertext) + decipher.final
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightJWT
4
+ module JWA
5
+ class JWS
6
+ HMAC_ALGORITHMS = {
7
+ 'HS256' => OpenSSL::Digest::SHA256,
8
+ 'HS384' => OpenSSL::Digest::SHA384,
9
+ 'HS512' => OpenSSL::Digest::SHA512
10
+ }.freeze
11
+
12
+ RSA_ALGORITHMS = {
13
+ 'RS256' => OpenSSL::Digest::SHA256,
14
+ 'RS384' => OpenSSL::Digest::SHA384,
15
+ 'RS512' => OpenSSL::Digest::SHA512
16
+ }.freeze
17
+
18
+ ECDSA_ALGORITHMS = {
19
+ 'ES256' => OpenSSL::Digest::SHA256,
20
+ 'ES384' => OpenSSL::Digest::SHA384,
21
+ 'ES512' => OpenSSL::Digest::SHA512
22
+ }.freeze
23
+
24
+ SUPPORTED_ALGORITHMS = HMAC_ALGORITHMS.keys + RSA_ALGORITHMS.keys + ECDSA_ALGORITHMS.keys + ['none']
25
+
26
+ class << self
27
+ def sign(alg, signing_key, token)
28
+ algorithm_handler(alg).sign(signing_key, token)
29
+ end
30
+
31
+ def verify(alg, key, token, signature)
32
+ algorithm_handler(alg).verify(key, token, signature)
33
+ end
34
+
35
+ def supported_algorithms
36
+ SUPPORTED_ALGORITHMS
37
+ end
38
+
39
+ private
40
+
41
+ def algorithm_handler(alg)
42
+ case alg
43
+ when *HMAC_ALGORITHMS.keys
44
+ HMACHandler.new(alg)
45
+ when *RSA_ALGORITHMS.keys
46
+ RSAHandler.new(alg)
47
+ when *ECDSA_ALGORITHMS.keys
48
+ ECDSAHandler.new(alg)
49
+ when 'none'
50
+ NoneHandler.new(alg)
51
+ else
52
+ raise LightJWT::Error::UnsupportedAlgorithm,
53
+ "Unsupported JWS algorithm: #{alg}. Supported algorithms are: #{SUPPORTED_ALGORITHMS.join(', ')}"
54
+ end
55
+ end
56
+ end
57
+
58
+ class BaseHandler
59
+ attr_reader :alg, :digest
60
+
61
+ def initialize(alg)
62
+ @alg = alg
63
+ @digest = build_digest unless alg == 'none'
64
+ end
65
+
66
+ # Constant-time comparison algorithm to prevent timing attacks
67
+ def secure_compare(a, b)
68
+ return false if a.bytesize != b.bytesize
69
+
70
+ a.bytes.zip(b.bytes).map { |x, y| x ^ y }.sum.zero?
71
+ end
72
+
73
+ private
74
+
75
+ def build_digest
76
+ case alg
77
+ when *HMAC_ALGORITHMS.keys
78
+ HMAC_ALGORITHMS[alg].new
79
+ when *RSA_ALGORITHMS.keys
80
+ RSA_ALGORITHMS[alg].new
81
+ when *ECDSA_ALGORITHMS.keys
82
+ ECDSA_ALGORITHMS[alg].new
83
+ else
84
+ raise LightJWT::Error::UnsupportedAlgorithm,
85
+ "Unsupported JWS algorithm: #{alg}. Supported algorithms are: #{SUPPORTED_ALGORITHMS.join(', ')}"
86
+ end
87
+ end
88
+ end
89
+
90
+ class HMACHandler < BaseHandler
91
+ def sign(signing_key, token)
92
+ validate_key_length(signing_key)
93
+
94
+ OpenSSL::HMAC.digest(digest, signing_key, token)
95
+ end
96
+
97
+ def verify(signing_key, token, signature)
98
+ expected_signature = sign(signing_key, token)
99
+ secure_compare(expected_signature, signature)
100
+ end
101
+
102
+ private
103
+
104
+ def validate_key_length(key)
105
+ return unless key.bytesize < digest.digest_length
106
+
107
+ raise LightJWT::Error::InvalidKey,
108
+ "Signing key must be at least #{digest.digest_length} bytes"
109
+ end
110
+ end
111
+
112
+ class RSAHandler < BaseHandler
113
+ def sign(private_key, token)
114
+ rsa_private_key = OpenSSL::PKey::RSA.new(private_key)
115
+ validate_key_size(rsa_private_key)
116
+
117
+ rsa_private_key.sign(digest, token)
118
+ end
119
+
120
+ def verify(public_key, token, signature)
121
+ rsa_public_key = OpenSSL::PKey::RSA.new(public_key)
122
+ validate_key_size(rsa_public_key)
123
+
124
+ rsa_public_key.verify(digest, signature, token)
125
+ end
126
+
127
+ private
128
+
129
+ def validate_key_size(key)
130
+ raise LightJWT::Error::InvalidKey, 'RSA key must be at least 2048 bits' if key.n.num_bits < 2048
131
+ end
132
+ end
133
+
134
+ class ECDSAHandler < BaseHandler
135
+ def sign(private_key, token)
136
+ ec_private_key = OpenSSL::PKey::EC.new(private_key)
137
+
138
+ asn1_to_raw(ec_private_key.sign(digest, token), ec_private_key)
139
+ end
140
+
141
+ def verify(public_key, token, signature)
142
+ ec_public_key = OpenSSL::PKey::EC.new(public_key)
143
+
144
+ raw_signature = raw_to_asn1(signature, ec_public_key)
145
+ ec_public_key.verify(digest, raw_signature, token)
146
+ end
147
+
148
+ private
149
+
150
+ # Convert ASN.1 DER format to raw signature, because RFC7518 requires it
151
+ # https://datatracker.ietf.org/doc/html/rfc7518#section-3.4
152
+ def asn1_to_raw(signature, private_key)
153
+ byte_size = (private_key.group.degree + 7) / 8
154
+ OpenSSL::ASN1.decode(signature).value.map { |value| value.value.to_s(2).rjust(byte_size, "\x00") }.join
155
+ end
156
+
157
+ # Convert raw signature to ASN.1 DER format. A raw ECDSA signature is comprised of two integers "r" and "s".
158
+ # OpenSSL expects them to be wrapped up inside a DER encoded representation.
159
+ # https://stackoverflow.com/questions/59904522/asn1-encoding-routines-errors-when-verifying-ecdsa-signature-type-with-openssl
160
+ def raw_to_asn1(signature, public_key)
161
+ byte_size = (public_key.group.degree + 7) / 8
162
+ r = signature[0..(byte_size - 1)]
163
+ s = signature[byte_size..]
164
+ OpenSSL::ASN1::Sequence.new([r, s].map { |int| OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(int, 2)) }).to_der
165
+ end
166
+ end
167
+
168
+ class NoneHandler < BaseHandler
169
+ def sign(_, _)
170
+ ''
171
+ end
172
+
173
+ def verify(_, _, signature)
174
+ signature == ''
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'base64'
5
+
6
+ module LightJWT
7
+ class JWE
8
+ attr_accessor :payload, :alg, :enc
9
+ attr_reader :encrypted_key, :iv, :ciphertext, :auth_tag, :key, :jwt_token, :header
10
+
11
+ NUM_OF_SEGMENTS = 5
12
+
13
+ class << self
14
+ def decode_compact_serialized(jwt_token, private_key)
15
+ jwe = new(private_key, jwt_token)
16
+ jwe.extract!
17
+ end
18
+ end
19
+
20
+ def initialize(key = nil, jwt_token = nil)
21
+ @key = key
22
+ @jwt_token = jwt_token
23
+ end
24
+
25
+ def encrypt!
26
+ validate_encrypt_requirements!
27
+
28
+ result = JWA::JWE.encrypt(alg, enc, payload.to_json, key)
29
+
30
+ @header = { alg:, enc: }
31
+ @encrypted_key = result[:encrypted_key]
32
+ @iv = result[:iv]
33
+ @ciphertext = result[:ciphertext]
34
+ @auth_tag = result[:auth_tag]
35
+
36
+ self
37
+ end
38
+
39
+ def decrypt!
40
+ validate_decrypt_requirements!
41
+
42
+ plaintext = JWA::JWE.decrypt(alg, enc, encrypted_key, iv, ciphertext, auth_tag, key)
43
+ @payload = JSON.parse(plaintext, symbolize_names: true)
44
+
45
+ self
46
+ end
47
+
48
+ def to_s
49
+ serialize_compact_format
50
+ end
51
+
52
+ def as_json
53
+ { payload: }
54
+ end
55
+
56
+ def extract!
57
+ segments = split_and_decode_segments
58
+ @encrypted_key, @iv, @ciphertext, @auth_tag = segments[1..]
59
+ @header = parse_header(segments[0])
60
+
61
+ @alg, @enc = header.values_at(:alg, :enc)
62
+
63
+ self
64
+ end
65
+
66
+ private
67
+
68
+ def validate_decrypt_requirements!
69
+ %i[alg enc key encrypted_key iv ciphertext auth_tag].each do |attr|
70
+ raise ArgumentError, "#{attr.to_s.capitalize} must be set" unless instance_variable_get("@#{attr}")
71
+ end
72
+ end
73
+
74
+ def validate_encrypt_requirements!
75
+ %i[alg enc key payload].each do |attr|
76
+ raise ArgumentError, "#{attr.to_s.capitalize} must be set" unless instance_variable_get("@#{attr}")
77
+ end
78
+ end
79
+
80
+ def serialize_compact_format
81
+ [
82
+ header.to_json,
83
+ encrypted_key,
84
+ iv,
85
+ ciphertext,
86
+ auth_tag
87
+ ].map { |segment| encode_segment(segment) }.join('.')
88
+ end
89
+
90
+ def encode_segment(segment)
91
+ Base64.urlsafe_encode64(segment, padding: false)
92
+ end
93
+
94
+ def parse_header(header_segment)
95
+ JSON.parse(header_segment, symbolize_names: true)
96
+ rescue JSON::ParserError
97
+ raise ArgumentError, 'Invalid protected header JSON format'
98
+ end
99
+
100
+ def split_and_decode_segments
101
+ segments = jwt_token.split('.')
102
+ raise ArgumentError, "JWT Token must contain exactly #{NUM_OF_SEGMENTS} segments" unless segments.size == NUM_OF_SEGMENTS
103
+
104
+ segments.map { |segment| Base64.urlsafe_decode64(segment) }
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'openssl'
6
+
7
+ module LightJWT
8
+ class JWK
9
+ SUPPORTED_KEY_TYPES = %w[RSA EC].freeze
10
+ SUPPORTED_KEY_USES = %w[sig enc].freeze
11
+ SUPPORTED_CURVES = %w[P-256 P-384 P-521].freeze
12
+
13
+ attr_reader :keys
14
+
15
+ def initialize(jwks_uri)
16
+ @jwks_uri = jwks_uri
17
+ @keys = fetch_jwks
18
+ end
19
+
20
+ def get(kid)
21
+ key_data = @keys.find { |k| k['kid'] == kid }
22
+ raise ArgumentError, "JWK with kid '#{kid}' not found" unless key_data
23
+
24
+ Key.new(key_data)
25
+ end
26
+
27
+ class Key
28
+ attr_reader :key_data, :kty, :use, :alg, :kid, :n, :e, :x, :y, :crv
29
+
30
+ def initialize(key_data)
31
+ @key_data = key_data
32
+ @kty = key_data['kty']
33
+ @use = key_data['use']
34
+ @alg = key_data['alg']
35
+ @kid = key_data['kid']
36
+ @n = key_data['n']
37
+ @e = key_data['e']
38
+ @x = key_data['x']
39
+ @y = key_data['y']
40
+ @crv = key_data['crv']
41
+
42
+ validate_key
43
+ end
44
+
45
+ def public_key
46
+ case kty
47
+ when 'RSA'
48
+ build_rsa_public_key
49
+ when 'EC'
50
+ build_ec_public_key
51
+ else
52
+ raise ArgumentError, "Unsupported key type: #{kty}"
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def validate_key
59
+ raise ArgumentError, 'Unsupported key type' unless SUPPORTED_KEY_TYPES.include?(kty)
60
+ raise ArgumentError, 'Invalid key use' unless SUPPORTED_KEY_USES.include?(use)
61
+ raise ArgumentError, 'Missing required parameters for RSA key' if rsa_key_missing?
62
+ raise ArgumentError, 'Missing required parameters for EC key' if ec_key_missing?
63
+ end
64
+
65
+ def rsa_key_missing?
66
+ kty == 'RSA' && (n.nil? || e.nil?)
67
+ end
68
+
69
+ def ec_key_missing?
70
+ kty == 'EC' && (x.nil? || y.nil? || !SUPPORTED_CURVES.include?(crv))
71
+ end
72
+
73
+ def build_rsa_public_key
74
+ asn1_sequence = OpenSSL::ASN1::Sequence.new([
75
+ OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(base64url_decode(n),
76
+ 2)),
77
+ OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(base64url_decode(e),
78
+ 2))
79
+ ])
80
+
81
+ OpenSSL::PKey::RSA.new(asn1_sequence.to_der)
82
+ end
83
+
84
+ def build_ec_public_key
85
+ curve_name = curve_name_from_crv
86
+
87
+ raw_x = base64url_decode(x)
88
+ raw_y = base64url_decode(y)
89
+
90
+ point = OpenSSL::PKey::EC::Point.new(
91
+ OpenSSL::PKey::EC::Group.new(curve_name),
92
+ OpenSSL::BN.new(["04#{raw_x.unpack1('H*')}#{raw_y.unpack1('H*')}"].pack('H*'), 2)
93
+ )
94
+
95
+ data_sequence = OpenSSL::ASN1::Sequence([
96
+ OpenSSL::ASN1::Sequence([
97
+ OpenSSL::ASN1::ObjectId('id-ecPublicKey'),
98
+ OpenSSL::ASN1::ObjectId(curve_name)
99
+ ]),
100
+ OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
101
+ ])
102
+
103
+ OpenSSL::PKey::EC.new(data_sequence.to_der)
104
+ end
105
+
106
+ def curve_name_from_crv
107
+ case crv
108
+ when 'P-256' then 'prime256v1'
109
+ when 'P-384' then 'secp384r1'
110
+ when 'P-521' then 'secp521r1'
111
+ else
112
+ raise ArgumentError, "Unsupported EC curve: #{crv}"
113
+ end
114
+ end
115
+
116
+ def ec_point_from_coordinates(group)
117
+ x_bn = OpenSSL::BN.new(base64url_decode(x), 2)
118
+ y_bn = OpenSSL::BN.new(base64url_decode(y), 2)
119
+ OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new("04#{x_bn.to_s(16)}#{y_bn.to_s(16)}", 16))
120
+ end
121
+
122
+ def base64url_decode(data)
123
+ Base64.urlsafe_decode64(data)
124
+ rescue StandardError
125
+ raise ArgumentError, "Invalid base64url encoding: #{data}"
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def fetch_jwks
132
+ uri = URI(@jwks_uri)
133
+ response = Net::HTTP.get(uri)
134
+ jwks = JSON.parse(response)
135
+ raise ArgumentError, 'Invalid JWK Set format' unless jwks['keys'].is_a?(Array)
136
+
137
+ jwks['keys']
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightJWT
4
+ class JWS
5
+ attr_reader :header, :payload, :signature, :alg, :key
6
+
7
+ NUM_OF_SEGMENTS = 3
8
+
9
+ class << self
10
+ def decode_compact_serialized(jwt_token, key)
11
+ segments = jwt_token.split('.')
12
+ validate_segment_count(segments)
13
+
14
+ header, payload, signature = segments
15
+ parsed_header = parse_segment(header)
16
+
17
+ new("#{header}.#{payload}", parsed_header[:alg], key, signature)
18
+ end
19
+
20
+ private
21
+
22
+ def parse_segment(segment)
23
+ JSON.parse(Base64.urlsafe_decode64(segment), symbolize_names: true)
24
+ rescue JSON::ParserError, ArgumentError
25
+ raise ArgumentError, 'Invalid segment encoding'
26
+ end
27
+
28
+ def validate_segment_count(segments)
29
+ return if segments.size == NUM_OF_SEGMENTS
30
+
31
+ raise ArgumentError,
32
+ "JWT Token must have exactly #{NUM_OF_SEGMENTS} segments"
33
+ end
34
+ end
35
+
36
+ def initialize(signing_data, alg, key, signature = nil)
37
+ @signing_data = signing_data
38
+ @alg = alg
39
+ @key = key
40
+ @signature = signature
41
+ @header, @payload = extract_header_and_payload
42
+ end
43
+
44
+ def sign!
45
+ raw_signature = JWA::JWS.sign(alg, key, signing_data)
46
+ @signature = encode_segment(raw_signature)
47
+
48
+ self
49
+ end
50
+
51
+ def verify!
52
+ raise Error::VerificationError, 'Signature verification failed' unless valid_signature?
53
+
54
+ true
55
+ end
56
+
57
+ def to_s
58
+ raise ArgumentError, 'Signature is missing' unless signature
59
+
60
+ [encoded_header, encoded_payload, signature].join('.')
61
+ end
62
+
63
+ def as_json
64
+ { header:, payload: }
65
+ end
66
+
67
+ private
68
+
69
+ def valid_signature?
70
+ raw_signature = decode_segment(signature)
71
+ JWA::JWS.verify(alg, key, signing_data, raw_signature)
72
+ end
73
+
74
+ def signing_data
75
+ @signing_data ||= [encoded_header, encoded_payload].join('.')
76
+ end
77
+
78
+ def encoded_header
79
+ @encoded_header ||= encode_segment(header.to_json)
80
+ end
81
+
82
+ def encoded_payload
83
+ @encoded_payload ||= encode_segment(payload.to_json)
84
+ end
85
+
86
+ def extract_header_and_payload
87
+ @signing_data.split('.').map { |segment| self.class.send(:parse_segment, segment) }
88
+ end
89
+
90
+ def encode_segment(segment)
91
+ Base64.urlsafe_encode64(segment, padding: false)
92
+ end
93
+
94
+ def decode_segment(segment)
95
+ Base64.urlsafe_decode64(segment)
96
+ rescue ArgumentError
97
+ raise ArgumentError, 'Invalid Base64 URL-safe encoding'
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightJWT
4
+ class JWT
5
+ attr_accessor :claims
6
+
7
+ class << self
8
+ def decode(input, key_or_options = {})
9
+ key, options = parse_key_and_options(key_or_options)
10
+ handler = handler_for(input)
11
+ handler.decode(input, key, options)
12
+ end
13
+
14
+ private
15
+
16
+ def handler_for(input)
17
+ case input.count('.') + 1
18
+ when JWS::NUM_OF_SEGMENTS
19
+ JWSHandler.new
20
+ when JWE::NUM_OF_SEGMENTS
21
+ JWEHandler.new
22
+ else
23
+ raise ArgumentError, 'Invalid JWT format'
24
+ end
25
+ end
26
+
27
+ def parse_key_and_options(key_or_options)
28
+ case key_or_options
29
+ when JWK::Key
30
+ [key_or_options.public_key, {}]
31
+ when Hash
32
+ [nil, key_or_options]
33
+ else
34
+ [key_or_options, {}]
35
+ end
36
+ end
37
+ end
38
+
39
+ def initialize(claims = {})
40
+ @claims = claims
41
+ end
42
+
43
+ def sign(alg, signing_key)
44
+ JWSHandler.new.sign(claims, alg, signing_key)
45
+ end
46
+
47
+ def encrypt(alg, enc, public_key)
48
+ JWEHandler.new.encrypt(claims, alg, enc, public_key)
49
+ end
50
+ end
51
+
52
+ class BaseHandler
53
+ def base64_encode(data)
54
+ Base64.urlsafe_encode64(data.to_json, padding: false)
55
+ end
56
+ end
57
+
58
+ class JWSHandler < BaseHandler
59
+ def sign(claims, alg, signing_key)
60
+ jose_header = { alg:, typ: 'JWT' }
61
+ token = [jose_header, claims].map { |segment| base64_encode(segment) }.join('.')
62
+ jws = JWS.new(token, alg, signing_key)
63
+ jws.sign!
64
+ end
65
+
66
+ def decode(input, key, options)
67
+ jws = JWS.decode_compact_serialized(input, key)
68
+ jws.verify! unless options[:skip_verification]
69
+ jws
70
+ end
71
+ end
72
+
73
+ class JWEHandler < BaseHandler
74
+ def encrypt(claims, alg, enc, public_key)
75
+ jwe = JWE.new(public_key)
76
+ jwe.alg = alg
77
+ jwe.enc = enc
78
+ jwe.payload = claims
79
+ jwe.encrypt!
80
+ end
81
+
82
+ def decode(input, key, _)
83
+ jwe = JWE.decode_compact_serialized(input, key)
84
+ jwe.decrypt!
85
+ jwe
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightJWT
4
+ VERSION = '0.1.0'
5
+ end
data/lib/light_jwt.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'light_jwt/version'
4
+
5
+ module LightJWT
6
+ autoload :Error, 'light_jwt/error'
7
+ autoload :JWT, 'light_jwt/jwt'
8
+ autoload :JWE, 'light_jwt/jwe'
9
+ autoload :JWS, 'light_jwt/jws'
10
+ autoload :JWK, 'light_jwt/jwk'
11
+ module JWA
12
+ autoload :JWS, 'light_jwt/jwa/jws'
13
+ autoload :JWE, 'light_jwt/jwa/jwe'
14
+ end
15
+ end
16
+
17
+ require 'openssl'
18
+ require 'base64'
19
+ require 'json'
20
+ require 'net/http'
data/sig/light_jwt.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module LightJWT
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: light_jwt
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nguyen Ngoc Hai
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-12-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.9.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.9.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: openssl
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 3.3.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 3.3.0
55
+ description: Ruby implementation of JWT (JSON Web Token) and its related specifications,
56
+ compliant with RFC 7515 (JWS), RFC 7516 (JWE), RFC 7517 (JWK), RFC 7518 (JWA), and
57
+ RFC 7519 (JWT) as much as possible.
58
+ email:
59
+ - ngochai220998@gmail.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CODE_OF_CONDUCT.md
65
+ - LICENSE.txt
66
+ - README.md
67
+ - Rakefile
68
+ - lib/light_jwt.rb
69
+ - lib/light_jwt/error.rb
70
+ - lib/light_jwt/jwa/jwe.rb
71
+ - lib/light_jwt/jwa/jws.rb
72
+ - lib/light_jwt/jwe.rb
73
+ - lib/light_jwt/jwk.rb
74
+ - lib/light_jwt/jws.rb
75
+ - lib/light_jwt/jwt.rb
76
+ - lib/light_jwt/version.rb
77
+ - sig/light_jwt.rbs
78
+ homepage: https://github.com/NgocHai220998/light_jwt
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ homepage_uri: https://github.com/NgocHai220998/light_jwt
83
+ post_install_message:
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 3.0.0
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.5.22
99
+ signing_key:
100
+ specification_version: 4
101
+ summary: JSON Web Token implementation in Ruby, compliant with RFC 7519
102
+ test_files: []