From e64ef9294824840d6cacf30dbf6dc435c8063fe4 Mon Sep 17 00:00:00 2001 From: Bryant Morrill Date: Thu, 4 May 2023 18:03:55 -0600 Subject: [PATCH 1/3] add support for unencoded payloads and detached payloads adds `encode_detached` to JWT, which produces a jwt with an empty payload segment adds support for the `b64` header, which when set to false will prevent the payload from being base64 encoded adds support for decoding JWTs with the `b64` header set to false adds support for decoding and verifying JWTs with detached payloads --- .rubocop.yml | 2 +- lib/jwt.rb | 11 ++++++++++- lib/jwt/decode.rb | 23 +++++++++++++++++++---- lib/jwt/encode.rb | 31 +++++++++++++++++++++++++++++-- lib/jwt/error.rb | 1 + spec/jwt_spec.rb | 40 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 8 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 82def84f..afcb0e7d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -29,7 +29,7 @@ Metrics/AbcSize: Max: 25 Metrics/ClassLength: - Max: 121 + Max: 135 Metrics/ModuleLength: Max: 100 diff --git a/lib/jwt.rb b/lib/jwt.rb index d42aaa66..391e9755 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -22,7 +22,16 @@ def encode(payload, key, algorithm = 'HS256', header_fields = {}) Encode.new(payload: payload, key: key, algorithm: algorithm, - headers: header_fields).segments + headers: header_fields, + detached: false).segments + end + + def encode_detached(payload, key, algorithm = 'HS256', header_fields = {}) + Encode.new(payload: payload, + key: key, + algorithm: algorithm, + headers: header_fields, + detached: true).segments end def decode(jwt, key = nil, verify = true, options = {}, &keyfinder) # rubocop:disable Style/OptionalBooleanParameter diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index 16217943..55128fc6 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -14,6 +14,7 @@ def initialize(jwt, key, verify, options, &keyfinder) @jwt = jwt @key = key + @detached_payload = options[:payload] @options = options @segments = jwt.split('.') @verify = verify @@ -152,15 +153,29 @@ def header end def payload - @payload ||= parse_and_decode @segments[1] + @payload ||= parse_and_decode(encoded_payload, decode: decode_payload?) + end + + def encoded_payload + payload = @detached_payload.to_json if !@detached_payload.nil? && @segments[1].empty? + payload ||= @segments[1] + payload + end + + def decode_payload? + header['b64'].nil? || !!header['b64'] end def signing_input - @segments.first(2).join('.') + [@segments[0], encoded_payload].join('.') end - def parse_and_decode(segment) - JWT::JSON.parse(::JWT::Base64.url_decode(segment)) + def parse_and_decode(segment, decode: true) + if decode + JWT::JSON.parse(::JWT::Base64.url_decode(segment)) + else + JWT::JSON.parse(segment) + end rescue ::JSON::ParserError raise JWT::DecodeError, 'Invalid segment encoding' end diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index 252ddf9b..ab085d46 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -15,11 +15,24 @@ def initialize(options) @algorithm = resolve_algorithm(options[:algorithm]) @headers = options[:headers].transform_keys(&:to_s) @headers[ALG_KEY] = @algorithm.alg + @detached = options[:detached] + + # add b64 claim to crit as per RFC7797 proposed standard + unless encode_payload? + @headers['crit'] ||= [] + @headers['crit'] << 'b64' unless @headers['crit'].include?('b64') + end end def segments validate_claims! - combine(encoded_header_and_payload, encoded_signature) + + parts = [] + parts << encoded_header + parts << (@detached ? '' : encoded_payload) + parts << encoded_signature + + combine(*parts) end private @@ -51,7 +64,21 @@ def encode_header end def encode_payload - encode_data(@payload) + # if b64 header is present and false, do not encode payload as per RFC7797 proposed standard + encode_payload? ? encode_data(@payload) : prepare_unencoded_payload + end + + def encode_payload? + # if b64 header is left out, default to true as per RFC7797 proposed standard + @headers['b64'].nil? || !!@headers['b64'] + end + + def prepare_unencoded_payload + json = @payload.to_json + + raise(JWT::InvalidUnencodedPayload, 'An unencoded payload cannot contain period/dot characters (i.e. ".").') if json.include?('.') + + json end def signature diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index ce3f3a9f..11186b2c 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -5,6 +5,7 @@ class EncodeError < StandardError; end class DecodeError < StandardError; end class RequiredDependencyError < StandardError; end + class InvalidUnencodedPayload < EncodeError; end class VerificationError < DecodeError; end class ExpiredSignature < DecodeError; end class IncorrectAlgorithm < DecodeError; end diff --git a/spec/jwt_spec.rb b/spec/jwt_spec.rb index 6ed99682..cca17efe 100644 --- a/spec/jwt_spec.rb +++ b/spec/jwt_spec.rb @@ -2,6 +2,8 @@ RSpec.describe JWT do let(:payload) { { 'user_id' => 'some@user.tld' } } + let(:non_encoded_payload) { { 'user_id' => 'safe_value' } } + let(:non_encoded_payload_unsafe) { { 'user_id' => 'unsafe.value' } } let :data do data = { @@ -24,6 +26,8 @@ 'ES256K_public' => OpenSSL::PKey.read(File.read(File.join(CERT_PATH, 'ec256k-public.pem'))), 'NONE' => 'eyJhbGciOiJub25lIn0.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.', 'HS256' => 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.kWOVtIOpWcG7JnyJG0qOkTDbOy636XrrQhMm_8JrRQ8', + 'HS256_non_encoded_payload' => 'eyJiNjQiOmZhbHNlLCJhbGciOiJIUzI1NiIsImNyaXQiOlsiYjY0Il19.{"user_id":"safe_value"}.eW4NSHANyJpL6ivfFut7a5CM5lpaif8vEQYr-CRzrUc', + 'HS256_non_encoded_payload_detached' => 'eyJiNjQiOmZhbHNlLCJhbGciOiJIUzI1NiIsImNyaXQiOlsiYjY0Il19..eW4NSHANyJpL6ivfFut7a5CM5lpaif8vEQYr-CRzrUc', 'HS512256' => 'eyJhbGciOiJIUzUxMjI1NiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.Ds_4ibvf7z4QOBoKntEjDfthy3WJ-3rKMspTEcHE2bA', 'HS384' => 'eyJhbGciOiJIUzM4NCJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.VuV4j4A1HKhWxCNzEcwc9qVF3frrEu-BRLzvYPkbWO0LENRGy5dOiBQ34remM3XH', 'HS512' => 'eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.8zNtCBTJIZTHpZ-BkhR-6sZY1K85Nm5YCKqV3AxRdsBJDt_RR-REH2db4T3Y0uQwNknhrCnZGvhNHrvhDwV1kA', @@ -933,4 +937,40 @@ def valid_alg?(alg) end end end + + context 'when payload is not encoded' do + it 'should generate a valid token' do + token = JWT.encode non_encoded_payload, data[:secret], 'HS256', { b64: false } + + expect(token).to eq data['HS256_non_encoded_payload'] + end + + it 'should decode a valid token' do + jwt_payload, header = JWT.decode data['HS256_non_encoded_payload'], data[:secret], true, algorithm: 'HS256' + + expect(header['alg']).to eq 'HS256' + expect(jwt_payload).to eq non_encoded_payload + end + + it 'should raise error when payload is unsafe for decoding' do + expect do + JWT.encode non_encoded_payload_unsafe, 'secret', 'HS256', { b64: false } + end.to raise_error JWT::InvalidUnencodedPayload + end + end + + context 'when payload is detached' do + it 'should generate a valid token' do + token = JWT.encode_detached non_encoded_payload, data[:secret], 'HS256', { b64: false } + + expect(token).to eq data['HS256_non_encoded_payload_detached'] + end + + it 'should decode a valid token' do + jwt_payload, header = JWT.decode data['HS256_non_encoded_payload_detached'], data[:secret], true, algorithm: 'HS256', payload: non_encoded_payload + + expect(header['alg']).to eq 'HS256' + expect(jwt_payload).to eq non_encoded_payload + end + end end From 1e368642f6f784108268531865cc05d5a6c81e40 Mon Sep 17 00:00:00 2001 From: Bryant Morrill Date: Thu, 4 May 2023 18:52:02 -0600 Subject: [PATCH 2/3] fix bug where decoding using a detached payload did not respect base 64 encoding --- .rubocop.yml | 2 +- lib/jwt/decode.rb | 8 +++++++- spec/jwt_spec.rb | 42 +++++++++++++++++++++++++++++------------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index afcb0e7d..89b34a85 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -29,7 +29,7 @@ Metrics/AbcSize: Max: 25 Metrics/ClassLength: - Max: 135 + Max: 140 Metrics/ModuleLength: Max: 100 diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index 55128fc6..98c75093 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -157,11 +157,17 @@ def payload end def encoded_payload - payload = @detached_payload.to_json if !@detached_payload.nil? && @segments[1].empty? + payload = encoded_detached_payload if !@detached_payload.nil? && @segments[1].empty? payload ||= @segments[1] payload end + def encoded_detached_payload + payload ||= ::JWT::Base64.url_encode(JWT::JSON.generate(@detached_payload)) if decode_payload? + payload ||= @detached_payload.to_json + payload + end + def decode_payload? header['b64'].nil? || !!header['b64'] end diff --git a/spec/jwt_spec.rb b/spec/jwt_spec.rb index cca17efe..b47bf9a3 100644 --- a/spec/jwt_spec.rb +++ b/spec/jwt_spec.rb @@ -2,8 +2,8 @@ RSpec.describe JWT do let(:payload) { { 'user_id' => 'some@user.tld' } } - let(:non_encoded_payload) { { 'user_id' => 'safe_value' } } - let(:non_encoded_payload_unsafe) { { 'user_id' => 'unsafe.value' } } + let(:unencoded_payload) { { 'user_id' => 'safe_value' } } + let(:unencoded_payload_unsafe) { { 'user_id' => 'unsafe.value' } } let :data do data = { @@ -26,8 +26,9 @@ 'ES256K_public' => OpenSSL::PKey.read(File.read(File.join(CERT_PATH, 'ec256k-public.pem'))), 'NONE' => 'eyJhbGciOiJub25lIn0.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.', 'HS256' => 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.kWOVtIOpWcG7JnyJG0qOkTDbOy636XrrQhMm_8JrRQ8', - 'HS256_non_encoded_payload' => 'eyJiNjQiOmZhbHNlLCJhbGciOiJIUzI1NiIsImNyaXQiOlsiYjY0Il19.{"user_id":"safe_value"}.eW4NSHANyJpL6ivfFut7a5CM5lpaif8vEQYr-CRzrUc', - 'HS256_non_encoded_payload_detached' => 'eyJiNjQiOmZhbHNlLCJhbGciOiJIUzI1NiIsImNyaXQiOlsiYjY0Il19..eW4NSHANyJpL6ivfFut7a5CM5lpaif8vEQYr-CRzrUc', + 'HS256_unencoded_payload' => 'eyJiNjQiOmZhbHNlLCJhbGciOiJIUzI1NiIsImNyaXQiOlsiYjY0Il19.{"user_id":"safe_value"}.eW4NSHANyJpL6ivfFut7a5CM5lpaif8vEQYr-CRzrUc', + 'HS256_detached_payload' => 'eyJhbGciOiJIUzI1NiJ9..oDy7wJe4wR3YcvGx5EmVm42H68g3L8nFfKnx3yeH25o', + 'HS256_unencoded_detached_payload' => 'eyJiNjQiOmZhbHNlLCJhbGciOiJIUzI1NiIsImNyaXQiOlsiYjY0Il19..eW4NSHANyJpL6ivfFut7a5CM5lpaif8vEQYr-CRzrUc', 'HS512256' => 'eyJhbGciOiJIUzUxMjI1NiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.Ds_4ibvf7z4QOBoKntEjDfthy3WJ-3rKMspTEcHE2bA', 'HS384' => 'eyJhbGciOiJIUzM4NCJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.VuV4j4A1HKhWxCNzEcwc9qVF3frrEu-BRLzvYPkbWO0LENRGy5dOiBQ34remM3XH', 'HS512' => 'eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.8zNtCBTJIZTHpZ-BkhR-6sZY1K85Nm5YCKqV3AxRdsBJDt_RR-REH2db4T3Y0uQwNknhrCnZGvhNHrvhDwV1kA', @@ -940,37 +941,52 @@ def valid_alg?(alg) context 'when payload is not encoded' do it 'should generate a valid token' do - token = JWT.encode non_encoded_payload, data[:secret], 'HS256', { b64: false } + token = JWT.encode unencoded_payload, data[:secret], 'HS256', { b64: false } - expect(token).to eq data['HS256_non_encoded_payload'] + expect(token).to eq data['HS256_unencoded_payload'] end it 'should decode a valid token' do - jwt_payload, header = JWT.decode data['HS256_non_encoded_payload'], data[:secret], true, algorithm: 'HS256' + jwt_payload, header = JWT.decode data['HS256_unencoded_payload'], data[:secret], true, algorithm: 'HS256' expect(header['alg']).to eq 'HS256' - expect(jwt_payload).to eq non_encoded_payload + expect(jwt_payload).to eq unencoded_payload end it 'should raise error when payload is unsafe for decoding' do expect do - JWT.encode non_encoded_payload_unsafe, 'secret', 'HS256', { b64: false } + JWT.encode unencoded_payload_unsafe, 'secret', 'HS256', { b64: false } end.to raise_error JWT::InvalidUnencodedPayload end end context 'when payload is detached' do it 'should generate a valid token' do - token = JWT.encode_detached non_encoded_payload, data[:secret], 'HS256', { b64: false } + token = JWT.encode_detached unencoded_payload, data[:secret], 'HS256' - expect(token).to eq data['HS256_non_encoded_payload_detached'] + expect(token).to eq data['HS256_detached_payload'] end it 'should decode a valid token' do - jwt_payload, header = JWT.decode data['HS256_non_encoded_payload_detached'], data[:secret], true, algorithm: 'HS256', payload: non_encoded_payload + jwt_payload, header = JWT.decode data['HS256_detached_payload'], data[:secret], true, algorithm: 'HS256', payload: unencoded_payload expect(header['alg']).to eq 'HS256' - expect(jwt_payload).to eq non_encoded_payload + expect(jwt_payload).to eq unencoded_payload + end + end + + context 'when payload is unencoded and detached' do + it 'should generate a valid token' do + token = JWT.encode_detached unencoded_payload, data[:secret], 'HS256', { b64: false } + + expect(token).to eq data['HS256_unencoded_detached_payload'] + end + + it 'should decode a valid token' do + jwt_payload, header = JWT.decode data['HS256_unencoded_detached_payload'], data[:secret], true, algorithm: 'HS256', payload: unencoded_payload + + expect(header['alg']).to eq 'HS256' + expect(jwt_payload).to eq unencoded_payload end end end From 8fdbb07d9cd5451ea3b999598389b9b52cb0fa86 Mon Sep 17 00:00:00 2001 From: Bryant Morrill Date: Thu, 4 May 2023 19:10:35 -0600 Subject: [PATCH 3/3] update readme with description of how to use unencoded and detached payloads --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/README.md b/README.md index 2c3a2250..abd426dd 100644 --- a/README.md +++ b/README.md @@ -680,6 +680,72 @@ jwk_hash = jwk.export thumbprint_as_the_kid = jwk_hash[:kid] ``` +### Unencoded and Detached Payloads + +#### Unencoded Payloads + +To generate a JWT with an unencoded payload, you may use the `b64` header set to false as described by RFC 7797. When you do this, the `crit` header will be added if it doesn't already exist, and the `b64` value will be appended to it. + +```ruby +private_key = RbNaCl::Signatures::Ed25519::SigningKey.new('abcdefghijklmnopqrstuvwxyzABCDEF') +public_key = private_key.verify_key +token = JWT.encode payload, private_key, 'ED25519', { b64: false } + +# eyJiNjQiOmZhbHNlLCJhbGciOiJFRDI1NTE5IiwiY3JpdCI6WyJiNjQiXX0.{\"data\":\"test\"}.RL6jDz7h_fbQQds1x_ABOVE_dp646ZIbzvBB_DlixrTTMAiG7k0q4wH8dpcQ7KUeGgqI0tqj7B4JG_jTwM6fCg +puts token + +decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519' } +# Array +# [ +# {"data"=>"test"}, # payload +# {"b64"=>false, "alg"=>"ED25519", "crit"=>["b64"]} # header +# ] +``` + +It is extremely important that one take great care when using unencoded payloads, as the payload must be url safe if it is intended to be transmitted, etc. Also, because `.` is used to delineate between JWT segments, the payload must not have any `.` characters. If the paylod contains `.` then an `InvalidUnencodedPayload` error is raised. + +For the above reasons, detached payloads are often used in combination with unencoded payloads. + +#### Detached Payloads + +To generate a JWT with a detached payload, you must call `encode_detached` instead of `encode`. Then, when decoding and verifying the token, you must pass the `payload` option with the value of the detached payload before encoding. + +```ruby +private_key = RbNaCl::Signatures::Ed25519::SigningKey.new('abcdefghijklmnopqrstuvwxyzABCDEF') +public_key = private_key.verify_key +token = JWT.encode_detached payload, private_key, 'ED25519' + +# eyJhbGciOiJFRDI1NTE5In0..6xIztXyOupskddGA_RvKU76V9b2dCQUYhoZEVFnRimJoPYIzZ2Fm47CWw8k2NTCNpgfAuxg9OXjaiVK7MvrbCQ +puts token + +decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519', payload: payload } +# Array +# [ +# {"test"=>"data"}, # payload +# {"alg"=>"ED25519"} # header +# ] +``` + +#### Combining Unencoded and Detached Payload Support + +Unencoded and detached payloads are often used hand in hand, such as in proof signatures of Verifiable Credentials. You may use the `b64` header in combination with `encode_detached` to combine both features. + +```ruby +private_key = RbNaCl::Signatures::Ed25519::SigningKey.new('abcdefghijklmnopqrstuvwxyzABCDEF') +public_key = private_key.verify_key +token = JWT.encode_detached payload, private_key, 'ED25519', { b64: false } + +# eyJiNjQiOmZhbHNlLCJhbGciOiJFRDI1NTE5IiwiY3JpdCI6WyJiNjQiXX0..RL6jDz7h_fbQQds1x_ABOVE_dp646ZIbzvBB_DlixrTTMAiG7k0q4wH8dpcQ7KUeGgqI0tqj7B4JG_jTwM6fCg +puts token + +decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519', payload: payload } +# Array +# [ +# {"data"=>"test"}, # payload +# {"b64"=>false, "alg"=>"ED25519", "crit"=>["b64"]} # header +# ] +``` + # Development and Tests We depend on [Bundler](http://rubygems.org/gems/bundler) for defining gemspec and performing releases to rubygems.org, which can be done with