Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased](https://github.com/jwt/ruby-jwe/tree/HEAD)

**Features:**

- Add JSON Serialization support (General and Flattened) per RFC 7516 Section 7.2

## [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)
Expand Down
185 changes: 183 additions & 2 deletions lib/jwe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@

require 'jwe/base64'
require 'jwe/serialization/compact'
require 'jwe/serialization/json'
require 'jwe/alg'
require 'jwe/enc'
require 'jwe/zip'
require 'jwe/recipient'
require 'jwe/decryption_result'

# A ruby implementation of the RFC 7516 JSON Web Encryption (JWE) standard.
module JWE
module JWE # rubocop:disable Metrics/ModuleLength
class DecodeError < RuntimeError; end
class NotImplementedError < RuntimeError; end
class BadCEK < RuntimeError; end
Expand All @@ -22,7 +25,7 @@ class InvalidData < RuntimeError; end
VALID_ENC = %w[A128CBC-HS256 A192CBC-HS384 A256CBC-HS512 A128GCM A192GCM A256GCM].freeze
VALID_ZIP = ['DEF'].freeze

class << self
class << self # rubocop:disable Metrics/ClassLength
def encrypt(payload, key, alg: 'RSA-OAEP', enc: 'A128GCM', **more_headers)
header = generate_header(alg, enc, more_headers)
check_params(header, key)
Expand Down Expand Up @@ -97,5 +100,183 @@ def generate_header(alg, enc, more)
def generate_serialization(hdr, cek, content, cipher)
Serialization::Compact.encode(hdr, cek, cipher.iv, content, cipher.tag)
end

# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
def encrypt_json(payload, recipients, protected_header: {}, unprotected_header: nil, aad: nil, format: :general)
raise ArgumentError, 'At least one recipient is required' if recipients.empty?
raise ArgumentError, 'Flattened serialization supports only one recipient' if format == :flattened && recipients.length > 1

enc = protected_header[:enc] || protected_header['enc']
raise ArgumentError, 'enc is required in protected_header' unless enc

check_enc(enc)

cipher = Enc.for(enc)
cek = cipher.cek

recipient_data = recipients.map do |recipient|
jose_header = build_jose_header(protected_header, unprotected_header, recipient.header)
alg = jose_header['alg'] || jose_header[:alg]
raise ArgumentError, 'alg is required for each recipient' unless alg

check_alg(alg)
check_key(recipient.key)

encrypted_key = Alg.encrypt_cek(alg, recipient.key, cek)
{ header: recipient.header, encrypted_key: encrypted_key }
end

payload = apply_zip(protected_header, payload, :compress)

protected_header_json = protected_header.transform_keys(&:to_s).to_json
encoded_protected = Base64.jwe_encode(protected_header_json)

aad_for_encryption = if aad
"#{encoded_protected}.#{Base64.jwe_encode(aad)}"
else
encoded_protected
end

ciphertext = cipher.encrypt(payload, aad_for_encryption)

if format == :flattened
Serialization::Json::Flattened.encode(
protected_header: encoded_protected,
unprotected_header: unprotected_header,
header: recipient_data[0][:header],
encrypted_key: recipient_data[0][:encrypted_key],
iv: cipher.iv,
ciphertext: ciphertext,
tag: cipher.tag,
aad: aad
)
else
Serialization::Json::General.encode(
protected_header: encoded_protected,
unprotected_header: unprotected_header,
recipients: recipient_data,
iv: cipher.iv,
ciphertext: ciphertext,
tag: cipher.tag,
aad: aad
)
end
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists

# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def decrypt_json(payload, keys)
data = JSON.parse(payload)

decoded = if data['recipients']
Serialization::Json::General.decode(data)
else
Serialization::Json::Flattened.decode(data)
end

protected_header = if decoded[:protected_header]
JSON.parse(Base64.jwe_decode(decoded[:protected_header]))
else
{}
end
unprotected_header = decoded[:unprotected_header] || {}

key_map = normalize_keys(keys)

successful_recipients = []
failed_recipients = []
plaintext = nil

decoded[:recipients].each_with_index do |recipient, index|
recipient_header = recipient[:header] || {}
jose_header = protected_header.merge(unprotected_header).merge(recipient_header)

validate_header_no_duplicates!(protected_header, unprotected_header, recipient_header)

alg = jose_header['alg']
enc = jose_header['enc']

raise DecodeError, 'Missing alg in JOSE header' unless alg
raise DecodeError, 'Missing enc in JOSE header' unless enc

check_alg(alg)
check_enc(enc)

key = select_key(key_map, jose_header)
next unless key

begin
cek = Alg.decrypt_cek(alg, key, recipient[:encrypted_key])

aad_for_decryption = if decoded[:aad]
"#{decoded[:protected_header]}.#{data['aad']}"
else
decoded[:protected_header] || ''
end

cipher = Enc.for(enc, cek, decoded[:iv], decoded[:tag])
decrypted = cipher.decrypt(decoded[:ciphertext], aad_for_decryption)

plaintext = apply_zip(jose_header, decrypted, :decompress)
successful_recipients << index
break
rescue StandardError
failed_recipients << index
end
end

raise InvalidData, 'No recipient could decrypt the message' unless plaintext

DecryptionResult.new(
plaintext: plaintext,
successful_recipients: successful_recipients,
failed_recipients: failed_recipients
)
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity

private

def build_jose_header(protected_header, unprotected_header, recipient_header)
result = {}
result.merge!(protected_header.transform_keys(&:to_s)) if protected_header
result.merge!(unprotected_header.transform_keys(&:to_s)) if unprotected_header
result.merge!(recipient_header.transform_keys(&:to_s)) if recipient_header
result
end

def normalize_keys(keys)
case keys
when Hash
keys.transform_keys(&:to_s)
when Array
keys.each_with_index.to_h { |k, i| [i.to_s, k] }
else
{ nil => keys }
end
end

def select_key(key_map, jose_header)
kid = jose_header['kid']
if kid && key_map.key?(kid)
key_map[kid]
elsif key_map.key?(nil)
key_map[nil]
else
key_map.values.first
end
end

# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def validate_header_no_duplicates!(protected_header, unprotected_header, recipient_header)
all_keys = []
all_keys.concat(protected_header.keys.map(&:to_s)) if protected_header
all_keys.concat(unprotected_header.keys.map(&:to_s)) if unprotected_header
all_keys.concat(recipient_header.keys.map(&:to_s)) if recipient_header

duplicates = all_keys.group_by(&:itself).select { |_, v| v.size > 1 }.keys
raise DecodeError, "Duplicate header parameters: #{duplicates.join(', ')}" if duplicates.any?
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
end
end
11 changes: 11 additions & 0 deletions lib/jwe/decryption_result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module JWE
# Decryption result for JSON Serialization
# Contains plaintext and information about successful/failed recipients
DecryptionResult = Struct.new(:plaintext, :successful_recipients, :failed_recipients, keyword_init: true) do
def initialize(plaintext:, successful_recipients: [], failed_recipients: [])
super
end
end
end
11 changes: 11 additions & 0 deletions lib/jwe/recipient.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module JWE
# Recipient structure for JSON Serialization
# Holds a key and optional per-recipient header
Recipient = Struct.new(:key, :header, keyword_init: true) do
def initialize(key:, header: {})
super(key: key, header: header || {})
end
end
end
100 changes: 100 additions & 0 deletions lib/jwe/serialization/json.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

module JWE
module Serialization
# JSON Serialization namespace (RFC 7516 Section 7.2)
module Json
# General JWE JSON Serialization (RFC 7516 Section 7.2.1)
# Supports multiple recipients
class General
class << self
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
def encode(protected_header:, unprotected_header:, recipients:, iv:, ciphertext:, tag:, aad:)
result = {}

result['protected'] = protected_header if protected_header && !protected_header.empty?
result['unprotected'] = unprotected_header if unprotected_header && !unprotected_header.empty?

result['recipients'] = recipients.map do |r|
recipient = {}
recipient['header'] = r[:header] if r[:header] && !r[:header].empty?
recipient['encrypted_key'] = Base64.jwe_encode(r[:encrypted_key])
recipient
end

result['aad'] = Base64.jwe_encode(aad) if aad
result['iv'] = Base64.jwe_encode(iv)
result['ciphertext'] = Base64.jwe_encode(ciphertext)
result['tag'] = Base64.jwe_encode(tag)

result.to_json
end

def decode(data)
raise JWE::DecodeError, 'Missing recipients' unless data['recipients']
raise JWE::DecodeError, 'Missing ciphertext' unless data['ciphertext']

{
protected_header: data['protected'],
unprotected_header: data['unprotected'],
recipients: data['recipients'].map do |r|
{
header: r['header'],
encrypted_key: Base64.jwe_decode(r['encrypted_key'] || '')
}
end,
iv: Base64.jwe_decode(data['iv'] || ''),
ciphertext: Base64.jwe_decode(data['ciphertext']),
tag: Base64.jwe_decode(data['tag'] || ''),
aad: data['aad'] ? Base64.jwe_decode(data['aad']) : nil
}
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
end
end

# Flattened JWE JSON Serialization (RFC 7516 Section 7.2.2)
# Single recipient only
class Flattened
class << self
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists
def encode(protected_header:, unprotected_header:, header:, encrypted_key:, iv:, ciphertext:, tag:, aad:)
result = {}

result['protected'] = protected_header if protected_header && !protected_header.empty?
result['unprotected'] = unprotected_header if unprotected_header && !unprotected_header.empty?
result['header'] = header if header && !header.empty?
result['encrypted_key'] = Base64.jwe_encode(encrypted_key)
result['aad'] = Base64.jwe_encode(aad) if aad
result['iv'] = Base64.jwe_encode(iv)
result['ciphertext'] = Base64.jwe_encode(ciphertext)
result['tag'] = Base64.jwe_encode(tag)

result.to_json
end

def decode(data)
raise JWE::DecodeError, 'Missing ciphertext' unless data['ciphertext']
raise JWE::DecodeError, 'Flattened format cannot have recipients' if data['recipients']

{
protected_header: data['protected'],
unprotected_header: data['unprotected'],
recipients: [
{
header: data['header'],
encrypted_key: Base64.jwe_decode(data['encrypted_key'] || '')
}
],
iv: Base64.jwe_decode(data['iv'] || ''),
ciphertext: Base64.jwe_decode(data['ciphertext']),
tag: Base64.jwe_decode(data['tag'] || ''),
aad: data['aad'] ? Base64.jwe_decode(data['aad']) : nil
}
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists
end
end
end
end
end
Loading