Wednesday, July 15, 2026 2:11:01 AM
> encryption.rb
# frozen_string_literal: true

module ESM
  module Arma
    class Encryption
      CIPHER = "aes-256-gcm"
      NONCE_SIZE = 12  # GCM standard
      TAG_SIZE = 16    # GCM authentication tag size

      # First 32 bytes.
      # A standard request will larger than 32 bytes so this _shouldn't_ cause issues with the nonce being stacked at the end of the bytes (because of the data packet being smaller than 32 bytes)
      INDEX_LOW_BOUNDS = 0
      INDEX_HIGH_BOUNDS = 31

      def self.generate_nonce_indices
        indices = (INDEX_LOW_BOUNDS...INDEX_HIGH_BOUNDS).to_a.shuffle.shuffle
        NONCE_SIZE.times.map { indices.pop }.sort
      end

      def initialize(key, nonce_indices: [], session_id: "")
        key = key.bytes[INDEX_LOW_BOUNDS..INDEX_HIGH_BOUNDS]
        raise ArgumentError, "Encryption key must be 32 bytes" if key.size != 32

        @key = key.pack("C*")
        @session_id = session_id
        @nonce_indices = nonce_indices.presence || (0...NONCE_SIZE).to_a
      end

      def encrypt(data)
        cipher = OpenSSL::Cipher.new(CIPHER).encrypt
        nonce = cipher.random_iv

        cipher.key = @key
        cipher.iv = nonce
        cipher.auth_data = @session_id

        encrypted_data = cipher.update(data) + cipher.final
        auth_tag = cipher.auth_tag

        # Combine encrypted data and auth tag
        encrypted_bytes = encrypted_data.bytes + auth_tag.bytes
        nonce_bytes = nonce.bytes

        # Insert nonce bytes at specified positions
        @nonce_indices.each_with_index do |nonce_index, index|
          encrypted_bytes.insert(nonce_index, nonce_bytes[index])
        end

        encrypted_bytes.pack("C*")
      end

      #
      # Attempts to decrypt the provided byte array
      #
      # @param input [String] A UTF-8 string containing encrypted data
      #
      # @return [String] The decoded string
      #
      # @raises DecryptionError
      # @raises InvalidSecretKey
      # @raises InvalidNonce
      #
      def decrypt(input)
        cipher = OpenSSL::Cipher.new(CIPHER).decrypt
        cipher.key = @key

        # Extract nonce and encrypted data
        nonce = []
        packet = []
        input.bytes.each_with_index do |byte, index|
          if @nonce_indices[nonce.size] == index
            nonce << byte
            next
          end

          packet << byte
        end

        # Separate auth tag from encrypted data
        auth_tag = packet.pop(TAG_SIZE).pack("C*")
        encrypted_data = packet.pack("C*")

        cipher.iv = nonce.pack("C*")
        cipher.auth_tag = auth_tag
        cipher.auth_data = @session_id

        decrypted_data = cipher.update(encrypted_data) + cipher.final
        raise ESM::Exception::DecryptionError if decrypted_data.blank?

        decrypted_data
      rescue ArgumentError => e
        case e.message
        when "key must be 32 bytes"
          raise ESM::Exception::DecryptionError, "Invalid secret key length"
        when "iv must be #{NONCE_SIZE} bytes"
          raise ESM::Exception::DecryptionError, "Invalid IV length"
        else
          raise e
        end
      rescue OpenSSL::Cipher::CipherError
        raise ESM::Exception::DecryptionError, "Authentication failed"
      end
    end
  end
end
All opinions represented herein are my own
- © 2024 - 2026 itsthedevman
- build 43aa0d7