AshFeistelCipher

Encrypted integer IDs for Ash resources using Feistel cipher

Database Support: PostgreSQL only (requires AshPostgres data layer and PostgreSQL database)

Overview

Sequential IDs (1, 2, 3...) leak business information. This library provides a declarative DSL to configure Feistel cipher encryption in your Ash resources, transforming sequential integers into non-sequential, unpredictable values automatically via database triggers.

Key Benefits:

Default profile: time_bits: 15, data_bits: 38

For detailed information about the Feistel cipher algorithm, how it works, security properties, and performance benchmarks, see the feistel_cipher library documentation.

This package currently depends on feistel_cipher 1.1.0.

Installation

mix igniter.install ash_feistel_cipher

You can customize the installation with the following options:

⚠️ Security Note: A cryptographically random salt is generated by default for each project. This ensures that encryption patterns cannot be analyzed across different projects. Never use the same salt across multiple production projects.

Manual Installation

If you need more control over the installation process, you can install manually:

  1. Add ash_feistel_cipher to your list of dependencies in mix.exs:

    def deps do
    [
    {:ash_feistel_cipher, "~> 1.1.1"}
    ]
    end
  2. Fetch the dependencies:

    mix deps.get
  3. Install FeistelCipher separately:

    mix igniter.install feistel_cipher --repo MyApp.Repo

    If you need an explicit dependency pin, use:

    {:feistel_cipher, "1.1.0"}
  4. Add :ash_feistel_cipher to your formatter configuration in .formatter.exs:

    [
    import_deps: [:ash_feistel_cipher]
    ]

Upgrading from v0.x

See UPGRADE.md for the project migration guide. If you need upstream details, refer to feistel_cipher v1.1.0 UPGRADE.md.

Usage

Quick Start

Add AshFeistelCipher extension to your Ash resource and use the declarative DSL:

defmodule MyApp.Post do
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
extensions: [AshFeistelCipher]
postgres do
table "posts"
repo MyApp.Repo
end
attributes do
integer_sequence :seq
encrypted_integer_primary_key :id, from: :seq
attribute :title, :string, allow_nil?: false
end
end

Generate the migration:

mix ash.codegen create_post

This creates a migration with database triggers that automatically encrypt seq into id.

Generated Migration Example:

defmodule MyApp.Repo.Migrations.CreatePost do
use Ecto.Migration
def up do
create table(:posts) do
add :seq, :bigserial, null: false
add :id, :bigint, null: false, primary_key: true
add :title, :string, null: false
end
# Automatically generates trigger for seq -> id encryption
execute(
FeistelCipher.up_for_trigger("public", "posts", "seq", "id",
time_bits: 15,
time_bucket: 86400,
encrypt_time: false,
data_bits: 38,
key: 1_984_253_769,
rounds: 16,
functions_prefix: "public"
)
)
end
def down do
execute(FeistelCipher.down_for_trigger("public", "posts", "seq", "id"))
drop table(:posts)
end
end

How it works: When you create a record, the database trigger automatically encrypts the sequential seq value:

# Create a post - seq and id are auto-generated
post = MyApp.Post.create!(%{title: "Hello World"})
# => %MyApp.Post{seq: 1, id: 3_141_592_653, title: "Hello World"}
# The encrypted id is deterministic and collision-free
post2 = MyApp.Post.create!(%{title: "Second Post"})
# => %MyApp.Post{seq: 2, id: 2_718_281_828, title: "Second Post"}
# You can query by the encrypted id
MyApp.Post.get!(3_141_592_653)
# => %MyApp.Post{seq: 1, id: 3_141_592_653, title: "Hello World"}

Advanced Examples

Custom ID range with data_bits:

attributes do
integer_sequence :seq
encrypted_integer_primary_key :id,
from: :seq,
data_bits: 32 # ~4 billion IDs (default: 38 = ~275 billion)
end

Disable time prefix (backward compatible with v0.x):

attributes do
integer_sequence :seq
encrypted_integer_primary_key :id, from: :seq, time_bits: 0, data_bits: 52
end

Multiple encrypted columns from same source:

attributes do
integer_sequence :seq
encrypted_integer_primary_key :id, from: :seq
encrypted_integer :referral_code, from: :seq, allow_nil?: false
attribute :title, :string, allow_nil?: false
end
# Each column uses a different encryption key, generating unique values:
# => %MyApp.Post{seq: 1, id: 3_141_592_653, referral_code: 8_237_401_928, title: "Hello"}

Using any integer attribute with from (e.g. optional postal code):

attributes do
attribute :postal_code, :integer, allow_nil?: true
encrypted_integer :encrypted_postal_code, from: :postal_code, allow_nil?: true
end

Backfill existing rows when adding a new encrypted column:

attributes do
integer_sequence :seq
encrypted_integer :public_id,
from: :seq,
data_bits: 32,
time_bits: 0,
allow_nil?: true
end

This generates a migration that:

DSL Reference

integer_sequence: Auto-incrementing bigserial column

integer_sequence :seq

encrypted_integer: Encrypted integer column

The base form for encrypted columns. Automatically sets writable?: false, generated?: true

encrypted_integer :id, from: :seq, primary_key?: true, allow_nil?: false, public?: true
encrypted_integer :referral_code, from: :seq

default: is not supported for encrypted_integer. Values are generated from from:, and encrypted_integer always uses an internal sentinel to avoid bigserial generation.

encrypted_integer_primary_key: Shorthand for encrypted primary keys

Convenience helper equivalent to encrypted_integer with primary_key?: true, allow_nil?: false, public?: true pre-set.

encrypted_integer_primary_key :id, from: :seq
encrypted_integer_primary_key :id, from: :seq, data_bits: 32

Common Options for Encrypted Columns:

Required:

Optional (⚠️ Treat changes as explicit migrations):

Important:

Why time_offset Is Needed

Without time_offset, daily time_bucket boundaries are anchored to UTC midnight. In local operations this can split one business day into two buckets at awkward local times (for example, evening in the Americas or early morning in Europe).

time_offset allows teams to keep the same bucket size (for example, one day) while moving the boundary to an operational cutover hour (for example, 03:00 local). This is especially useful when encrypt_time: true is enabled and continuity must be controlled by configuration, not by reading the encrypted prefix.

In this DSL, time_offset is added to epoch before bucketing. So time_offset: 21600 (not -21600) is the correct setting for a 03:00 KST daily boundary.

License

MIT