towerops/lib/mix/tasks/totp_diagnose.exs
2026-01-28 17:02:36 -06:00

126 lines
4.3 KiB
Elixir

defmodule Mix.Tasks.Totp.Diagnose do
@shortdoc "Diagnose TOTP verification failures"
@moduledoc """
Diagnostic tool for TOTP verification failures.
Usage:
mix totp.diagnose <user_email> <provided_code> <server_unix_timestamp>
Example:
mix totp.diagnose user@example.com 776330 1769641093
This will:
1. Check if the provided code matches the user's secret at any time within ±24 hours
2. Report the exact time offset if found
3. Help identify if the issue is clock drift or secret mismatch
"""
use Mix.Task
require Logger
def run(args) do
Mix.Task.run("app.start")
case args do
[email, provided_code, timestamp_str] ->
diagnose(email, provided_code, String.to_integer(timestamp_str))
_ ->
IO.puts("""
Usage: mix totp.diagnose <user_email> <provided_code> <server_unix_timestamp>
Example:
mix totp.diagnose user@example.com 776330 1769641093
""")
end
end
defp diagnose(email, provided_code, server_timestamp) do
IO.puts("\n=== TOTP Diagnostic Tool ===\n")
IO.puts("Email: #{email}")
IO.puts("Provided code: #{provided_code}")
IO.puts("Server time: #{DateTime.from_unix!(server_timestamp)} (unix: #{server_timestamp})")
user = Towerops.Accounts.get_user_by_email(email)
if is_nil(user) do
IO.puts("\n❌ User not found!")
exit({:shutdown, 1})
end
if is_nil(user.totp_secret) do
IO.puts("\n❌ User does not have TOTP enabled!")
exit({:shutdown, 1})
end
IO.puts("\nTOTP enabled: Yes")
IO.puts("Secret length: #{byte_size(user.totp_secret)} bytes")
# Check current expected codes
IO.puts("\n--- Expected codes at failure time ---")
expected_current = NimbleTOTP.verification_code(user.totp_secret, time: server_timestamp)
expected_prev = NimbleTOTP.verification_code(user.totp_secret, time: server_timestamp - 30)
expected_next = NimbleTOTP.verification_code(user.totp_secret, time: server_timestamp + 30)
IO.puts("Current window: #{expected_current}")
IO.puts("Previous (-30s): #{expected_prev}")
IO.puts("Next (+30s): #{expected_next}")
# Search for when the provided code would have been valid
IO.puts("\n--- Searching for provided code in wider time window ---")
IO.puts("Scanning ±24 hours (2880 time steps)...")
found_time = search_for_code(user.totp_secret, provided_code, server_timestamp)
if found_time do
offset_seconds = found_time - server_timestamp
offset_minutes = Float.round(offset_seconds / 60, 1)
IO.puts("\n✓ FOUND! The provided code was valid at:")
IO.puts(" Time: #{DateTime.from_unix!(found_time)}")
IO.puts(" Offset: #{offset_seconds} seconds (#{offset_minutes} minutes)")
cond do
abs(offset_seconds) <= 120 ->
IO.puts("\n⚠️ This should have been accepted (within ±2 minute window)")
IO.puts(" Possible bug in verification logic!")
abs(offset_seconds) <= 900 ->
IO.puts("\n⏰ Clock drift detected: #{abs(offset_minutes)} minutes")
IO.puts(" The client's clock is off by more than 2 minutes.")
true ->
IO.puts("\n⏰ Extreme clock drift: #{abs(offset_minutes)} minutes")
IO.puts(" The client's clock is significantly incorrect.")
end
else
IO.puts("\n❌ NOT FOUND in ±24 hour window")
IO.puts("\nThis strongly suggests a SECRET MISMATCH:")
IO.puts(" - The secret in the database differs from what's in the authenticator app")
IO.puts(" - The user may have scanned a different QR code")
IO.puts(" - The secret may have been regenerated without updating the authenticator")
IO.puts("\nRecommended action:")
IO.puts(" 1. Have the user disable TOTP")
IO.puts(" 2. Re-enable TOTP and scan the new QR code")
IO.puts(" 3. Verify the new code works before proceeding")
end
IO.puts("\n")
end
defp search_for_code(secret, provided_code, server_timestamp) do
# Check ±24 hours (86400 seconds = 2880 time steps of 30 seconds)
time_steps = 2880
Enum.find_value(-time_steps..time_steps, fn step ->
test_time = server_timestamp + step * 30
expected = NimbleTOTP.verification_code(secret, time: test_time)
if expected == provided_code do
test_time
end
end)
end
end