FeistelCipher (feistel_cipher v1.1.0)

View Source

Encrypted integer IDs using Feistel cipher.

Basic Usage

defmodule MyApp.Repo.Migrations.AddFeistelCipher do
  use Ecto.Migration

  def up, do: FeistelCipher.up_v1_functions()
  def down, do: FeistelCipher.down_v1_functions()
end

With Custom Prefix

def up, do: FeistelCipher.up_v1_functions(functions_prefix: "private")
def down, do: FeistelCipher.down_v1_functions(functions_prefix: "private")

See function documentation for detailed options and examples.

Summary

Functions

Returns SQL to backfill a v1 encrypted column for existing rows.

Returns SQL to drop a legacy trigger.

Same as down_for_legacy_trigger/4 but targets v1 trigger naming convention.

Drops legacy v0.x PostgreSQL functions (feistel and handle_feistel_encryption).

Drop FeistelCipher functions from the database.

Generates a deterministic encryption key based on table/column information.

Generates a cryptographically secure random salt for the Feistel cipher.

Returns SQL to create a trigger that encrypts a from column to a to column.

Creates legacy v0.x PostgreSQL functions (feistel and handle_feistel_encryption).

Create FeistelCipher functions in the database.

Functions

backfill_for_v1_column(prefix, table, from, to, opts \\ [])

@spec backfill_for_v1_column(
  String.t(),
  String.t(),
  String.t(),
  String.t(),
  keyword()
) :: String.t()

Returns SQL to backfill a v1 encrypted column for existing rows.

This is useful when adding a new encrypted column to a table that already has data. New rows will be handled by the trigger, and this statement fills only rows where the encrypted target column is currently NULL.

It uses the same encryption rules as up_for_v1_trigger/5, so you should pass the exact same options used by the trigger.

down_for_legacy_trigger(prefix, table, from, to)

@spec down_for_legacy_trigger(String.t(), String.t(), String.t(), String.t()) ::
  String.t()

Returns SQL to drop a legacy trigger.

⚠️ Warning

Dropping a trigger breaks encryption for the affected column pair. Only use this when intentionally removing or recreating the trigger (e.g., column rename).

If recreating the trigger, you MUST use the exact same encryption parameters:

  • time_bits, time_bucket, encrypt_time: Same time configuration
  • data_bits: Same data bit size
  • key: Same encryption key
  • rounds: Same number of rounds
  • functions_prefix: Same schema where cipher functions reside

For auto-generated keys, use generate_key/4 with the original column names.

Example

# Column rename (seq -> sequence, id -> external_id)
def change do
  execute FeistelCipher.down_for_legacy_trigger("public", "posts", "seq", "id")

  rename table(:posts), :seq, to: :sequence
  rename table(:posts), :id, to: :external_id

  old_key = FeistelCipher.generate_key("public", "posts", "seq", "id")

  execute FeistelCipher.up_for_legacy_trigger("public", "posts", "sequence", "external_id",
    time_bits: 15,
    time_bucket: 86400,
    data_bits: 38,
    key: old_key,
    rounds: 16,
    functions_prefix: "public"
  )
end

down_for_v1_trigger(prefix, table, from, to)

@spec down_for_v1_trigger(String.t(), String.t(), String.t(), String.t()) ::
  String.t()

Same as down_for_legacy_trigger/4 but targets v1 trigger naming convention.

down_legacy_functions(opts \\ [])

@spec down_legacy_functions(keyword()) :: :ok

Drops legacy v0.x PostgreSQL functions (feistel and handle_feistel_encryption).

Uses IF EXISTS for idempotency.

Options

  • :functions_prefix - Schema prefix (default: "public").

down_v1_functions(opts \\ [])

@spec down_v1_functions(keyword()) :: :ok

Drop FeistelCipher functions from the database.

Note: PostgreSQL will automatically prevent this operation if any triggers are still using these functions. Drop all triggers first using down_for_trigger/4.

Options

  • :functions_prefix - Schema prefix where functions are located (default: "public").

generate_key(prefix, table, from, to)

@spec generate_key(String.t(), String.t(), String.t(), String.t()) ::
  non_neg_integer()

Generates a deterministic encryption key based on table/column information.

Uses SHA-512 hash to derive a 31-bit key (valid range: 0 to 2^31-1). Same parameters always generate the same key, ensuring consistency across deployments.

This is useful when recreating triggers (e.g., column rename) to maintain the same encryption key.

Examples

# Get the key used by the original trigger
key = FeistelCipher.generate_key("public", "posts", "seq", "id")

# Use it when recreating with new column names
FeistelCipher.up_for_legacy_trigger("public", "posts", "sequence", "external_id", key: key)

generate_random_salt()

@spec generate_random_salt() :: non_neg_integer()

Generates a cryptographically secure random salt for the Feistel cipher.

Returns a random integer between 0 and 2^31-1.

Example

salt = FeistelCipher.generate_random_salt()
FeistelCipher.up_v1_functions(functions_salt: salt)

up_for_legacy_trigger(prefix, table, from, to, opts \\ [])

@spec up_for_legacy_trigger(String.t(), String.t(), String.t(), String.t(), keyword()) ::
  String.t()

Returns SQL to create a trigger that encrypts a from column to a to column.

The resulting ID structure is [time_bits | data_bits], where time_bits serve as a prefix to cluster rows created in the same time bucket on nearby PostgreSQL pages, optimizing incremental backup efficiency.

Options

  • :time_bits - Time prefix bits (default: 15). Set to 0 for no time prefix. Can be changed, but should be treated as an explicit migration because old/new IDs will use different time-prefix semantics.
  • :time_bucket - Time bucket size in seconds (default: 86400 = 1 day). Can be changed, but should be treated as an explicit migration because clustering behavior changes immediately.
  • :time_offset - Time offset in seconds applied before bucket calculation (default: 0). For example, 21600 shifts a daily boundary from 00:00 UTC to 18:00 UTC (03:00 KST). Can be changed, but should be treated as an explicit migration because clustering behavior changes immediately.
  • :encrypt_time - Whether to encrypt time_bits with feistel cipher (default: false). When true, time_bits must be even. Ignored by the trigger when time_bits is 0. Can be changed, but should be treated as an explicit migration because time-prefix interpretation changes.
  • :data_bits - Data cipher bits (default: 38, must be even). Should be treated as immutable in-place; changing it requires a planned migration.
  • :key - Encryption key (0 to 2^31-1). Auto-generated if not provided. Should be treated as immutable in-place; changing it requires a planned migration.
  • :rounds - Number of Feistel rounds (default: 16, min: 1, max: 32). Should be treated as immutable in-place; changing it requires a planned migration.
  • :functions_prefix - Schema where cipher functions are located (default: "public"). Can be changed, but should be treated as an explicit migration because triggers must call the correct schema.

Examples

FeistelCipher.up_for_legacy_trigger("public", "posts", "seq", "id")

FeistelCipher.up_for_legacy_trigger("public", "posts", "seq", "id",
  time_bits: 8, time_bucket: 86400, data_bits: 32)

FeistelCipher.up_for_legacy_trigger("public", "posts", "seq", "id",
  time_bits: 0)

up_for_v1_trigger(prefix, table, from, to, opts \\ [])

@spec up_for_v1_trigger(String.t(), String.t(), String.t(), String.t(), keyword()) ::
  String.t()

Same as up_for_legacy_trigger/5 but uses v1 trigger naming convention.

up_legacy_functions(opts \\ [])

@spec up_legacy_functions(keyword()) :: :ok

Creates legacy v0.x PostgreSQL functions (feistel and handle_feistel_encryption).

Use this in old migrations that previously called FeistelCipher.Migration.up/1 so that mix ecto.migrate works from scratch with feistel_cipher v1.0.

The created legacy functions are compatibility stubs that raise if called. They exist only so historical migrations can run.

Uses CREATE OR REPLACE for idempotency.

Options

  • :functions_prefix - Schema prefix (default: "public").
  • :functions_salt - Required for backward compatibility with old migrations (unused by stubs).

up_v1_functions(opts \\ [])

@spec up_v1_functions(keyword()) :: :ok

Create FeistelCipher functions in the database.

Options

  • :functions_prefix - Schema prefix for functions (default: "public"). Can be changed, but should be treated as an explicit migration because trigger SQL must reference the same function schema.
  • :functions_salt - Salt constant for cipher algorithm. A random value is generated by default if not specified. Must be 0 to 2^31-1. Can be changed, but should be treated as an explicit migration because cipher output compatibility changes.