File Storage Impl: Part 1

2023-12-21 18:14:39.24497+00


File Storage Impl: Part 1

Installation

Firstly, I installed and import the forge.js module. The module can be copied or downloaded from node-forge npm by selecting the dist/forge.min.js file and moving the content into the assets/vendor/forge.js (could be named anything).

Next, import the forge object in your assets/js/app.js file:

import forge from "../vendor/forge";

Key Exchange

crypto_web.ex

defmodule FileStorage.Crypto.Web do
  @moduledoc """
  Cryptography methods used for encryption and signatures on files downloaded from web form
  """
  @key_encryption_padding_options [{:encrypt, false}, {:padding, :pkcs_padding}]
  @aes_cipher :aes_256_ctr
  @hash_cipher :sha512
  @pbkdf2_interations 310 # Unsure if 310_000 is required since this is not password. Therefore, no rainbow attack.
  @private_key_pem System.fetch_env!("PRIVATE_KEY_PEM")
                   |> to_string()
                   |> String.replace("\\n", "\n")


  @spec gen_ecdh_keypair(atom()) :: {binary(), tuple()}
  def gen_ecdh_keypair(named_curve) when is_atom(named_curve) do
    private_key = :public_key.generate_key({:namedCurve, named_curve})
    public_key = {{:ECPoint, elem(private_key, 4)}, elem(private_key, 3)}

    public_entry = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)

    {public_entry |> elem(1), private_key}
  end

  @spec compute_shared_secret!(binary(), tuple()) :: binary()
  def compute_shared_secret!(public_key_der, private_key)
      when is_binary(public_key_der) and is_tuple(private_key) do
    public_key_point =
      :public_key.pem_entry_decode({:SubjectPublicKeyInfo, public_key_der, :not_encrypted})
      |> elem(0)

    :public_key.compute_key(public_key_point, private_key)
  end

  @spec key_derivation!(binary()) :: binary()
  def key_derivation!(shared_secret) when is_binary(shared_secret) do
    key_part = binary_part(shared_secret, 0, 32)
    salt_part = binary_part(shared_secret, 32, 32)

    Pbkdf2.Base.hash_password(key_part, salt_part, rounds: @pbkdf2_interations, digest: :sha256, format: :hex)
    |> Base.decode16!(case: :lower)
  end

  @spec sign_key!(binary()) :: binary()
  def sign_key!(public_key_pem) when is_binary(public_key_pem) do
    {:ok, private_key} = decode_private_pem(@private_key_pem)
    :public_key.sign(public_key_pem, :sha256, private_key)
  end

  # ...
end

form_component.ex

defmodule FileStorageWeb.FileLive.FormComponent do
  use FileStorageWeb, :live_component

  alias FileStorage.Storages
  alias FileStorage.Accounts
  alias FileStorageWeb.MultiSelectComponent
  alias FileStorageWeb.FileLive.Filewriter
  alias FileStorage.Crypto.Web, as: Crypto
  # ...

    def render(assigns) do
        # ...
            <.live_file_input upload={@uploads.file} class="hidden" />
            <%= if @folder do %>
              <input
                id="dropzone-file"
                type="file"
                class="hidden"
                webkitdirectory={@folder}
                phx-hook="Folder"
                name={@edh}
              />
            <% else %>
              <input id="dropzone-file" name={@edh} type="file" class="hidden" phx-hook="File" />
            <% end %>
        # ...
    end
    def update(%{file: file, current_user: user, action: action} = assigns, socket) do
        {public_key_der, private_key} = Crypto.gen_ecdh_keypair
        (:secp521r1)
        base64_public_key_der = public_key_der |> Base.encode64()
        base64_signature = Crypto.sign_key!(base64_public_key_der) |> Base.encode64()

        {:ok,
        socket
        |> assign(assigns)
        |> assign(:current_user, updated_user)
        |> assign(:folder, action == :folder || file.is_folder)
        |> assign(:loading, false)
        |> assign(:edh, base64_signature <> "," <> base64_public_key_der)
        |> assign(:edh_private, private_key)
        |> assign(:shared_key, nil)
        # ...
        }
    end

    def handle_event("DHexchange", %{"publicKey" => base64_public_key_der}, socket) do
        shared_key =
          Crypto.compute_shared_secret!(
        Base.decode64!(base64_public_key_der),
        socket.assigns.edh_private
      )
      |> Crypto.key_derivation!()

    # TODO: Secret storage? or often updated
    {:noreply, assign(socket, :shared_key, shared_key) |> assign(:edh_private, nil)}
  end

app.js

// ...
import { encryptContentToBlob, generateSymmetricKey } from "./utils";

let hooks = {};
hooks.Folder = {
  async mounted() {
    const sharedKey = await generateSymmetricKey(this);
    // ...
  },
};
hooks.File = {
  async mounted() {
    const sharedKey = await generateSymmetricKey(this);
    // ...
  },
};
// ...

utils.js

// ...
/**
 * TODO: Should be stored better ;(
 * @returns public signing key
 */
const getPublicSignKey = async () => {
  const pemDecoded = forge.pem.decode("-----BEGIN PUBLIC KEY-----\nMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQAA/OGbMbh8AFZep/vYM1o7BunL48x\nZuciAFDQ551pTT7SCffLL9xhCelJhJhM1ywDtDU5KVYKYWAlEX8yM3B7U/cAZr77\nwwrHkoW+orROTAHnjiMuRq5GfHXgZoAbQoU8EYbfQlIenAWh4FocWwceqc6XUDhK\n2xsQG6ZoEL477ssRGmc=\n-----END PUBLIC KEY-----")[0]
  return await crypto.subtle.importKey("spki", stringToArrayBuffer(pemDecoded.body), {name: "ECDSA", namedCurve: "P-521"}, false, ["verify"])
}

/**
 * Checks the signature against the data
 * @param {string} signature 
 * @param {string} data -> a DER encoded EC public key
 * @returns verification of the signature represented by a boolean
 */
const verifySignature = async (signature, data) => {
  const publicKey = await getPublicSignKey()
  return await crypto.subtle.verify({name: "ECDSA", hash: "SHA-256"}, publicKey, signature, data)
}

/**
 * Derives a key from a shared secret using PBKDF2 (a key derivation function)
 * @param {string} sharedSecret 
 * @returns a symmetric key 
 */
const generateSymmetricKeyFromSharedSecret = async (sharedSecret) => {
  const keyPart = sharedSecret.slice(0, 32);
  const saltPart = sharedSecret.slice(32, 64);
  const importedKey = await crypto.subtle.importKey("raw", keyPart, "PBKDF2", false, ["deriveBits"]);
  return new Uint8Array(await crypto.subtle.deriveBits({name: "PBKDF2", hash: "SHA-256", salt: saltPart, iterations: 310}, importedKey, 256));

}

/**
 * Verifies the DER encoded publicKey with the signature
 * Generates its own EDH-part and calculates the shared secret => key derivation
 * @param {string} signature 
 * @param {string} publicKeyDer 
 * @returns symmetricKey derviced from the sharedSecret and its publicKey part
 */
const verifyAndGenerateSymmetricKey = async (signature, publicKeyDer) => {
  try {
    const signatureBuffer = stringToArrayBuffer(window.atob(signature))
    const publicKeyDerBuffer = stringToArrayBuffer(window.atob(publicKeyDer))
    if(verifySignature(signatureBuffer, publicKeyDerBuffer)){
      const importedPublicKey = await crypto.subtle.importKey("spki", publicKeyDerBuffer, {name: "ECDH", namedCurve: "P-521"}, true, ["deriveBits"])
      const browserKeyPair = await crypto.subtle.generateKey({name: "ECDH", namedCurve: "P-521"}, true, ["deriveBits", "deriveKey"])
      const sharedSecret = await crypto.subtle.deriveBits({ name:"ECDH", public: importedPublicKey }, browserKeyPair.privateKey, 512)
      return {key: await generateSymmetricKeyFromSharedSecret(sharedSecret), publicKey: new Uint8Array(await crypto.subtle.exportKey('spki', browserKeyPair.publicKey))}
    } else
      return {error: "Could not verify signature of key"}
  } catch {
    return {error: "Issue occured trying to generate shared secret"}
  }
}

/** 
* Fetches the name of the mounted element, which should contain the server's key part and its signature
* Generates its own EDH-part and calculates the shared secret => key derivation
* Sends its public EDH-part to the server 
* @param {HTMLInputElement} mountEl
* @returns symmetricKey derviced from the sharedSecret
*/
export const generateSymmetricKey = async (mountEl) => {
  const nameSplit = mountEl.el.name.split(",")
  const sharedSecretAndDHPart = await verifyAndGenerateSymmetricKey(nameSplit[0], nameSplit[1])
  try {
    if(sharedSecretAndDHPart.error == undefined){
      await mountEl.pushEventTo("1", "DHexchange", {publicKey: window.btoa(String.fromCharCode.apply(null, sharedSecretAndDHPart.publicKey))})
      return sharedSecretAndDHPart.key
    } else {
      await mountEl.pushEvent("error", sharedSecretAndDHPart)
      return undefined
    }
  } catch(e) {
    await mountEl.pushEvent("error", "Could not transfer key")
    return undefined
  }
}

const stringToArrayBuffer = (byteString) => {
  const byteArray = new Uint8Array(byteString.length);
  for (var i = 0; i < byteString.length; i++) {
    byteArray[i] = byteString.charCodeAt(i);
  }

  return byteArray;
}
// ...

Client Encryption

app.js

// ...

import JSZip from "../vendor/jszip";
import { encryptContentToBlob, generateSymmetricKey } from "./utils";

let hooks = {};
hooks.Folder = {
  async mounted() {
    const sharedKey = await generateSymmetricKey(this);
    this.el.addEventListener("input", async (e) => {
      e.preventDefault();
      this.pushEventTo("1", "loading")
      const folderName = e.target.files[0].webkitRelativePath.split("/")[0];
      const zip = new JSZip();
      Array.from(e.target.files).forEach((file) => {
        zip.file(file.webkitRelativePath || file.name, file, { binary: true });
      });
      const zipBlob = await zip.generateAsync({ type: "blob" });
      const encryptedBlob = await encryptContentToBlob(zipBlob, folderName, sharedKey);
      this.upload("file", [encryptedBlob]);
    });
  },
};
hooks.File = {
  async mounted() {
    const sharedKey = await generateSymmetricKey(this);
    this.el.addEventListener("input", async (e) => {
      e.preventDefault();
      await this.pushEventTo("1", "loading")
      const file = Array.from(e.target.files)[0];
      const encryptedBlob = await encryptContentToBlob(file, file.name, sharedKey);
      this.upload("file", [encryptedBlob]);
    });
  },
};
// ...

utils.js

import forge from "../vendor/forge";

/**
 * Converts a Blob and a filename to a File type
 * @param {Blob} blob 
 * @param {string} fileName 
 * @returns {File} of the Blob and filename
 */
function blobToFile(blob, fileName) {
  // Get the mime type of the blob
  var mimeType = blob.type;

  // Create a new file object using the blob and filename
  var file = new File([blob], fileName, { type: mimeType });

  return file;
}

/**
 * Encrypts a file using AES in CTR mode
 * SHA512 hashes the ciphertext 
 * @param {File} file 
 * @param {string} key 
 * @param {string} iv 
 * @returns the ciphertext with its hash
 */
const encryptAesCtrAndSha512 = async (file, key, iv) => {
  return new Promise(async (res, _rej) => {
    const cipher = forge.cipher.createCipher("AES-CTR", key);
    const md = forge.hmac.create();

    cipher.start({
      iv: iv,
    });
    md.start("sha512", key)
    const fileReader = file.stream().getReader();
    const encryptedData = [];
    let finished = false;
    let i = 0;
    while (!finished) {
      const { value, done } = await fileReader.read();
      finished = done;
      if (!finished) {
        if (i % 5 == 0) await refreshUi();
        cipher.update(forge.util.createBuffer(value));
        const encryptedBytes = cipher.output.getBytes();
        md.update(encryptedBytes);
        bytes = window.btoa(encryptedBytes);

        encryptedData.push(bytes);
      }
      i++;
    }
    cipher.finish();

    res({
      encrypted: encryptedData.join(","),
      hash: md.digest().bytes(),
    });
  });
};

/**
 * Encrypts a file using the sharedKey
 * @param {File} file
 * @param {string} sharedKey 
 * @returns Object containing the ciphertext with its IV and the hash of the ciphertext
 */
const encryptFile = async (file, sharedKey) => {
  return new Promise(async (res, rej) => {

    forge.options.usePureJavaScript = true;
    const iv = forge.random.getBytesSync(16);
    const { encrypted, hash } = await encryptAesCtrAndSha512(file, sharedKey, iv);
    res({
      cipherText: encrypted,
      hash: window.btoa(hash),
      iv: window.btoa(iv),
    });
  });
};

/**
 * Encrypts a file
 * Converts the filename to contain the hash and iv
 * @param {File} file 
 * @param {string} filename 
 * @param {Uint8Array} sharedKey 
 * @returns {File} the encrypted file 
 */
export const encryptContentToBlob = async (file, filename, sharedKey) => {
  const encryptedFile = await encryptFile(file, String.fromCharCode.apply(null, sharedKey));
  const encryptedBlob = new Blob([encryptedFile.cipherText], {
    type: file.type,
  });

  const combinedFilename =
    encryptedFile.hash + "," + encryptedFile.iv + "," + filename;

  if (combinedFilename.length - filename.length >= 100)
    return blobToFile(encryptedBlob, combinedFilename);
  else return blobToFile(file, filename);
};

// ...

Server Decryption and re-encryption

crypto_web.ex

defmodule FileStorage.Crypto.Web do
  @moduledoc """
  Cryptography methods used for encryption and signatures on files downloaded from web form
  """
  @key_encryption_padding_options [{:encrypt, false}, {:padding, :pkcs_padding}]
  @aes_cipher :aes_256_ctr
  @hash_cipher :sha512
  @pbkdf2_interations 310 # Unsure if 310_000 is required since this is not password. Therefore, no rainbow attack.
  @private_key_pem System.fetch_env!("PRIVATE_KEY_PEM")
                   |> to_string()
                   |> String.replace("\\n", "\n")


    # ...

  @spec mac_init(binary()) :: {:ok, reference()}
  def mac_init(key) when is_binary(key) do
    {:ok, :crypto.mac_init(:hmac, @hash_cipher, key)}
  end

  @spec mac_chunk(reference(), binary()) :: {:ok, reference()}
  def mac_chunk(state, chunk) when is_binary(chunk) and is_reference(state) do
    {:ok, :crypto.mac_update(state, chunk)}
  end

  @spec mac_digest(reference()) :: {:ok, binary()}
  def mac_digest(state) when is_reference(state) do
    {:ok, :crypto.mac_final(state)}
  end

  @spec mac_equals!(binary(), binary()) :: boolean()
  def mac_equals!(mac1, mac2) when is_binary(mac1) and is_binary(mac2) do
    :crypto.hash_equals(mac1, mac2)
  end

  @spec decrypt_init(binary(), binary()) ::
          {:ok, reference()}
  def decrypt_init(key, iv) when is_binary(key) and is_binary(iv) do
    {:ok, :crypto.crypto_init(@aes_cipher, key, iv, @key_encryption_padding_options)}
  end

  @spec decrypt_chunk(reference(), binary()) :: {:ok, binary()} | {:error, :aes_decrypt_error}
  def decrypt_chunk(decrypter_state, cipher_chunk)
      when is_reference(decrypter_state) and is_binary(cipher_chunk) do
    {:ok, :crypto.crypto_update(decrypter_state, cipher_chunk)}
  rescue
    _ -> {:error, :aes_decrypt_error}
  end

  @spec decrypt_finalize(reference()) :: {:ok, binary()} | {:error, :aes_decrypt_error}
  def decrypt_finalize(decrypter_state) when is_reference(decrypter_state) do
    {:ok, :crypto.crypto_final(decrypter_state)}
  rescue
    _ -> {:error, :aes_decrypt_error}
  end

  @spec decode_private_pem(binary()) :: {:ok, binary()}
  defp decode_private_pem(pem) when is_binary(pem) do
    {:ok,
     pem
     |> :public_key.pem_decode()
     |> List.first()
     |> :public_key.pem_entry_decode()}
  end
end

crypto_s3.ex

defmodule FileStorage.Crypto.S3 do
  @moduledoc """
  Cryptography methods used for encryption and signatures on files uploaded to S3
  """

  @key_encryption_padding_options {:padding, :pkcs_padding}
  @aes_mode :aes_256_ctr
  @hash_cipher :sha512

  @spec random_bytes(number()) :: binary()
  def random_bytes(length) do
    :crypto.strong_rand_bytes(length)
  end

  @spec encrypt_key!(binary(), binary()) :: binary()
  @spec encrypt_key!({binary(), binary()}, binary(), binary()) :: binary()
  def encrypt_key!(key, key_iv) when is_binary(key) and is_binary(key_iv) do
    :crypto.crypto_one_time(@aes_mode, System.fetch_env!("KEK"), key_iv, key, [
      {:encrypt, true},
      @key_encryption_padding_options
    ])
  end

  def encrypt_key!({key, key_iv}, encrypted_key, encrypted_key_iv)
      when is_binary(key) and is_binary(key_iv) and is_binary(encrypted_key) and
             is_binary(encrypted_key_iv) do
    decrypted_key = decrypt_key!(encrypted_key, encrypted_key_iv)

    :crypto.crypto_one_time(@aes_mode, decrypted_key, key_iv, key, [
      {:encrypt, true},
      @key_encryption_padding_options
    ])
  end

  @spec decrypt_key!(binary(), binary()) :: binary()
  @spec decrypt_key!({binary(), binary()}, binary(), binary()) :: binary()
  def decrypt_key!(key_cipher, key_iv) when is_binary(key_cipher) and is_binary(key_iv) do
    :crypto.crypto_one_time(@aes_mode, System.fetch_env!("KEK"), key_iv, key_cipher, [
      {:encrypt, false},
      @key_encryption_padding_options
    ])
  end

  def decrypt_key!({key_cipher, key_iv}, encrypted_key, encrypted_key_iv)
      when is_binary(key_cipher) and is_binary(key_iv) and is_binary(encrypted_key) and
             is_binary(encrypted_key_iv) do
    decrypted_key = decrypt_key!(encrypted_key, encrypted_key_iv)

    :crypto.crypto_one_time(@aes_mode, decrypted_key, key_iv, key_cipher, [
      {:encrypt, false},
      @key_encryption_padding_options
    ])
  end

  @spec encrypt_init(binary(), binary(), boolean()) ::
          {:ok, reference()}
  def encrypt_init(key, iv, is_encrypt)
      when is_binary(key) and is_binary(iv) and is_boolean(is_encrypt) do
    {:ok,
     :crypto.crypto_init(@aes_mode, key, iv, [
       {:encrypt, is_encrypt},
       @key_encryption_padding_options
     ])}
  end

  @spec encrypt_chunk(reference(), binary()) :: {:ok, binary()} | {:error, :aes_encrypt_error}
  def encrypt_chunk(encrypter_state, cipher_chunk)
      when is_reference(encrypter_state) and is_binary(cipher_chunk) do
    {:ok, :crypto.crypto_update(encrypter_state, cipher_chunk)}
  rescue
    _ -> {:error, :aes_encrypt_error}
  end

  @spec encrypt_finalize(reference()) :: {:ok, binary()} | {:error, :aes_encrypt_error}
  def encrypt_finalize(encrypter_state) when is_reference(encrypter_state) do
    {:ok, :crypto.crypto_final(encrypter_state)}
  rescue
    _ -> {:error, :aes_encrypt_error}
  end

  @spec mac_init(binary()) :: {:ok, reference()}
  def mac_init(key) when is_binary(key) do
    {:ok, :crypto.mac_init(:hmac, @hash_cipher, key)}
  rescue
    _ -> {:error, :mac_init_error}
  end

  @spec mac_chunk(reference(), binary()) :: {:ok, reference()}
  def mac_chunk(state, chunk) when is_binary(chunk) and is_reference(state) do
    {:ok, :crypto.mac_update(state, chunk)}
  rescue
    _ -> {:error, :mac_chunk_error}
  end

  @spec mac_digest(reference()) :: {:ok, binary()}
  def mac_digest(state) when is_reference(state) do
    {:ok, :crypto.mac_final(state)}
  rescue
    _ -> {:error, :mac_digest_error}
  end

  @spec mac!(binary(), binary()) :: binary()
  def mac!(key, data) when is_binary(key) and is_binary(data) do
    :crypto.mac(:hmac, @hash_cipher, key, data)
  end

  @spec mac_equals!(binary(), binary()) :: boolean()
  def mac_equals!(mac1, mac2) when is_binary(mac1) and is_binary(mac2) do
    :crypto.hash_equals(mac1, mac2)
  end
end

form_component.ex

allow_upload(:file,
       accept: :any,
       max_entries: 1,
       max_file_size: updated_user.max_size,
       # 65536 base64 encoded (plus 1 for comma)
       chunk_size: 87_385,
       writer: fn _name, %{client_name: name}, socket ->
         {mac, iv, _} = filename_to_meta(name)
         {Filewriter, mac: mac, key: socket.assigns.shared_key, iv: iv, parent: self()}
       end,
       auto_upload: true)

filewriter.ex

# https://github.com/phoenixframework/phoenix_live_view/blob/main/lib/phoenix_live_view/upload_tmp_file_writer.ex
defmodule FileStorageWeb.FileLive.Filewriter do
  @moduledoc """
  FileWriter implementation
  Decrypts the file received from the client
  Re-encrypts the file with a new symmetric key
  """
  @behaviour Phoenix.LiveView.UploadWriter

  alias FileStorage.Crypto.Web, as: Crypto
  alias FileStorage.Crypto.S3, as: CryptoS3

  @spec init(keyword()) ::
          {:error, atom}
          | {:no_tmp, [binary]}
          | {:ok,
             %{
               file: pid | {:file_descriptor, atom, any},
               mac: any,
               path: <<_::32, _::_*8>>,
               mac_state: reference(),
               state: reference
             }}
          | {:too_many_attempts, binary, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10}
  def init(opts) do
    key = Keyword.fetch!(opts, :key)
    parent = Keyword.fetch!(opts, :parent)

    encrypt_key = CryptoS3.random_bytes(32)
    encrypt_iv = CryptoS3.random_bytes(16)

    if key != nil do
      iv = Keyword.fetch!(opts, :iv)
      mac = Keyword.fetch!(opts, :mac)

      with {:ok, path} <- Plug.Upload.random_file("live_view_upload"),
           {:ok, file} <- File.open(path, [:binary, :write]),
           {:ok, decrypt_state} <- Crypto.decrypt_init(key, iv),
           {:ok, mac_state} <- Crypto.mac_init(key),
           {:ok, encrypt_state} <- CryptoS3.encrypt_init(encrypt_key, encrypt_iv, true),
           {:ok, encrypt_mac_state} <- CryptoS3.mac_init(encrypt_key) do
        {:ok,
         %{
           path: path,
           file: file,
           mac: mac,
           decrypt_state: decrypt_state,
           mac_state: mac_state,
           encrypt_state: encrypt_state,
           encrypt_mac_state: encrypt_mac_state,
           encrypt_key: encrypt_key,
           encrypt_iv: encrypt_iv,
           overflow: "",
           parent: parent
         }}
      end
    else
      # Defaults to no encryption, could be attack vector I guess ;(
      with {:ok, path} <- Plug.Upload.random_file("live_view_upload"),
           {:ok, file} <- File.open(path, [:binary, :write]),
           {:ok, encrypt_state} <- CryptoS3.encrypt_init(encrypt_key, encrypt_iv, true),
           {:ok, encrypt_mac_state} <- CryptoS3.mac_init(key) do
        {:ok,
         %{
           path: path,
           file: file,
           default: true,
           parent: parent,
           encrypt_state: encrypt_state,
           encrypt_mac_state: encrypt_mac_state,
           encrypt_key: encrypt_key,
           encrypt_iv: encrypt_iv
         }}
      end
    end
  end

  @spec meta(map()) :: map()
  def meta(state) do
    %{
      path: state.path,
      key: state.encrypt_key,
      iv: state.encrypt_iv,
      mac: state.encrypt_mac_state
    }
  end

  @spec write_chunk(binary(), map()) :: {:ok, map()} | {:error, any()}
  def write_chunk(data, %{default: true} = state) do
    case encrypt_and_write_to_file(state, {:ok, data}) do
      {:ok, new_encrypt_mac_state} ->
        {:ok, Map.put(state, :encrypt_mac_state, new_encrypt_mac_state)}

      {:error, reason} ->
        notify_error_parent({:error, reason}, state.parent)
        {:error, reason}
    end
  end

  def write_chunk(encrypted_data, state) do
    {decoded, overflow} = decode64!(state.overflow <> encrypted_data)
    decrypt_result = Crypto.decrypt_chunk(state.decrypt_state, decoded)

    {:ok, state} =
      if(byte_size(overflow) > 0, do: Map.put(state, :overflow, overflow), else: state)
      |> update_mac_state(decoded)

    case encrypt_and_write_to_file(state, decrypt_result) do
      {:ok, new_encrypt_mac_state} ->
        {:ok, Map.put(state, :encrypt_mac_state, new_encrypt_mac_state)}

      {:error, reason} ->
        notify_error_parent({:error, reason}, state.parent)
        {:error, reason}
    end
  end

  @spec close(map(), :done | :cancel | {:error, term}) :: {:ok, map()} | {:error, any()}
  def close(state, reason) do
    case reason do
      :cancel -> {:error, :canceled}
      :done -> close(state)
      {:error, term} -> {:error, term}
    end
  end

  defp close(%{default: true} = state) do
    case File.close(state.file) do
      :ok ->
        {:ok, state}

      {:error, reason} ->
        notify_error_parent({:error, reason}, state.parent)
        {:error, reason}
    end
  end

  defp close(state) do
    # Write to file overflow in state
    {:ok, state} =
      if byte_size(state.overflow) > 0, do: write_chunk("", state), else: {:ok, state}

    # Write to file decrypted bytes in decryptor state
    write_result = encrypt_and_write_to_file(state, Crypto.decrypt_finalize(state.decrypt_state))

    if elem(write_result, 0) == :ok and
         Crypto.mac_equals!(elem(Crypto.mac_digest(state.mac_state), 1), state.mac) do
      case File.close(state.file) do
        :ok ->
          {:ok,
           Map.put(
             state,
             :encrypt_mac_state,
             elem(write_result, 1) |> CryptoS3.mac_digest() |> elem(1)
           )}

        {:error, reason} ->
          notify_error_parent({:error, reason}, state.parent)
          {:error, reason}
      end
    else
      File.close(state.file)

      if elem(write_result, 0) == :ok do
        notify_error_parent({:error, "MACs did not match"}, state.parent)
        {:error, "MACs did not match"}
      else
        notify_error_parent(write_result, state.parent)
        write_result
      end
    end
  end

  @spec encrypt_and_write_to_file(map(), {:ok, binary()} | {:error, term()}) ::
          {:ok, reference()} | {:error, term()}
  defp encrypt_and_write_to_file(state, {:ok, data}) when is_binary(data) do
    case CryptoS3.encrypt_chunk(state.encrypt_state, data) do
      {:ok, encrypted_data} ->
        write_to_file(state.file, encrypted_data)
        |> mac_chunk(state, encrypted_data)

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp encrypt_and_write_to_file(_state, {:error, reason}) do
    {:error, reason}
  end

  @spec write_to_file(:file.io_device(), binary()) ::
          :ok | {:error, any()}
  defp write_to_file(file, data) when is_binary(data) do
    IO.binwrite(file, data)
  end

  @spec decode64!(binary()) :: {binary(), binary()}
  defp decode64!(data) do
    check_base64_data = String.trim_leading(data, ",") |> String.split(",")

    {chunk, overflow} =
      cond do
        # Unlikely this will ever happen
        length(check_base64_data) > 2 ->
          {first_chunk, rest_list} = List.pop_at(check_base64_data, 0)
          {first_chunk, Enum.join(rest_list, "")}

        length(check_base64_data) == 2 and byte_size(List.last(check_base64_data)) > 0 ->
          [head | tail] = check_base64_data
          {head, tail}

        true ->
          {List.first(check_base64_data), ""}
      end

    {Base.decode64!(chunk), overflow}
  end

  @spec update_mac_state(map(), binary()) :: {:ok, map()}
  defp update_mac_state(state, data) do
    {:ok, new_mac_state} = Crypto.mac_chunk(state.mac_state, data)
    {:ok, Map.put(state, :mac_state, new_mac_state)}
  end

  defp notify_error_parent({:error, reason}, parent),
    do: send(parent, {__MODULE__, {:error, reason}})

  @spec mac_chunk(:ok | {:error, term()}, map(), binary()) ::
          {:ok, reference()} | {:error, term()}
  defp mac_chunk(:ok, state, data) when is_binary(data) do
    CryptoS3.mac_chunk(state.encrypt_mac_state, data)
  end

  defp mac_chunk({:error, reason}, _state, _data) do
    {:error, reason}
  end
end