Skip to content

Encrypts integer attributes using a Feistel cipher with a Postgres trigger.

License

Notifications You must be signed in to change notification settings

devall-org/ash_feistel_cipher

Repository files navigation

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:

  • Secure IDs without UUIDs: Hide sequential patterns while keeping efficient integer IDs with adjustable bit size per column
  • Deterministic encryption: Same insertion order always produces same encrypted ID (consistent seed data in dev/staging environments, unlike UUIDs/random integers)
  • Automatic encryption: Database triggers handle encryption transparently
  • Collision-free: One-to-one mapping

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

Installation

Using igniter (Recommended)

mix igniter.install ash_feistel_cipher

You can customize the installation with the following options:

  • --repo or -r: Specify an Ecto repo for FeistelCipher to use.
  • --functions-prefix or -p: Specify the PostgreSQL schema where the FeistelCipher functions will be created, defaults to public.
  • --functions-salt or -s: Specify the constant value used in the Feistel cipher algorithm. A random value is generated by default if not specified. Must be between 0 and 2^31-1.

⚠️ 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"}
      ]
    end
  2. Fetch the dependencies:

    mix deps.get
  3. Install FeistelCipher separately:

    mix igniter.install feistel_cipher --repo MyApp.Repo
  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 migration guide.

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:

  • from: Integer attribute to encrypt (can be any integer attribute)

Optional (⚠️ Treat changes as explicit migrations):

  • time_bits (default: 15): Time prefix bits for backup optimization. Set to 0 for no time prefix
  • time_bucket (default: 86400): Time bucket size in seconds
  • time_offset (default: 0): Time offset in seconds applied before bucket calculation
    • Formula: time_value = floor((epoch + time_offset) / time_bucket)
    • Sign convention: positive values move the boundary earlier in local time; negative values move it later
    • Example: time_bucket: 86400, time_offset: 21600 shifts daily boundary from 00:00 UTC to 18:00 UTC (03:00 KST)
  • With defaults (time_bits: 15, time_bucket: 86400, encrypt_time: false), the time prefix wraps after about 89 years 9 months
  • encrypt_time (default: false): Whether to encrypt the time prefix
  • data_bits (default: 38): Data encryption bit size (must be even)
  • key: Custom 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

Important:

  • allow_nil? on encrypted column must match from attribute's nullability
  • For primary keys, prefer encrypted_integer_primary_key for cleaner syntax

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

About

Encrypts integer attributes using a Feistel cipher with a Postgres trigger.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors 2

  •  
  •  

Languages