require "openssl" require "base64" require "json" # Portable authenticated encryption, identical in both applications. # The file contains no password or key. Never accept client-selected algorithms/KDF costs. class ProfessionalTransferEnvelope FORMAT = "pcm-professional-transfer-encrypted-v1".freeze AAD = "pcm-professional-transfer:v1".freeze ITERATIONS = 600_000 MAX_FILE_BYTES = 4 * 1024 * 1024 class Invalid < StandardError; end def self.encrypt(payload, password) check_password!(password) salt = OpenSSL::Random.random_bytes(16) iv = OpenSSL::Random.random_bytes(12) cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt cipher.key = key(password, salt) cipher.iv = iv cipher.auth_data = AAD ciphertext = cipher.update(JSON.generate(payload)) + cipher.final envelope = { format: FORMAT, salt: Base64.strict_encode64(salt), iv: Base64.strict_encode64(iv), tag: Base64.strict_encode64(cipher.auth_tag), ciphertext: Base64.strict_encode64(ciphertext) } output = JSON.generate(envelope) raise Invalid, "too_large" if output.bytesize > MAX_FILE_BYTES output end def self.decrypt(file, password) check_password!(password) raise Invalid, "too_large" if file.bytesize > MAX_FILE_BYTES envelope = JSON.parse(file, max_nesting: 5) raise Invalid, "invalid_file" unless envelope.is_a?(Hash) && envelope.keys.sort == %w[ciphertext format iv salt tag] && envelope["format"] == FORMAT salt, iv, tag, ciphertext = %w[salt iv tag ciphertext].map { |name| Base64.strict_decode64(envelope.fetch(name)) } raise Invalid, "invalid_file" unless salt.bytesize == 16 && iv.bytesize == 12 && tag.bytesize == 16 && ciphertext.bytesize.positive? cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt cipher.key = key(password, salt) cipher.iv = iv cipher.auth_tag = tag cipher.auth_data = AAD JSON.parse(cipher.update(ciphertext) + cipher.final, max_nesting: 10) rescue Invalid raise rescue JSON::ParserError, ArgumentError, TypeError, KeyError, OpenSSL::Cipher::CipherError raise Invalid, "invalid_file" end def self.check_password!(password) raise Invalid, "password" unless password.is_a?(String) && password.length >= 12 && password.bytesize <= 256 end def self.key(password, salt) OpenSSL::PKCS5.pbkdf2_hmac(password, salt, ITERATIONS, 32, "sha256") end private_class_method :key, :check_password! end