security fixes

This commit is contained in:
Graham McIntire 2026-01-28 17:02:36 -06:00
parent 58b1aa50c2
commit 6ab1333b41
4 changed files with 227 additions and 0 deletions

View file

@ -0,0 +1,126 @@
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

View file

@ -0,0 +1,75 @@
defmodule Mix.Tasks.Totp.TimeCheck do
@shortdoc "Check server time and TOTP time synchronization"
@moduledoc """
Diagnostic tool to check if the server's clock is correct.
Usage:
mix totp.time_check
This will:
1. Show the server's current time (BEAM VM time)
2. Show the system's current time
3. Calculate what TOTP codes should be valid right now for a test secret
"""
use Mix.Task
require Logger
def run(_args) do
Mix.Task.run("app.start")
IO.puts("\n=== Time Synchronization Check ===\n")
# Get different time sources
system_time = System.system_time(:second)
os_time = :os.system_time(:second)
datetime_now = DateTime.utc_now()
IO.puts("System.system_time(:second): #{system_time}")
IO.puts("#{DateTime.from_unix!(system_time)}")
IO.puts("")
IO.puts(":os.system_time(:second): #{os_time}")
IO.puts("#{DateTime.from_unix!(os_time)}")
IO.puts("")
IO.puts("DateTime.utc_now(): #{datetime_now}")
IO.puts(" → unix: #{DateTime.to_unix(datetime_now)}")
IO.puts("")
# Check if they match
if system_time == os_time and system_time == DateTime.to_unix(datetime_now) do
IO.puts("✓ All time sources agree")
else
IO.puts("⚠️ WARNING: Time sources disagree!")
IO.puts(" System vs OS diff: #{system_time - os_time} seconds")
IO.puts(" System vs DateTime diff: #{system_time - DateTime.to_unix(datetime_now)} seconds")
end
IO.puts("")
# Show what TOTP codes would be generated now
test_secret = NimbleTOTP.secret()
current_code = NimbleTOTP.verification_code(test_secret, time: system_time)
prev_code = NimbleTOTP.verification_code(test_secret, time: system_time - 30)
next_code = NimbleTOTP.verification_code(test_secret, time: system_time + 30)
IO.puts("Test TOTP codes (with random secret):")
IO.puts(" Current window: #{current_code}")
IO.puts(" Previous (-30s): #{prev_code}")
IO.puts(" Next (+30s): #{next_code}")
IO.puts("")
# Recommend action
IO.puts("=== Recommendations ===")
IO.puts("")
IO.puts("Compare the server time above with actual UTC time:")
IO.puts(" Current actual UTC: Run 'date -u' on your local machine")
IO.puts("")
IO.puts("If the server time is wrong:")
IO.puts(" 1. Check K8s node time: kubectl exec -it <pod> -- date -u")
IO.puts(" 2. Check if NTP is running on the node")
IO.puts(" 3. Restart the pod to resync time")
IO.puts("")
end
end

View file

@ -78,4 +78,29 @@ defmodule ToweropsWeb.HealthController do
defp redis_status_string(:ok), do: "connected"
defp redis_status_string(:error), do: "disconnected"
defp redis_status_string(:not_configured), do: "not_configured"
@doc """
Time diagnostic endpoint for debugging TOTP issues.
Returns server time from multiple sources.
"""
def time(conn, _params) do
system_time = System.system_time(:second)
os_time = :os.system_time(:second)
datetime_now = DateTime.utc_now()
conn
|> put_resp_content_type("application/json")
|> send_resp(
200,
Jason.encode!(%{
system_time: system_time,
system_time_iso: system_time |> DateTime.from_unix!() |> DateTime.to_iso8601(),
os_time: os_time,
os_time_iso: os_time |> DateTime.from_unix!() |> DateTime.to_iso8601(),
datetime_now: DateTime.to_unix(datetime_now),
datetime_now_iso: DateTime.to_iso8601(datetime_now),
time_sources_agree: system_time == os_time and system_time == DateTime.to_unix(datetime_now)
})
)
end
end

View file

@ -39,6 +39,7 @@ defmodule ToweropsWeb.Router do
# Health check endpoint for Kubernetes probes (no authentication required)
scope "/", ToweropsWeb do
get "/health", HealthController, :index
get "/health/time", HealthController, :time
end
scope "/", ToweropsWeb do