diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6a0abc4..6cdb3b1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,27 +1,53 @@ -name: Ruby +--- + name: test + on: + push: + branches: + - "*" + pull_request: + branches: + - "*" + jobs: + lint: + name: RuboCop + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + - name: Run RuboCop + run: bundle exec rubocop + test: + name: Ruby ${{ matrix.ruby }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: + - "2.5" + - "2.6" + - "2.7" + - "3.0" + - "3.1" + - "3.2" + - "3.3" + steps: + - uses: actions/checkout@v3 -on: - push: - branches: - - master + - name: Install libsodium + run: | + sudo apt-get update -q + sudo apt-get install libsodium-dev -y - pull_request: + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true -jobs: - build: - runs-on: ubuntu-latest - name: Ruby ${{ matrix.ruby }} - strategy: - matrix: - ruby: - - '3.3.0' - - steps: - - uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - name: Run the default task - run: bundle exec rake + - name: Run tests + run: bundle exec rspec diff --git a/Gemfile b/Gemfile index 5b66d2d..ac34526 100644 --- a/Gemfile +++ b/Gemfile @@ -8,3 +8,4 @@ gem "jwt", github: "anakinj/ruby-jwt", branch: "extendable-algos" gem "rake" gem "rspec" gem "rubocop" +gem "simplecov" diff --git a/README.md b/README.md index 1d639ed..5c02206 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jwt-eddsa -A library extending the ruby-jwt gem with EdDSA algorithms +A library extending the ruby-jwt gem with EdDSA algorithms. Based on [RFC 8037](https://datatracker.ietf.org/doc/html/rfc8037). **NOTE: This gem is still WIP** diff --git a/jwt-eddsa.gemspec b/jwt-eddsa.gemspec index 8747116..da109c9 100644 --- a/jwt-eddsa.gemspec +++ b/jwt-eddsa.gemspec @@ -4,7 +4,7 @@ require_relative "lib/jwt/eddsa/version" Gem::Specification.new do |spec| spec.name = "jwt-eddsa" - spec.version = JWT::Eddsa::VERSION + spec.version = JWT::EdDSA::VERSION spec.authors = ["Joakim Antman"] spec.email = ["antmanj@gmail.com"] @@ -15,7 +15,7 @@ Gem::Specification.new do |spec| spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/anakinj/jwt-eddsa" - spec.metadata["changelog_uri"] = "https://github.com/anakinj/jwt-eddsablob/v#{JWT::Eddsa::VERSION}/CHANGELOG.md" + spec.metadata["changelog_uri"] = "https://github.com/anakinj/jwt-eddsablob/v#{JWT::EdDSA::VERSION}/CHANGELOG.md" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -30,6 +30,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + spec.add_dependency "base64" spec.add_dependency "jwt", "> 2.8.2" spec.add_dependency "rbnacl", "~> 6.0" diff --git a/lib/jwt/eddsa.rb b/lib/jwt/eddsa.rb index b99e98b..5b251a2 100644 --- a/lib/jwt/eddsa.rb +++ b/lib/jwt/eddsa.rb @@ -1,33 +1,7 @@ # frozen_string_literal: true require "jwt" -require_relative "eddsa/version" - -module JWT - # EdDSA algorithm implementation - module Eddsa - include JWT::JWA::Algorithm - - register_algorithm("EdDSA") - - class << self - def sign(_algorithm, msg, key) - unless key.is_a?(RbNaCl::Signatures::Ed25519::SigningKey) - raise_sign_error!("Key given is a #{key.class} but needs to be a RbNaCl::Signatures::Ed25519::SigningKey") - end - key.sign(msg) - end - - def verify(_algorithm, public_key, signing_input, signature) - unless public_key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey) - raise_verify_error!("Key given is a #{public_key.class} but needs to be a RbNaCl::Signatures::Ed25519::VerifyKey") - end - - public_key.verify(signature, signing_input) - rescue RbNaCl::CryptoError - false - end - end - end -end +require_relative "eddsa/version" +require_relative "eddsa/jwk/okp" +require_relative "eddsa/algo" diff --git a/lib/jwt/eddsa/algo.rb b/lib/jwt/eddsa/algo.rb new file mode 100644 index 0000000..30f6e69 --- /dev/null +++ b/lib/jwt/eddsa/algo.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module JWT + module EdDSA + # EdDSA algorithm implementation + module Algo + include JWT::JWA::Algorithm + + register_algorithm("EdDSA") + + class << self + def sign(_algorithm, msg, key) + unless key.is_a?(RbNaCl::Signatures::Ed25519::SigningKey) + raise_sign_error!("Key given is a #{key.class} but needs to be a " \ + "RbNaCl::Signatures::Ed25519::SigningKey") + end + + key.sign(msg) + end + + def verify(_algorithm, public_key, signing_input, signature) + unless public_key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey) + raise_verify_error!("Key given is a #{public_key.class} but needs to be a " \ + "RbNaCl::Signatures::Ed25519::VerifyKey") + end + + public_key.verify(signature, signing_input) + rescue RbNaCl::CryptoError + false + end + end + end + end +end diff --git a/lib/jwt/eddsa/jwk/okp.rb b/lib/jwt/eddsa/jwk/okp.rb new file mode 100644 index 0000000..3c92ec8 --- /dev/null +++ b/lib/jwt/eddsa/jwk/okp.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module JWT + module EdDSA + module JWK + # https://datatracker.ietf.org/doc/html/rfc8037 + class OKP < ::JWT::JWK::KeyBase + KTY = "OKP" + KTYS = [KTY, JWT::EdDSA::JWK::OKP, RbNaCl::Signatures::Ed25519::SigningKey, + RbNaCl::Signatures::Ed25519::VerifyKey].freeze + OKP_PUBLIC_KEY_ELEMENTS = %i[kty n x].freeze + OKP_PRIVATE_KEY_ELEMENTS = %i[d].freeze + + def initialize(key, params = nil, options = {}) + params ||= {} + # For backwards compatibility when kid was a String + params = { kid: params } if params.is_a?(String) + + key_params = extract_key_params(key) + + params = params.transform_keys(&:to_sym) + check_jwk_params!(key_params, params) + super(options, key_params.merge(params)) + end + + def verify_key + return @verify_key if defined?(@verify_key) + + @verify_key = verify_key_from_parameters + end + + def signing_key + return @signing_key if defined?(@signing_key) + + @signing_key = signing_key_from_parameters + end + + def key_digest + ::JWT::JWK::Thumbprint.new(self).to_s + end + + def private? + !signing_key.nil? + end + + def members + OKP_PUBLIC_KEY_ELEMENTS.each_with_object({}) { |i, h| h[i] = self[i] } + end + + def export(options = {}) + exported = parameters.clone + unless private? && options[:include_private] == true + exported.reject! do |k, _| + OKP_PRIVATE_KEY_ELEMENTS.include?(k) + end + end + exported + end + + private + + def extract_key_params(key) # rubocop:disable Metric/MethodLength + case key + when JWT::JWK::KeyBase + key.export(include_private: true) + when RbNaCl::Signatures::Ed25519::SigningKey + @signing_key = key + @verify_key = key.verify_key + parse_okp_key_params(@verify_key, @signing_key) + when RbNaCl::Signatures::Ed25519::VerifyKey + @signing_key = nil + @verify_key = key + parse_okp_key_params(@verify_key) + when Hash + key.transform_keys(&:to_sym) + else + raise ArgumentError, + "key must be of type RbNaCl::Signatures::Ed25519::SigningKey, " \ + "RbNaCl::Signatures::Ed25519::VerifyKey " \ + "or Hash with key parameters" + end + end + + def check_jwk_params!(key_params, _given_params) + return if key_params[:kty] == KTY + + raise JWT::JWKError, + "Incorrect 'kty' value: #{key_params[:kty]}, expected #{KTY}" + end + + def parse_okp_key_params(verify_key, signing_key = nil) + params = { + kty: KTY, + crv: "Ed25519", + x: ::Base64.urlsafe_encode64(verify_key.to_bytes, padding: false) + } + + params[:d] = ::Base64.urlsafe_encode64(signing_key.to_bytes, padding: false) if signing_key + + params + end + + def verify_key_from_parameters + RbNaCl::Signatures::Ed25519::VerifyKey.new(::Base64.urlsafe_decode64(self[:x])) + end + + def signing_key_from_parameters + return nil unless self[:d] + + RbNaCl::Signatures::Ed25519::SigningKey.new(::Base64.urlsafe_decode64(self[:d])) + end + + class << self + def import(jwk_data) + new(jwk_data) + end + end + end + end + end +end diff --git a/lib/jwt/eddsa/version.rb b/lib/jwt/eddsa/version.rb index edfb648..c5dec0c 100644 --- a/lib/jwt/eddsa/version.rb +++ b/lib/jwt/eddsa/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module JWT - module Eddsa + module EdDSA VERSION = "0.2.0" end end diff --git a/spec/integration_spec.rb b/spec/integration_spec.rb index ad492ad..d195bde 100644 --- a/spec/integration_spec.rb +++ b/spec/integration_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "securerandom" + RSpec.describe "Usage via ruby-jwt" do let(:private_key) { RbNaCl::Signatures::Ed25519::SigningKey.new("b" * 32) } let(:public_key) { private_key.verify_key } @@ -44,4 +46,19 @@ end end end + + describe "OKP JWK usage" do + let(:jwk) { JWT::JWK.new(RbNaCl::Signatures::Ed25519::SigningKey.new(SecureRandom.hex)) } + let(:public_jwks) { { keys: [jwk.export, { kid: "not_the_correct_one", kty: "oct", k: "secret" }] } } + let(:signed_token) { JWT.encode(token_payload, jwk.signing_key, "EdDSA", token_headers) } + let(:token_payload) { { "data" => "something" } } + let(:token_headers) { { kid: jwk.kid } } + + it "decodes the token" do + key_loader = ->(_options) { JSON.parse(JSON.generate(public_jwks)) } + payload, _header = JWT.decode(signed_token, nil, true, + { algorithms: ["EDDSA"], jwks: key_loader }) + expect(payload).to eq(token_payload) + end + end end diff --git a/spec/jwt/eddsa_spec.rb b/spec/jwt/eddsa_spec.rb index c76ab65..cc8d570 100644 --- a/spec/jwt/eddsa_spec.rb +++ b/spec/jwt/eddsa_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe JWT::Eddsa do +RSpec.describe JWT::EdDSA do it "has a version number" do expect(described_class::VERSION).not_to be nil end diff --git a/spec/jwt/jwk/okp_spec.rb b/spec/jwt/jwk/okp_spec.rb new file mode 100644 index 0000000..7e5c78a --- /dev/null +++ b/spec/jwt/jwk/okp_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "securerandom" + +RSpec.describe JWT::EdDSA::JWK::OKP do + let(:private_key) { RbNaCl::Signatures::Ed25519::SigningKey.new(SecureRandom.hex) } + let(:public_key) { private_key.verify_key } + let(:key) { nil } + + subject(:instance) { described_class.new(key) } + + describe ".new" do + context "when private key is given" do + let(:key) { private_key } + it { is_expected.to be_a(described_class) } + end + context "when public key is given" do + let(:key) { public_key } + it { is_expected.to be_a(described_class) } + end + context "when something else than a public or private key is given" do + let(:key) { OpenSSL::PKey::RSA.new(2048) } + it "raises an ArgumentError" do + expect { instance }.to raise_error(ArgumentError) + end + end + + context "when jwk parameters given" do + let(:key) do + { + kty: "OKP", + use: "sig", + crv: "Ed25519", + kid: "27zV", + x: "0I6olrZGYml7JGusuKJW9G7D0DZ9UormSady9kR7V4Q" + } + end + it { is_expected.to be_a(described_class) } + end + end + + describe "#verify_key" do + let(:key) { private_key } + subject { instance.verify_key } + it "is the verify key" do + expect(subject).to be_a(RbNaCl::Signatures::Ed25519::VerifyKey) + end + end + + describe "#private?" do + subject { instance.private? } + context "when private key is given" do + let(:key) { private_key } + it { is_expected.to eq(true) } + end + context "when public key is given" do + let(:key) { public_key } + it { is_expected.to eq(false) } + end + end + + describe "#export" do + let(:options) { {} } + subject { instance.export(options) } + context "when private key is given" do + let(:key) { private_key } + it "exports the public key" do + expect(subject).to include(crv: "Ed25519", kty: "OKP") + expect(subject.keys).to eq(%i[kty crv x kid]) + expect(subject[:x].size).to eq(43) + expect(subject[:kid].size).to eq(43) + end + end + context "when private key is asked for" do + let(:key) { private_key } + let(:options) { { include_private: true } } + it "exports the private key" do + expect(subject).to include(crv: "Ed25519", kty: "OKP") + expect(subject.keys).to eq(%i[kty crv x d kid]) + expect(subject[:x].size).to eq(43) + expect(subject[:d].size).to eq(43) + expect(subject[:kid].size).to eq(43) + end + end + end + + describe ".import" do + subject { described_class.import(import_data) } + + context "when exported public key is given" do + let(:import_data) { described_class.new(public_key).export } + it "creates a new instance of the class" do + expect(subject.private?).to eq(false) + expect(subject.verify_key).to be_a(RbNaCl::Signatures::Ed25519::VerifyKey) + expect(subject.signing_key).to eq(nil) + expect(subject.verify_key.to_bytes).to eq(public_key.to_bytes) + expect(subject.kid).to eq(import_data[:kid]) + end + end + + context "when exported private key is given" do + let(:import_data) { described_class.new(private_key).export(include_private: true) } + it "creates a new instance of the class" do + expect(subject.private?).to eq(true) + expect(subject.verify_key).to be_a(RbNaCl::Signatures::Ed25519::VerifyKey) + expect(subject.signing_key).to be_a(RbNaCl::Signatures::Ed25519::SigningKey) + expect(subject.verify_key.to_bytes).to eq(public_key.to_bytes) + expect(subject.kid).to eq(import_data[:kid]) + end + end + + context "when JWK is given" do + let(:import_data) { described_class.new(private_key) } + it "creates a new instance of the class" do + expect(subject.private?).to eq(true) + expect(subject.verify_key).to be_a(RbNaCl::Signatures::Ed25519::VerifyKey) + expect(subject.signing_key).to be_a(RbNaCl::Signatures::Ed25519::SigningKey) + expect(subject.verify_key.to_bytes).to eq(public_key.to_bytes) + expect(subject.kid).to eq(import_data[:kid]) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3372565..fc1f715 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "simplecov" +SimpleCov.start + require "jwt/eddsa" RSpec.configure do |config|