# Rules for working with AshFeistelCipher
## 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:
```bash
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
```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 migration:
```bash
mix ash.codegen create_post
```
### Multiple Encrypted Columns
Create multiple encrypted columns from the same source (each uses different key):
```elixir
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:
```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`
Declares an auto-incrementing bigserial column:
```elixir
integer_sequence :seq
```
### `encrypted_integer`
Encrypted integer column (automatically sets `writable?: false`, `generated?: true`):
```elixir
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`):
```elixir
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
```elixir
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:
```elixir
# 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:
```elixir
# 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:
```elixir
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:
```elixir
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:
```elixir
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`:
```elixir
# 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:
```elixir
# 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](https://github.com/devall-org/feistel_cipher/blob/v1.1.0/UPGRADE.md).