From c832b95e5e16526cbff70c0ddda403c0fcc5d77b Mon Sep 17 00:00:00 2001 From: Adi Iyengar Date: Sun, 29 Oct 2023 14:32:45 -0400 Subject: [PATCH] Wrap up max attempts and reads feature --- lib/heimdall/secrets.ex | 23 ++- .../secret_html/secret_404.html.heex | 44 ++++- lib/heimdall_web/live/secret_revealer_live.ex | 16 +- .../live/secret_revealer_live.html.heex | 176 +++++++++--------- test/heimdall/secrets_test.exs | 43 +++++ .../live/secret_revealer_live_test.exs | 87 +++++++-- 6 files changed, 272 insertions(+), 117 deletions(-) diff --git a/lib/heimdall/secrets.ex b/lib/heimdall/secrets.ex index c88a08f..09eb374 100644 --- a/lib/heimdall/secrets.ex +++ b/lib/heimdall/secrets.ex @@ -36,7 +36,7 @@ defmodule Heimdall.Secrets do end @doc """ - TODO + Attempts to decrypt a secret using the given decryption_key """ @spec decrypt(Secret.t(), String.t()) :: {:ok, String.t()} | {:error, term()} def decrypt(secret, decryption_key) do @@ -54,7 +54,7 @@ defmodule Heimdall.Secrets do end @doc """ - TODO + Returns a secret with the given ID. Returns nil if none exist. """ @spec get(Ecto.UUID.t()) :: Secret.t() | nil def get(secret_id) do @@ -62,7 +62,7 @@ defmodule Heimdall.Secrets do end @doc """ - TODO + Checks whether secret is not expired or deleted from the database """ @spec not_expired?(Secret.t()) :: boolean() def not_expired?(%Secret{id: secret_id}) do @@ -73,6 +73,23 @@ defmodule Heimdall.Secrets do |> Kernel.not() end + @doc """ + Checks whether secret is not stale: + not maxed out in terms of attempts or reads + """ + @spec not_stale?(Secret.t()) :: boolean() + def not_stale?(%Secret{id: secret_id}) do + secret = + Secret + |> Repo.get(secret_id) + |> Repo.preload([:attempts, :reads]) + + (is_nil(secret.max_decryption_attempts) or + length(secret.attempts) < secret.max_decryption_attempts) and + (is_nil(secret.max_reads) or + length(secret.reads) < secret.max_reads) + end + @doc """ Creates a Read record for a Secret """ diff --git a/lib/heimdall_web/controllers/secret_html/secret_404.html.heex b/lib/heimdall_web/controllers/secret_html/secret_404.html.heex index 1f44005..fbcf2f9 100644 --- a/lib/heimdall_web/controllers/secret_html/secret_404.html.heex +++ b/lib/heimdall_web/controllers/secret_html/secret_404.html.heex @@ -1,14 +1,38 @@ -
- Secret cannot be accessed due to one of the following reasons: - -
+<.modal id="secret_404" show={true}> +
+ Secret cannot be accessed due to one of the following reasons: +
+
    +
  • - It doesn't exist
  • +
  • - It has expired
  • +
  • - It has reached max reads or decryption attempts
  • +
  • - Your IP doesn't match the IP addresses allowed
  • +
+
+
+ +
+
+ Redirecting in + + + seconds.. +
+
+ -
+
Redirecting in assign(:secret, secret) |> assign(:decrypted_text, nil) + |> assign(:redirect, false) |> assign(:ip, ip) if secret_viewable?(secret, socket) do @@ -20,10 +21,11 @@ defmodule HeimdallWeb.SecretRevealerLive do assign(socket, :secret, secret) } else - { - :noreply, - socket |> redirect(to: ~p"/secret_404") - } + socket = assign(socket, :redirect, true) + + Process.send_after(self(), :check_expiration, 1000) + + {:ok, socket} end end @@ -42,8 +44,9 @@ defmodule HeimdallWeb.SecretRevealerLive do def handle_info(:check_expiration, socket) do secret = socket.assigns[:secret] + redirect = socket.assigns[:redirect] - if secret_viewable?(secret, socket) do + if !redirect and Secrets.not_expired?(secret) do schedule_expiration_check() {:noreply, socket} @@ -86,7 +89,8 @@ defmodule HeimdallWeb.SecretRevealerLive do defp secret_viewable?(secret, socket) do ip = socket.assigns[:ip] - Secrets.not_expired?(secret) and Secrets.ip_allowed?(secret, ip) + Secrets.not_expired?(secret) and Secrets.not_stale?(secret) and + Secrets.ip_allowed?(secret, ip) end defp schedule_expiration_check do diff --git a/lib/heimdall_web/live/secret_revealer_live.html.heex b/lib/heimdall_web/live/secret_revealer_live.html.heex index 273254c..17963b3 100644 --- a/lib/heimdall_web/live/secret_revealer_live.html.heex +++ b/lib/heimdall_web/live/secret_revealer_live.html.heex @@ -1,104 +1,106 @@ -
-
- Showing Secret: "<%= @secret.title %>" -
+<%= unless @redirect do %> +
+
+ Showing Secret: "<%= @secret.title %>" +
- <%= if @decrypted_text do %> -
- <.label for="decrypted_secret">Decrypted Secret + <%= if @decrypted_text do %> +
+ <.label for="decrypted_secret">Decrypted Secret - <.input type="textarea" name="key" value={@decrypted_text} readonly /> -
- <% else %> -
- <.form for={%{}} phx-submit="decrypt"> - <%= if @secret.encryption_algo == :rsa do %> -
-
-
- <.label for="decryption_key">RSA Private Key PEM -
+ <.input type="textarea" name="key" value={@decrypted_text} readonly /> +
+ <% else %> +
+ <.form for={%{}} phx-submit="decrypt"> + <%= if @secret.encryption_algo == :rsa do %> +
+
+
+ <.label for="decryption_key">RSA Private Key PEM +
-
- <.icon name="hero-eye" class="h-5 w-5" /> -
+
+ <.icon name="hero-eye" class="h-5 w-5" /> +
- + + <.input + type="textarea" + autocomplete="off" + name="key" + spellcheck="false" + value="" + style="color: transparent;text-shadow: 0 0 8px rgba(0,0,0,0.5);" + x-bind:style="!show && 'color: transparent;text-shadow: 0 0 8px rgba(0,0,0,0.5);'" + />
- <.input - type="textarea" - autocomplete="off" - name="key" - spellcheck="false" - value="" - style="color: transparent;text-shadow: 0 0 8px rgba(0,0,0,0.5);" - x-bind:style="!show && 'color: transparent;text-shadow: 0 0 8px rgba(0,0,0,0.5);'" - /> -
+
+ <% end %> -
- <% end %> + <%= if @secret.encryption_algo == :aes_gcm do %> +
+
+
+ <.label for="decryption_key">Password +
- <%= if @secret.encryption_algo == :aes_gcm do %> -
-
-
- <.label for="decryption_key">Password -
+
+ <.icon name="hero-eye" class="h-5 w-5" /> +
-
- <.icon name="hero-eye" class="h-5 w-5" /> +
- + <.input + autocomplete="off" + name="key" + spellcheck="false" + value="" + style="color: transparent;text-shadow: 0 0 8px rgba(0,0,0,0.5);" + x-bind:style="!show && 'color: transparent;text-shadow: 0 0 8px rgba(0,0,0,0.5);'" + />
- <.input - autocomplete="off" - name="key" - spellcheck="false" - value="" - style="color: transparent;text-shadow: 0 0 8px rgba(0,0,0,0.5);" - x-bind:style="!show && 'color: transparent;text-shadow: 0 0 8px rgba(0,0,0,0.5);'" - /> -
+
+ <% end %> -
- <% end %> + <%= if @secret.encryption_algo == :plaintext do %> + + <% end %> - <%= if @secret.encryption_algo == :plaintext do %> - - <% end %> -
+ +
+ <% end %> +
+<% end %> diff --git a/test/heimdall/secrets_test.exs b/test/heimdall/secrets_test.exs index 45a6686..0eae420 100644 --- a/test/heimdall/secrets_test.exs +++ b/test/heimdall/secrets_test.exs @@ -176,4 +176,47 @@ defmodule Heimdall.SecretsTest do assert error == "Error in decryption" end end + + describe "not_stale?/1" do + test "returns true when max reads are greater than secret reads" do + {:ok, secret} = Factory.encrypt_and_create(%{max_reads: 2}) + + assert Secrets.not_stale?(secret) + end + + test "returns false when max reads are less than or eq secret reads" do + {:ok, secret} = Factory.encrypt_and_create(%{max_reads: 1}) + + _read = Secrets.create_secret_read(secret, "ip", DateTime.utc_now()) + + refute Secrets.not_stale?(secret) + end + + test "returns true when max reads are nil" do + {:ok, secret} = Factory.encrypt_and_create(%{max_reads: nil}) + + assert Secrets.not_stale?(secret) + end + + test "returns true when max attempts are greater than secret attempts" do + {:ok, secret} = Factory.encrypt_and_create(%{max_decryption_attempts: 2}) + + assert Secrets.not_stale?(secret) + end + + test "returns false when max attempts are less than or eq secret attempts" do + {:ok, secret} = Factory.encrypt_and_create(%{max_decryption_attempts: 1}) + + _attempt = Secrets.create_secret_attempt(secret, "ip", DateTime.utc_now()) + + refute Secrets.not_stale?(secret) + end + + test "returns true when max attempts are nil" do + {:ok, secret} = + Factory.encrypt_and_create(%{max_decryption_attempts: nil}) + + assert Secrets.not_stale?(secret) + end + end end diff --git a/test/heimdall_web/live/secret_revealer_live_test.exs b/test/heimdall_web/live/secret_revealer_live_test.exs index c2926a9..6731a6d 100644 --- a/test/heimdall_web/live/secret_revealer_live_test.exs +++ b/test/heimdall_web/live/secret_revealer_live_test.exs @@ -2,6 +2,7 @@ defmodule HeimdallWeb.SecretRevealerLiveTest do use HeimdallWeb.ConnCase import Phoenix.LiveViewTest + alias Heimdall.Data.Secret.Read alias Heimdall.Factory alias Heimdall.Repo alias HeimdallWeb.SecretRevealerLive @@ -26,6 +27,22 @@ defmodule HeimdallWeb.SecretRevealerLiveTest do assert html =~ secret.title end + + test "doesn't show secret parameters if wrong ip", %{conn: conn} do + {:ok, secret} = + Factory.encrypt_and_create(%{ + ip_regex: "bad_regex" + }) + + {:ok, _view, html} = + live_isolated( + conn, + SecretRevealerLive, + session: %{"secret_id" => secret.id, "ip" => "ip"} + ) + + refute html =~ secret.title + end end describe "handle_event/3 (decrypt)" do @@ -85,6 +102,24 @@ defmodule HeimdallWeb.SecretRevealerLiveTest do |> element("form") |> render_submit(%{"key" => "bad_key"}) =~ "Error in decryption" end + + test "doesn't show secret parameters if wrong ip", %{conn: conn} do + {:ok, secret} = + Factory.encrypt_and_create(%{ + ip_regex: "bad_regex" + }) + + {:ok, view, _html} = + live_isolated( + conn, + SecretRevealerLive, + session: %{"secret_id" => secret.id, "ip" => "ip"} + ) + + # Secret isn't visible if bad ip is given + refute view + |> has_element?("form") + end end describe "handle_info/3 (check_expiration)" do @@ -155,14 +190,16 @@ defmodule HeimdallWeb.SecretRevealerLiveTest do refute Process.alive?(view.pid) end - test "doesn't redirect if secret isn't expired", %{conn: conn} do + test "redirects if secret is not expired but stale", %{conn: conn} do raw = "supersecretpassword" key = "key" {:ok, secret} = Factory.encrypt_and_create(%{ encryption_key: key, - encrypted_text: raw + encrypted_text: raw, + expires_at: DateTime.add(DateTime.utc_now(), 2, :second), + max_reads: 1 }) {:ok, view, _html} = @@ -172,19 +209,47 @@ defmodule HeimdallWeb.SecretRevealerLiveTest do session: %{"secret_id" => secret.id, "ip" => "ip"} ) - html = - view - |> element("form") - |> render_submit(%{"key" => key}) + %{secret_id: secret.id} + |> Factory.valid_secret_read_params() + |> Read.changeset() + |> Repo.insert() - # Doesn't say secret is expired - refute html =~ "Secret Expired" + view + |> element("form") + |> render_submit(%{"key" => key}) + + # Wait for enough time for the secret to expire + :timer.sleep(5_000) + + # Live redirect + refute Process.alive?(view.pid) + end + + test "doesn't redirect if secret isn't expired", %{conn: conn} do + raw = "supersecretpassword" + key = "key" - :timer.sleep(1_000) + {:ok, secret} = + Factory.encrypt_and_create(%{ + encryption_key: key, + encrypted_text: raw + }) - updated_html = render(view) + {:ok, view, _html} = + live_isolated( + conn, + SecretRevealerLive, + session: %{"secret_id" => secret.id, "ip" => "ip"} + ) + + view + |> element("form") + |> render_submit(%{"key" => key}) + + :timer.sleep(5_000) - refute updated_html =~ "Secret Expired" + # No Live redirect + assert Process.alive?(view.pid) end end end