Application-wide encryption without KMS storage

Copy Markdown View Source

Use this pattern when an application should encrypt and decrypt its own data at any time, without principals, sessions, bindings, KMS tables, or the KMS OTP application.

You still use KMS modules for secure primitives:

This is application-level symmetric encryption. It is simpler than full KMS authority mode.

When to use

Good fit:

  • one app owns both encrypt and decrypt;
  • data only needs protection at rest from database-only disclosure;
  • no per-user KMS authorization is needed;
  • no sessions, MFA, principals, or bindings are needed;
  • you want clean, reviewed crypto primitives instead of hand-rolling AES-GCM.

Examples:

  • encrypting API tokens stored in app tables;
  • encrypting webhook secrets;
  • encrypting integration credentials;
  • encrypting private notes where the app may decrypt at any time;
  • small Phoenix apps that do not need a dedicated KMS service.

Poor fit:

  • users should hold separate decrypt rights;
  • app operators must not be able to decrypt;
  • decryption should require MFA/session authorization;
  • you need remote KMS audit/authorization;
  • you need break-glass principal/KEK recovery.

Use multi-user encryption or a full KMS deployment for those.

Key model

Use two pieces:

  1. Local RMK key file: host-owned root key material.
  2. Application secret: high-entropy env/secret-manager value.

Generate one random application data key, wrap it with the local RMK, and store only the wrapped form in config or an application table.

At runtime:

  1. unwrap application data key with KMS.RMK.Local;
  2. derive a field encryption key from the application secret + unwrapped data key;
  3. encrypt/decrypt values with KMS.Crypto and AAD.

This does not start the KMS OTP application, a KMS repo, or any KMS worker.

One-time setup

Generate a wrapped application data key:

rmk_key_path = "priv/kms_rmk.key"
aad = "my-app:v1:application-data-key"

app_data_key = KMS.Crypto.generate_key(32)
{:ok, wrapped_app_data_key} = KMS.RMK.Local.encrypt(app_data_key, key_path: rmk_key_path, aad: aad)

Store wrapped_app_data_key in your app config, secret manager, or a protected table. Do not store app_data_key directly.

Set an application secret separately:

export MY_APP_ENCRYPTION_SECRET=<32-plus-random-bytes-base64url>

Runtime module

defmodule MyApp.Encryption do
  @rmk_key_path "priv/kms_rmk.key"
  @app_key_aad "my-app:v1:application-data-key"

  def encrypt(plaintext, aad) when is_binary(plaintext) and is_binary(aad) do
    plaintext
    |> KMS.Crypto.encrypt(field_key!(), aad)
    |> then(&{:ok, &1})
  rescue
    _error -> {:error, :encryption_failed}
  end

  def decrypt(ciphertext, aad) when is_binary(ciphertext) and is_binary(aad) do
    case KMS.Crypto.decrypt(ciphertext, field_key!(), aad) do
      plaintext when is_binary(plaintext) -> {:ok, plaintext}
      :error -> {:error, :decryption_failed}
    end
  rescue
    _error -> {:error, :decryption_failed}
  end

  defp field_key! do
    {:ok, app_data_key} =
      KMS.RMK.Local.decrypt(System.fetch_env!("MY_APP_WRAPPED_DATA_KEY"),
        key_path: @rmk_key_path,
        aad: @app_key_aad
      )

    KMS.Crypto.derive_key(
      System.fetch_env!("MY_APP_ENCRYPTION_SECRET"),
      app_data_key,
      length: 32,
      info: "my-app-field-encryption-v1"
    )
  end
end

Use it from a changeset or context:

aad = "document:#{document_id}/body"
{:ok, encrypted_body} = MyApp.Encryption.encrypt(body, aad)
{:ok, body} = MyApp.Encryption.decrypt(encrypted_body, aad)

AAD guidance

AAD should identify where ciphertext is valid:

"document:#{document_id}/body"
"integration:#{integration_id}/api-token"
"webhook-endpoint:#{endpoint_id}/secret"

AAD is not secret. It must be stable and exactly reproducible during decrypt.

What this protects

This pattern protects against database-only disclosure when the attacker does not also have:

  • the local RMK key file;
  • the wrapped application data key;
  • the application encryption secret.

It does not protect against a fully compromised running application process. That process can decrypt by design.

Why still use KMS primitives?

Even without the KMS OTP application, KMS gives you:

  • AES-GCM encryption/decryption with authenticated data;
  • base64 key generation with supported AES key sizes;
  • HKDF-style key derivation helper;
  • file-backed local RMK wrapping;
  • consistent failure behavior and examples shared with the full KMS path.

This is safer than copying crypto snippets from random sources.

Rotation

To rotate the application secret:

  1. decrypt old ciphertext with old secret;
  2. re-encrypt with new secret;
  3. deploy new secret after re-encryption completes.

To rotate the local RMK key:

  1. decrypt wrapped_app_data_key with old RMK;
  2. write/choose new RMK key file;
  3. re-wrap the same app data key with new RMK;
  4. deploy new wrapped key + RMK path together.