Skip to content

Commit

Permalink
Don't reuse IVs and add future placeholder for key rotation.
Browse files Browse the repository at this point in the history
  • Loading branch information
TreyE committed Oct 3, 2023
1 parent c15b9cd commit 06bc12f
Show file tree
Hide file tree
Showing 13 changed files with 311 additions and 26 deletions.
1 change: 1 addition & 0 deletions lib/aca_entities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require 'aca_entities/error'

require 'aca_entities/configuration/encryption'
require 'aca_entities/encryption'
require 'aca_entities/operations/mongoid/model_adapter'

require 'aca_entities/libraries/aca_individual_market_library'
Expand Down
10 changes: 10 additions & 0 deletions lib/aca_entities/encryption.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

require_relative "encryption/symmetric"

module AcaEntities
# Manages encryption and encryption primatives for ACA Entities
# and associated payloads.
module Encryption
end
end
45 changes: 45 additions & 0 deletions lib/aca_entities/encryption/symmetric.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

require "base64"
require_relative "symmetric/legacy_keyset"
require_relative "symmetric/key_manager"
require_relative "symmetric/encrypted_payload"
require_relative "symmetric/legacy_encrypted_payload"
require_relative "symmetric/parse_encrypted_payload"
require_relative "symmetric/decrypt_payload"
require_relative "symmetric/encrypt_payload"

module AcaEntities
module Encryption
# Management and algorithms for symmetric algorithms, supported by
# libsodium.
module Symmetric
# Algorithm implementation versions.
#
# Right now we have only Version 1.
ALGO_VERSIONS = ["S1"].freeze
CURRENT_ALGO_VERSION = "S1"

class InvalidPayloadHeaderError < StandardError; end

class KeyNotFoundError < StandardError; end

# Manages nonces for symmetric encryption
class Nonce
def self.generate(byte_size)
RbNaCl::Random.random_bytes(byte_size)
end
end

def decrypt(payload)
DecryptPayload.new.call(payload)
end

def encrypt(payload)
EncryptPayload.new.call(payload)
end

module_function :decrypt, :encrypt
end
end
end
47 changes: 47 additions & 0 deletions lib/aca_entities/encryption/symmetric/decrypt_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

require 'dry/monads'
require 'dry/monads/do'

module AcaEntities
module Encryption
module Symmetric
# Decrypt a payload.
class DecryptPayload
send(:include, Dry::Monads[:result, :do])
send(:include, Dry::Monads[:try])

def call(payload)
encrypted_payload = yield parse_header_and_payload(payload)
decrypt_payload(encrypted_payload)
end

def parse_header_and_payload(payload)
ParseEncryptedPayload.new.call(payload)
end

def decrypt_payload(encrypted_payload)
if encrypted_payload.header?
found_key = yield lookup_key(encrypted_payload)
decryption_result = Try do
decryption_box = RbNaCl::SecretBox.new(found_key)
decryption_box.decrypt(encrypted_payload.nonce, encrypted_payload.content)
end
decryption_result.to_result
else
secret_box = RbNaCl::SecretBox.new(LegacyKeyset.secret_key)
Success(secret_box.decrypt(LegacyKeyset.iv, Base64.decode64(encrypted_payload.content)))
end
end

def lookup_key(encrypted_payload)
key_result = Try do
KeyManager.resolve_key(encrypted_payload.algo_version, encrypted_payload.key_version)
end

key_result.to_result
end
end
end
end
end
51 changes: 51 additions & 0 deletions lib/aca_entities/encryption/symmetric/encrypt_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require 'dry/monads'
require 'dry/monads/do'

module AcaEntities
module Encryption
module Symmetric
# Encrypte a payload.
class EncryptPayload
send(:include, Dry::Monads[:result, :do])
send(:include, Dry::Monads[:try])

def call(payload)
encrypted = yield construct_encrypted_payload(payload)
encode_payload(encrypted)
end

def construct_encrypted_payload(payload)
encryption_attempt = Try do
current_algo_v = KeyManager.current_algo_version
current_key_v = KeyManager.current_key_version
key = KeyManager.resolve_key(current_algo_v, current_key_v)
encrypt_box = RbNaCl::SecretBox.new(key)
nonce = Nonce.generate(encrypt_box.nonce_bytes)
content = encrypt_box.encrypt(nonce, payload)
EncryptedPayload.new(
current_algo_v,
current_key_v,
nonce,
content
)
end
encryption_attempt.to_result
end

def encode_payload(encrypted)
encoded_result = Try do
# rubocop:disable Style/StringConcatenation
encrypted.algo_version + "." + encrypted.key_version + "." +
Base64.encode64(encrypted.nonce) + "." +
Base64.encode64(encrypted.content)
# rubocop:enable Style/StringConcatenation
end

encoded_result.to_result
end
end
end
end
end
23 changes: 23 additions & 0 deletions lib/aca_entities/encryption/symmetric/encrypted_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module AcaEntities
module Encryption
module Symmetric
# A standard payload with a header for encryption.
class EncryptedPayload
attr_reader :algo_version, :key_version, :nonce, :content

def initialize(a_version, k_version, nonce_value, content_value)
@algo_version = a_version
@key_version = k_version
@nonce = nonce_value
@content = content_value
end

def header?
true
end
end
end
end
end
26 changes: 26 additions & 0 deletions lib/aca_entities/encryption/symmetric/key_manager.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module AcaEntities
module Encryption
module Symmetric
# Manages the selection of a key and algorithm using version headers
class KeyManager
def self.current_algo_version
CURRENT_ALGO_VERSION
end

# Right now, since we're using the legacy configuration as our source,
# we'll tag our key version as 'L'.
def self.current_key_version
"L"
end

# Eventually we will replace this with a dynamic lookup to allow key
# rotation.
def self.resolve_key(_algo_version, _key_version)
LegacyKeyset.secret_key
end
end
end
end
end
20 changes: 20 additions & 0 deletions lib/aca_entities/encryption/symmetric/legacy_encrypted_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module AcaEntities
module Encryption
module Symmetric
# A payload from before we versioned our algorithms or keys.
class LegacyEncryptedPayload
attr_reader :content

def initialize(content_value)
@content = content_value
end

def header?
false
end
end
end
end
end
19 changes: 19 additions & 0 deletions lib/aca_entities/encryption/symmetric/legacy_keyset.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module AcaEntities
module Encryption
module Symmetric
# Manages key settings for the 'original' implementation.
class LegacyKeyset
def self.secret_key
key = AcaEntities::Configuration::Encryption.config.secret_key
[key].pack("H*")
end

def self.iv
AcaEntities::Configuration::Encryption.config.iv
end
end
end
end
end
50 changes: 50 additions & 0 deletions lib/aca_entities/encryption/symmetric/parse_encrypted_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

require 'dry/monads'
require 'dry/monads/do'

module AcaEntities
module Encryption
module Symmetric
# Parse the header and body for an encrypted payload.
#
# We might return an error in the case of an invalid payload, or we
# we might return an empty payload, as the current data was provided
# in the 'legacy' encryption format.
class ParseEncryptedPayload
send(:include, Dry::Monads[:result, :do])
send(:include, Dry::Monads[:try])

def call(payload)
return Success(LegacyEncryptedPayload.new(payload)) if payload.blank?
return Success(LegacyEncryptedPayload.new(payload)) unless payload.include?(".")

payload_parts = yield split_parts(payload)
parse_header_values(payload_parts)
end

def split_parts(payload)
split_attempt = Try do
payload_parts = payload.split(".").map(&:strip)
raise InvalidPayloadHeaderError, "Payload only had #{payload_parts.length} parts, expected 4." if payload_parts.length < 4
payload_parts
end

split_attempt.to_result
end

def parse_header_values(payload_parts)
parse_attempt = Try do
algo_version = payload_parts.first
key_version = payload_parts[1]
nonce = Base64.decode64(payload_parts[2])
content = Base64.decode64(payload_parts[3])
EncryptedPayload.new(algo_version, key_version, nonce, content)
end

parse_attempt.to_result
end
end
end
end
end
13 changes: 1 addition & 12 deletions lib/aca_entities/operations/encryption/decrypt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,7 @@ class Decrypt

# @param [hash] pass in value to be encrypted
def call(params)
decrypted_value = yield decrypt(params[:value])

Success(decrypted_value)
end

private

def decrypt(value)
key = AcaEntities::Configuration::Encryption.config.secret_key
iv = AcaEntities::Configuration::Encryption.config.iv
secret_box = RbNaCl::SecretBox.new([key].pack("H*"))
Success(secret_box.decrypt(iv, Base64.decode64(value)))
AcaEntities::Encryption::Symmetric.decrypt(params[:value])
end
end
end
Expand Down
13 changes: 1 addition & 12 deletions lib/aca_entities/operations/encryption/encrypt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,7 @@ class Encrypt

# @param [hash] pass in value to be encrypted
def call(params)
encrypted_value = yield encrypt(params[:value])

Success(encrypted_value)
end

private

def encrypt(value)
key = AcaEntities::Configuration::Encryption.config.secret_key
iv = AcaEntities::Configuration::Encryption.config.iv
secret_box = RbNaCl::SecretBox.new([key].pack("H*"))
Success(Base64.encode64(secret_box.encrypt(iv, value)))
AcaEntities::Encryption::Symmetric.encrypt(params[:value])
end
end
end
Expand Down
19 changes: 17 additions & 2 deletions spec/aca_entities/operations/encryption/decrypt_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,27 @@

subject { described_class.new.call(input) }

context 'When any type of value is passed' do
context 'Given a value encrypted with the corresponding encryption operation' do

let(:encrypted_value) {AcaEntities::Operations::Encryption::Encrypt.new.call({ value: "Hello World" }).value! }
let(:input) { { value: encrypted_value } }

it 'should return success with decrypted value' do
it 'can decrypt correctly' do
expect(subject).to be_a Dry::Monads::Result::Success
expect(subject.value!).to eq "Hello World"
end
end

context 'Given a legacy encrypted value' do
let(:encrypted_value) do
key = AcaEntities::Encryption::Symmetric::LegacyKeyset.secret_key
iv = AcaEntities::Encryption::Symmetric::LegacyKeyset.iv
box = RbNaCl::SecretBox.new(key)
Base64.encode64(box.encrypt(iv, "Hello World"))
end
let(:input) { { value: encrypted_value } }

it 'can decrypt correctly' do
expect(subject).to be_a Dry::Monads::Result::Success
expect(subject.value!).to eq "Hello World"
end
Expand Down

0 comments on commit 06bc12f

Please sign in to comment.