diff --git a/lib/aca_entities.rb b/lib/aca_entities.rb index e3a9cf3c5..d099f6588 100644 --- a/lib/aca_entities.rb +++ b/lib/aca_entities.rb @@ -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' diff --git a/lib/aca_entities/encryption.rb b/lib/aca_entities/encryption.rb new file mode 100644 index 000000000..9e638a79c --- /dev/null +++ b/lib/aca_entities/encryption.rb @@ -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 \ No newline at end of file diff --git a/lib/aca_entities/encryption/symmetric.rb b/lib/aca_entities/encryption/symmetric.rb new file mode 100644 index 000000000..04c90d1c4 --- /dev/null +++ b/lib/aca_entities/encryption/symmetric.rb @@ -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 \ No newline at end of file diff --git a/lib/aca_entities/encryption/symmetric/decrypt_payload.rb b/lib/aca_entities/encryption/symmetric/decrypt_payload.rb new file mode 100644 index 000000000..be1bc8a2f --- /dev/null +++ b/lib/aca_entities/encryption/symmetric/decrypt_payload.rb @@ -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 \ No newline at end of file diff --git a/lib/aca_entities/encryption/symmetric/encrypt_payload.rb b/lib/aca_entities/encryption/symmetric/encrypt_payload.rb new file mode 100644 index 000000000..903d71bc0 --- /dev/null +++ b/lib/aca_entities/encryption/symmetric/encrypt_payload.rb @@ -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 \ No newline at end of file diff --git a/lib/aca_entities/encryption/symmetric/encrypted_payload.rb b/lib/aca_entities/encryption/symmetric/encrypted_payload.rb new file mode 100644 index 000000000..334464e02 --- /dev/null +++ b/lib/aca_entities/encryption/symmetric/encrypted_payload.rb @@ -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 \ No newline at end of file diff --git a/lib/aca_entities/encryption/symmetric/key_manager.rb b/lib/aca_entities/encryption/symmetric/key_manager.rb new file mode 100644 index 000000000..2d1954f8d --- /dev/null +++ b/lib/aca_entities/encryption/symmetric/key_manager.rb @@ -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 \ No newline at end of file diff --git a/lib/aca_entities/encryption/symmetric/legacy_encrypted_payload.rb b/lib/aca_entities/encryption/symmetric/legacy_encrypted_payload.rb new file mode 100644 index 000000000..04bd5bc2b --- /dev/null +++ b/lib/aca_entities/encryption/symmetric/legacy_encrypted_payload.rb @@ -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 \ No newline at end of file diff --git a/lib/aca_entities/encryption/symmetric/legacy_keyset.rb b/lib/aca_entities/encryption/symmetric/legacy_keyset.rb new file mode 100644 index 000000000..296f90612 --- /dev/null +++ b/lib/aca_entities/encryption/symmetric/legacy_keyset.rb @@ -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 \ No newline at end of file diff --git a/lib/aca_entities/encryption/symmetric/parse_encrypted_payload.rb b/lib/aca_entities/encryption/symmetric/parse_encrypted_payload.rb new file mode 100644 index 000000000..fcc0b464b --- /dev/null +++ b/lib/aca_entities/encryption/symmetric/parse_encrypted_payload.rb @@ -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 \ No newline at end of file diff --git a/lib/aca_entities/operations/encryption/decrypt.rb b/lib/aca_entities/operations/encryption/decrypt.rb index c919a8321..53b93b901 100644 --- a/lib/aca_entities/operations/encryption/decrypt.rb +++ b/lib/aca_entities/operations/encryption/decrypt.rb @@ -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 diff --git a/lib/aca_entities/operations/encryption/encrypt.rb b/lib/aca_entities/operations/encryption/encrypt.rb index eba080f10..a87358d9f 100644 --- a/lib/aca_entities/operations/encryption/encrypt.rb +++ b/lib/aca_entities/operations/encryption/encrypt.rb @@ -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 diff --git a/spec/aca_entities/operations/encryption/decrypt_spec.rb b/spec/aca_entities/operations/encryption/decrypt_spec.rb index 1118187d7..97ce000b4 100644 --- a/spec/aca_entities/operations/encryption/decrypt_spec.rb +++ b/spec/aca_entities/operations/encryption/decrypt_spec.rb @@ -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