Current section

31 Versions

Jump to

Compare versions

4 files changed
+153 additions
-59 deletions
  @@ -44,7 +44,7 @@ mix igniter.install feistel_cipher
44 44 ```elixir
45 45 # mix.exs
46 46 def deps do
47 - [{:feistel_cipher, "~> 1.0"}]
47 + [{:feistel_cipher, "~> 1.1"}]
48 48 end
49 49 ```
50 50
  @@ -129,6 +129,33 @@ Now when you insert a record, `seq` auto-increments and the trigger automaticall
129 129
130 130 **Security Note**: Keep `seq` internal. Only expose `id` in APIs to prevent enumeration attacks.
131 131
132 + ## Backfilling Existing Rows
133 +
134 + When you add a new encrypted column to a table that already has data, use
135 + `backfill_for_v1_column/5` to fill rows that were inserted before the trigger
136 + existed.
137 +
138 + ```elixir
139 + def up do
140 + alter table(:posts) do
141 + add :public_id, :bigint, default: -1
142 + end
143 +
144 + execute FeistelCipher.up_for_v1_trigger("public", "posts", "seq", "public_id",
145 + time_bits: 0,
146 + data_bits: 32
147 + )
148 +
149 + execute FeistelCipher.backfill_for_v1_column("public", "posts", "seq", "public_id",
150 + time_bits: 0,
151 + data_bits: 32
152 + )
153 + end
154 + ```
155 +
156 + Backfill uses an internal sentinel value of `-1`, which is safe because
157 + FeistelCipher only emits non-negative integers.
158 +
132 159 ## ID Structure
133 160
134 161 The generated ID has the structure `[time_bits | data_bits]`:
  @@ -1,16 +1,16 @@
1 1 {<<"links">>,
2 2 [{<<"GitHub">>,<<"https://github.com/devall-org/feistel_cipher">>}]}.
3 3 {<<"name">>,<<"feistel_cipher">>}.
4 - {<<"version">>,<<"1.0.0">>}.
4 + {<<"version">>,<<"1.1.0">>}.
5 5 {<<"description">>,<<"Encrypted integer IDs using Feistel cipher">>}.
6 6 {<<"elixir">>,<<"~> 1.17">>}.
7 + {<<"app">>,<<"feistel_cipher">>}.
8 + {<<"licenses">>,[<<"MIT">>]}.
7 9 {<<"files">>,
8 10 [<<"lib">>,<<"lib/upgrade.ex">>,<<"lib/feistel_cipher.ex">>,
9 11 <<"lib/install.ex">>,<<"mix.exs">>,<<"README.md">>,<<"UPGRADE.md">>,
10 12 <<"LICENSE">>,<<"assets">>,<<"assets/feistel-diagram.png">>,
11 13 <<"assets/feistel-diagram.dot">>]}.
12 - {<<"app">>,<<"feistel_cipher">>}.
13 - {<<"licenses">>,[<<"MIT">>]}.
14 14 {<<"requirements">>,
15 15 [[{<<"name">>,<<"igniter">>},
16 16 {<<"app">>,<<"igniter">>},
  @@ -25,6 +25,8 @@ defmodule FeistelCipher do
25 25
26 26 use Ecto.Migration
27 27
28 + @backfill_sentinel -1
29 +
28 30 @doc """
29 31 Generates a cryptographically secure random salt for the Feistel cipher.
30 32
  @@ -357,66 +359,40 @@ defmodule FeistelCipher do
357 359 end
358 360
359 361 defp do_up_for_trigger(prefix, table, from, to, opts, name_fn) do
360 - time_bits = Keyword.get(opts, :time_bits, 15)
361 - encrypt_time = Keyword.get(opts, :encrypt_time, false)
362 - use_time_prefix = time_bits > 0
363 -
364 - unless time_bits >= 0 do
365 - raise ArgumentError, "time_bits must be non-negative, got: #{time_bits}"
366 - end
367 -
368 - if encrypt_time and rem(time_bits, 2) != 0 do
369 - raise ArgumentError,
370 - "time_bits must be an even number when encrypt_time is true, got: #{time_bits}"
371 - end
372 -
373 - time_bucket = Keyword.get(opts, :time_bucket, 86400)
374 - time_offset = Keyword.get(opts, :time_offset, 0)
375 -
376 - if use_time_prefix do
377 - unless time_bucket > 0 do
378 - raise ArgumentError, "time_bucket must be positive, got: #{time_bucket}"
379 - end
380 -
381 - unless is_integer(time_offset) do
382 - raise ArgumentError, "time_offset must be an integer, got: #{inspect(time_offset)}"
383 - end
384 - end
385 -
386 - data_bits = Keyword.get(opts, :data_bits, 38)
387 -
388 - unless rem(data_bits, 2) == 0 do
389 - raise ArgumentError, "data_bits must be an even number, got: #{data_bits}"
390 - end
391 -
392 - unless data_bits >= 0 do
393 - raise ArgumentError, "data_bits must be non-negative, got: #{data_bits}"
394 - end
395 -
396 - max_total_bits = if encrypt_time, do: 62, else: 63
397 -
398 - unless time_bits + data_bits <= max_total_bits do
399 - raise ArgumentError,
400 - "time_bits + data_bits must be <= #{max_total_bits} when encrypt_time is #{encrypt_time}, got: #{time_bits} + #{data_bits} = #{time_bits + data_bits}"
401 - end
402 -
403 - rounds = Keyword.get(opts, :rounds, 16)
404 -
405 - unless rounds >= 1 and rounds <= 32 do
406 - raise ArgumentError, "rounds must be between 1 and 32, got: #{rounds}"
407 - end
408 -
409 - key = Keyword.get(opts, :key) || generate_key(prefix, table, from, to)
410 - validate_key!(key, "key")
411 -
412 - functions_prefix = Keyword.get(opts, :functions_prefix, "public")
362 + resolved_opts = resolve_cipher_options(prefix, table, from, to, opts)
413 363
414 364 """
415 365 CREATE TRIGGER #{name_fn.(table, from, to)}
416 366 BEFORE INSERT OR UPDATE
417 367 ON #{prefix}.#{table}
418 368 FOR EACH ROW
419 - EXECUTE PROCEDURE #{functions_prefix}.feistel_trigger_v1('#{from}', '#{to}', #{time_bits}, #{time_bucket}, #{time_offset}, #{encrypt_time}, #{data_bits}, #{key}, #{rounds});
369 + EXECUTE PROCEDURE #{resolved_opts.functions_prefix}.feistel_trigger_v1('#{from}', '#{to}', #{resolved_opts.time_bits}, #{resolved_opts.time_bucket}, #{resolved_opts.time_offset}, #{resolved_opts.encrypt_time}, #{resolved_opts.data_bits}, #{resolved_opts.key}, #{resolved_opts.rounds});
370 + """
371 + end
372 +
373 + @doc """
374 + Returns SQL to backfill a v1 encrypted column for existing rows.
375 +
376 + This is useful when adding a new encrypted column to a table that already has data.
377 + New rows will be handled by the trigger, and this statement fills only rows where
378 + the encrypted target column is currently `NULL`.
379 +
380 + It uses the same encryption rules as `up_for_v1_trigger/5`, so you should pass the
381 + exact same options used by the trigger.
382 + """
383 + @spec backfill_for_v1_column(String.t(), String.t(), String.t(), String.t(), keyword()) ::
384 + String.t()
385 + def backfill_for_v1_column(prefix, table, from, to, opts \\ []) when is_list(opts) do
386 + resolved_opts = resolve_cipher_options(prefix, table, from, to, opts)
387 +
388 + """
389 + UPDATE #{prefix}.#{table}
390 + SET #{to} =
391 + CASE
392 + WHEN #{from} IS NULL THEN NULL
393 + ELSE #{encrypted_value_sql(from, resolved_opts)}
394 + END
395 + WHERE #{backfill_condition_sql(to)};
420 396 """
421 397 end
422 398
  @@ -503,6 +479,97 @@ defmodule FeistelCipher do
503 479 "#{table}_encrypt_#{from}_to_#{to}_trigger"
504 480 end
505 481
482 + defp encrypted_value_sql(from, resolved_opts) do
483 + data_component_sql =
484 + "#{resolved_opts.functions_prefix}.feistel_cipher_v1(#{from}, #{resolved_opts.data_bits}, #{resolved_opts.key}, #{resolved_opts.rounds})"
485 +
486 + "(#{time_component_sql(resolved_opts)} << #{resolved_opts.data_bits}) | #{data_component_sql}"
487 + end
488 +
489 + defp time_component_sql(%{time_bits: 0}) do
490 + "0"
491 + end
492 +
493 + defp time_component_sql(resolved_opts) do
494 + time_value_sql =
495 + "(floor((extract(epoch from now()) + #{resolved_opts.time_offset}::double precision) / #{resolved_opts.time_bucket}::double precision)::bigint & ((1::bigint << #{resolved_opts.time_bits}) - 1))"
496 +
497 + if resolved_opts.encrypt_time do
498 + "#{resolved_opts.functions_prefix}.feistel_cipher_v1(#{time_value_sql}, #{resolved_opts.time_bits}, #{resolved_opts.key}, #{resolved_opts.rounds})"
499 + else
500 + time_value_sql
501 + end
502 + end
503 +
504 + defp backfill_condition_sql(to) do
505 + "#{to} IS NULL OR #{to} = #{@backfill_sentinel}"
506 + end
507 +
508 + defp resolve_cipher_options(prefix, table, from, to, opts) do
509 + time_bits = Keyword.get(opts, :time_bits, 15)
510 + encrypt_time = Keyword.get(opts, :encrypt_time, false)
511 + use_time_prefix = time_bits > 0
512 +
513 + unless time_bits >= 0 do
514 + raise ArgumentError, "time_bits must be non-negative, got: #{time_bits}"
515 + end
516 +
517 + if encrypt_time and rem(time_bits, 2) != 0 do
518 + raise ArgumentError,
519 + "time_bits must be an even number when encrypt_time is true, got: #{time_bits}"
520 + end
521 +
522 + time_bucket = Keyword.get(opts, :time_bucket, 86400)
523 + time_offset = Keyword.get(opts, :time_offset, 0)
524 +
525 + if use_time_prefix do
526 + unless time_bucket > 0 do
527 + raise ArgumentError, "time_bucket must be positive, got: #{time_bucket}"
528 + end
529 +
530 + unless is_integer(time_offset) do
531 + raise ArgumentError, "time_offset must be an integer, got: #{inspect(time_offset)}"
532 + end
533 + end
534 +
535 + data_bits = Keyword.get(opts, :data_bits, 38)
536 +
537 + unless rem(data_bits, 2) == 0 do
538 + raise ArgumentError, "data_bits must be an even number, got: #{data_bits}"
539 + end
540 +
541 + unless data_bits >= 0 do
542 + raise ArgumentError, "data_bits must be non-negative, got: #{data_bits}"
543 + end
544 +
545 + max_total_bits = if encrypt_time, do: 62, else: 63
546 +
547 + unless time_bits + data_bits <= max_total_bits do
548 + raise ArgumentError,
549 + "time_bits + data_bits must be <= #{max_total_bits} when encrypt_time is #{encrypt_time}, got: #{time_bits} + #{data_bits} = #{time_bits + data_bits}"
550 + end
551 +
552 + rounds = Keyword.get(opts, :rounds, 16)
553 +
554 + unless rounds >= 1 and rounds <= 32 do
555 + raise ArgumentError, "rounds must be between 1 and 32, got: #{rounds}"
556 + end
557 +
558 + key = Keyword.get(opts, :key) || generate_key(prefix, table, from, to)
559 + validate_key!(key, "key")
560 +
561 + %{
562 + time_bits: time_bits,
563 + time_bucket: time_bucket,
564 + time_offset: time_offset,
565 + encrypt_time: encrypt_time,
566 + data_bits: data_bits,
567 + key: key,
568 + rounds: rounds,
569 + functions_prefix: Keyword.get(opts, :functions_prefix, "public")
570 + }
571 + end
572 +
506 573 defp validate_key!(key, name) do
507 574 max_key = Bitwise.bsl(1, 31) - 1
  @@ -4,7 +4,7 @@ defmodule FeistelCipher.MixProject do
4 4 def project do
5 5 [
6 6 app: :feistel_cipher,
7 - version: "1.0.0",
7 + version: "1.1.0",
8 8 elixir: "~> 1.17",
9 9 elixirc_paths: elixirc_paths(Mix.env()),
10 10 consolidate_protocols: Mix.env() not in [:dev, :test],