README.md

# 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