Current section

Files

Jump to
feistel_cipher lib migration.ex
Raw

lib/migration.ex

defmodule FeistelCipher.Migration do
@moduledoc """
Migrations create functions FeistelCipher needs to function.
## Usage
To use migrations in your application you'll need to generate an `Ecto.Migration` that wraps
calls to `FeistelCipher.Migration`:
```bash
mix ecto.gen.migration add_feistel_cipher
```
Open the generated migration in your editor and call the `up` and `down` functions on
`FeistelCipher.Migration`:
```elixir
defmodule MyApp.Repo.Migrations.AddFeistelCipher do
use Ecto.Migration
def up, do: FeistelCipher.Migration.up()
def down, do: FeistelCipher.Migration.down()
end
```
This will run all of FeistelCipher's versioned migrations for your database.
Now, run the migration to create the table:
```bash
mix ecto.migrate
```
## Isolation with Prefixes
FeistelCipher supports namespacing through PostgreSQL schemas, also called "prefixes" in Ecto. With
prefixes your jobs table can reside outside of your primary schema (usually public) and you can
have multiple separate job tables.
To use a prefix you first have to specify it within your migration:
```elixir
defmodule MyApp.Repo.Migrations.AddPrefixedFeistelIdJobsTable do
use Ecto.Migration
def up, do: FeistelCipher.Migration.up(prefix: "private")
def down, do: FeistelCipher.Migration.down(prefix: "private")
end
```
In some cases, for example if your "private" schema already exists and your database user in
production doesn't have permissions to create a new schema, trying to create the schema from the
migration will result in an error. In such situations, it may be useful to inhibit the creation
of the "private" schema:
```elixir
defmodule MyApp.Repo.Migrations.AddPrefixedFeistelIdJobsTable do
use Ecto.Migration
def up, do: FeistelCipher.Migration.up(prefix: "private", create_schema: false)
def down, do: FeistelCipher.Migration.down(prefix: "private")
end
```
## Migrating Without Ecto
If your application uses something other than Ecto for migrations, be it an external system or
another ORM, it may be helpful to create plain SQL migrations for FeistelCipher database schema changes.
The simplest mechanism for obtaining the SQL changes is to create the migration locally and run
`mix ecto.migrate --log-migrations-sql`. That will log all of the generated SQL, which you can
then paste into your migration system of choice.
Alternatively, if you'd like a more automated approach, try using the [feistel_id_migations_sql][sql]
project to generate `up` and `down` SQL migrations for you.
[sql]: https://github.com/btwb/feistel_id_migrations_sql
"""
use Ecto.Migration
@doc """
Run the `up` changes.
## Example
Run migrations in an alternate prefix:
FeistelCipher.Migration.up(prefix: "payments")
"""
def up(opts \\ []) when is_list(opts) do
%{
create_schema: create_schema,
prefix: prefix,
quoted_prefix: quoted_prefix,
xor: xor,
mul: mul
} = FeistelCipher.with_defaults(opts)
if create_schema, do: execute("CREATE SCHEMA IF NOT EXISTS #{quoted_prefix}")
# Copied from https://wiki.postgresql.org/wiki/Pseudo_encrypt
# Algorithm reference from https://www.youtube.com/watch?v=FGhj3CGxl8I
# bigint is 64 bits, but excluding negative numbers, only 63 bits are usable.
# For operational convenience, it's limited to 62 bits.
# Multiplication and operation parameters are all limited to 31 bits.
# Since 31 bits (half of 62 bits) are multiplied by a 31-bit parameter,
# the calculation result is also within the 62-bit range, making it safe for bigint.
execute("""
CREATE FUNCTION #{prefix}.feistel(input bigint, bits int, key bigint) returns bigint AS $$
DECLARE
i int := 1;
a bigint array[5];
b bigint array[5];
half_bits int := bits / 2;
half_mask bigint := (1::bigint << half_bits) - 1;
mask bigint := (1::bigint << bits) - 1;
BEGIN
IF bits > 62 THEN
RAISE EXCEPTION 'feistel bits must be 62 or less: %', bits;
END IF;
IF bits % 2 = 1 THEN
RAISE EXCEPTION 'feistel bits must be an even number: %', bits;
END IF;
IF key >= (1::bigint << 31) THEN
RAISE EXCEPTION 'feistel key is larger than 31 bits: %', key;
END IF;
IF input > mask THEN
RAISE EXCEPTION 'feistel input is larger than % bits: %', bits, input;
END IF;
a[1] := (input >> half_bits) & half_mask;
b[1] := input & half_mask;
WHILE i < 4 LOOP
a[i + 1] := b[i];
b[i + 1] := a[i] # ((((b[i] # #{xor}) * #{mul}) # key) & half_mask);
i := i + 1;
END LOOP;
a[5] := b[4];
b[5] := a[4];
RETURN ((a[5] << half_bits) | b[5]);
END;
$$ LANGUAGE plpgsql strict immutable;
""")
execute("""
CREATE FUNCTION #{prefix}.handle_feistel_encryption() RETURNS trigger AS $$
DECLARE
bits int;
key bigint;
source_column text;
target_column text;
clear bigint;
encrypted bigint;
decrypted bigint;
new_target_value bigint;
old_target_value bigint;
BEGIN
bits := TG_ARGV[0]::int;
key := TG_ARGV[1]::bigint;
source_column := TG_ARGV[2];
target_column := TG_ARGV[3];
IF TG_OP = 'UPDATE' THEN
EXECUTE format('SELECT ($1).%I::bigint, ($2).%I::bigint', target_column, target_column)
INTO old_target_value, new_target_value
USING OLD, NEW;
IF old_target_value != new_target_value THEN
RAISE EXCEPTION '% cannot be modified on UPDATE. OLD.%: %, NEW.%: %', target_column, target_column, old_target_value, target_column, new_target_value;
END IF;
END IF;
EXECUTE format('SELECT ($1).%I::bigint', source_column)
INTO clear
USING NEW;
IF clear IS NULL THEN
encrypted := NULL;
ELSE
encrypted := #{prefix}.feistel(clear, bits, key);
decrypted := #{prefix}.feistel(encrypted, bits, key);
IF decrypted != clear THEN
RAISE EXCEPTION 'feistel function does not have an inverse. clear: %, encrypted: %, decrypted: %, bits: %, key: %',
clear, encrypted, decrypted, bits, key;
END IF;
END IF;
-- Dynamically set the value of the target column in the NEW record
NEW := jsonb_populate_record(NEW, jsonb_build_object(target_column, to_jsonb(encrypted)));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
""")
end
@doc """
Run the `down` changes.
## Example
Run migrations in an alternate prefix:
FeistelCipher.Migration.down(prefix: "payments")
"""
def down(opts \\ []) when is_list(opts) do
%{prefix: prefix} = FeistelCipher.with_defaults(opts)
execute("DROP FUNCTION #{prefix}.feistel(bigint, int, bigint)")
execute("DROP FUNCTION #{prefix}.handle_feistel_encryption()")
end
@doc """
Returns the SQL for creating a trigger for a table to encrypt a `source` field to a `target` field.
## Example
FeistelCipher.Migration.up_sql_for_table("posts", source: "seq", target: "id")
"""
def up_sql_for_table(table, opts \\ []) when is_list(opts) do
bits = opts |> Keyword.get(:bits, 62)
0 = rem(bits, 2)
source = opts |> Keyword.fetch!(:source)
target = opts |> Keyword.fetch!(:target)
"""
CREATE TRIGGER "#{FeistelCipher.trigger_name(table, source, target)}"
BEFORE INSERT OR UPDATE
ON "#{table}"
FOR EACH ROW
EXECUTE PROCEDURE handle_feistel_encryption(#{bits}, #{FeistelCipher.key_for_table(table)}, '#{source}', '#{target}');
"""
end
@doc """
Returns the SQL for dropping a trigger for a table to encrypt a `source` field to a `target` field.
## Example
FeistelCipher.Migration.down_sql_for_table("posts", source: "seq", target: "id")
"""
def down_sql_for_table(table, opts \\ []) when is_list(opts) do
source = opts |> Keyword.fetch!(:source)
target = opts |> Keyword.fetch!(:target)
"""
DROP TRIGGER "#{FeistelCipher.trigger_name(table, source, target)}" ON "#{table}";
"""
end
end