# 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
**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](https://github.com/devall-org/feistel_cipher) library documentation.
This package currently depends on `feistel_cipher 1.0.0`.
## 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, "~> 1.0.1"}
]
end
```
2. Fetch the dependencies:
```bash
mix deps.get
```
3. Install FeistelCipher separately:
```bash
mix igniter.install feistel_cipher --repo MyApp.Repo
```
If you need an explicit dependency pin, use:
```elixir
{:feistel_cipher, "1.0.0"}
```
4. Add `:ash_feistel_cipher` to your formatter configuration in `.formatter.exs`:
```elixir
[
import_deps: [:ash_feistel_cipher]
]
```
## Upgrading from v0.x
See [UPGRADE.md](UPGRADE.md) for the project migration guide.
If you need upstream details, refer to [feistel_cipher v1.0.0 UPGRADE.md](https://github.com/devall-org/feistel_cipher/blob/v1.0.0/UPGRADE.md).
## 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",
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:
```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 `data_bits`:**
```elixir
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):**
```elixir
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:**
```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, 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