Skip to main content

lib/ex_credstash.ex

# Copyright 2026 Relay, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

defmodule ExCredstash do
  @moduledoc """
  Elixir implementation of credstash - a utility for managing credentials
  in AWS using KMS and DynamoDB.

  ## Usage as a Library

      # Store a secret
      ExCredstash.put("db_password", "super_secret", region: "us-east-1")

      # Retrieve a secret
      {:ok, "super_secret"} = ExCredstash.get("db_password", region: "us-east-1")

      # List all secrets
      {:ok, secrets} = ExCredstash.list(region: "us-east-1")

      # Delete a secret
      {:ok, count} = ExCredstash.delete("db_password", region: "us-east-1")

  ## Configuration

  Options can be passed to each function or configured globally:

      config :ex_credstash,
        region: "us-east-1",
        table: "credential-store",
        kms_key: "alias/credstash"

  ## Encryption Scheme

  Credstash uses a two-key system:
  - KMS generates a 64-byte data key
  - First 32 bytes are used for AES-256-CTR encryption
  - Last 32 bytes are used for HMAC integrity verification

  This implementation is fully compatible with the Python credstash library.
  """

  alias ExCredstash.{Config, Crypto, Dynamo, KMS, Version}

  @doc """
  Create the DynamoDB table for storing credentials.

  ## Options

    * `:region` - AWS region (required unless configured)
    * `:table` - Table name (default: "credential-store")
    * `:tags` - Map of tags to apply to the table
    * `:access_key_id` - AWS access key ID (optional, falls back to `:aws_credentials`)
    * `:secret_access_key` - AWS secret access key (optional, falls back to `:aws_credentials`)
    * `:session_token` - AWS session token (optional)

  ## Returns

    * `{:ok, :created}` - Table was created
    * `{:ok, :exists}` - Table already exists
    * `{:error, reason}` - Error occurred

  ## Examples

      iex> ExCredstash.setup(region: "us-east-1")
      {:ok, :created}

      iex> ExCredstash.setup(region: "us-east-1", table: "my-secrets")
      {:ok, :created}

  """
  @spec setup(keyword()) :: {:ok, :created | :exists} | {:error, term()}
  def setup(opts \\ []) do
    Dynamo.create_table(
      [region: Config.region(opts), table: Config.table(opts), tags: opts[:tags]] ++ creds(opts)
    )
  end

  @doc """
  Store a secret in credstash.

  The secret is encrypted using a KMS data key and stored in DynamoDB.

  ## Options

    * `:region` - AWS region (required unless configured)
    * `:table` - Table name (default: "credential-store")
    * `:key_id` or `:kms_key` - KMS key ID (default: "alias/credstash")
    * `:version` - Explicit version integer (auto-incremented if not provided)
    * `:context` - KMS encryption context map
    * `:digest` - Hash algorithm atom (default: :sha256)
    * `:comment` - Optional comment to store with the secret
    * `:access_key_id` - AWS access key ID (optional, falls back to `:aws_credentials`)
    * `:secret_access_key` - AWS secret access key (optional, falls back to `:aws_credentials`)
    * `:session_token` - AWS session token (optional)

  ## Returns

    * `{:ok, version_string}` - The version that was stored (zero-padded)
    * `{:error, {:already_exists, version}}` - Version already exists
    * `{:error, reason}` - Other error

  ## Examples

      iex> ExCredstash.put("db_password", "super_secret", region: "us-east-1")
      {:ok, "0000000000000000001"}

      iex> ExCredstash.put("api_key", "key123", region: "us-east-1", version: 5)
      {:ok, "0000000000000000005"}

  """
  @spec put(String.t(), String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
  def put(name, secret, opts \\ []) do
    region = Config.region(opts)
    table = Config.table(opts)
    key_id = Config.kms_key(opts)
    context = Config.context(opts)
    digest = Config.digest(opts)
    comment = opts[:comment]
    credential_opts = creds(opts)

    # Step 1: Determine the version
    version_result =
      case opts[:version] do
        nil ->
          # Auto-increment: get highest version and increment
          case Dynamo.get_highest_version(
                 [region: region, table: table, name: name] ++ credential_opts
               ) do
            {:ok, current} -> {:ok, Version.next(current)}
            {:error, _} = error -> error
          end

        explicit_version when is_integer(explicit_version) ->
          {:ok, {explicit_version, Version.pad(explicit_version)}}
      end

    with {:ok, {_version_int, version_string}} <- version_result,
         # Step 2: Generate KMS data key (64 bytes: 32 for AES, 32 for HMAC)
         {:ok, %{plaintext: data_key, ciphertext_blob: encrypted_key}} <-
           KMS.generate_data_key(
             [region: region, key_id: key_id, context: context] ++ credential_opts
           ),
         # Step 3: Encrypt the secret with AES-CTR
         {ciphertext, hmac_value} <- Crypto.encrypt(secret, data_key, digest),
         # Step 4: Prepare data for storage
         key_b64 <- Base.encode64(encrypted_key),
         contents_b64 <- Base.encode64(ciphertext),
         hmac_hex <- Base.encode16(hmac_value, case: :lower),
         digest_string <- Crypto.digest_to_string(digest),
         # Step 5: Store in DynamoDB
         {:ok, :created} <-
           Dynamo.put_secret(
             [
               region: region,
               table: table,
               name: name,
               version: version_string,
               key: key_b64,
               contents: contents_b64,
               hmac: hmac_hex,
               digest: digest_string,
               comment: comment
             ] ++ credential_opts
           ) do
      {:ok, version_string}
    end
  end

  @doc """
  Store multiple secrets at once.

  Takes a map of name => value pairs.

  ## Options

  Same as `put/3`, including `:access_key_id`, `:secret_access_key`, and `:session_token`.

  ## Returns

    * `{:ok, %{name => version_string}}` - All secrets stored successfully
    * `{:error, %{name => reason}, %{name => version_string}}` - Some failed

  ## Examples

      iex> ExCredstash.put_all(%{"key1" => "val1", "key2" => "val2"}, region: "us-east-1")
      {:ok, %{"key1" => "0000000000000000001", "key2" => "0000000000000000001"}}

  """
  @spec put_all(map(), keyword()) :: {:ok, map()} | {:error, map(), map()}
  def put_all(secrets, opts \\ []) when is_map(secrets) do
    results =
      Enum.reduce(secrets, {%{}, %{}}, fn {name, value}, {successes, failures} ->
        case put(name, value, opts) do
          {:ok, version} ->
            {Map.put(successes, name, version), failures}

          {:error, reason} ->
            {successes, Map.put(failures, name, reason)}
        end
      end)

    case results do
      {successes, failures} when map_size(failures) == 0 ->
        {:ok, successes}

      {successes, failures} ->
        {:error, failures, successes}
    end
  end

  @doc """
  Retrieve a secret from credstash.

  ## Options

    * `:region` - AWS region (required unless configured)
    * `:table` - Table name (default: "credential-store")
    * `:version` - Specific version to retrieve (default: latest)
    * `:context` - KMS encryption context map (must match what was used for encryption)
    * `:access_key_id` - AWS access key ID (optional, falls back to `:aws_credentials`)
    * `:secret_access_key` - AWS secret access key (optional, falls back to `:aws_credentials`)
    * `:session_token` - AWS session token (optional)

  ## Returns

    * `{:ok, secret_string}` - The decrypted secret
    * `{:error, :not_found}` - Secret not found
    * `{:error, :integrity_error}` - HMAC verification failed
    * `{:error, reason}` - Other error

  ## Examples

      iex> ExCredstash.get("db_password", region: "us-east-1")
      {:ok, "super_secret"}

      iex> ExCredstash.get("db_password", region: "us-east-1", version: 1)
      {:ok, "old_secret"}

  """
  @spec get(String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
  def get(name, opts \\ []) do
    region = Config.region(opts)
    table = Config.table(opts)
    context = Config.context(opts)
    credential_opts = creds(opts)

    # Step 1: Fetch from DynamoDB
    fetch_result =
      case opts[:version] do
        nil ->
          # Get latest version
          Dynamo.get_latest_secret([region: region, table: table, name: name] ++ credential_opts)

        version when is_integer(version) ->
          # Get specific version
          Dynamo.get_secret(
            [region: region, table: table, name: name, version: Version.pad(version)] ++
              credential_opts
          )

        version when is_binary(version) ->
          # Already a string version
          padded =
            if Version.valid?(version) do
              version
            else
              Version.pad(String.to_integer(version))
            end

          Dynamo.get_secret(
            [region: region, table: table, name: name, version: padded] ++ credential_opts
          )
      end

    with {:ok, item} <- fetch_result do
      decrypt_item(item, region, context, credential_opts)
    end
  end

  @doc """
  Retrieve all secrets from credstash.

  ## Options

    * `:region` - AWS region (required unless configured)
    * `:table` - Table name (default: "credential-store")
    * `:context` - KMS encryption context map
    * `:version` - `:all` to get all versions, or `:latest` (default)
    * `:access_key_id` - AWS access key ID (optional, falls back to `:aws_credentials`)
    * `:secret_access_key` - AWS secret access key (optional, falls back to `:aws_credentials`)
    * `:session_token` - AWS session token (optional)

  ## Returns

    * `{:ok, %{name => secret_string}}` - Map of all secrets (latest version for each)
    * `{:error, reason}` - Error occurred

  ## Examples

      iex> ExCredstash.get_all(region: "us-east-1")
      {:ok, %{"db_password" => "secret1", "api_key" => "secret2"}}

  """
  @spec get_all(keyword()) :: {:ok, map()} | {:error, term()}
  def get_all(opts \\ []) do
    region = Config.region(opts)
    table = Config.table(opts)
    context = Config.context(opts)
    credential_opts = creds(opts)

    # First get list of all unique names
    with {:ok, names} <- Dynamo.list_names([region: region, table: table] ++ credential_opts) do
      # Fetch and decrypt each secret
      results =
        Enum.reduce_while(names, {:ok, %{}}, fn name, {:ok, acc} ->
          case get(name, [region: region, table: table, context: context] ++ credential_opts) do
            {:ok, secret} ->
              {:cont, {:ok, Map.put(acc, name, secret)}}

            {:error, _reason} ->
              # Skip secrets that fail to decrypt (e.g., wrong context)
              # This matches Python credstash behavior
              {:cont, {:ok, acc}}
          end
        end)

      results
    end
  end

  @doc """
  List all credentials in the table.

  Does NOT decrypt the secrets, only returns metadata.

  ## Options

    * `:region` - AWS region (required unless configured)
    * `:table` - Table name (default: "credential-store")
    * `:access_key_id` - AWS access key ID (optional, falls back to `:aws_credentials`)
    * `:secret_access_key` - AWS secret access key (optional, falls back to `:aws_credentials`)
    * `:session_token` - AWS session token (optional)

  ## Returns

    * `{:ok, [%{name: String.t(), version: String.t(), comment: String.t() | nil}]}`
    * `{:error, reason}`

  ## Examples

      iex> ExCredstash.list(region: "us-east-1")
      {:ok, [%{name: "db_password", version: "0000000000000000001", comment: nil}]}

  """
  @spec list(keyword()) :: {:ok, list(map())} | {:error, term()}
  def list(opts \\ []) do
    Dynamo.list_secrets([region: Config.region(opts), table: Config.table(opts)] ++ creds(opts))
  end

  @doc """
  List unique credential names.

  ## Options

    * `:region` - AWS region (required unless configured)
    * `:table` - Table name (default: "credential-store")
    * `:access_key_id` - AWS access key ID (optional, falls back to `:aws_credentials`)
    * `:secret_access_key` - AWS secret access key (optional, falls back to `:aws_credentials`)
    * `:session_token` - AWS session token (optional)

  ## Returns

    * `{:ok, [String.t()]}` - Sorted list of unique secret names
    * `{:error, reason}`

  ## Examples

      iex> ExCredstash.keys(region: "us-east-1")
      {:ok, ["api_key", "db_password"]}

  """
  @spec keys(keyword()) :: {:ok, list(String.t())} | {:error, term()}
  def keys(opts \\ []) do
    Dynamo.list_names([region: Config.region(opts), table: Config.table(opts)] ++ creds(opts))
  end

  @doc """
  Delete all versions of a secret.

  ## Options

    * `:region` - AWS region (required unless configured)
    * `:table` - Table name (default: "credential-store")
    * `:access_key_id` - AWS access key ID (optional, falls back to `:aws_credentials`)
    * `:secret_access_key` - AWS secret access key (optional, falls back to `:aws_credentials`)
    * `:session_token` - AWS session token (optional)

  ## Returns

    * `{:ok, deleted_count}` - Number of versions deleted
    * `{:error, reason}`

  ## Examples

      iex> ExCredstash.delete("old_secret", region: "us-east-1")
      {:ok, 3}

  """
  @spec delete(String.t(), keyword()) :: {:ok, non_neg_integer()} | {:error, term()}
  def delete(name, opts \\ []) do
    Dynamo.delete_secret(
      [region: Config.region(opts), table: Config.table(opts), name: name] ++ creds(opts)
    )
  end

  # Private Functions

  @doc false
  defp decrypt_item(item, region, context, credential_opts) do
    # Step 1: Decode stored values
    with {:ok, encrypted_key} <- Base.decode64(item.key),
         {:ok, ciphertext} <- Base.decode64(item.contents),
         {:ok, expected_hmac} <- decode_hmac(item.hmac),
         digest <- Crypto.digest_to_atom(item.digest || "SHA256"),
         # Step 2: Decrypt the KMS data key
         {:ok, data_key} <-
           KMS.decrypt(
             [region: region, ciphertext_blob: encrypted_key, context: context] ++ credential_opts
           ),
         # Step 3: Verify HMAC and decrypt
         {:ok, plaintext} <- Crypto.decrypt(ciphertext, expected_hmac, data_key, digest) do
      {:ok, plaintext}
    else
      :error ->
        {:error, :invalid_base64}

      {:error, _reason} = error ->
        error
    end
  end

  defp creds(opts), do: Keyword.take(opts, [:access_key_id, :secret_access_key, :session_token])

  defp decode_hmac(hmac_string) when is_binary(hmac_string) do
    # HMAC is stored as hex string
    case Base.decode16(hmac_string, case: :mixed) do
      {:ok, _hmac} = result -> result
      :error -> {:error, :invalid_hmac_format}
    end
  end
end