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.0.0.

Installation

Using igniter (Recommended)

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.0.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.0.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.0.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

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

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