You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
57 lines
2.4 KiB
57 lines
2.4 KiB
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
|