From 857c8d21964547b080ca7c56a96eabc8a2eac46e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 29 Jan 2026 09:14:48 -0600 Subject: [PATCH] totp debugging --- .../live/account_live/totp_enrollment.ex | 4 +- search_totp_code.exs | 61 +++++++++++++++++++ test_totp_flow.exs | 60 ++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 search_totp_code.exs create mode 100644 test_totp_flow.exs diff --git a/lib/towerops_web/live/account_live/totp_enrollment.ex b/lib/towerops_web/live/account_live/totp_enrollment.ex index 1d74061e..b2d18adf 100644 --- a/lib/towerops_web/live/account_live/totp_enrollment.ex +++ b/lib/towerops_web/live/account_live/totp_enrollment.ex @@ -80,7 +80,7 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do @impl true def render(assigns) do ~H""" - +
@@ -225,7 +225,7 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do
-
+ """ end end diff --git a/search_totp_code.exs b/search_totp_code.exs new file mode 100644 index 00000000..446aaa00 --- /dev/null +++ b/search_totp_code.exs @@ -0,0 +1,61 @@ +alias Towerops.Accounts + +# The code from the error +provided_code = "147273" +server_time = 1_769_699_437 + +IO.puts("=== Searching for when code #{provided_code} would be valid ===") +IO.puts("Server time: #{DateTime.from_unix!(server_time)}") +IO.puts("") + +# Generate a fresh secret to test with +secret = Accounts.generate_totp_secret() +IO.puts("Testing with a fresh 20-byte secret...") +IO.puts("") + +# Search ±1 hour (120 time steps of 30 seconds each) +found = + Enum.find_value(-120..120, fn offset -> + test_time = server_time + offset * 30 + expected = NimbleTOTP.verification_code(secret, time: test_time) + + if expected == provided_code do + %{ + offset_seconds: offset * 30, + offset_minutes: Float.round(offset * 30 / 60, 1), + time: DateTime.from_unix!(test_time) + } + end + end) + +case found do + nil -> + IO.puts("❌ Code #{provided_code} would NEVER be valid for a fresh 20-byte secret") + IO.puts("") + IO.puts("This means:") + IO.puts(" 1. Your authenticator app has a DIFFERENT secret than the server") + IO.puts(" 2. You need to:") + IO.puts(" - Delete the Towerops entry from your authenticator app") + IO.puts(" - Visit the enrollment page again (fresh)") + IO.puts(" - Scan the NEW QR code") + IO.puts(" - Enter the code immediately") + + match -> + IO.puts("✓ Code #{provided_code} would be valid at:") + IO.puts(" Time: #{match.time}") + IO.puts(" Offset: #{match.offset_seconds} seconds (#{match.offset_minutes} minutes)") + IO.puts("") + + cond do + abs(match.offset_seconds) <= 120 -> + IO.puts("⚠️ This is within the ±2 minute window - should have worked!") + + abs(match.offset_seconds) <= 3600 -> + IO.puts("⏰ Clock drift: #{abs(match.offset_minutes)} minutes") + IO.puts(" Your device's clock is incorrect") + + true -> + IO.puts("⏰ Extreme time difference: #{abs(match.offset_minutes)} minutes") + IO.puts(" Your device's clock is way off") + end +end diff --git a/test_totp_flow.exs b/test_totp_flow.exs new file mode 100644 index 00000000..5d02117b --- /dev/null +++ b/test_totp_flow.exs @@ -0,0 +1,60 @@ +alias Towerops.Accounts +alias Towerops.Accounts.User + +# Simulate enrollment flow +secret = Accounts.generate_totp_secret() +user = %User{email: "test@example.com"} + +IO.puts("=== TOTP Enrollment Flow Test ===") +IO.puts("") +IO.puts("1. Generated secret:") +IO.puts(" Bytes: #{byte_size(secret)}") +IO.puts(" Type: #{inspect(:io_lib.printable_list(secret))}") + +# Generate URI (what goes in QR code) +uri = Accounts.generate_totp_uri(user, secret) +IO.puts("") +IO.puts("2. Generated URI:") +IO.puts(" #{uri}") + +# Extract the Base32 secret from the URI +case Regex.run(~r/secret=([A-Z2-7]+)/, uri) do + [_, base32] -> + IO.puts("") + IO.puts("3. Extracted Base32 from URI:") + IO.puts(" Base32: #{base32}") + IO.puts(" Length: #{String.length(base32)} characters") + + # Verify the Base32 can be decoded back + try do + decoded = Base.decode32!(base32, padding: false) + IO.puts("") + IO.puts("4. Decode verification:") + IO.puts(" Decoded bytes: #{byte_size(decoded)}") + IO.puts(" Matches original: #{decoded == secret}") + + if decoded != secret do + IO.puts(" ERROR: Decoded secret doesn't match!") + IO.puts(" Original bytes: #{byte_size(secret)}") + IO.puts(" Decoded bytes: #{byte_size(decoded)}") + end + rescue + e -> + IO.puts("") + IO.puts("4. Decode ERROR: #{inspect(e)}") + end + + _ -> + IO.puts("") + IO.puts("ERROR: Failed to extract Base32 from URI") +end + +# Test verification +current_time = System.system_time(:second) +code = NimbleTOTP.verification_code(secret, time: current_time) +valid = Accounts.verify_totp(secret, code) + +IO.puts("") +IO.puts("5. Verification test:") +IO.puts(" Generated code: #{code}") +IO.puts(" Validates: #{valid}")