Current section
31 Versions
Jump to
Current section
31 Versions
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], |