├── .github └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── jwe.gemspec ├── lib ├── jwe.rb └── jwe │ ├── alg.rb │ ├── alg │ ├── a128_kw.rb │ ├── a192_kw.rb │ ├── a256_kw.rb │ ├── aes_kw.rb │ ├── dir.rb │ ├── rsa15.rb │ └── rsa_oaep.rb │ ├── base64.rb │ ├── enc.rb │ ├── enc │ ├── a128cbc_hs256.rb │ ├── a128gcm.rb │ ├── a192cbc_hs384.rb │ ├── a192gcm.rb │ ├── a256cbc_hs512.rb │ ├── a256gcm.rb │ ├── aes_cbc_hs.rb │ ├── aes_gcm.rb │ └── cipher.rb │ ├── serialization │ └── compact.rb │ ├── version.rb │ ├── zip.rb │ └── zip │ └── def.rb └── spec ├── jwe ├── alg_spec.rb ├── base64_spec.rb ├── enc_spec.rb ├── serialization_spec.rb └── zip_spec.rb ├── jwe_spec.rb ├── keys └── rsa.pem └── spec_helper.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | lint: 13 | name: RuboCop 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "ruby" 21 | bundler-cache: true 22 | - name: Run RuboCop 23 | run: bundle exec rubocop 24 | 25 | test: 26 | name: Ruby ${{ matrix.ruby }} 27 | runs-on: ubuntu-latest 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | ruby: 32 | - "2.5" 33 | - "2.6" 34 | - "2.7" 35 | - "3.0" 36 | - "3.1" 37 | - "3.2" 38 | - "3.3" 39 | - "3.4" 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Set up Ruby 45 | uses: ruby/setup-ruby@v1 46 | with: 47 | ruby-version: ${{ matrix.ruby }} 48 | bundler-cache: true 49 | 50 | - name: Run tests 51 | run: bundle exec rspec 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | *.gem 3 | .rvmrc 4 | bundle 5 | .bundle 6 | .rbenv-version 7 | .rbx 8 | /.ruby-gemset 9 | /.ruby-version 10 | Gemfile.lock 11 | /coverage 12 | 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format doc 3 | -r spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 2.5 5 | NewCops: enable 6 | SuggestExtensions: false 7 | 8 | Layout/LineLength: 9 | Enabled: false 10 | 11 | Style/RaiseArgs: 12 | Enabled: false 13 | 14 | Metrics/BlockLength: 15 | Enabled: false 16 | 17 | Style/PercentLiteralDelimiters: 18 | PreferredDelimiters: 19 | "%w": "[]" 20 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2025-02-16 07:54:09 UTC using RuboCop version 1.72.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 6 10 | Lint/DuplicateMethods: 11 | Exclude: 12 | - 'lib/jwe/enc/aes_cbc_hs.rb' 13 | - 'lib/jwe/enc/aes_gcm.rb' 14 | 15 | # Offense count: 15 16 | # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. 17 | # AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to 18 | Naming/MethodParameterName: 19 | Exclude: 20 | - 'lib/jwe/alg/aes_kw.rb' 21 | - 'lib/jwe/enc.rb' 22 | - 'lib/jwe/enc/aes_cbc_hs.rb' 23 | - 'lib/jwe/enc/aes_gcm.rb' 24 | - 'lib/jwe/serialization/compact.rb' 25 | 26 | # Offense count: 8 27 | # This cop supports safe autocorrection (--autocorrect). 28 | # Configuration parameters: EnforcedStyle. 29 | # SupportedStyles: separated, grouped 30 | Style/AccessorGrouping: 31 | Exclude: 32 | - 'lib/jwe/alg/aes_kw.rb' 33 | - 'lib/jwe/enc/aes_cbc_hs.rb' 34 | - 'lib/jwe/enc/aes_gcm.rb' 35 | 36 | # Offense count: 1 37 | # This cop supports safe autocorrection (--autocorrect). 38 | Style/ExpandPathArguments: 39 | Exclude: 40 | - 'jwe.gemspec' 41 | 42 | # Offense count: 2 43 | # This cop supports safe autocorrection (--autocorrect). 44 | Style/IfUnlessModifier: 45 | Exclude: 46 | - 'lib/jwe/alg/aes_kw.rb' 47 | - 'lib/jwe/enc/aes_cbc_hs.rb' 48 | 49 | # Offense count: 1 50 | # This cop supports safe autocorrection (--autocorrect). 51 | Style/PerlBackrefs: 52 | Exclude: 53 | - 'lib/jwe.rb' 54 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v1.0.0](https://github.com/jwt/ruby-jwe/tree/v1.0.0) (2025-02-16) 4 | 5 | [Full Changelog](https://github.com/jwt/ruby-jwe/compare/v0.4.0...v1.0.0) 6 | 7 | **Features:** 8 | 9 | - Support Ruby 3.4 (https://github.com/jwt/ruby-jwe/pull/26) 10 | - Drop support for Ruby versions prior to 2.5 (https://github.com/jwt/ruby-jwe/pull/27) 11 | 12 | **Fixes and enhancements:** 13 | 14 | - Refreshed codebase (CI and linter fixes) (https://github.com/jwt/ruby-jwe/pull/27, https://github.com/jwt/ruby-jwe/pull/28) 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'rake' 8 | gem 'rspec' 9 | gem 'rubocop' 10 | gem 'simplecov' 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | * Copyright © 2016 Francesco Boffa 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 20 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 21 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 22 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 23 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JWE 2 | 3 | [![Build Status](https://github.com/jwt/ruby-jwe/actions/workflows/test.yml/badge.svg)](https://github.com/jwt/ruby-jwe/actions/workflows/test.yml) 4 | [![Gem Version](https://badge.fury.io/rb/jwe.svg)](https://badge.fury.io/rb/jwe) 5 | 6 | A ruby implementation of the [RFC 7516 JSON Web Encryption (JWE)](https://tools.ietf.org/html/rfc7516) standard. 7 | 8 | ## Installing 9 | 10 | ```bash 11 | gem install jwe 12 | ``` 13 | ## Usage 14 | 15 | This example uses the default alg and enc methods (RSA-OAEP and A128CBC-HS256). It requires an RSA key. 16 | 17 | ```ruby 18 | require 'jwe' 19 | 20 | key = OpenSSL::PKey::RSA.generate(2048) 21 | payload = "The quick brown fox jumps over the lazy dog." 22 | 23 | encrypted = JWE.encrypt(payload, key) 24 | puts encrypted 25 | 26 | plaintext = JWE.decrypt(encrypted, key) 27 | puts plaintext #"The quick brown fox jumps over the lazy dog." 28 | ``` 29 | 30 | This example uses a custom enc method: 31 | 32 | ```ruby 33 | require 'jwe' 34 | 35 | key = OpenSSL::PKey::RSA.generate(2048) 36 | payload = "The quick brown fox jumps over the lazy dog." 37 | 38 | encrypted = JWE.encrypt(payload, key, enc: 'A192GCM') 39 | puts encrypted 40 | 41 | plaintext = JWE.decrypt(encrypted, key) 42 | puts plaintext #"The quick brown fox jumps over the lazy dog." 43 | ``` 44 | 45 | This example uses the 'dir' alg method. It requires an encryption key of the correct size for the enc method 46 | 47 | ```ruby 48 | require 'jwe' 49 | 50 | key = SecureRandom.random_bytes(32) 51 | payload = "The quick brown fox jumps over the lazy dog." 52 | 53 | encrypted = JWE.encrypt(payload, key, alg: 'dir') 54 | puts encrypted 55 | 56 | plaintext = JWE.decrypt(encrypted, key) 57 | puts plaintext #"The quick brown fox jumps over the lazy dog." 58 | ``` 59 | 60 | This example uses the DEFLATE algorithm on the plaintext to reduce the result size. 61 | 62 | ```ruby 63 | require 'jwe' 64 | 65 | key = OpenSSL::PKey::RSA.generate(2048) 66 | payload = "The quick brown fox jumps over the lazy dog." 67 | 68 | encrypted = JWE.encrypt(payload, key, zip: 'DEF') 69 | puts encrypted 70 | 71 | plaintext = JWE.decrypt(encrypted, key) 72 | puts plaintext #"The quick brown fox jumps over the lazy dog." 73 | ``` 74 | 75 | This example sets an extra **plaintext** custom header. 76 | 77 | ```ruby 78 | require 'jwe' 79 | 80 | key = OpenSSL::PKey::RSA.generate(2048) 81 | payload = "The quick brown fox jumps over the lazy dog." 82 | 83 | # In this case we add a copyright line to the headers (it can be anything you like 84 | # just remember it is plaintext). 85 | encrypted = JWE.encrypt(payload, key, copyright: 'This is my stuff! All rights reserved') 86 | puts encrypted 87 | ``` 88 | 89 | ## Available Algorithms 90 | 91 | The RFC 7518 JSON Web Algorithms (JWA) spec defines the algorithms for [encryption](https://tools.ietf.org/html/rfc7518#section-5.1) 92 | and [key management](https://tools.ietf.org/html/rfc7518#section-4.1) to be supported by a JWE implementation. 93 | 94 | Only a subset of these algorithms is implemented in this gem. Striked elements are not available: 95 | 96 | Key management: 97 | * RSA1_5 98 | * RSA-OAEP (default) 99 | * ~~RSA-OAEP-256~~ 100 | * A128KW 101 | * A192KW 102 | * A256KW 103 | * dir 104 | * ~~ECDH-ES~~ 105 | * ~~ECDH-ES+A128KW~~ 106 | * ~~ECDH-ES+A192KW~~ 107 | * ~~ECDH-ES+A256KW~~ 108 | * ~~A128GCMKW~~ 109 | * ~~A192GCMKW~~ 110 | * ~~A256GCMKW~~ 111 | * ~~PBES2-HS256+A128KW~~ 112 | * ~~PBES2-HS384+A192KW~~ 113 | * ~~PBES2-HS512+A256KW~~ 114 | 115 | Encryption: 116 | * A128CBC-HS256 (default) 117 | * A192CBC-HS384 118 | * A256CBC-HS512 119 | * A128GCM 120 | * A192GCM 121 | * A256GCM 122 | 123 | ## License 124 | 125 | The MIT License 126 | 127 | * Copyright © 2016 Francesco Boffa 128 | 129 | Permission is hereby granted, free of charge, to any person obtaining 130 | a copy of this software and associated documentation files (the 131 | "Software"), to deal in the Software without restriction, including 132 | without limitation the rights to use, copy, modify, merge, publish, 133 | distribute, sublicense, and/or sell copies of the Software, and to 134 | permit persons to whom the Software is furnished to do so, subject to 135 | the following conditions: 136 | 137 | The above copyright notice and this permission notice shall be 138 | included in all copies or substantial portions of the Software. 139 | 140 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 141 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 142 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 143 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 144 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 145 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 146 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 147 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | 5 | require 'bundler/gem_tasks' 6 | require 'rspec/core/rake_task' 7 | 8 | RSpec::Core::RakeTask.new(:spec) 9 | task default: :spec 10 | -------------------------------------------------------------------------------- /jwe.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('../lib/', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'jwe/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'jwe' 9 | s.version = JWE::VERSION 10 | s.summary = 'JSON Web Encryption implementation in Ruby' 11 | s.description = 'A Ruby implementation of the RFC 7516 JSON Web Encryption (JWE) standard' 12 | s.authors = ['Francesco Boffa'] 13 | s.email = 'fra.boffa@gmail.com' 14 | s.homepage = 'https://github.com/jwt/ruby-jwe' 15 | s.license = 'MIT' 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.require_paths = %w[lib] 19 | 20 | s.required_ruby_version = '>= 2.5.0' 21 | 22 | s.metadata = { 23 | 'bug_tracker_uri' => 'https://github.com/jwt/ruby-jwe/issues', 24 | 'changelog_uri' => "https://github.com/jwt/ruby-jwe/blob/v#{JWE::VERSION}/CHANGELOG.md", 25 | 'rubygems_mfa_required' => 'true' 26 | } 27 | 28 | s.add_dependency 'base64' 29 | end 30 | -------------------------------------------------------------------------------- /lib/jwe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'base64' 4 | require 'json' 5 | require 'openssl' 6 | require 'securerandom' 7 | 8 | require 'jwe/base64' 9 | require 'jwe/serialization/compact' 10 | require 'jwe/alg' 11 | require 'jwe/enc' 12 | require 'jwe/zip' 13 | 14 | # A ruby implementation of the RFC 7516 JSON Web Encryption (JWE) standard. 15 | module JWE 16 | class DecodeError < RuntimeError; end 17 | class NotImplementedError < RuntimeError; end 18 | class BadCEK < RuntimeError; end 19 | class InvalidData < RuntimeError; end 20 | 21 | 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 22 | VALID_ENC = %w[A128CBC-HS256 A192CBC-HS384 A256CBC-HS512 A128GCM A192GCM A256GCM].freeze 23 | VALID_ZIP = ['DEF'].freeze 24 | 25 | class << self 26 | def encrypt(payload, key, alg: 'RSA-OAEP', enc: 'A128GCM', **more_headers) 27 | header = generate_header(alg, enc, more_headers) 28 | check_params(header, key) 29 | 30 | payload = apply_zip(header, payload, :compress) 31 | 32 | cipher = Enc.for(enc) 33 | cipher.cek = key if alg == 'dir' 34 | 35 | json_hdr = header.to_json 36 | ciphertext = cipher.encrypt(payload, Base64.jwe_encode(json_hdr)) 37 | 38 | generate_serialization(json_hdr, Alg.encrypt_cek(alg, key, cipher.cek), ciphertext, cipher) 39 | end 40 | 41 | def decrypt(payload, key) 42 | header, enc_key, iv, ciphertext, tag = Serialization::Compact.decode(payload) 43 | header = JSON.parse(header) 44 | check_params(header, key) 45 | 46 | cek = Alg.decrypt_cek(header['alg'], key, enc_key) 47 | cipher = Enc.for(header['enc'], cek, iv, tag) 48 | 49 | plaintext = cipher.decrypt(ciphertext, payload.split('.').first) 50 | 51 | apply_zip(header, plaintext, :decompress) 52 | end 53 | 54 | def check_params(header, key) 55 | check_alg(header[:alg] || header['alg']) 56 | check_enc(header[:enc] || header['enc']) 57 | check_zip(header[:zip] || header['zip']) 58 | check_key(key) 59 | end 60 | 61 | def check_alg(alg) 62 | raise ArgumentError.new("\"#{alg}\" is not a valid alg method") unless VALID_ALG.include?(alg) 63 | end 64 | 65 | def check_enc(enc) 66 | raise ArgumentError.new("\"#{enc}\" is not a valid enc method") unless VALID_ENC.include?(enc) 67 | end 68 | 69 | def check_zip(zip) 70 | raise ArgumentError.new("\"#{zip}\" is not a valid zip method") unless zip.nil? || zip == '' || VALID_ZIP.include?(zip) 71 | end 72 | 73 | def check_key(key) 74 | raise ArgumentError.new('The key must not be nil or blank') if key.nil? || (key.is_a?(String) && key.strip == '') 75 | end 76 | 77 | def param_to_class_name(param) 78 | klass = param.gsub(/[-\+]/, '_').downcase.sub(/^[a-z\d]*/) { $&.capitalize } 79 | klass.gsub(/_([a-z\d]*)/i) { Regexp.last_match(1).capitalize } 80 | end 81 | 82 | def apply_zip(header, data, direction) 83 | zip = header[:zip] || header['zip'] 84 | if zip 85 | Zip.for(zip).new.send(direction, data) 86 | else 87 | data 88 | end 89 | end 90 | 91 | def generate_header(alg, enc, more) 92 | header = { alg: alg, enc: enc }.merge(more) 93 | header.delete(:zip) if header[:zip] == '' 94 | header 95 | end 96 | 97 | def generate_serialization(hdr, cek, content, cipher) 98 | Serialization::Compact.encode(hdr, cek, cipher.iv, content, cipher.tag) 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/jwe/alg.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/alg/a128_kw' 4 | require 'jwe/alg/a192_kw' 5 | require 'jwe/alg/a256_kw' 6 | require 'jwe/alg/dir' 7 | require 'jwe/alg/rsa_oaep' 8 | require 'jwe/alg/rsa15' 9 | 10 | module JWE 11 | # Key encryption algorithms namespace 12 | module Alg 13 | def self.for(alg) 14 | const_get(JWE.param_to_class_name(alg)) 15 | rescue NameError 16 | raise NotImplementedError.new("Unsupported alg type: #{alg}") 17 | end 18 | 19 | def self.encrypt_cek(alg, key, cek) 20 | self.for(alg).new(key).encrypt(cek) 21 | end 22 | 23 | def self.decrypt_cek(alg, key, encrypted_cek) 24 | self.for(alg).new(key).decrypt(encrypted_cek) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/jwe/alg/a128_kw.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/alg/aes_kw' 4 | 5 | module JWE 6 | module Alg 7 | # AES-128 Key Wrapping algorithm 8 | class A128kw 9 | include AesKw 10 | 11 | def cipher_name 12 | 'AES-128-ECB' 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/jwe/alg/a192_kw.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/alg/aes_kw' 4 | 5 | module JWE 6 | module Alg 7 | # AES-192 Key Wrapping algorithm 8 | class A192kw 9 | include AesKw 10 | 11 | def cipher_name 12 | 'AES-192-ECB' 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/jwe/alg/a256_kw.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/alg/aes_kw' 4 | 5 | module JWE 6 | module Alg 7 | # AES-256 Key Wrapping algorithm 8 | class A256kw 9 | include AesKw 10 | 11 | def cipher_name 12 | 'AES-256-ECB' 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/jwe/alg/aes_kw.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/cipher' 4 | 5 | module JWE 6 | module Alg 7 | # Generic AES Key Wrapping algorithm for any key size. 8 | module AesKw 9 | attr_accessor :key 10 | attr_accessor :iv 11 | 12 | def initialize(key = nil, iv = "\xA6\xA6\xA6\xA6\xA6\xA6\xA6\xA6") 13 | self.iv = iv.b 14 | self.key = key.b 15 | end 16 | 17 | def encrypt(cek) 18 | a = iv 19 | r = cek.b.scan(/.{8}/m) 20 | 21 | 6.times do |j| 22 | a, r = kw_encrypt_round(j, a, r) 23 | end 24 | 25 | ([a] + r).join 26 | end 27 | 28 | def kw_encrypt_round(j, a, r) 29 | r.length.times do |i| 30 | b = encrypt_round(a + r[i]).chars 31 | 32 | a, r[i] = a_ri(b) 33 | 34 | a = xor(a, (r.length * j) + i + 1) 35 | end 36 | 37 | [a, r] 38 | end 39 | 40 | def decrypt(encrypted_cek) 41 | c = encrypted_cek.b.scan(/.{8}/m) 42 | a, *r = c 43 | 44 | 5.downto(0) do |j| 45 | a, r = kw_decrypt_round(j, a, r) 46 | end 47 | 48 | if a != iv 49 | raise StandardError.new('The encrypted key has been tampered. Do not use this key.') 50 | end 51 | 52 | r.join 53 | end 54 | 55 | def kw_decrypt_round(j, a, r) 56 | r.length.downto(1) do |i| 57 | a = xor(a, (r.length * j) + i) 58 | 59 | b = decrypt_round(a + r[i - 1]).chars 60 | 61 | a, r[i - 1] = a_ri(b) 62 | end 63 | 64 | [a, r] 65 | end 66 | 67 | def a_ri(b) 68 | [b.first(8).join, b.last(8).join] 69 | end 70 | 71 | def cipher 72 | @cipher ||= Enc::Cipher.for(cipher_name) 73 | end 74 | 75 | def encrypt_round(data) 76 | cipher.encrypt 77 | cipher.key = key 78 | cipher.padding = 0 79 | cipher.update(data) + cipher.final 80 | end 81 | 82 | def decrypt_round(data) 83 | cipher.decrypt 84 | cipher.key = key 85 | cipher.padding = 0 86 | cipher.update(data) + cipher.final 87 | end 88 | 89 | def xor(data, t) 90 | t = ([0] * (data.length - 1)) + [t] 91 | data = data.chars.map(&:ord) 92 | 93 | data.zip(t).map { |a, b| (a ^ b).chr }.join 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/jwe/alg/dir.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JWE 4 | module Alg 5 | # Direct (no-op) key encryption algorithm. 6 | class Dir 7 | attr_accessor :key 8 | 9 | def initialize(key) 10 | self.key = key 11 | end 12 | 13 | def encrypt(_cek) 14 | '' 15 | end 16 | 17 | def decrypt(_encrypted_cek) 18 | key 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/jwe/alg/rsa15.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JWE 4 | module Alg 5 | # RSA RSA with PKCS1 v1.5 algorithm. 6 | class Rsa15 7 | attr_accessor :key 8 | 9 | def initialize(key) 10 | self.key = key 11 | end 12 | 13 | def encrypt(cek) 14 | key.public_encrypt(cek) 15 | end 16 | 17 | def decrypt(encrypted_cek) 18 | key.private_decrypt(encrypted_cek) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/jwe/alg/rsa_oaep.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JWE 4 | module Alg 5 | # RSA-OAEP key encryption algorithm. 6 | class RsaOaep 7 | attr_accessor :key 8 | 9 | def initialize(key) 10 | self.key = key 11 | end 12 | 13 | def encrypt(cek) 14 | key.public_encrypt(cek, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING) 15 | end 16 | 17 | def decrypt(encrypted_cek) 18 | key.private_decrypt(encrypted_cek, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/jwe/base64.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JWE 4 | # Base64 for JWE is slightly different from what ruby provides. 5 | module Base64 6 | def self.jwe_encode(payload) 7 | ::Base64.urlsafe_encode64(payload).delete('=') 8 | end 9 | 10 | def self.jwe_decode(payload) 11 | padlen = 4 - (payload.length % 4) 12 | if padlen < 4 13 | pad = '=' * padlen 14 | payload += pad 15 | end 16 | ::Base64.urlsafe_decode64(payload) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/jwe/enc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/a128cbc_hs256' 4 | require 'jwe/enc/a192cbc_hs384' 5 | require 'jwe/enc/a256cbc_hs512' 6 | require 'jwe/enc/a128gcm' 7 | require 'jwe/enc/a192gcm' 8 | require 'jwe/enc/a256gcm' 9 | 10 | module JWE 11 | # Content encryption algorithms namespace 12 | module Enc 13 | def self.for(enc, cek = nil, iv = nil, tag = nil) 14 | klass = const_get(JWE.param_to_class_name(enc)) 15 | inst = klass.new(cek, iv) 16 | inst.tag = tag if tag 17 | inst 18 | rescue NameError 19 | raise NotImplementedError.new("Unsupported enc type: #{enc}") 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/jwe/enc/a128cbc_hs256.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/aes_cbc_hs' 4 | 5 | module JWE 6 | module Enc 7 | # AES CBC 128 + SHA256 message verification algorithm. 8 | class A128cbcHs256 9 | include AesCbcHs 10 | 11 | def key_length 12 | 32 13 | end 14 | 15 | def cipher_name 16 | 'AES-128-CBC' 17 | end 18 | 19 | def hash_name 20 | 'sha256' 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/jwe/enc/a128gcm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/aes_gcm' 4 | 5 | module JWE 6 | module Enc 7 | # AES GCM 128 algorithm. 8 | class A128gcm 9 | include AesGcm 10 | 11 | def key_length 12 | 16 13 | end 14 | 15 | def cipher_name 16 | 'aes-128-gcm' 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/jwe/enc/a192cbc_hs384.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/aes_cbc_hs' 4 | 5 | module JWE 6 | module Enc 7 | # AES CBC 192 + SHA384 message verification algorithm. 8 | class A192cbcHs384 9 | include AesCbcHs 10 | 11 | def key_length 12 | 48 13 | end 14 | 15 | def cipher_name 16 | 'AES-192-CBC' 17 | end 18 | 19 | def hash_name 20 | 'sha384' 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/jwe/enc/a192gcm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/aes_gcm' 4 | 5 | module JWE 6 | module Enc 7 | # AES GCM 192 algorithm. 8 | class A192gcm 9 | include AesGcm 10 | 11 | def key_length 12 | 24 13 | end 14 | 15 | def cipher_name 16 | 'aes-192-gcm' 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/jwe/enc/a256cbc_hs512.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/aes_cbc_hs' 4 | 5 | module JWE 6 | module Enc 7 | # AES CBC 256 + SHA512 message verification algorithm. 8 | class A256cbcHs512 9 | include AesCbcHs 10 | 11 | def key_length 12 | 64 13 | end 14 | 15 | def cipher_name 16 | 'AES-256-CBC' 17 | end 18 | 19 | def hash_name 20 | 'sha512' 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/jwe/enc/a256gcm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/aes_gcm' 4 | 5 | module JWE 6 | module Enc 7 | # AES GCM 256 algorithm. 8 | class A256gcm 9 | include AesGcm 10 | 11 | def key_length 12 | 32 13 | end 14 | 15 | def cipher_name 16 | 'aes-256-gcm' 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/jwe/enc/aes_cbc_hs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/cipher' 4 | 5 | module JWE 6 | module Enc 7 | # Abstract AES in Block cipher mode, with message signature for different key sizes. 8 | module AesCbcHs 9 | attr_accessor :cek 10 | attr_accessor :iv 11 | attr_accessor :tag 12 | 13 | def initialize(cek = nil, iv = nil) 14 | self.iv = iv 15 | self.cek = cek 16 | end 17 | 18 | def encrypt(cleartext, authenticated_data) 19 | raise JWE::BadCEK.new("The supplied key is invalid. Required length: #{key_length}") if cek.length != key_length 20 | 21 | ciphertext = cipher_round(:encrypt, iv, cleartext) 22 | 23 | signature = generate_tag(authenticated_data, iv, ciphertext) 24 | self.tag = signature 25 | 26 | ciphertext 27 | end 28 | 29 | def decrypt(ciphertext, authenticated_data) 30 | raise JWE::BadCEK, "The supplied key is invalid. Required length: #{key_length}" if cek.length != key_length 31 | 32 | signature = generate_tag(authenticated_data, iv, ciphertext) 33 | if signature != tag 34 | raise JWE::InvalidData, 'Authentication tag verification failed' 35 | end 36 | 37 | cipher_round(:decrypt, iv, ciphertext) 38 | rescue OpenSSL::Cipher::CipherError 39 | raise JWE::InvalidData, 'Invalid ciphertext or authentication tag' 40 | end 41 | 42 | def cipher_round(direction, iv, data) 43 | cipher.send(direction) 44 | cipher.key = enc_key 45 | cipher.iv = iv 46 | 47 | cipher.update(data) + cipher.final 48 | end 49 | 50 | def generate_tag(authenticated_data, iv, ciphertext) 51 | length = [authenticated_data.length * 8].pack('Q>') # 64bit big endian 52 | to_sign = authenticated_data + iv + ciphertext + length 53 | signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new(hash_name), mac_key, to_sign) 54 | 55 | signature[0...mac_key.length] 56 | end 57 | 58 | def iv 59 | @iv ||= SecureRandom.random_bytes(16) 60 | end 61 | 62 | def cek 63 | @cek ||= SecureRandom.random_bytes(key_length) 64 | end 65 | 66 | def mac_key 67 | cek[0...key_length / 2] 68 | end 69 | 70 | def enc_key 71 | cek[key_length / 2..-1] 72 | end 73 | 74 | def cipher 75 | @cipher ||= Cipher.for(cipher_name) 76 | end 77 | 78 | def tag 79 | @tag || '' 80 | end 81 | 82 | def self.included(base) 83 | base.extend(ClassMethods) 84 | end 85 | 86 | # Provides availability checks for Key Encryption algorithms 87 | module ClassMethods 88 | def available? 89 | new.cipher 90 | true 91 | rescue JWE::NotImplementedError 92 | false 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/jwe/enc/aes_gcm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/cipher' 4 | 5 | module JWE 6 | module Enc 7 | # Abstract AES in Galois Counter mode for different key sizes. 8 | module AesGcm 9 | attr_accessor :cek 10 | attr_accessor :iv 11 | attr_accessor :tag 12 | 13 | def initialize(cek = nil, iv = nil) 14 | self.iv = iv 15 | self.cek = cek 16 | end 17 | 18 | def encrypt(cleartext, authenticated_data) 19 | raise JWE::BadCEK, "The supplied key is too short. Required length: #{key_length}" if cek.length < key_length 20 | 21 | setup_cipher(:encrypt, authenticated_data) 22 | ciphertext = cipher.update(cleartext) + cipher.final 23 | self.tag = cipher.auth_tag 24 | 25 | ciphertext 26 | end 27 | 28 | def decrypt(ciphertext, authenticated_data) 29 | raise JWE::BadCEK, "The supplied key is too short. Required length: #{key_length}" if cek.length < key_length 30 | 31 | setup_cipher(:decrypt, authenticated_data) 32 | cipher.update(ciphertext) + cipher.final 33 | rescue OpenSSL::Cipher::CipherError 34 | raise JWE::InvalidData, 'Invalid ciphertext or authentication tag' 35 | end 36 | 37 | def setup_cipher(direction, auth_data) 38 | cipher.send(direction) 39 | cipher.key = cek 40 | cipher.iv = iv 41 | cipher.auth_tag = tag if direction == :decrypt 42 | cipher.auth_data = auth_data 43 | end 44 | 45 | def iv 46 | @iv ||= SecureRandom.random_bytes(12) 47 | end 48 | 49 | def cek 50 | @cek ||= SecureRandom.random_bytes(key_length) 51 | end 52 | 53 | def cipher 54 | @cipher ||= Cipher.for(cipher_name) 55 | end 56 | 57 | def tag 58 | @tag || '' 59 | end 60 | 61 | def self.included(base) 62 | base.extend(ClassMethods) 63 | end 64 | 65 | # Provides availability checks for Key Encryption algorithms 66 | module ClassMethods 67 | def available? 68 | new.cipher 69 | true 70 | rescue JWE::NotImplementedError 71 | false 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/jwe/enc/cipher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JWE 4 | module Enc 5 | # Helper to get OpenSSL cipher instance from a string. 6 | module Cipher 7 | class << self 8 | def for(cipher_name) 9 | OpenSSL::Cipher.new(cipher_name) 10 | rescue RuntimeError 11 | raise JWE::NotImplementedError.new("The version of OpenSSL linked to your Ruby does not support the cipher #{cipher_name}.") 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/jwe/serialization/compact.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JWE 4 | # Serialization namespace. 5 | module Serialization 6 | # The default and suggested way of serializing JWE messages. 7 | class Compact 8 | def self.encode(header, encrypted_cek, iv, ciphertext, tag) 9 | [header, encrypted_cek, iv, ciphertext, tag].map { |piece| JWE::Base64.jwe_encode(piece) }.join '.' 10 | end 11 | 12 | def self.decode(payload) 13 | parts = payload.split('.') 14 | raise JWE::DecodeError.new('Not enough or too many segments') unless parts.length == 5 15 | 16 | parts.map do |part| 17 | JWE::Base64.jwe_decode(part) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/jwe/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JWE 4 | VERSION = '1.0.0' 5 | end 6 | -------------------------------------------------------------------------------- /lib/jwe/zip.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/zip/def' 4 | 5 | module JWE 6 | # Message deflating algorithms namespace 7 | module Zip 8 | def self.for(zip) 9 | const_get(JWE.param_to_class_name(zip)) 10 | rescue NameError 11 | raise NotImplementedError.new("Unsupported zip type: #{zip}") 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/jwe/zip/def.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'zlib' 4 | 5 | module JWE 6 | module Zip 7 | # Deflate algorithm. 8 | class Def 9 | def compress(payload) 10 | zlib = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS) 11 | zlib.deflate(payload, Zlib::FINISH) 12 | end 13 | 14 | # Was using RFC 1950 instead of 1951. 15 | def decompress(payload) 16 | Zlib::Inflate.inflate(payload) 17 | 18 | # Keeping compatibility for old encoded tokens 19 | rescue Zlib::DataError 20 | inflate = Zlib::Inflate.new(-Zlib::MAX_WBITS) 21 | inflate.inflate(payload) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/jwe/alg_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/alg/dir' 4 | require 'jwe/alg/rsa_oaep' 5 | require 'jwe/alg/rsa15' 6 | require 'jwe/alg/a128_kw' 7 | require 'jwe/alg/a192_kw' 8 | require 'jwe/alg/a256_kw' 9 | require 'openssl' 10 | 11 | describe JWE::Alg do 12 | describe '.for' do 13 | it 'returns a class for the specified alg' do 14 | expect(JWE::Alg.for('RSA-OAEP')).to eq JWE::Alg::RsaOaep 15 | end 16 | 17 | it 'raises an error for a not-implemented alg' do 18 | expect { JWE::Alg.for('ERSA-4096-MAGIC') }.to raise_error(JWE::NotImplementedError) 19 | end 20 | end 21 | end 22 | 23 | describe JWE::Alg::Dir do 24 | # The direct encryption method does not Encrypt the CEK. 25 | # When building the final JWE object, the "Encrypted CEK" part is left blank 26 | 27 | describe '#encrypt' do 28 | it 'returns an empty string' do 29 | expect(JWE::Alg::Dir.new('whatever').encrypt('any')).to eq '' 30 | end 31 | end 32 | 33 | describe '#decrypt' do 34 | it 'returns the original key' do 35 | expect(JWE::Alg::Dir.new('whatever').decrypt('any')).to eq 'whatever' 36 | end 37 | end 38 | end 39 | 40 | key_path = "#{File.dirname(__FILE__)}/../keys/rsa.pem" 41 | key = OpenSSL::PKey::RSA.new File.read(key_path) 42 | 43 | describe JWE::Alg::RsaOaep do 44 | let(:alg) { JWE::Alg::RsaOaep.new(key) } 45 | 46 | describe '#encrypt' do 47 | it 'returns an encrypted string' do 48 | expect(alg.encrypt('random key')).to_not eq 'random key' 49 | end 50 | end 51 | 52 | it 'decrypts the encrypted key to the original key' do 53 | ciphertext = alg.encrypt('random key') 54 | expect(alg.decrypt(ciphertext)).to eq 'random key' 55 | end 56 | end 57 | 58 | describe JWE::Alg::Rsa15 do 59 | let(:alg) { JWE::Alg::Rsa15.new(key) } 60 | 61 | describe '#encrypt' do 62 | it 'returns an encrypted string' do 63 | expect(alg.encrypt('random key')).to_not eq 'random key' 64 | end 65 | end 66 | 67 | it 'decrypts the encrypted key to the original key' do 68 | ciphertext = alg.encrypt('random key') 69 | expect(alg.decrypt(ciphertext)).to eq 'random key' 70 | end 71 | end 72 | 73 | [ 74 | JWE::Alg::A128kw, 75 | JWE::Alg::A192kw, 76 | JWE::Alg::A256kw 77 | ].each_with_index do |klass, i| 78 | describe klass do 79 | let(:kek) { SecureRandom.random_bytes(16 + (i * 8)) } 80 | let(:cek) { SecureRandom.random_bytes(32) } 81 | let(:alg) { klass.new(kek) } 82 | 83 | describe '#encrypt' do 84 | it 'returns an encrypted string' do 85 | expect(alg.encrypt(cek)).to_not eq cek 86 | end 87 | end 88 | 89 | it 'decrypts the encrypted key to the original key' do 90 | ciphertext = alg.encrypt(cek) 91 | expect(alg.decrypt(ciphertext)).to eq cek 92 | end 93 | 94 | it 'raises when trying to decrypt tampered keys' do 95 | alg = klass.new(kek, "\xA7\xA7\xA7\xA7\xA6\xA6\xA6\xA6") 96 | ciphertext = alg.encrypt(cek) 97 | 98 | bad_alg = klass.new(kek, "\xA7\xA7\xA7\xA7\xA7\xA7\xA7\xA7") 99 | expect { bad_alg.decrypt(ciphertext) }.to raise_error(StandardError) 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/jwe/base64_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/base64' 4 | 5 | module JWE 6 | describe Base64 do 7 | describe '.jwe_encode' do 8 | it 'encodes the payload using the urlsafe encoding' do 9 | # "me?" encodes to "bWU/" in standard encoding 10 | expect(Base64.jwe_encode('me?')).to_not include '/' 11 | end 12 | 13 | it 'strips the standard padding' do 14 | expect(Base64.jwe_encode('a')).to_not end_with '=' 15 | end 16 | end 17 | 18 | describe '.jwe_decode' do 19 | it 'decodes the payload using the urlsafe encoding' do 20 | # "me?" encodes to "bWU/" in standard encoding 21 | expect(Base64.jwe_decode('bWU_')).to eq 'me?' 22 | end 23 | 24 | it 'fixes the padding' do 25 | expect(Base64.jwe_decode('YQ')).to eq 'a' 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/jwe/enc_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/enc/a128cbc_hs256' 4 | require 'jwe/enc/a192cbc_hs384' 5 | require 'jwe/enc/a256cbc_hs512' 6 | require 'jwe/enc/a128gcm' 7 | require 'jwe/enc/a192gcm' 8 | require 'jwe/enc/a256gcm' 9 | 10 | describe JWE::Enc do 11 | describe '.for' do 12 | it 'returns an instance for the specified enc' do 13 | expect(JWE::Enc.for('A128GCM')).to be_a JWE::Enc::A128gcm 14 | end 15 | 16 | it 'raises an error for a not-implemented enc' do 17 | expect { JWE::Enc.for('ERSA-4096-MAGIC') }.to raise_error(JWE::NotImplementedError) 18 | end 19 | end 20 | end 21 | 22 | gcm = [ 23 | { 24 | class: JWE::Enc::A128gcm, 25 | keylen: 16, 26 | helloworld: "\"\xC6\xE4h\x8AI\x83\x90v\xAF\xE2\x11".b, 27 | tag: "\x85|\xF7\xE1\x94\tVG\x84\xE1\xA8\x81\a\xF4\xC60".b, 28 | ivlen: 12, 29 | iv: "\x0" * 12 30 | }, 31 | { 32 | class: JWE::Enc::A192gcm, 33 | keylen: 24, 34 | helloworld: "\x9F\xA4\xEC\xCCa\x86\tRO\xD7\xE3\x8D".b, 35 | tag: "\xF6\xC0\xB8\x91A\xB1\xF0}\xD4u\xD0_\xCD\xA7\x17'".b, 36 | ivlen: 12, 37 | iv: "\x0" * 12 38 | }, 39 | { 40 | class: JWE::Enc::A256gcm, 41 | keylen: 32, 42 | helloworld: "\xFDq\xDC\xDD\x87\x9DK\x97\x03G\x99\f".b, 43 | tag: "\xC6\xF1\r\xDD\x14\x7Fqf,6\x0EK\x7F\x9D\x1D\t".b, 44 | ivlen: 12, 45 | iv: "\x0" * 12 46 | }, 47 | { 48 | class: JWE::Enc::A128cbcHs256, 49 | keylen: 32, 50 | helloworld: "\a\x02F\xA4m%\xDFH\xB4\xA4.\xBF:\xBF$\xE2".b, 51 | tag: "\xDE$t\xBA\x8B\xEE\u001Df\x81\a\xC1\xBB\x98\xDFl\xF2".b, 52 | ivlen: 16, 53 | iv: "\x0" * 16 54 | }, 55 | { 56 | class: JWE::Enc::A192cbcHs384, 57 | keylen: 48, 58 | helloworld: "p\xFES\xF0\xB4\xCC]8\x1D\xDE\x8Dt\xE7tMh".b, 59 | tag: "\xA8a\x04kRJ\x06`tp6\x8E\x9Ba\xE1e\xF6\xDA\"\x15\xEBk\xFDm".b, 60 | ivlen: 16, 61 | iv: "\x0" * 16 62 | }, 63 | { 64 | class: JWE::Enc::A256cbcHs512, 65 | keylen: 64, 66 | helloworld: "c\xFD\\\xB9Z\xB6\xE3\xB7\xEE\xA1\xD8\xDF\xB5\xB2\xF8\xEB".b, 67 | tag: "wC\xE3:\x91\x89W\x97\xBE\xB0\xBD\xEAo\xC66\x9F\xB82\xFDn\xA7.\u0014l\xFC2\xD7\xDFq\xB5[\xC6".b, 68 | ivlen: 16, 69 | iv: "\x0" * 16 70 | } 71 | ] 72 | 73 | gcm.each do |group| 74 | describe group[:class] do 75 | let(:klass) { group[:class] } 76 | let(:key) { 'a' * group[:keylen] } 77 | let(:plaintext) { 'hello world!' } 78 | 79 | describe '#encrypt' do 80 | context 'when an invalid key is used' do 81 | it 'raises an error' do 82 | enc = klass.new('small') 83 | expect { enc.encrypt('plain', 'auth') }.to raise_error(JWE::BadCEK) 84 | end 85 | end 86 | 87 | context 'with a valid key' do 88 | it 'returns the encrypted payload' do 89 | enc = klass.new(key, group[:iv]) 90 | expect(enc.encrypt(plaintext, '').b).to eq group[:helloworld] 91 | end 92 | 93 | it 'sets an authentication tag' do 94 | enc = klass.new(key, group[:iv]) 95 | enc.encrypt(plaintext, '') 96 | expect(enc.tag).to eq group[:tag] 97 | end 98 | end 99 | end 100 | 101 | describe '#decrypt' do 102 | context 'when an invalid key is used' do 103 | it 'raises an error' do 104 | enc = klass.new('small') 105 | expect { enc.decrypt('plain', 'auth') }.to raise_error(JWE::BadCEK) 106 | end 107 | end 108 | 109 | context 'with a valid key' do 110 | context 'when a valid tag is authenticated' do 111 | it 'returns the plaintext' do 112 | enc = klass.new(key, group[:iv]) 113 | enc.tag = group[:tag] 114 | expect(enc.decrypt(group[:helloworld], '')).to eq plaintext 115 | end 116 | end 117 | 118 | context 'when the tag is not valid' do 119 | it 'raises an error' do 120 | enc = klass.new(key, group[:iv]) 121 | enc.tag = 'random' 122 | expect { enc.decrypt(group[:helloworld], '') }.to raise_error(JWE::InvalidData) 123 | end 124 | end 125 | 126 | context 'when the tag is not set' do 127 | it 'raises an error' do 128 | enc = klass.new(key, group[:iv]) 129 | expect { enc.decrypt(group[:helloworld], '') }.to raise_error(JWE::InvalidData) 130 | end 131 | end 132 | 133 | context 'when the ciphertext is not valid' do 134 | it 'raises an error' do 135 | enc = klass.new(key, group[:iv]) 136 | enc.tag = group[:tag] 137 | expect { enc.decrypt('random', '') }.to raise_error(JWE::InvalidData) 138 | end 139 | end 140 | end 141 | end 142 | 143 | describe '#cipher' do 144 | context 'when the cipher is not supported by the OpenSSL lib' do 145 | it 'raises an error' do 146 | enc = klass.new 147 | allow(enc).to receive(:cipher_name) { 'bad-cipher-128' } 148 | expect { enc.cipher }.to raise_error(JWE::NotImplementedError) 149 | end 150 | end 151 | 152 | context 'when the cipher is supported' do 153 | it 'returns the cipher object' do 154 | enc = klass.new 155 | allow(enc).to receive(:cipher_name) { OpenSSL::Cipher.ciphers.first } 156 | expect(enc.cipher).to be_an OpenSSL::Cipher 157 | end 158 | end 159 | end 160 | 161 | describe '#cek' do 162 | context 'when a key is not specified in initialization' do 163 | it "returns a randomly generated #{group[:keylen]}-bytes key" do 164 | expect(klass.new.cek.length).to eq group[:keylen] 165 | end 166 | end 167 | 168 | context 'when a cek is given' do 169 | it 'returns the cek' do 170 | expect(klass.new('cek').cek).to eq 'cek' 171 | end 172 | end 173 | end 174 | 175 | describe '#iv' do 176 | context 'when an iv is not specified in initialization' do 177 | it "returns a randomly generated #{group[:ivlen]}-bytes iv" do 178 | expect(klass.new.iv.length).to eq group[:ivlen] 179 | end 180 | end 181 | 182 | context 'when a iv is given' do 183 | it 'returns the iv' do 184 | expect(klass.new('cek', 'iv').iv).to eq 'iv' 185 | end 186 | end 187 | end 188 | 189 | describe '.available?' do 190 | context 'when the cipher is not available' do 191 | it 'is false' do 192 | allow_any_instance_of(klass).to receive(:cipher) { raise JWE::NotImplementedError.new } 193 | expect(klass.available?).to be_falsey 194 | end 195 | end 196 | 197 | context 'when the cipher is available' do 198 | it 'is true' do 199 | allow_any_instance_of(klass).to receive(:cipher) 200 | expect(klass.available?).to be_truthy 201 | end 202 | end 203 | end 204 | 205 | describe 'full roundtrip' do 206 | it 'decrypts the ciphertext to the original plaintext' do 207 | enc = klass.new 208 | ciphertext = enc.encrypt(plaintext, '') 209 | expect(enc.decrypt(ciphertext, '')).to eq plaintext 210 | end 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /spec/jwe/serialization_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe JWE::Serialization::Compact do 4 | describe '#encode' do 5 | it 'returns components base64ed and joined with a dot' do 6 | components = %w[a b c d e] 7 | expect(JWE::Serialization::Compact.encode(*components)).to eq 'YQ.Yg.Yw.ZA.ZQ' 8 | end 9 | end 10 | 11 | describe '#decode' do 12 | it 'returns an array with the 5 components' do 13 | expect(JWE::Serialization::Compact.decode('YQ.Yg.Yw.ZA.ZQ')).to eq %w[a b c d e] 14 | end 15 | 16 | it 'raises an error when passed a badly formatted payload' do 17 | expect { JWE::Serialization::Compact.decode('YQ.YQ.Yg.Yw.ZA.ZQ') }.to raise_error(JWE::DecodeError) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/jwe/zip_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwe/zip/def' 4 | 5 | describe JWE::Zip do 6 | describe '.for' do 7 | it 'returns a class for the specified zip' do 8 | expect(JWE::Zip.for('DEF')).to eq JWE::Zip::Def 9 | end 10 | 11 | it 'raises an error for a not-implemented zip' do 12 | expect { JWE::Zip.for('BZIP2+JPG') }.to raise_error(JWE::NotImplementedError) 13 | end 14 | end 15 | end 16 | 17 | describe JWE::Zip::Def do 18 | context 'with the orginal payload' do 19 | it 'deflates and inflates to original payload' do 20 | deflate = JWE::Zip::Def.new 21 | deflated = deflate.compress('hello world') 22 | expect(deflate.decompress(deflated)).to eq 'hello world' 23 | end 24 | 25 | it 'deflates and inflates a large payload' do 26 | deflate = JWE::Zip::Def.new 27 | chars = [*'0'..'9', *'A'..'Z', *'a'..'z'] 28 | payload = Array.new(1_000_000) { chars.sample }.join 29 | deflated = deflate.compress(payload) 30 | expect(deflate.decompress(deflated)).to eq payload 31 | end 32 | end 33 | 34 | it 'can deflate an RFC 1950 compressed message' do 35 | deflated = Zlib::Deflate.deflate('hello world') 36 | deflate = JWE::Zip::Def.new 37 | expect(deflate.decompress(deflated)).to eq 'hello world' 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/jwe_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe JWE do 4 | let(:plaintext) { 'The true sign of intelligence is not knowledge but imagination.' } 5 | let(:rsa_key) { OpenSSL::PKey::RSA.new File.read("#{File.dirname(__FILE__)}/keys/rsa.pem") } 6 | let(:password) { SecureRandom.random_bytes(64) } 7 | 8 | it 'roundtrips' do 9 | encrypted = JWE.encrypt(plaintext, rsa_key) 10 | result = JWE.decrypt(encrypted, rsa_key) 11 | 12 | expect(result).to eq plaintext 13 | end 14 | 15 | describe 'when using DEF compression' do 16 | it 'roundtrips' do 17 | encrypted = JWE.encrypt(plaintext, rsa_key, zip: 'DEF') 18 | result = JWE.decrypt(encrypted, rsa_key) 19 | 20 | expect(result).to eq plaintext 21 | end 22 | end 23 | 24 | describe 'when using dir alg method' do 25 | it 'roundtrips' do 26 | aes_password = SecureRandom.random_bytes(16) 27 | encrypted = JWE.encrypt(plaintext, aes_password, alg: 'dir') 28 | result = JWE.decrypt(encrypted, aes_password) 29 | 30 | expect(result).to eq plaintext 31 | end 32 | end 33 | 34 | describe 'when using extra headers' do 35 | it 'roundtrips' do 36 | encrypted = JWE.encrypt(plaintext, rsa_key, kid: 'some-kid-1') 37 | result = JWE.decrypt(encrypted, rsa_key) 38 | header, = JWE::Serialization::Compact.decode(encrypted) 39 | header = JSON.parse(header) 40 | 41 | expect(header['kid']).to eq 'some-kid-1' 42 | expect(result).to eq plaintext 43 | end 44 | end 45 | 46 | it 'raises when passed a bad alg' do 47 | expect { JWE.encrypt(plaintext, rsa_key, alg: 'TEST') }.to raise_error(ArgumentError) 48 | end 49 | 50 | it 'raises when passed a bad enc' do 51 | expect { JWE.encrypt(plaintext, rsa_key, enc: 'TEST') }.to raise_error(ArgumentError) 52 | end 53 | 54 | it 'raises when passed a bad zip' do 55 | expect { JWE.encrypt(plaintext, rsa_key, zip: 'TEST') }.to raise_error(ArgumentError) 56 | end 57 | 58 | it 'raises when decoding a bad alg' do 59 | hdr = { alg: 'TEST', enc: 'A128GCM' } 60 | payload = "#{JWE::Base64.jwe_encode(hdr.to_json)}.QY.QY.QY.QY" 61 | expect { JWE.decrypt(payload, rsa_key) }.to raise_error(ArgumentError) 62 | end 63 | 64 | it 'raises when decoding a bad enc' do 65 | hdr = { alg: 'A192CBC-HS384', enc: 'TEST' } 66 | payload = "#{JWE::Base64.jwe_encode(hdr.to_json)}.QY.QY.QY.QY" 67 | expect { JWE.decrypt(payload, rsa_key) }.to raise_error(ArgumentError) 68 | end 69 | 70 | it 'raises when decoding a bad zip' do 71 | hdr = { alg: 'A192CBC-HS384', enc: 'A128GCM', zip: 'TEST' } 72 | payload = "#{JWE::Base64.jwe_encode(hdr.to_json)}.QY.QY.QY.QY" 73 | expect { JWE.decrypt(payload, rsa_key) }.to raise_error(ArgumentError) 74 | end 75 | 76 | it 'raises when encrypting with a nil key' do 77 | expect { JWE.encrypt(plaintext, nil) }.to raise_error(ArgumentError) 78 | end 79 | 80 | it 'raises when decrypting with a nil key' do 81 | hdr = { alg: 'A192CBC-HS384', enc: 'A128GCM', zip: 'TEST' } 82 | payload = "#{JWE::Base64.jwe_encode(hdr.to_json)}.QY.QY.QY.QY" 83 | expect { JWE.decrypt(payload, nil) }.to raise_error(ArgumentError) 84 | end 85 | 86 | it 'raises when encrypting with a blank key' do 87 | expect { JWE.encrypt(plaintext, " \t \n ") }.to raise_error(ArgumentError) 88 | end 89 | 90 | it 'raises when decrypting with a blank key' do 91 | hdr = { alg: 'A192CBC-HS384', enc: 'A128GCM', zip: 'TEST' } 92 | payload = "#{JWE::Base64.jwe_encode(hdr.to_json)}.QY.QY.QY.QY" 93 | expect { JWE.decrypt(payload, " \t \n ") }.to raise_error(ArgumentError) 94 | end 95 | 96 | it 'raises when encrypting with a nil key with `dir` algorithm' do 97 | expect { JWE.encrypt(plaintext, nil, alg: 'dir') }.to raise_error(ArgumentError) 98 | end 99 | 100 | it 'raises when decrypting with a nil key with `dir` algorithm' do 101 | hdr = { alg: 'A192CBC-HS384', enc: 'A128GCM', zip: 'TEST' } 102 | payload = "#{JWE::Base64.jwe_encode(hdr.to_json)}.QY.QY.QY.QY" 103 | expect { JWE.decrypt(payload, nil, alg: 'dir') }.to raise_error(ArgumentError) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/keys/rsa.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEAsqm+NfpjE/i27FvgfOZmoQsC8WcokDRT7pJwK6fVL8nPs1KF 3 | 0YYHgtQMtsgh2KR1Z+y6cFiiXfzbksMP7XWn5h3G9uZVzaUAz3LM07TUqSA+9dkx 4 | /QA9Q3VWP5iNBgo59E7LqkAKIE0wfx/rzH85VUCZLFBW5tjcaxzRWyCI9RcpPwmp 5 | LtmNqoOxdhoy4O7r1mNTrcjlh+l/4I/yavS0+TXeImvOJkIbhIJhhbjE+GDiLEH4 6 | GxE8j2SThs1nxJtboO1MMZr9hhoHL4Z5qRu6/t+ckO9ONYwUu8eDwQCOsluXsAoe 7 | CdprYk92M/bvfhtV/C37AUZ6iZUFCS0/FE/VrQIDAQABAoIBAGL+jKdqAmX5hJm4 8 | Ws25+Bm5eTr7Ns2YQP1K5J4703M0NkKdMgqjYhwKlLTedWqNzYP09mTzp5u+VIeg 9 | T336mDp4O1toyxg0GhvX90hCxSak+F3Op9UQweFT7aM1SsaS+gO1eUHvU+0L+Bgo 10 | PsZDpCfpsDWOmmg0twUepZ4BjAGIk8wBPA+cWi8Vmbvnwrwo1643LvtA3p76qUwJ 11 | EPllQMmEnJ6gUNxQVgqQt/QM0UKPtZ5FyOK3zPcztY5xVO3SJCVvcKkciDo2M7wp 12 | x9qkRrnBYmouNaQjZJZLHKngHr0DF16sw3ajk8qBZW3d2O72loiOxzAhMmnzm68f 13 | dDHGNd0CgYEA2JueNMglqYWwOBQipboSMrDprSVR2We19Ji3VS1nXV+tvLsMyspq 14 | YinH0SkW/xbLnpsOr9yC9jkW0KwFFqPK7TXnrXTVs3a4nAAaDyG8ciZQB2nKSRHt 15 | H8DrM4IvIa1wU+mxj/Kdkp1L8dD6LLoLpmMsnxpjcvATImuZd5KqInsCgYEA0yeR 16 | frrx8fOMo40WVVpinxxqycIHIBeI/jHeiwU8kGilQJcrWw3VxpoJriuLsNW2HWR+ 17 | nYz3Th6/FrJv6qhzwTFkgoqRS2Tw7qP+4gxk75hUR0M2a5d4Z0fjltQHaoP+P0kj 18 | 5iPgshDFDmRcMEPUFx+KtI+g59bTlo6U0gmJY/cCgYBiy/gJExE6lSOfMG/tL0WF 19 | oXOz6cW/Z7JycgWM8DypNi7EWnynMlP7mhrtp9Q5XWhaW1cDl4yUSc3CN/PKM8Mn 20 | FuMpFpUyWgAyB0nbhQOy/Q6bkwEU+vww84lT4RkmPzlwzLKUeZCtgtlU3oB9Tg5q 21 | QenkV+DsV9wiYvmItHitaQKBgFNbe4ScKIdrrkmimP55ABXwEfg0MLvqjppK9Z/M 22 | IWyg4xvskaEQhSQyC0BG0I6uz4Yq9hEcZUThvm4nYycv+QJ7jUI7kcBByRtsgmKa 23 | of40FJFNZ15yHYYoSyBv872I/gXdyd5Aq6OgGyrjU8F6BXBbc1Z0nQDpPf5hqz5/ 24 | pU1hAoGAWLjOMTJCFoOOxtdZ39oJmSDN0hImu+KtYWAxa4BmBDimLxwa1Xn4qEsn 25 | THDodsfMJc7HxYfeyzFZoqjf7vm2Et9eI+/PjT1CQx3DTYxsjk+8BaMqG6p/49Qr 26 | //JbxdDS735BW/A5rU4TEiJfcV66lT7gI8lL8cFsV1rYPMJWkqc= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start 5 | 6 | require 'rspec' 7 | require 'jwe' 8 | 9 | RSpec.configure do |config| 10 | config.order = 'random' 11 | end 12 | --------------------------------------------------------------------------------