# 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](https://github.com/devall-org/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](https://github.com/devall-org/feistel_cipher) library documentation.
## Installation
### Using igniter (Recommended)
```bash
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`:
```elixir
def deps do
[
{:ash_feistel_cipher, "~> 0.13.4"}
]
end
```
2. Fetch the dependencies:
```bash
mix deps.get
```
3. Install FeistelCipher separately:
```bash
mix igniter.install feistel_cipher --repo MyApp.Repo
```
4. Add `:ash_feistel_cipher` to your formatter configuration in `.formatter.exs`:
```elixir
[
import_deps: [:ash_feistel_cipher]
]
```
## Usage
### Quick Start
Add `AshFeistelCipher` extension to your Ash resource and use the declarative DSL:
```elixir
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:
```bash
mix ash.codegen create_post
```
This creates a migration with database triggers that automatically encrypt `seq` into `id`.
**Generated Migration Example:**
```elixir
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",
bits: 52,
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:
```elixir
# 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 `bits`:**
```elixir
attributes do
integer_sequence :seq
encrypted_integer_primary_key :id,
from: :seq,
bits: 40 # ~1 trillion IDs (default: 52 = ~4.5 quadrillion)
end
```
**Multiple encrypted columns from same source:**
```elixir
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):**
```elixir
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
```elixir
integer_sequence :seq
```
**`encrypted_integer`**: Encrypted integer column
The base form for encrypted columns. Automatically sets `writable?: false`, `generated?: true`
```elixir
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.
```elixir
encrypted_integer_primary_key :id, from: :seq
encrypted_integer_primary_key :id, from: :seq, bits: 40
```
---
**Common Options for Encrypted Columns:**
Required:
- `from`: Integer attribute to encrypt (can be any integer attribute)
Optional (⚠️ **Cannot be changed after records are created**):
- `bits` (default: 52): Encryption bit size - determines ID range (40 bits = ~1T IDs, 52 bits = ~4.5Q IDs)
- `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 (set via `--functions-prefix` during installation)
**Important**:
- `allow_nil?` on encrypted column must match `from` attribute's nullability
- For primary keys, prefer `encrypted_integer_primary_key` for cleaner syntax
## License
MIT