---
-- Common utilities for session library and storage backends
--
-- @module resty.session.utils


local require = require


local buffer = require "string.buffer"
local bit = require "bit"


local select = select
local floor = math.floor
local ceil = math.ceil
local byte = string.byte
local char = string.char
local band = bit.band
local bnot = bit.bnot
local bor = bit.bor
local fmt = string.format
local sub = string.sub


---
-- Creates a pre-sized table, just like the C API equivalent lua_createtable().
--
-- @function utils.table_new
-- @treturn table a pre-sized table
--
-- @usage
-- local tbl = require("resty.session.utils").table_new(100, 0)
local table_new do
  local ok
  ok, table_new = pcall(require, "table.new")
  if not ok then
    table_new = function() return {} end
  end
end


---
-- Returns `true` when the given Lua table contains neither non-nil array elements
-- nor non-nil key-value pairs, or false otherwise.
--
-- @function utils.table_is_empty
-- @treturn boolean `true` if table is empty, otherwise `false`
--
-- @usage
-- local empty = require("resty.session.utils").table_is_empty({})
local table_is_empty do
  local ok
  ok, table_is_empty = pcall(require, "table.isempty")
  if not ok then
    local assert = assert
    local type = type
    local next = next
    table_is_empty = function(t)
      local typ = type(t)
      assert(typ == "table", "bad argument #1 to 'isempty' (table expected, got " .. typ .. ")")
      return next(t) and true or false
    end
  end
end


local is_fips_mode do
  local IS_FIPS

  local function is_fips_mode_real()
    return IS_FIPS == true
  end

  ---
  -- Returns whether OpenSSL is in FIPS-mode.
  --
  -- @function utils.is_fips_mode
  -- @treturn boolean `true` if OpenSSL is in FIPS-mode, otherwise `false`
  --
  -- @usage
  -- local is_fips = require("resty.session.utils").is_fips_mode()
  is_fips_mode = function()
    local openssl = require("resty.openssl")
    if not openssl.get_fips_mode then
      IS_FIPS = false

    else
      local ok
      ok, IS_FIPS = pcall(openssl.get_fips_mode)
      if not ok then
        IS_FIPS = false
      end
    end

    is_fips_mode = is_fips_mode_real
    return is_fips_mode()
  end
end


local bpack, bunpack do
  local buf = buffer.new()

  ---
  -- Binary pack unsigned integer.
  --
  -- Returns binary packed version of an integer in little endian unsigned format.
  --
  -- Size can be:
  --
  -- * `1`, pack input as a little endian unsigned char (`<C`)
  -- * `2`, pack input as a little endian unsigned short (`<S`)
  -- * `3`, pack input as a little endian unsigned integer (truncated) (`<I`)
  -- * `4`, pack input as a little endian unsigned integer (`<I`)
  -- * `5`, pack input as a little endian unsigned long (truncated) (`<L`)
  -- * `6`, pack input as a little endian unsigned long (truncated) (`<L`)
  -- * `7`, pack input as a little endian unsigned long (truncated) (`<L`)
  -- * `8`, pack input as a little endian unsigned long (`<L`)
  --
  -- @function utils.bpack
  -- @tparam number size size of binary packed output
  -- @tparam number value value to binary pack
  -- @treturn string binary packed value
  --
  -- @usage
  -- local packed_128 = require("resty.session.utils").bpack(1, 128)
  -- local packed_now = require("resty.session.utils").bpack(8, ngx.time())
  bpack = function(size, value)
    buf:reset()
    for i = 1, size do
      buf:put(char(value % 256))
      if i == size then
        return buf:get()
      end
      value = floor(value / 256)
    end
  end

  ---
  -- Binary unpack unsigned integer.
  --
  -- Returns number from a little endian unsigned binary packed format.
  --
  -- Size can be:
  --
  -- * `1`, unpack input from little endian unsigned char (`<C`)
  -- * `2`, unpack input from little endian unsigned short (`<S`)
  -- * `3`, unpack input from little endian unsigned integer (truncated) (`<I`)
  -- * `4`, unpack input from little endian unsigned integer (`<I`)
  -- * `5`, unpack input from little endian unsigned integer (truncated) (`<L`)
  -- * `6`, unpack input from little endian unsigned integer (truncated) (`<L`)
  -- * `7`, unpack input from little endian unsigned integer (truncated) (`<L`)
  -- * `8`, unpack input from little endian unsigned long (`<L`)
  --
  -- @function utils.bunpack
  -- @tparam number size size of binary packed output
  -- @tparam number value value to binary pack
  -- @treturn number binary unpacked value
  --
  -- @usage
  -- local utils = require "resty.session.utils"
  -- local value = 128
  -- local packed_value = utils.bpack(1, value)
  -- local unpacked_value = utils.bunpack(1, packed_value)
  -- print(value == unpacked_value) -- true
  bunpack = function(size, value)
    buf:set(value)
    value = 0
    for i = 1, size do
      value = value + byte(buf:get(1)) * (2 ^ ((i - 1) * 8))
    end
    return value
  end
end


local trim do
  local SPACE_BYTE = byte(" ")
  local TAB_BYTE   = byte("\t")
  local CR_BYTE    = byte("\r")

  ---
  -- Trim whitespace from the start and from the end of string.
  --
  -- Characters that are trimmed:
  --
  -- * space `" "`
  -- * tab `"\t"`
  -- * carriage return `"\r"`
  -- * line feed `"\n"`
  -- * vertical tab `"\v"`
  -- * form feed `"\f"`
  --
  -- @function utils.trim
  -- @tparam string value string to trim
  -- @treturn string a whitespace trimmed string
  --
  -- @usage
  -- local trimmed = require("resty.session.utils").trim("  hello world  ")
  trim = function(value)
    if value == nil or value == "" then
      return ""
    end

    local s = 1 -- position of the leftmost non-whitespace char
    ::spos::
    local b = byte(value, s)
    if not b then -- reached the end of the all whitespace string
      return ""
    end
    if b == SPACE_BYTE or (b >= TAB_BYTE and b <= CR_BYTE) then
      s = s + 1
      goto spos
    end

    local e = -1 -- position of the rightmost non-whitespace char
    ::epos::
    b = byte(value, e)
    if b == SPACE_BYTE or (b >= TAB_BYTE and b <= CR_BYTE) then
      e = e - 1
      goto epos
    end

    if s ~= 1 or e ~= -1 then
      value = sub(value, s, e)
    end

    return value
  end
end


local encode_json, decode_json do
  local cjson

  ---
  -- JSON encode value.
  --
  -- @function utils.encode_json
  -- @tparam any value value to json encode
  -- @treturn string json encoded value
  --
  -- @usage
  -- local json = require("resty.session.utils").encode_json({ hello = "world" })
  encode_json = function(value)
    if not cjson then
      cjson = require("cjson.safe").new()
    end
    encode_json = cjson.encode
    return encode_json(value)
  end

  ---
  -- JSON decode value.
  --
  -- @function utils.decode_json
  -- @tparam string value string to json decode
  -- @treturn any json decoded value
  --
  -- @usage
  -- local tbl = require("resty.session.utils").decode_json('{ "hello": "world" }')
  decode_json = function(value)
    if not cjson then
      cjson = require("cjson.safe").new()
    end
    decode_json = cjson.decode
    return decode_json(value)
  end
end


local encode_base64url, decode_base64url, base64_size do
  local base64

  ---
  -- Base64 URL encode value.
  --
  -- @function utils.encode_base64url
  -- @tparam string value string to base64 url encode
  -- @treturn string base64 url encoded value
  --
  -- @usage
  -- local encoded = require("resty.session.utils").encode_base64url("test")
  encode_base64url = function(value)
    if not base64 then
      base64 = require "ngx.base64"
    end
    encode_base64url = base64.encode_base64url
    return encode_base64url(value)
  end

  ---
  -- Base64 URL decode value
  --
  -- @function utils.decode_base64url
  -- @tparam string value string to base64 url decode
  -- @treturn string base64 url decoded value
  --
  -- @usage
  -- local utils = require "resty.session.utils"
  -- local encoded = utils.encode_base64url("test")
  -- local decoded = utils.decode_base64url(encoded)
  decode_base64url = function(value)
    if not base64 then
      base64 = require("ngx.base64")
    end
    decode_base64url = base64.decode_base64url
    return decode_base64url(value)
  end

  ---
  -- Base64 size from original size (without padding).
  --
  -- @function utils.base64_size
  -- @tparam number size original size
  -- @treturn number base64 url encoded size without padding
  --
  -- @usage
  -- local test = "test"
  -- local b64len = require("resty.session.utils").base64_size(#test)
  base64_size = function(size)
    return ceil(4 * size / 3)
  end
end


local deflate, inflate do
  local DEFLATE_WINDOW_BITS = -15
  local DEFLATE_CHUNK_SIZE = 8192
  local DEFLATE_OPTIONS = {
    windowBits = DEFLATE_WINDOW_BITS,
  }

  local zlib
  local input_buffer = buffer.new()
  local output_buffer = buffer.new()

  local function prepare_buffers(input)
    input_buffer:set(input)
    output_buffer:reset()
  end

  local function read_input_buffer(size)
    local data = input_buffer:get(size)
    return data ~= "" and data or nil
  end

  local function write_output_buffer(data)
    return output_buffer:put(data)
  end

  local function gzip(inflate_or_deflate, input, chunk_size, window_bits_or_options)
    prepare_buffers(input)
    local ok, err = inflate_or_deflate(read_input_buffer, write_output_buffer,
                                       chunk_size, window_bits_or_options)
    if not ok then
      return nil, err
    end

    return output_buffer:get()
  end

  local function deflate_real(data)
    return gzip(zlib.deflateGzip, data, DEFLATE_CHUNK_SIZE, DEFLATE_OPTIONS)
  end

  local function inflate_real(data)
    return gzip(zlib.inflateGzip, data, DEFLATE_CHUNK_SIZE, DEFLATE_WINDOW_BITS)
  end


  ---
  -- Compress the data with deflate algorithm.
  --
  -- @function utils.deflate
  -- @tparam string data data to deflate
  -- @treturn string deflated data
  --
  -- @usage
  -- local test = "test"
  -- local deflated = require("resty.session.utils").deflate(("a"):rep(100))
  deflate = function(data)
    if not zlib then
      zlib = require("ffi-zlib")
    end
    deflate = deflate_real
    return deflate(data)
  end

  ---
  -- Decompress the data compressed with deflate algorithm.
  --
  -- @function utils.inflate
  -- @tparam string data data to inflate
  -- @treturn string inflated data
  --
  -- @usage
  -- local utils = require "resty.session.utils"
  -- local deflated = utils.deflate(("a"):rep(100))
  -- local inflated = utils.inflate(deflated)
  inflate = function(data)
    if not zlib then
      zlib = require("ffi-zlib")
    end
    inflate = inflate_real
    return inflate(data)
  end
end


local rand_bytes do
  local rand

  ---
  -- Generate crypto random bytes.
  --
  -- @function utils.rand_bytes
  -- @tparam number length how many bytes of random data to generate
  -- @treturn string|nil random bytes
  -- @treturn string|nil error message
  --
  -- @usage
  -- local bytes = require("resty.session.utils").rand_bytes(32)
  rand_bytes = function(length)
    if not rand then
      rand = require("resty.openssl.rand")
    end
    rand_bytes = rand.bytes
    return rand_bytes(length)
  end
end


local sha256 do
  local digest
  local sha256_digest

  local function sha256_real(value)
    local _, err, output
    if not sha256_digest then
      sha256_digest, err = digest.new("sha256")
      if err then
        return nil, err
      end
    end

    output, err = sha256_digest:final(value)
    if err then
      sha256_digest = nil
      return nil, err
    end

    _, err = sha256_digest:reset()
    if err then
      sha256_digest = nil
    end

    return output
  end

  ---
  -- Calculates SHA-256 hash of the value.
  --
  -- @function utils.sha256
  -- @tparam string value value from which to calculate hash
  -- @treturn string|nil sha-256 hash (32 bytes)
  -- @treturn string|nil error message
  --
  -- @usage
  -- local hash, err = require("resty.session.utils").sha256("hello world")
  sha256 = function(value)
    if not digest then
      digest = require("resty.openssl.digest")
    end
    sha256 = sha256_real
    return sha256(value)
  end
end


local derive_hkdf_sha256 do
  local kdf
  local hkdf_extract
  local hkdf_expand

  local EXTRACTED_KEYS = {}

  local HKDF_SHA256_EXTRACT_OPTS
  local HKDF_SHA256_EXPAND_OPTS

  local function derive_hkdf_sha256_real(ikm, nonce, usage, size)
    local key, err, _ = EXTRACTED_KEYS[ikm]
    if not key then
      if not hkdf_extract then
        hkdf_extract, err = kdf.new("HKDF")
        if not hkdf_extract then
          return nil, err
        end
      end

      HKDF_SHA256_EXTRACT_OPTS.key = ikm
      key, err = hkdf_extract:derive(32, HKDF_SHA256_EXTRACT_OPTS)
      HKDF_SHA256_EXTRACT_OPTS.key = ""
      if not key then
        hkdf_extract = nil
        return nil, err
      end
      EXTRACTED_KEYS[ikm] = key
    end

    if not hkdf_expand then
      hkdf_expand, err = kdf.new("HKDF")
      if not hkdf_expand then
        return nil, err
      end
    end

    HKDF_SHA256_EXPAND_OPTS.key = key
    HKDF_SHA256_EXPAND_OPTS.info = usage .. ":" .. nonce

    key, err = hkdf_expand:derive(size or 44, HKDF_SHA256_EXPAND_OPTS)
    if not key then
      hkdf_expand = nil
      return nil, err
    end

    _, err = hkdf_expand:reset()
    if err then
      hkdf_expand = nil
    end

    return key
  end

  ---
  -- Derive a new key using HKDF with SHA-256.
  --
  -- @function utils.derive_hkdf_sha256
  -- @tparam string ikm initial key material
  -- @tparam string nonce nonce
  -- @tparam string usage e.g. `"encryption"` or `"authentication"`
  -- @tparam number size how many bytes to return
  -- @treturn string|nil key material
  -- @treturn string|nil error message
  --
  -- @usage
  -- local utils = require "resty.session.utils"
  -- local ikm = utils.rand_bytes(32)
  -- local nonce = utils.rand_bytes(32)
  -- local key, err = utils.derive_hkdf_sha256(ikm, nonce, "encryption", 32)
  derive_hkdf_sha256 = function(ikm, nonce, usage, size)
    if not kdf then
      kdf = require("resty.openssl.kdf")
      HKDF_SHA256_EXTRACT_OPTS = {
        digest = "sha256",
        mode = kdf.HKDEF_MODE_EXTRACT_ONLY,
        key = "",
        salt = "",
        info = "",
      }
      HKDF_SHA256_EXPAND_OPTS = {
        digest = "sha256",
        mode = kdf.HKDEF_MODE_EXPAND_ONLY,
        key = "",
        salt = "",
        info = "",
      }
    end
    derive_hkdf_sha256 = derive_hkdf_sha256_real
    return derive_hkdf_sha256(ikm, nonce, usage, size)
  end
end


local derive_pbkdf2_hmac_sha256 do
  local kdf
  local pbkdf2

  local PBKDF2_SHA256_OPTS

  local function derive_pbkdf2_hmac_sha256_real(pass, salt, usage, size, iterations)
    local _, err, key
    if not pbkdf2 then
      pbkdf2, err = kdf.new("PBKDF2")
      if err then
        return nil, err
      end
    end

    PBKDF2_SHA256_OPTS.iter = iterations or 10000
    PBKDF2_SHA256_OPTS.salt = usage .. ":" .. salt
    PBKDF2_SHA256_OPTS.pass = pass

    key, err = pbkdf2:derive(size or 44, PBKDF2_SHA256_OPTS)
    if not key then
      pbkdf2 = nil
      return nil, err
    end

    _, err = pbkdf2:reset()
    if err then
      pbkdf2 = nil
    end

    return key
  end

  ---
  -- Derive a new key using PBKDF2 with SHA-256.
  --
  -- @function utils.derive_pbkdf2_hmac_sha256
  -- @tparam string pass password
  -- @tparam string salt salt
  -- @tparam string usage e.g. `"encryption"` or `"authentication"`
  -- @tparam number size how many bytes to return
  -- @tparam number iterations how many iterations to run, e.g. `10000`
  -- @treturn string|nil key material
  -- @treturn string|nil error message
  --
  -- @usage
  -- local utils = require "resty.session.utils"
  -- local pass = "my-super-secret-password"
  -- local salt = utils.rand_bytes(32)
  -- local key, err = utils.derive_pbkdf2_hmac_sha256(pass, salt, "encryption", 32, 10000)
  derive_pbkdf2_hmac_sha256 = function(pass, salt, usage, size, iterations)
    if not kdf then
      kdf = require("resty.openssl.kdf")
      PBKDF2_SHA256_OPTS = {
        digest = "sha256",
        iter = 10000,
        salt = "",
        pass = "",
        pkcs5 = 1, -- Disables the SP800-132 compliance checks on FIPS-mode
      }
    end
    derive_pbkdf2_hmac_sha256 = derive_pbkdf2_hmac_sha256_real
    return derive_pbkdf2_hmac_sha256(pass, salt, usage, size, iterations)
  end
end


---
-- Derive a new AES-256 GCM-mode key and initialization vector.
--
-- Safety can be:
-- * `nil` or `"None"`: key and iv will be derived using HKDF with SHA-256, except on FIPS-mode uses PBKDF2 with SHA-256 (single iteration)
-- * `Low`: key and iv will be derived using PBKDF2 with SHA-256 (1.000 iterations)
-- * `Medium`: key and iv will be derived using PBKDF2 with SHA-256 (10.000 iterations)
-- * `High`: key and iv will be derived using PBKDF2 with SHA-256 (100.000 iterations)
-- * `Very High`: key and iv will be derived using PBKDF2 with SHA-256 (1.000.000 iterations)
--
-- @function utils.derive_aes_gcm_256_key_and_iv
-- @tparam string ikm initial key material
-- @tparam string nonce nonce
-- @tparam[opt] string safety safety of key generation
-- @treturn string|nil key
-- @treturn string|nil error message
-- @treturn string|nil initialization vector
--
-- @usage
-- local utils = require "resty.session.utils"
-- local ikm = utils.rand_bytes(32)
-- local nonce = utils.rand_bytes(32)
-- local key, err, iv = utils.derive_aes_gcm_256_key_and_iv(ikm, nonce, "Medium")
local function derive_aes_gcm_256_key_and_iv(ikm, nonce, safety)
  local bytes, err
  if safety and safety ~= "None" then
    if safety == "Very High" then
      bytes, err = derive_pbkdf2_hmac_sha256(ikm, nonce, "encryption", 44, 1000000)
    elseif safety == "High" then
      bytes, err = derive_pbkdf2_hmac_sha256(ikm, nonce, "encryption", 44, 100000)
    elseif safety == "Low" then
      bytes, err = derive_pbkdf2_hmac_sha256(ikm, nonce, "encryption", 44, 1000)
    else
      bytes, err = derive_pbkdf2_hmac_sha256(ikm, nonce, "encryption", 44, 10000)
    end

  else
    if is_fips_mode() then
      bytes, err = derive_pbkdf2_hmac_sha256(ikm, nonce, "encryption", 44, 1)
    else
      bytes, err = derive_hkdf_sha256(ikm, nonce, "encryption", 44)
    end
  end

  if not bytes then
    return nil, err
  end

  local key = sub(bytes, 1, 32)  -- 32 bytes
  local iv  = sub(bytes, 33, 44) -- 12 bytes

  return key, nil, iv
end


---
-- Derive HMAC SHA-256 key for message authentication using HDKF with SHA-256,
-- except on FIPS-mode it uses PBKDF2 with SHA-256 (single iteration).
--
-- @function utils.derive_hmac_sha256_key
-- @tparam string ikm initial key material
-- @tparam string nonce nonce
-- @treturn string|nil key
-- @treturn string|nil error message
--
-- @usage
-- local utils = require "resty.session.utils"
-- local ikm = utils.rand_bytes(32)
-- local nonce = utils.rand_bytes(32)
-- local key, err = utils.derive_hmac_sha256_key(ikm, nonce)
local function derive_hmac_sha256_key(ikm, nonce)
  if is_fips_mode() then
    return derive_pbkdf2_hmac_sha256(ikm, nonce, "authentication", 32, 1)
  end

  return derive_hkdf_sha256(ikm, nonce, "authentication", 32)
end


local encrypt_aes_256_gcm, decrypt_aes_256_gcm do
  local cipher
  local cipher_encrypt
  local cipher_decrypt

  local AES_256_GCP_CIPHER = "aes-256-gcm"
  local AES_256_GCM_TAG_SIZE = 16

  local function encrypt_aes_256_gcm_real(key, iv, plaintext, aad)
    if not cipher_encrypt then
      cipher_encrypt = cipher.new(AES_256_GCP_CIPHER)
    end

    local ciphertext, err = cipher_encrypt:encrypt(key, iv, plaintext, false, aad)
    if not ciphertext then
      cipher_encrypt = nil
      return nil, err
    end

    local tag, err = cipher_encrypt:get_aead_tag(AES_256_GCM_TAG_SIZE)
    if not tag then
      cipher_encrypt = nil
      return nil, err
    end

    return ciphertext, nil, tag
  end

  local function decrypt_aes_256_gcm_real(key, iv, ciphertext, aad, tag)
    if not cipher_decrypt then
      cipher_decrypt = cipher.new(AES_256_GCP_CIPHER)
    end

    local plaintext, err = cipher_decrypt:decrypt(key, iv, ciphertext, false, aad, tag)
    if not plaintext then
      cipher_decrypt = nil
    end

    return plaintext, err
  end

  ---
  -- Encrypt plain text using AES-256 in GCM-mode.
  --
  -- @function utils.encrypt_aes_256_gcm
  -- @tparam string key encryption key
  -- @tparam string iv initialization vector
  -- @tparam string plaintext plain text
  -- @tparam string aad additional authenticated data
  -- @treturn string|nil ciphertext
  -- @treturn string|nil error message
  -- @treturn string|nil authentication tag
  --
  -- @usage
  -- local utils = require "resty.session.utils"
  -- local ikm = utils.rand_bytes(32)
  -- local nonce = utils.rand_bytes(32)
  -- local key, err, iv = utils.derive_aes_gcm_256_key_and_iv(ikm, nonce)
  -- local enc, err, tag = utils.encrypt_aes_256_gcm(key, iv, "hello", "john@doe.com")
  encrypt_aes_256_gcm = function(key, iv, plaintext, aad)
    if not cipher then
      cipher = require("resty.openssl.cipher")
    end
    encrypt_aes_256_gcm = encrypt_aes_256_gcm_real
    return encrypt_aes_256_gcm(key, iv, plaintext, aad)
  end

  ---
  -- Decrypt ciphertext using AES-256 in GCM-mode.
  --
  -- @function utils.decrypt_aes_256_gcm
  -- @tparam string key encryption key
  -- @tparam string iv initialization vector
  -- @tparam string ciphertext plain text
  -- @tparam string aad additional authenticated data
  -- @tparam string tag authentication tag
  -- @treturn string|nil plaintext
  -- @treturn string|nil error message
  --
  -- @usage
  -- local utils = require "resty.session.utils"
  -- local ikm = utils.rand_bytes(32)
  -- local nonce = utils.rand_bytes(32)
  -- local key, err, iv = utils.derive_aes_gcm_256_key_and_iv(ikm, nonce)
  -- local enc, err, tag = utils.encrypt_aes_256_gcm(key, iv, "hello", "john@doe.com")
  -- local out, err = utils.decrypt_aes_256_gcm(key, iv, ciphertext, "john@doe.com", tag)
  decrypt_aes_256_gcm = function(key, iv, ciphertext, aad, tag)
    if not cipher then
      cipher = require("resty.openssl.cipher")
    end
    decrypt_aes_256_gcm = decrypt_aes_256_gcm_real
    return decrypt_aes_256_gcm(key, iv, ciphertext, aad, tag)
  end
end


local hmac_sha256 do
  local hmac
  local HMAC_SHA256_DIGEST = "sha256"

  local function hmac_sha256_real(key, value)
    local mac, err = hmac.new(key, HMAC_SHA256_DIGEST)
    if not mac then
      return nil, err
    end

    local digest, err = mac:final(value)
    if not digest then
      return nil, err
    end

    return digest
  end

  ---
  -- Calculate message authentication code with HMAC with SHA-256.
  --
  -- @function utils.hmac_sha256
  -- @tparam string key key
  -- @tparam string value value
  -- @treturn string|nil message authentication code (32 bytes)
  -- @treturn string|nil error message
  --
  -- @usage
  -- local utils = require "resty.session.utils"
  -- local ikm = utils.rand_bytes(32)
  -- local nonce = utils.rand_bytes(32)
  -- local key, err = utils.derive_hmac_sha256_key(ikm, nonce)
  -- local mac, err = utils.hmac_sha256(key, "hello")
  hmac_sha256 = function(key, value)
    if not hmac then
      hmac = require("resty.openssl.hmac")
    end
    hmac_sha256 = hmac_sha256_real
    return hmac_sha256(key, value)
  end
end



local load_storage do
  local DSHM
  local FILE
  local MEMCACHED
  local MYSQL
  local POSTGRES
  local REDIS
  local REDIS_SENTINEL
  local REDIS_CLUSTER
  local SHM
  local CUSTOM = {}

  ---
  -- Loads session storage and creates a new instance using session configuration.
  --
  -- @function utils.load_storage
  -- @tparam string storage name of the storage to load
  -- @tparam[opt] table configuration session configuration
  -- @treturn table|nil instance of session storage
  -- @treturn string|nil error message
  --
  -- @usage
  -- local postgres = require("resty.session.utils").load_storage("postgres", {
  --   postgres = {
  --     host = "127.0.0.1",
  --   }
  -- })
  load_storage = function(storage, configuration)
    if storage == "cookie" then
      return nil

    elseif storage == "dshm" then
      if not DSHM then
        DSHM = require("resty.session.dshm")
      end
      return DSHM.new(configuration and configuration.dshm)

    elseif storage == "file" then
      if not FILE then
        FILE = require("resty.session.file")
      end
      return FILE.new(configuration and configuration.file)

    elseif storage == "memcached" or storage == "memcache" then
      if not MEMCACHED then
        MEMCACHED = require("resty.session.memcached")
      end
      return MEMCACHED.new(configuration and configuration.memcached or configuration.memcache)

    elseif storage == "mysql" or storage == "mariadb" then
      if not MYSQL then
        MYSQL = require("resty.session.mysql")
      end
      return MYSQL.new(configuration and configuration.mysql or configuration.mariadb)

    elseif storage == "postgres" or storage == "postgresql" then
      if not POSTGRES then
        POSTGRES = require("resty.session.postgres")
      end
      return POSTGRES.new(configuration and configuration.postgres or configuration.postgresql)

    elseif storage == "redis" then
      local cfg = configuration and configuration.redis
      if cfg then
        if cfg.nodes then
          if not REDIS_CLUSTER then
            REDIS_CLUSTER = require("resty.session.redis.cluster")
          end
          return REDIS_CLUSTER.new(cfg)

        elseif cfg.sentinels then
          if not REDIS_SENTINEL then
            REDIS_SENTINEL = require("resty.session.redis.sentinel")
          end
          return REDIS_SENTINEL.new(cfg)
        end
      end

      if not REDIS then
        REDIS = require("resty.session.redis")
      end
      return REDIS.new(cfg)

    elseif storage == "shm" then
      if not SHM then
        SHM = require("resty.session.shm")
      end

      return SHM.new(configuration and configuration.shm)

    else
      if not CUSTOM[storage] then
        CUSTOM[storage] = require(storage)
      end
      return CUSTOM[storage].new(configuration and configuration[storage])
    end
  end
end


---
-- Helper to format error messages.
--
-- @function utils.errmsg
-- @tparam[opt] string|nil err a possible error coming from underlying library
-- @tparam string|nil msg error message
-- @tparam any ... arguments for formatting the msg
-- @treturn string formatted error message
--
-- @usage
-- local utils = require "resty.session.utils"
-- local test = "aaaa"
-- local data, err = utils.deflate(test)
-- if not data then
--   print(utils.errmsg(err, "unable to deflate data '%s'", test)
-- end
local function errmsg(err, msg, ...)
  if not msg then
    if err then
      return err
    end

    return "unknown error"
  end

  if select("#", ...) > 0 then
    msg = fmt(msg, ...)
  end

  if err then
    return fmt("%s (%s)", msg, err)
  end

  return msg
end


---
-- Helper to create a delimited key.
--
-- @function utils.get_name
-- @tparam table storage a storage implementation
-- @tparam string name name
-- @tparam string key key
-- @tparam string subject subject
-- @treturn string formatted and delimited name
local function get_name(storage, name, key, subject)
  local prefix = storage.prefix
  local suffix = storage.suffix
  if prefix and suffix and subject then
    return fmt("%s:%s:%s:%s:%s", prefix, name, key, subject, suffix)
  elseif prefix and suffix then
    return fmt("%s:%s:%s:%s", prefix, name, key, suffix)
  elseif prefix and subject then
    return fmt("%s:%s:%s:%s", prefix, name, key, subject)
  elseif suffix and subject then
    return fmt("%s:%s:%s:%s", name, key, subject, suffix)
  elseif prefix then
    return fmt("%s:%s:%s", prefix, name, key)
  elseif suffix then
    return fmt("%s:%s:%s", name, key, suffix)
  elseif subject then
    return fmt("%s:%s:%s", name, key, subject)
  else
    return fmt("%s:%s", name, key)
  end
end


---
-- Helper to turn on a flag.
--
-- @function utils.set_flag
-- @tparam number flags flags on which the flag is applied
-- @tparam number flag flag that is applied
-- @treturn number flags with the flag applied
--
-- @usage
-- local flags = 0x0000
-- local FLAG_DOG = 0x001
-- flags = utils.set_flag(flags, FLAG_DOG)
local function set_flag(options, flag)
  return bor(options, flag)
end


---
-- Helper to turn off a flag.
--
-- @function utils.unset_flag
-- @tparam number flags flags on which the flag is removed
-- @tparam number flag flag that is removed
-- @treturn number flags with the flag removed
--
-- @usage
-- local options = 0x0000
-- local FLAG_DOG = 0x001
-- flags = utils.set_flag(options, FLAG_DOG)
-- flags = utils.unset_flag(options, FLAG_DOG)
local function unset_flag(flags, flag)
  return band(flags, bnot(flag))
end


---
-- Helper to check if flag is enabled.
--
-- @function utils.has_flag
-- @tparam number flags flags on which the flag is checked
-- @tparam number flag flag that is checked
-- @treturn boolean true if flag has is present, otherwise false
--
-- @usage
-- local flags = 0x0000
-- local FLAG_DOG = 0x001
-- local FLAG_BONE = 0x010
-- flags = utils.set_flag(flags, FLAG_DOG)
-- flags = utils.set_flag(flags, FLAG_BONE)
-- print(utils.has_flag(flags, FLAG_BONE)
local function has_flag(flags, flag)
  return band(flags, flag) ~= 0
end


---
-- Helper to get the value used to store metadata for a certain aud and sub
-- Empty exp means the session id has been invalidated
--
-- @function utils.meta_get_value
-- @tparam string key storage key
-- @tparam string exp expiration
-- @treturn string the value to store in the metadata collection
local function meta_get_value(key, exp)
  if not exp or exp == 0 then
    return fmt("%s;", key)
  end
  return fmt("%s:%s;", key, encode_base64url(bpack(5, exp)))
end


local meta_get_next do
  local KEY_OFFSET = 43
  local DEL_OFFSET = 1
  local EXP_OFFSET = 7

  local COLON = byte(":")

  ---
  -- Function to extract the next key and exp from a serialized
  -- metadata list, starting from index
  --
  -- @function utils.meta_get_next
  -- @tparam string val list of key:exp;
  -- @tparam number index start index
  -- @treturn key string session id
  -- @treturn err string error
  -- @treturn exp number expiration
  -- @treturn index number|nil index of the cursor
  meta_get_next = function(val, index)
    local key = sub(val, index, index + KEY_OFFSET - 1)
    index = index + KEY_OFFSET
    local del = byte(val, index + DEL_OFFSET - 1)
    index = index + DEL_OFFSET

    if del ~= COLON then
      return key, nil, nil, index
    end

    local exp = sub(val, index, index + EXP_OFFSET - 1)
    index = index + EXP_OFFSET + DEL_OFFSET
    local exp, err = decode_base64url(exp)
    if err then
      return nil, err
    end

    exp = bunpack(5, exp)
    return key, nil, exp, index
  end
end


---
-- Function to filter out the latest valid key:exp from a
-- serialized list, used to store session metadata
--
-- @function utils.meta_get_latest
-- @tparam string sessions list of key:exp;
-- @treturn table valid sessions and their exp
local function meta_get_latest(sessions, current_time)
  current_time = current_time

  local latest = {}
  local index = 1
  local length = #sessions
  while index < length do
    local key, err, exp
    key, err, exp, index = meta_get_next(sessions, index)
    if err then
      return nil, err
    end

    if exp and exp > current_time then
      latest[key] = exp
    else
      latest[key] = nil
    end
  end

  return latest
end


return {
  table_new = table_new,
  table_is_empty = table_is_empty,
  is_fips_mode = is_fips_mode,
  bpack = bpack,
  bunpack = bunpack,
  trim = trim,
  encode_json = encode_json,
  decode_json = decode_json,
  encode_base64url = encode_base64url,
  decode_base64url = decode_base64url,
  base64_size = base64_size,
  inflate = inflate,
  deflate = deflate,
  rand_bytes = rand_bytes,
  sha256 = sha256,
  derive_hkdf_sha256 = derive_hkdf_sha256,
  derive_pbkdf2_hmac_sha256 = derive_pbkdf2_hmac_sha256,
  derive_aes_gcm_256_key_and_iv = derive_aes_gcm_256_key_and_iv,
  derive_hmac_sha256_key = derive_hmac_sha256_key,
  encrypt_aes_256_gcm = encrypt_aes_256_gcm,
  decrypt_aes_256_gcm = decrypt_aes_256_gcm,
  hmac_sha256 = hmac_sha256,
  load_storage = load_storage,
  errmsg = errmsg,
  get_name = get_name,
  set_flag = set_flag,
  unset_flag = unset_flag,
  has_flag = has_flag,
  meta_get_value = meta_get_value,
  meta_get_next = meta_get_next,
  meta_get_latest = meta_get_latest,
}
