From 8c0c9ee040d9232857a7f64a86b54f87f0707c01 Mon Sep 17 00:00:00 2001 From: traffic Date: Wed, 8 Oct 2025 21:47:07 +0200 Subject: [PATCH] - Add support for AES-GCM key wrap algorithms (A128GCMKW, A192GCMKW, A256GCMKW) - Add new internal architecture with `Base`, `Validator`, `Header`, and `NameResolver` classes - Improve code organization with refactored module structure - RuboCop compliance improvements - Deprecated `JWE.check_params`, `JWE.check_alg`, `JWE.check_enc`, `JWE.check_zip`, `JWE.check_key` (use `JWE::Validator` instead) - Deprecated `JWE.param_to_class_name` (use `JWE::NameResolver` instead) - Deprecated internal methods `JWE.apply_zip`, `JWE.generate_header`, `JWE.generate_serialization` --- .rubocop.yml | 21 ++++++- CHANGELOG.md | 19 ++++++ Gemfile | 1 + README.md | 21 ++++++- lib/jwe.rb | 71 +++++++++++++++------- lib/jwe/alg.rb | 8 ++- lib/jwe/alg/a128_kw.rb | 2 +- lib/jwe/alg/a128gcmkw.rb | 30 +++++++++ lib/jwe/alg/a192_kw.rb | 2 +- lib/jwe/alg/a192gcmkw.rb | 30 +++++++++ lib/jwe/alg/a256gcmkw.rb | 30 +++++++++ lib/jwe/alg/aes_gcm.rb | 66 ++++++++++++++++++++ lib/jwe/alg/base.rb | 22 +++++++ lib/jwe/alg/dir.rb | 6 +- lib/jwe/alg/rsa15.rb | 2 +- lib/jwe/alg/rsa_oaep.rb | 2 +- lib/jwe/alg/rsa_oaep_256.rb | 2 +- lib/jwe/enc.rb | 4 +- lib/jwe/enc/a128cbc_hs256.rb | 3 +- lib/jwe/enc/a128gcm.rb | 3 +- lib/jwe/enc/a192cbc_hs384.rb | 3 +- lib/jwe/enc/a192gcm.rb | 3 +- lib/jwe/enc/a256cbc_hs512.rb | 3 +- lib/jwe/enc/a256gcm.rb | 3 +- lib/jwe/enc/base.rb | 18 ++++++ lib/jwe/header.rb | 19 ++++++ lib/jwe/name_resolver.rb | 20 ++++++ lib/jwe/validator.rb | 36 +++++++++++ lib/jwe/version.rb | 2 +- lib/jwe/zip.rb | 4 +- spec/jwe/alg_spec.rb | 52 ++++++++++++++++ spec/jwe/header_spec.rb | 108 +++++++++++++++++++++++++++++++++ spec/jwe/name_resolver_spec.rb | 73 ++++++++++++++++++++++ spec/jwe/validator_spec.rb | 62 +++++++++++++++++++ spec/jwe_spec.rb | 61 +++++++++++++++++++ 35 files changed, 771 insertions(+), 41 deletions(-) create mode 100644 lib/jwe/alg/a128gcmkw.rb create mode 100644 lib/jwe/alg/a192gcmkw.rb create mode 100644 lib/jwe/alg/a256gcmkw.rb create mode 100644 lib/jwe/alg/aes_gcm.rb create mode 100644 lib/jwe/alg/base.rb create mode 100644 lib/jwe/enc/base.rb create mode 100644 lib/jwe/header.rb create mode 100644 lib/jwe/name_resolver.rb create mode 100644 lib/jwe/validator.rb create mode 100644 spec/jwe/header_spec.rb create mode 100644 spec/jwe/name_resolver_spec.rb create mode 100644 spec/jwe/validator_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 48e7d18..71cefab 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,4 +18,23 @@ Style/PercentLiteralDelimiters: Naming/MethodParameterName: AllowedNames: ["iv", "b", "j", "a", "r", "t"] - + +Metrics/AbcSize: + Exclude: + - 'lib/jwe.rb' + +Metrics/ParameterLists: + Exclude: + - 'lib/jwe.rb' + +Metrics/MethodLength: + Exclude: + - 'lib/jwe.rb' + +Lint/MissingSuper: + Exclude: + - 'lib/jwe/alg/dir.rb' + - 'lib/jwe/alg/rsa15.rb' + - 'lib/jwe/alg/rsa_oaep.rb' + - 'lib/jwe/alg/rsa_oaep_256.rb' + diff --git a/CHANGELOG.md b/CHANGELOG.md index fc8df5c..f1eb7b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Add support for AES-GCM key wrap algorithms(https://github.com/jwt/ruby-jwe/pull/38) (2025-10-08) + +**Features:** +- Add support for AES-GCM key wrap algorithms (A128GCMKW, A192GCMKW, A256GCMKW) +- Add new internal architecture with `Base`, `Validator`, `Header`, and `NameResolver` classes +- Improve code organization with refactored module structure +- RuboCop compliance improvements + +**Deprecations:** + +- Deprecated `JWE.check_params`, `JWE.check_alg`, `JWE.check_enc`, `JWE.check_zip`, `JWE.check_key` +(use `JWE::Validator` instead) +- Deprecated `JWE.param_to_class_name` (use `JWE::NameResolver` instead) +- Deprecated internal methods `JWE.apply_zip`, `JWE.generate_header`, `JWE.generate_serialization` + +**Notes:** + +All deprecated methods remain functional with deprecation warnings for backward compatibility. + ## [v1.1.1](https://github.com/jwt/ruby-jwe/tree/v1.1.1) (2025-08-07) [Full Changelog](https://github.com/jwt/ruby-jwe/compare/v1.1.0...v1.1.1) diff --git a/Gemfile b/Gemfile index f44cf9d..49cf130 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,7 @@ source 'https://rubygems.org' gemspec +gem 'pry' gem 'rake' gem 'rspec' gem 'rubocop' diff --git a/README.md b/README.md index e4decca..beb7d3c 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,21 @@ encrypted = JWE.encrypt(payload, key, copyright: 'This is my stuff! All rights r puts encrypted ``` +This example uses AES-GCM key wrap algorithm (A128GCMKW, A192GCMKW, or A256GCMKW). + +```ruby +require 'jwe' + +key = SecureRandom.random_bytes(16) # 16 bytes for A128GCMKW, 24 for A192GCMKW, 32 for A256GCMKW +payload = "The quick brown fox jumps over the lazy dog." + +encrypted = JWE.encrypt(payload, key, alg: 'A128GCMKW') +puts encrypted + +plaintext = JWE.decrypt(encrypted, key) +puts plaintext #"The quick brown fox jumps over the lazy dog." +``` + ## Available Algorithms The RFC 7518 JSON Web Algorithms (JWA) spec defines the algorithms for [encryption](https://tools.ietf.org/html/rfc7518#section-5.1) @@ -105,9 +120,9 @@ Key management: * ~~ECDH-ES+A128KW~~ * ~~ECDH-ES+A192KW~~ * ~~ECDH-ES+A256KW~~ -* ~~A128GCMKW~~ -* ~~A192GCMKW~~ -* ~~A256GCMKW~~ +* A128GCMKW +* A192GCMKW +* A256GCMKW * ~~PBES2-HS256+A128KW~~ * ~~PBES2-HS384+A192KW~~ * ~~PBES2-HS512+A256KW~~ diff --git a/lib/jwe.rb b/lib/jwe.rb index be8f36b..a28b622 100644 --- a/lib/jwe.rb +++ b/lib/jwe.rb @@ -7,9 +7,12 @@ require 'jwe/base64' require 'jwe/serialization/compact' +require 'jwe/name_resolver' require 'jwe/alg' require 'jwe/enc' require 'jwe/zip' +require 'jwe/validator' +require 'jwe/header' # A ruby implementation of the RFC 7516 JSON Web Encryption (JWE) standard. module JWE @@ -18,68 +21,90 @@ class NotImplementedError < RuntimeError; end class BadCEK < RuntimeError; end class InvalidData < RuntimeError; end - VALID_ALG = ['RSA1_5', 'RSA-OAEP', 'RSA-OAEP-256', 'A128KW', 'A192KW', 'A256KW', 'dir', 'ECDH-ES', 'ECDH-ES+A128KW', 'ECDH-ES+A192KW', 'ECDH-ES+A256KW', 'A128GCMKW', 'A192GCMKW', 'A256GCMKW', 'PBES2-HS256+A128KW', 'PBES2-HS384+A192KW', 'PBES2-HS512+A256KW'].freeze - VALID_ENC = %w[A128CBC-HS256 A192CBC-HS384 A256CBC-HS512 A128GCM A192GCM A256GCM].freeze - VALID_ZIP = ['DEF'].freeze - class << self - def encrypt(payload, key, alg: 'RSA-OAEP', enc: 'A128GCM', **more_headers) - header = generate_header(alg, enc, more_headers) - check_params(header, key) + def encrypt(payload, key, alg: 'RSA-OAEP', enc: 'A128GCM', zip: nil, **more_headers) + Validator.new.check_params(alg, enc, zip, key) + payload = Zip.for(zip).new.compress(payload) if zip + + enc_cipher = Enc.for(enc) + enc_cipher.cek = key if alg == 'dir' - payload = apply_zip(header, payload, :compress) + alg_cipher = Alg.for(alg).new(key) + encrypted_cek = alg_cipher.encrypt(enc_cipher.cek) - cipher = Enc.for(enc) - cipher.cek = key if alg == 'dir' + header = Header.new.generate_header(alg_cipher, enc_cipher, zip, more_headers) - json_hdr = header.to_json - ciphertext = cipher.encrypt(payload, Base64.jwe_encode(json_hdr)) + ciphertext = enc_cipher.encrypt(payload, Base64.jwe_encode(header)) - generate_serialization(json_hdr, Alg.encrypt_cek(alg, key, cipher.cek), ciphertext, cipher) + Serialization::Compact.encode(header, encrypted_cek, enc_cipher.iv, ciphertext, enc_cipher.tag) end def decrypt(payload, key) header, enc_key, iv, ciphertext, tag = Serialization::Compact.decode(payload) header = JSON.parse(header) - check_params(header, key) + alg, enc, zip = header.values_at('alg', 'enc', 'zip') + + Validator.new.check_params(alg, enc, zip, key) + + alg_cipher = Alg.for(alg).new(key) + if alg_cipher.need_additional_header_parameters? + alg_cipher.iv = Base64.jwe_decode(header['iv']) + alg_cipher.tag = Base64.jwe_decode(header['tag']) + end + + cek = alg_cipher.decrypt(enc_key) + enc_cipher = Enc.for(enc, cek, iv, tag) - cek = Alg.decrypt_cek(header['alg'], key, enc_key) - cipher = Enc.for(header['enc'], cek, iv, tag) + plaintext = enc_cipher.decrypt(ciphertext, payload.split('.').first) - plaintext = cipher.decrypt(ciphertext, payload.split('.').first) + return plaintext unless zip - apply_zip(header, plaintext, :decompress) + Zip.for(zip).new.decompress(plaintext) end + # @deprecated Use Validator.new.check_params instead def check_params(header, key) + warn '[DEPRECATION] `JWE.check_params` is deprecated. Use `JWE::Validator.new.check_params` instead.' check_alg(header[:alg] || header['alg']) check_enc(header[:enc] || header['enc']) check_zip(header[:zip] || header['zip']) check_key(key) end + # @deprecated Use Validator.new.check_params instead def check_alg(alg) - raise ArgumentError.new("\"#{alg}\" is not a valid alg method") unless VALID_ALG.include?(alg) + warn '[DEPRECATION] `JWE.check_alg` is deprecated. Please validate parameters manually.' + raise ArgumentError.new("\"#{alg}\" is not a valid alg method") unless Validator::VALID_ALG.include?(alg) end + # @deprecated Use Validator.new.check_params instead def check_enc(enc) - raise ArgumentError.new("\"#{enc}\" is not a valid enc method") unless VALID_ENC.include?(enc) + warn '[DEPRECATION] `JWE.check_enc` is deprecated. Please validate parameters manually.' + raise ArgumentError.new("\"#{enc}\" is not a valid enc method") unless Validator::VALID_ENC.include?(enc) end + # @deprecated Use Validator.new.check_params instead def check_zip(zip) - raise ArgumentError.new("\"#{zip}\" is not a valid zip method") unless zip.nil? || zip == '' || VALID_ZIP.include?(zip) + warn '[DEPRECATION] `JWE.check_zip` is deprecated. Please validate parameters manually.' + raise ArgumentError.new("\"#{zip}\" is not a valid zip method") unless zip.nil? || zip == '' || Validator::VALID_ZIP.include?(zip) end + # @deprecated Use Validator.new.check_params instead def check_key(key) + warn '[DEPRECATION] `JWE.check_key` is deprecated. Please validate parameters manually.' raise ArgumentError.new('The key must not be nil or blank') if key.nil? || (key.is_a?(String) && key.strip == '') end + # @deprecated Use NameResolver#param_to_class_name instead def param_to_class_name(param) + warn '[DEPRECATION] `JWE.param_to_class_name` is deprecated. Use `JWE::NameResolver#param_to_class_name` instead.' klass = param.gsub(/[-+]/, '_').downcase.sub(/^[a-z\d]*/) { ::Regexp.last_match(0).capitalize } klass.gsub(/_([a-z\d]*)/i) { Regexp.last_match(1).capitalize } end + # @deprecated Internal method, do not use def apply_zip(header, data, direction) + warn '[DEPRECATION] `JWE.apply_zip` is deprecated. This is an internal method and should not be used externally.' zip = header[:zip] || header['zip'] if zip Zip.for(zip).new.send(direction, data) @@ -88,13 +113,17 @@ def apply_zip(header, data, direction) end end + # @deprecated Use Header.new.generate_header instead def generate_header(alg, enc, more) + warn '[DEPRECATION] `JWE.generate_header` is deprecated. This is an internal method and should not be used externally.' header = { alg: alg, enc: enc }.merge(more) header.delete(:zip) if header[:zip] == '' header end + # @deprecated Use Serialization::Compact.encode instead def generate_serialization(hdr, cek, content, cipher) + warn '[DEPRECATION] `JWE.generate_serialization` is deprecated. Use `JWE::Serialization::Compact.encode` instead.' Serialization::Compact.encode(hdr, cek, cipher.iv, content, cipher.tag) end end diff --git a/lib/jwe/alg.rb b/lib/jwe/alg.rb index 6158cc7..0fd9e30 100644 --- a/lib/jwe/alg.rb +++ b/lib/jwe/alg.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true +require 'jwe/alg/base' require 'jwe/alg/a128_kw' require 'jwe/alg/a192_kw' require 'jwe/alg/a256_kw' +require 'jwe/alg/a128gcmkw' +require 'jwe/alg/a192gcmkw' +require 'jwe/alg/a256gcmkw' require 'jwe/alg/dir' require 'jwe/alg/rsa_oaep' require 'jwe/alg/rsa_oaep_256' if OpenSSL::VERSION >= '3.0' @@ -11,8 +15,10 @@ module JWE # Key encryption algorithms namespace module Alg + extend JWE::NameResolver + def self.for(alg) - const_get(JWE.param_to_class_name(alg)) + const_get(param_to_class_name(alg)) rescue NameError raise NotImplementedError.new("Unsupported alg type: #{alg}") end diff --git a/lib/jwe/alg/a128_kw.rb b/lib/jwe/alg/a128_kw.rb index 1933d5b..e779517 100644 --- a/lib/jwe/alg/a128_kw.rb +++ b/lib/jwe/alg/a128_kw.rb @@ -5,7 +5,7 @@ module JWE module Alg # AES-128 Key Wrapping algorithm - class A128kw + class A128kw < Base include AesKw def cipher_name diff --git a/lib/jwe/alg/a128gcmkw.rb b/lib/jwe/alg/a128gcmkw.rb new file mode 100644 index 0000000..93f688f --- /dev/null +++ b/lib/jwe/alg/a128gcmkw.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'jwe/alg/aes_gcm' + +module JWE + module Alg + # AES-128 key wrap with GCM algorithm + class A128gcmkw < Base + include AesGcm + + def initialize(key, iv = nil) + super + end + + private + + def key_length + 16 + end + + def cipher_name + 'aes-128-gcm' + end + + def required_additional_header_parameters? + true + end + end + end +end diff --git a/lib/jwe/alg/a192_kw.rb b/lib/jwe/alg/a192_kw.rb index 66210ca..53bd469 100644 --- a/lib/jwe/alg/a192_kw.rb +++ b/lib/jwe/alg/a192_kw.rb @@ -5,7 +5,7 @@ module JWE module Alg # AES-192 Key Wrapping algorithm - class A192kw + class A192kw < Base include AesKw def cipher_name diff --git a/lib/jwe/alg/a192gcmkw.rb b/lib/jwe/alg/a192gcmkw.rb new file mode 100644 index 0000000..4619935 --- /dev/null +++ b/lib/jwe/alg/a192gcmkw.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'jwe/alg/aes_gcm' + +module JWE + module Alg + # AES-192 key wrap with GCM algorithm + class A192gcmkw < Base + include AesGcm + + def initialize(key, iv = nil) + super + end + + private + + def key_length + 24 + end + + def cipher_name + 'aes-192-gcm' + end + + def required_additional_header_parameters? + true + end + end + end +end diff --git a/lib/jwe/alg/a256gcmkw.rb b/lib/jwe/alg/a256gcmkw.rb new file mode 100644 index 0000000..7d79cfa --- /dev/null +++ b/lib/jwe/alg/a256gcmkw.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'jwe/alg/aes_gcm' + +module JWE + module Alg + # AES-256 key wrap with GCM algorithm + class A256gcmkw < Base + include AesGcm + + def initialize(key, iv = nil) + super + end + + private + + def key_length + 32 + end + + def cipher_name + 'aes-256-gcm' + end + + def required_additional_header_parameters? + true + end + end + end +end diff --git a/lib/jwe/alg/aes_gcm.rb b/lib/jwe/alg/aes_gcm.rb new file mode 100644 index 0000000..70193ef --- /dev/null +++ b/lib/jwe/alg/aes_gcm.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'jwe/enc/cipher' + +module JWE + module Alg + # Abstract AES in Galois Counter mode for different key sizes. + module AesGcm + attr_accessor :iv, :cek, :tag + + def initialize(cek, iv = nil) + self.iv = iv || SecureRandom.random_bytes(12) + self.cek = cek + self.tag = '' + end + + def encrypt(cleartext) + raise JWE::BadCEK, "The supplied key is too short. Required length: #{key_length}" if cek.length < key_length + + setup_cipher(:encrypt) + ciphertext = cipher.update(cleartext) + cipher.final + self.tag = cipher.auth_tag + + ciphertext + end + + def decrypt(ciphertext) + raise JWE::BadCEK, "The supplied key is too short. Required length: #{key_length}" if cek.length < key_length + + setup_cipher(:decrypt) + cipher.update(ciphertext) + cipher.final + rescue OpenSSL::Cipher::CipherError + raise JWE::InvalidData, 'Invalid ciphertext or authentication tag' + end + + def setup_cipher(direction) + cipher.send(direction) + cipher.key = cek + cipher.iv = iv + if direction == :decrypt + raise JWE::InvalidData, 'Invalid ciphertext or authentication tag' unless tag.bytesize == 16 + + cipher.auth_tag = tag + end + cipher.auth_data = '' + end + + def cipher + @cipher ||= OpenSSL::Cipher.new(cipher_name) + rescue RuntimeError + raise JWE::NotImplementedError.new("The version of OpenSSL linked to your Ruby does not support the cipher #{cipher_name}.") + end + + def header_parameters + { + iv: JWE::Base64.jwe_encode(iv), + tag: JWE::Base64.jwe_encode(tag) + } + end + + def need_additional_header_parameters? + true + end + end + end +end diff --git a/lib/jwe/alg/base.rb b/lib/jwe/alg/base.rb new file mode 100644 index 0000000..59a9cd0 --- /dev/null +++ b/lib/jwe/alg/base.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module JWE + module Alg + # Base class for key encryption algorithms + class Base + include JWE::NameResolver + + def encrypt(_cek) + raise NotImplementedError, "#{self.class} must implement #encrypt" + end + + def decrypt(_encrypted_cek) + raise NotImplementedError, "#{self.class} must implement #decrypt" + end + + def need_additional_header_parameters? + false + end + end + end +end diff --git a/lib/jwe/alg/dir.rb b/lib/jwe/alg/dir.rb index 521c083..99c0787 100644 --- a/lib/jwe/alg/dir.rb +++ b/lib/jwe/alg/dir.rb @@ -3,7 +3,7 @@ module JWE module Alg # Direct (no-op) key encryption algorithm. - class Dir + class Dir < Base attr_accessor :key def initialize(key) @@ -17,6 +17,10 @@ def encrypt(_cek) def decrypt(_encrypted_cek) key end + + def class_name_to_param + 'dir' + end end end end diff --git a/lib/jwe/alg/rsa15.rb b/lib/jwe/alg/rsa15.rb index 5a1024b..4d3386b 100644 --- a/lib/jwe/alg/rsa15.rb +++ b/lib/jwe/alg/rsa15.rb @@ -3,7 +3,7 @@ module JWE module Alg # RSA RSA with PKCS1 v1.5 algorithm. - class Rsa15 + class Rsa15 < Base attr_accessor :key def initialize(key) diff --git a/lib/jwe/alg/rsa_oaep.rb b/lib/jwe/alg/rsa_oaep.rb index e92dbc5..594ae3c 100644 --- a/lib/jwe/alg/rsa_oaep.rb +++ b/lib/jwe/alg/rsa_oaep.rb @@ -3,7 +3,7 @@ module JWE module Alg # RSA-OAEP key encryption algorithm. - class RsaOaep + class RsaOaep < Base attr_accessor :key def initialize(key) diff --git a/lib/jwe/alg/rsa_oaep_256.rb b/lib/jwe/alg/rsa_oaep_256.rb index de25252..47ee825 100644 --- a/lib/jwe/alg/rsa_oaep_256.rb +++ b/lib/jwe/alg/rsa_oaep_256.rb @@ -3,7 +3,7 @@ module JWE module Alg # RSA-OAEP-256 key encryption algorithm. - class RsaOaep256 + class RsaOaep256 < Base attr_accessor :key def initialize(key) diff --git a/lib/jwe/enc.rb b/lib/jwe/enc.rb index 36670fe..e88f51b 100644 --- a/lib/jwe/enc.rb +++ b/lib/jwe/enc.rb @@ -10,8 +10,10 @@ module JWE # Content encryption algorithms namespace module Enc + extend JWE::NameResolver + def self.for(enc, cek = nil, iv = nil, tag = nil) - klass = const_get(JWE.param_to_class_name(enc)) + klass = const_get(param_to_class_name(enc)) inst = klass.new(cek, iv) inst.tag = tag if tag inst diff --git a/lib/jwe/enc/a128cbc_hs256.rb b/lib/jwe/enc/a128cbc_hs256.rb index 36c498b..3a74482 100644 --- a/lib/jwe/enc/a128cbc_hs256.rb +++ b/lib/jwe/enc/a128cbc_hs256.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true +require 'jwe/enc/base' require 'jwe/enc/aes_cbc_hs' module JWE module Enc # AES CBC 128 + SHA256 message verification algorithm. - class A128cbcHs256 + class A128cbcHs256 < Base include AesCbcHs def key_length diff --git a/lib/jwe/enc/a128gcm.rb b/lib/jwe/enc/a128gcm.rb index e88e415..d48fe8a 100644 --- a/lib/jwe/enc/a128gcm.rb +++ b/lib/jwe/enc/a128gcm.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true +require 'jwe/enc/base' require 'jwe/enc/aes_gcm' module JWE module Enc # AES GCM 128 algorithm. - class A128gcm + class A128gcm < Base include AesGcm def key_length diff --git a/lib/jwe/enc/a192cbc_hs384.rb b/lib/jwe/enc/a192cbc_hs384.rb index 14a7378..6e95893 100644 --- a/lib/jwe/enc/a192cbc_hs384.rb +++ b/lib/jwe/enc/a192cbc_hs384.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true +require 'jwe/enc/base' require 'jwe/enc/aes_cbc_hs' module JWE module Enc # AES CBC 192 + SHA384 message verification algorithm. - class A192cbcHs384 + class A192cbcHs384 < Base include AesCbcHs def key_length diff --git a/lib/jwe/enc/a192gcm.rb b/lib/jwe/enc/a192gcm.rb index 3a8ece4..de1a85f 100644 --- a/lib/jwe/enc/a192gcm.rb +++ b/lib/jwe/enc/a192gcm.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true +require 'jwe/enc/base' require 'jwe/enc/aes_gcm' module JWE module Enc # AES GCM 192 algorithm. - class A192gcm + class A192gcm < Base include AesGcm def key_length diff --git a/lib/jwe/enc/a256cbc_hs512.rb b/lib/jwe/enc/a256cbc_hs512.rb index d069fe3..d94e6f1 100644 --- a/lib/jwe/enc/a256cbc_hs512.rb +++ b/lib/jwe/enc/a256cbc_hs512.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true +require 'jwe/enc/base' require 'jwe/enc/aes_cbc_hs' module JWE module Enc # AES CBC 256 + SHA512 message verification algorithm. - class A256cbcHs512 + class A256cbcHs512 < Base include AesCbcHs def key_length diff --git a/lib/jwe/enc/a256gcm.rb b/lib/jwe/enc/a256gcm.rb index 8453eac..27bb50b 100644 --- a/lib/jwe/enc/a256gcm.rb +++ b/lib/jwe/enc/a256gcm.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true +require 'jwe/enc/base' require 'jwe/enc/aes_gcm' module JWE module Enc # AES GCM 256 algorithm. - class A256gcm + class A256gcm < Base include AesGcm def key_length diff --git a/lib/jwe/enc/base.rb b/lib/jwe/enc/base.rb new file mode 100644 index 0000000..a13f40a --- /dev/null +++ b/lib/jwe/enc/base.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module JWE + module Enc + # Base class for content encryption algorithms + class Base + include JWE::NameResolver + + def encrypt(_cleartext, _authenticated_data) + raise NotImplementedError, "#{self.class} must implement #encrypt" + end + + def decrypt(_ciphertext, _authenticated_data) + raise NotImplementedError, "#{self.class} must implement #decrypt" + end + end + end +end diff --git a/lib/jwe/header.rb b/lib/jwe/header.rb new file mode 100644 index 0000000..3f8334b --- /dev/null +++ b/lib/jwe/header.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module JWE + # Generates JWE header from algorithm, encryption, and compression parameters + class Header + def generate_header(alg_cipher, enc_cipher, zip, additional_header_parameters) + header_parameters = { + alg: alg_cipher.class_name_to_param, + enc: enc_cipher.class_name_to_param + } + + header_parameters.merge!(zip: zip) if zip + + header_parameters.merge!(alg_cipher.header_parameters) if alg_cipher.need_additional_header_parameters? + + header_parameters.merge(additional_header_parameters).to_json + end + end +end diff --git a/lib/jwe/name_resolver.rb b/lib/jwe/name_resolver.rb new file mode 100644 index 0000000..4501f6d --- /dev/null +++ b/lib/jwe/name_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module JWE + # Converts between JWE parameter names and Ruby class names + module NameResolver + def param_to_class_name(param) + klass = param.gsub(/[-+]/, '_').downcase.sub(/^[a-z\d]*/) { ::Regexp.last_match(0).capitalize } + klass.gsub(/_([a-z\d]*)/i) { Regexp.last_match(1).capitalize } + end + + def class_name_to_param + klass = self.class.name.split('::').last + + klass.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .gsub('_', '-') + .upcase + end + end +end diff --git a/lib/jwe/validator.rb b/lib/jwe/validator.rb new file mode 100644 index 0000000..7b77f5d --- /dev/null +++ b/lib/jwe/validator.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module JWE + # Validates JWE parameters (algorithm, encryption, compression, and key) + class Validator + VALID_ALG = ['RSA1_5', 'RSA-OAEP', 'RSA-OAEP-256', 'A128KW', 'A192KW', 'A256KW', 'dir', 'ECDH-ES', 'ECDH-ES+A128KW', 'ECDH-ES+A192KW', 'ECDH-ES+A256KW', 'A128GCMKW', 'A192GCMKW', 'A256GCMKW', 'PBES2-HS256+A128KW', 'PBES2-HS384+A192KW', 'PBES2-HS512+A256KW'].freeze + VALID_ENC = %w[A128CBC-HS256 A192CBC-HS384 A256CBC-HS512 A128GCM A192GCM A256GCM].freeze + VALID_ZIP = ['DEF'].freeze + public_constant :VALID_ALG, :VALID_ENC, :VALID_ZIP + + def check_params(alg, enc, zip, key) + check_alg(alg) + check_enc(enc) + check_zip(zip) + check_key(key) + end + + private + + def check_alg(alg) + raise ArgumentError.new("\"#{alg}\" is not a valid alg method") unless VALID_ALG.include?(alg) + end + + def check_enc(enc) + raise ArgumentError.new("\"#{enc}\" is not a valid enc method") unless VALID_ENC.include?(enc) + end + + def check_zip(zip) + raise ArgumentError.new("\"#{zip}\" is not a valid zip method") unless zip.nil? || zip == '' || VALID_ZIP.include?(zip) + end + + def check_key(key) + raise ArgumentError.new('The key must not be nil or blank') if key.nil? || (key.is_a?(String) && key.strip == '') + end + end +end diff --git a/lib/jwe/version.rb b/lib/jwe/version.rb index df0d137..8c51a01 100644 --- a/lib/jwe/version.rb +++ b/lib/jwe/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module JWE - VERSION = '1.1.1' + VERSION = '1.2.0' end diff --git a/lib/jwe/zip.rb b/lib/jwe/zip.rb index c9fe6aa..a9cf995 100644 --- a/lib/jwe/zip.rb +++ b/lib/jwe/zip.rb @@ -5,8 +5,10 @@ module JWE # Message deflating algorithms namespace module Zip + extend JWE::NameResolver + def self.for(zip) - const_get(JWE.param_to_class_name(zip)) + const_get(param_to_class_name(zip)) rescue NameError raise NotImplementedError.new("Unsupported zip type: #{zip}") end diff --git a/spec/jwe/alg_spec.rb b/spec/jwe/alg_spec.rb index a112163..5709b21 100644 --- a/spec/jwe/alg_spec.rb +++ b/spec/jwe/alg_spec.rb @@ -6,6 +6,9 @@ require 'jwe/alg/a128_kw' require 'jwe/alg/a192_kw' require 'jwe/alg/a256_kw' +require 'jwe/alg/a128gcmkw' +require 'jwe/alg/a192gcmkw' +require 'jwe/alg/a256gcmkw' require 'openssl' describe JWE::Alg do @@ -123,3 +126,52 @@ end end end + +[ + JWE::Alg::A128gcmkw, + JWE::Alg::A192gcmkw, + JWE::Alg::A256gcmkw +].each_with_index do |klass, i| + describe klass do + let(:kek) { SecureRandom.random_bytes(16 + (i * 8)) } + let(:cek) { SecureRandom.random_bytes(32) } + let(:alg) { klass.new(kek) } + + describe '#encrypt' do + it 'returns an encrypted string' do + encrypted = alg.encrypt(cek) + expect(encrypted).to_not eq cek + expect(encrypted).to be_a(String) + end + end + + it 'decrypts the encrypted key to the original key' do + ciphertext = alg.encrypt(cek) + expect(alg.decrypt(ciphertext)).to eq cek + end + + it 'generates IV and authentication tag' do + alg.encrypt(cek) + expect(alg.send(:iv)).to_not be_nil + expect(alg.send(:iv).length).to eq 12 + expect(alg.send(:tag)).to_not be_nil + expect(alg.send(:tag).length).to eq 16 + end + + it 'raises when trying to decrypt with wrong IV' do + alg1 = klass.new(kek) + ciphertext = alg1.encrypt(cek) + + alg2 = klass.new(kek) + expect { alg2.decrypt(ciphertext) }.to raise_error(StandardError) + end + + it 'raises when trying to decrypt tampered ciphertext' do + ciphertext = alg.encrypt(cek) + tampered = ciphertext.dup + tampered[0] = (tampered[0].ord ^ 1).chr + + expect { alg.decrypt(tampered) }.to raise_error(StandardError) + end + end +end diff --git a/spec/jwe/header_spec.rb b/spec/jwe/header_spec.rb new file mode 100644 index 0000000..0e001da --- /dev/null +++ b/spec/jwe/header_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'jwe/header' +require 'jwe/alg' +require 'jwe/enc' +require 'jwe/alg/a128gcmkw' +require 'jwe/alg/a192gcmkw' +require 'jwe/alg/a256gcmkw' + +module JWE + describe Header do + describe '#generate_header' do + it 'generates basic header with alg and enc' do + alg_cipher = Alg.for('RSA-OAEP').new(OpenSSL::PKey::RSA.new(2048)) + enc_cipher = Enc.for('A128GCM') + + header_json = Header.new.generate_header(alg_cipher, enc_cipher, nil, {}) + header = JSON.parse(header_json) + + expect(header['alg']).to eq 'RSA-OAEP' + expect(header['enc']).to eq 'A128GCM' + end + + it 'includes zip parameter when provided' do + alg_cipher = Alg.for('A128KW').new(SecureRandom.random_bytes(16)) + enc_cipher = Enc.for('A256GCM') + + header_json = Header.new.generate_header(alg_cipher, enc_cipher, 'DEF', {}) + header = JSON.parse(header_json) + + expect(header['alg']).to eq 'A128KW' + expect(header['enc']).to eq 'A256GCM' + expect(header['zip']).to eq 'DEF' + end + + it 'excludes zip parameter when nil' do + alg_cipher = Alg.for('A128KW').new(SecureRandom.random_bytes(16)) + enc_cipher = Enc.for('A128GCM') + + header_json = Header.new.generate_header(alg_cipher, enc_cipher, nil, {}) + header = JSON.parse(header_json) + + expect(header).not_to have_key('zip') + end + + it 'includes additional header parameters from algorithm' do + alg_cipher = Alg.for('A128GCMKW').new(SecureRandom.random_bytes(16)) + enc_cipher = Enc.for('A128GCM') + + header_json = Header.new.generate_header(alg_cipher, enc_cipher, nil, {}) + header = JSON.parse(header_json) + + expect(header['alg']).to eq 'A128GCMKW' + expect(header['enc']).to eq 'A128GCM' + expect(header).to have_key('iv') + expect(header).to have_key('tag') + end + + it 'does not include additional parameters when algorithm does not need them' do + alg_cipher = Alg.for('RSA-OAEP').new(OpenSSL::PKey::RSA.new(2048)) + enc_cipher = Enc.for('A128GCM') + + header_json = Header.new.generate_header(alg_cipher, enc_cipher, nil, {}) + header = JSON.parse(header_json) + + expect(header.keys).to contain_exactly('alg', 'enc') + end + + it 'includes custom additional header parameters' do + alg_cipher = Alg.for('RSA-OAEP').new(OpenSSL::PKey::RSA.new(2048)) + enc_cipher = Enc.for('A128GCM') + + header_json = Header.new.generate_header(alg_cipher, enc_cipher, nil, { custom: 'value', foo: 'bar' }) + header = JSON.parse(header_json) + + expect(header['alg']).to eq 'RSA-OAEP' + expect(header['enc']).to eq 'A128GCM' + expect(header['custom']).to eq 'value' + expect(header['foo']).to eq 'bar' + end + + it 'combines all parameters correctly' do + alg_cipher = Alg.for('A256GCMKW').new(SecureRandom.random_bytes(32)) + enc_cipher = Enc.for('A256GCM') + + header_json = Header.new.generate_header(alg_cipher, enc_cipher, 'DEF', { copyright: 'MIT' }) + header = JSON.parse(header_json) + + expect(header['alg']).to eq 'A256GCMKW' + expect(header['enc']).to eq 'A256GCM' + expect(header['zip']).to eq 'DEF' + expect(header).to have_key('iv') + expect(header).to have_key('tag') + expect(header['copyright']).to eq 'MIT' + end + + it 'returns valid JSON string' do + alg_cipher = Alg.for('dir').new('test_key') + enc_cipher = Enc.for('A128GCM') + + header_json = Header.new.generate_header(alg_cipher, enc_cipher, nil, {}) + + expect { JSON.parse(header_json) }.not_to raise_error + expect(header_json).to be_a(String) + end + end + end +end diff --git a/spec/jwe/name_resolver_spec.rb b/spec/jwe/name_resolver_spec.rb new file mode 100644 index 0000000..13bdf98 --- /dev/null +++ b/spec/jwe/name_resolver_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'jwe/name_resolver' + +module JWE + describe NameResolver do + def test_class + Class.new do + include JWE::NameResolver + end + end + + describe '#param_to_class_name' do + it 'converts params to class names' do + conversions = { + 'dir' => 'Dir', + 'RSA-OAEP' => 'RsaOaep', + 'RSA-OAEP-256' => 'RsaOaep256', + 'ECDH-ES+A128KW' => 'EcdhEsA128kw', + 'A128KW' => 'A128kw', + 'A192KW' => 'A192kw', + 'A256KW' => 'A256kw', + 'A128GCMKW' => 'A128gcmkw', + 'A192GCMKW' => 'A192gcmkw', + 'A256GCMKW' => 'A256gcmkw', + 'A128GCM' => 'A128gcm', + 'A256GCM' => 'A256gcm', + 'A128CBC-HS256' => 'A128cbcHs256', + 'A256CBC-HS512' => 'A256cbcHs512' + } + conversions.each do |param, class_name| + expect(test_class.new.param_to_class_name(param)).to eq class_name + end + end + end + + describe '#class_name_to_param' do + it 'converts class names to params' do + conversions = { + 'JWE::Alg::Dir' => 'DIR', + 'JWE::Alg::RsaOaep' => 'RSA-OAEP', + 'JWE::Alg::RsaOaep256' => 'RSA-OAEP256', + 'JWE::Alg::A128kw' => 'A128KW', + 'JWE::Alg::A128gcmkw' => 'A128GCMKW', + 'JWE::Enc::A128gcm' => 'A128GCM', + 'JWE::Enc::A128cbcHs256' => 'A128CBC-HS256' + } + conversions.each do |full_class_name, param| + stub_const(full_class_name, test_class) + instance = Object.const_get(full_class_name).new + expect(instance.class_name_to_param).to eq param + end + end + end + + describe 'roundtrip conversions' do + it 'converts param to class name and back' do + params = { + 'RSA-OAEP' => 'RSA-OAEP', + 'A128KW' => 'A128KW', + 'A128GCMKW' => 'A128GCMKW', + 'dir' => 'DIR' + } + params.each do |param, expected| + class_name = test_class.new.param_to_class_name(param) + stub_const("JWE::Alg::#{class_name}", test_class) + instance = Object.const_get("JWE::Alg::#{class_name}").new + expect(instance.class_name_to_param).to eq expected + end + end + end + end +end diff --git a/spec/jwe/validator_spec.rb b/spec/jwe/validator_spec.rb new file mode 100644 index 0000000..789b8e5 --- /dev/null +++ b/spec/jwe/validator_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'jwe/validator' + +module JWE + describe Validator do + describe '#check_params' do + it 'accepts valid algorithm parameters' do + valid_algs = %w[RSA1_5 RSA-OAEP RSA-OAEP-256 A128KW A192KW A256KW + A128GCMKW A192GCMKW A256GCMKW dir] + valid_algs.each do |alg| + expect { Validator.new.check_params(alg, 'A128GCM', nil, 'key') }.not_to raise_error + end + end + + it 'accepts valid encryption parameters' do + valid_encs = %w[A128CBC-HS256 A192CBC-HS384 A256CBC-HS512 + A128GCM A192GCM A256GCM] + valid_encs.each do |enc| + expect { Validator.new.check_params('RSA-OAEP', enc, nil, 'key') }.not_to raise_error + end + end + + it 'accepts valid compression parameters' do + valid_zips = ['DEF', nil, ''] + valid_zips.each do |zip| + expect { Validator.new.check_params('RSA-OAEP', 'A128GCM', zip, 'key') }.not_to raise_error + end + end + + it 'accepts valid keys' do + valid_keys = ['valid_key', OpenSSL::PKey::RSA.new(2048)] + valid_keys.each do |key| + expect { Validator.new.check_params('RSA-OAEP', 'A128GCM', nil, key) }.not_to raise_error + end + end + + it 'raises error for invalid algorithm' do + expect { Validator.new.check_params('INVALID', 'A128GCM', nil, 'key') } + .to raise_error(ArgumentError, /"INVALID" is not a valid alg method/) + end + + it 'raises error for invalid encryption' do + expect { Validator.new.check_params('RSA-OAEP', 'INVALID', nil, 'key') } + .to raise_error(ArgumentError, /"INVALID" is not a valid enc method/) + end + + it 'raises error for invalid compression' do + expect { Validator.new.check_params('RSA-OAEP', 'A128GCM', 'GZIP', 'key') } + .to raise_error(ArgumentError, /"GZIP" is not a valid zip method/) + end + + it 'raises error for invalid keys' do + invalid_keys = [nil, '', ' '] + invalid_keys.each do |key| + expect { Validator.new.check_params('RSA-OAEP', 'A128GCM', nil, key) } + .to raise_error(ArgumentError, /must not be nil or blank/) + end + end + end + end +end diff --git a/spec/jwe_spec.rb b/spec/jwe_spec.rb index 1b4409e..be18bdf 100644 --- a/spec/jwe_spec.rb +++ b/spec/jwe_spec.rb @@ -43,6 +43,67 @@ end end + describe 'when using A128GCMKW algorithm' do + it 'roundtrips' do + aes_key = SecureRandom.random_bytes(16) + encrypted = JWE.encrypt(plaintext, aes_key, alg: 'A128GCMKW') + result = JWE.decrypt(encrypted, aes_key) + + expect(result).to eq plaintext + end + + it 'includes iv and tag in header' do + aes_key = SecureRandom.random_bytes(16) + encrypted = JWE.encrypt(plaintext, aes_key, alg: 'A128GCMKW') + header, = JWE::Serialization::Compact.decode(encrypted) + header = JSON.parse(header) + + expect(header['alg']).to eq 'A128GCMKW' + expect(header).to have_key('iv') + expect(header).to have_key('tag') + end + end + + describe 'when using A192GCMKW algorithm' do + it 'roundtrips' do + aes_key = SecureRandom.random_bytes(24) + encrypted = JWE.encrypt(plaintext, aes_key, alg: 'A192GCMKW', enc: 'A192GCM') + result = JWE.decrypt(encrypted, aes_key) + + expect(result).to eq plaintext + end + end + + describe 'when using A256GCMKW algorithm with A256GCM encryption' do + it 'roundtrips' do + aes_key = SecureRandom.random_bytes(32) + encrypted = JWE.encrypt(plaintext, aes_key, alg: 'A256GCMKW', enc: 'A256GCM') + result = JWE.decrypt(encrypted, aes_key) + + expect(result).to eq plaintext + end + + it 'includes iv and tag in header' do + aes_key = SecureRandom.random_bytes(32) + encrypted = JWE.encrypt(plaintext, aes_key, alg: 'A256GCMKW', enc: 'A256GCM') + header, = JWE::Serialization::Compact.decode(encrypted) + header = JSON.parse(header) + + expect(header['alg']).to eq 'A256GCMKW' + expect(header['enc']).to eq 'A256GCM' + expect(header).to have_key('iv') + expect(header).to have_key('tag') + end + + it 'works with compression' do + aes_key = SecureRandom.random_bytes(32) + encrypted = JWE.encrypt(plaintext, aes_key, alg: 'A256GCMKW', enc: 'A256GCM', zip: 'DEF') + result = JWE.decrypt(encrypted, aes_key) + + expect(result).to eq plaintext + end + end + it 'raises when passed a bad alg' do expect { JWE.encrypt(plaintext, rsa_key, alg: 'TEST') }.to raise_error(ArgumentError) end