Rules for working with AshFeistelCipher

View Source

Overview

AshFeistelCipher encrypts sequential integer IDs using Feistel cipher to prevent business information leakage. It handles encryption automatically via PostgreSQL database triggers.

Database Support: PostgreSQL only (requires AshPostgres data layer)

Default profile: time_bits: 15, data_bits: 38

Installation

Recommended using igniter:

mix igniter.install ash_feistel_cipher

Key options:

  • --repo or -r: Specify Ecto repo
  • --functions-prefix or -p: PostgreSQL schema for functions (default: public)
  • --functions-salt or -s: Feistel cipher salt (default: randomly generated)

⚠️ Security Note: A unique salt is automatically generated per project. Never use the same salt across multiple production projects.

Basic Usage

Simple Primary Key Encryption

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 migration:

mix ash.codegen create_post

Multiple Encrypted Columns

Create multiple encrypted columns from the same source (each uses different key):

attributes do
  integer_sequence :seq
  encrypted_integer_primary_key :id, from: :seq
  encrypted_integer :referral_code, from: :seq, allow_nil?: false
end

Optional Integer Encryption

Nullable integer attributes can also be encrypted:

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

Declares an auto-incrementing bigserial column:

integer_sequence :seq

encrypted_integer

Encrypted integer column (automatically sets writable?: false, generated?: true):

encrypted_integer :id, from: :seq, primary_key?: true
encrypted_integer :referral_code, from: :seq, key: 12345
encrypted_integer :public_id, from: :seq, allow_nil?: true

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 primary keys (automatically sets primary_key?: true, allow_nil?: false, public?: true):

encrypted_integer_primary_key :id, from: :seq
encrypted_integer_primary_key :id, from: :seq, time_bits: 15, data_bits: 38

Configuration Options

Required

  • from: Integer attribute to encrypt (required)

Optional

⚠️ Treat changes as explicit migrations:

  • time_bits (default: 15): Time prefix bits. Set to 0 for v0.x-compatible behavior.
  • time_bucket (default: 86400): Time bucket size in seconds.
  • encrypt_time (default: false): Whether to encrypt the time prefix.
  • data_bits (default: 38): Data encryption bit size. Must be even.
  • key: Encryption key (auto-generated from table/column names if not provided)
  • rounds (default: 16): Number of Feistel rounds (higher = more secure but slower)
  • functions_prefix (default: "public"): PostgreSQL schema where feistel functions are installed
  • backfill? (default: true): Backfill existing rows when adding a new encrypted column. Set backfill?: false to leave existing rows at the internal sentinel value until you handle them explicitly.

Custom Bit Size Example

encrypted_integer_primary_key :id, 
  from: :seq,
  time_bits: 15,
  data_bits: 40  # ~1 trillion ID range

Important Rules

Nullability Consistency

The encrypted column's allow_nil? must match the source attribute:

# CORRECT
attribute :postal_code, :integer, allow_nil?: true
encrypted_integer :encrypted_postal_code, from: :postal_code, allow_nil?: true

# WRONG - will fail verification
attribute :postal_code, :integer, allow_nil?: true
encrypted_integer :encrypted_postal_code, from: :postal_code, allow_nil?: false

Source Attribute Must Exist

The attribute specified in from must be defined:

# WRONG - :seq not defined
attributes do
  encrypted_integer_primary_key :id, from: :seq  # Error!
end

# CORRECT
attributes do
  integer_sequence :seq
  encrypted_integer_primary_key :id, from: :seq
end

Migration Required For Parameter Changes

These options should be treated as immutable in-place once records exist:

  • time_bits
  • time_bucket
  • encrypt_time
  • data_bits
  • key
  • rounds

Changing them requires data migration.

How It Works

Database triggers handle encryption automatically:

post = MyApp.Post.create!(%{title: "Hello"})
# => %MyApp.Post{seq: 1, id: 3_141_592_653, ...}

post2 = MyApp.Post.create!(%{title: "World"})
# => %MyApp.Post{seq: 2, id: 2_718_281_828, ...}
  • Sequential seq → Non-sequential id via automatic encryption
  • Deterministic (same seq always produces same id)
  • Collision-free (one-to-one mapping)

Migration

mix ash.codegen automatically includes trigger creation code in migrations:

def up do
  create table(:posts) do
    add :seq, :bigserial, null: false
    add :id, :bigint, null: false, primary_key: true
  end

  execute(
    FeistelCipher.up_for_trigger("public", "posts", "seq", "id",
      time_bits: 15,
      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

Testing

Use standard Ash testing patterns:

test "encrypted IDs are generated" do
  post = MyApp.Domain.create_post!(%{title: "Test"})
  
  assert post.id != post.seq
  assert post.id > 0
  
  # Deterministic - same seq always produces same id
  post2 = MyApp.Domain.create_post!(%{title: "Test2"})
  assert post.id != post2.id
end

Common Pitfalls

Using UUID Instead

If you need UUIDs, use Ash's built-in uuid_primary_key:

# Use Feistel for integer IDs
encrypted_integer_primary_key :id, from: :seq

# Use UUID for random IDs
uuid_primary_key :id

Exposing Sequential IDs

Don't expose sequential IDs directly:

# BAD - exposes sequential pattern
%{id: post.seq}

# GOOD - use encrypted ID
%{id: post.id}

Changing Encryption Settings

Cannot change time_bits, time_bucket, encrypt_time, data_bits, key, or rounds after records are created. Requires data migration to change.

Upgrading to v1.1.0

  • ash_feistel_cipher 1.1.0 and this guide now target feistel_cipher 1.1.0.
  • Keep the default profile as time_bits: 15, data_bits: 38 for new resources.
  • Replace legacy bits: N with time_bits: 0, data_bits: N in resource DSL.
  • Run mix ash_feistel_cipher.upgrade to generate SQL function migration scaffolding.
  • Run mix ash.codegen --name upgrade_feistel_triggers_to_v1 and apply trigger updates.
  • For full upstream details, see feistel_cipher v1.1.0 UPGRADE.md.