// TwoFactorAuth.cs
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
//
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using System.Net.Http;
using System.Text;
using System.IO;
namespace WelsonJS.Launcher.ResourceTools
{
public class TwoFactorAuth : IResourceTool
{
private readonly ResourceServer Server;
private readonly HttpClient _httpClient;
private const string Prefix = "tfa/";
private const string Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private static readonly int[] ValidKeyCharLengths = new[] { 16, 32 };
public TwoFactorAuth(ResourceServer server, HttpClient httpClient)
{
Server = server;
_httpClient = httpClient;
}
public bool CanHandle(string path)
{
return path.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase);
}
public async Task HandleAsync(HttpListenerContext context, string path)
{
string endpoint = path.Substring(Prefix.Length);
// GET /tfa/pubkey[?len=16|32]
if (endpoint.Equals("pubkey", StringComparison.OrdinalIgnoreCase))
{
int length = 32;
var q = context.Request?.QueryString?["len"];
if (!string.IsNullOrEmpty(q) &&
int.TryParse(q, out var parsed) &&
Array.IndexOf(ValidKeyCharLengths, parsed) >= 0)
{
length = parsed;
}
Server.ServeResource(context, GetPubKey(length), "text/plain", 200);
return;
}
// POST /tfa/otp
// Body: secret=BASE32KEY
if (endpoint.Equals("otp", StringComparison.OrdinalIgnoreCase) &&
context.Request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase))
{
string body;
using (var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding))
{
body = await reader.ReadToEndAsync();
}
var parsed = ParseFormEncoded(body);
if (!parsed.TryGetValue("secret", out string secret) || string.IsNullOrWhiteSpace(secret))
{
Server.ServeResource(context, "missing 'secret' parameter", "text/plain", 400);
return;
}
try
{
int otp = GetOtp(secret.Trim());
string otp6 = otp.ToString("D6"); // always 6 digits
Server.ServeResource(context, otp6, "text/plain", 200);
}
catch (Exception ex)
{
Server.ServeResource(context, $"invalid secret: {ex.Message}", "text/plain", 400);
}
return;
}
// Default: not found
Server.ServeResource(context, "not found", "text/plain", 404);
}
///
/// Compute a 6-digit TOTP from a Base32 secret.
///
public int GetOtp(string key, int period = 30)
{
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("Secret key is required.", nameof(key));
string normalized = NormalizeBase32(key);
byte[] binaryKey = DecodeBase32(normalized);
if (binaryKey.Length == 0)
throw new ArgumentException("Secret could not be decoded.", nameof(key));
long timestep = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
byte[] msg = BitConverter.GetBytes(timestep);
if (BitConverter.IsLittleEndian) Array.Reverse(msg);
using (var hmac = new HMACSHA1(binaryKey))
{
byte[] hash = hmac.ComputeHash(msg);
int offset = hash[hash.Length - 1] & 0x0F;
int binCode = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
return binCode % 1_000_000; // fixed 6 digits
}
}
public string GetPubKey(int charCount = 32)
{
if (Array.IndexOf(ValidKeyCharLengths, charCount) < 0)
throw new ArgumentException("charCount must be 16 or 32.", nameof(charCount));
int bytesLen = (charCount * 5) / 8;
using (var rng = RandomNumberGenerator.Create())
{
var randomBytes = new byte[bytesLen];
rng.GetBytes(randomBytes);
string b32 = EncodeBase32(randomBytes);
return b32.ToLowerInvariant();
}
}
private static string NormalizeBase32(string s)
{
var sb = new StringBuilder(s.Length);
foreach (var ch in s)
{
if (ch == ' ' || ch == '-') continue;
sb.Append(char.ToUpperInvariant(ch));
}
return sb.ToString();
}
private static string EncodeBase32(byte[] data)
{
if (data == null || data.Length == 0) return string.Empty;
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
int buffer = 0, bitsLeft = 0;
foreach (byte b in data)
{
buffer = (buffer << 8) | b;
bitsLeft += 8;
while (bitsLeft >= 5)
{
sb.Append(Base32Chars[(buffer >> (bitsLeft - 5)) & 31]);
bitsLeft -= 5;
}
}
if (bitsLeft > 0)
sb.Append(Base32Chars[(buffer << (5 - bitsLeft)) & 31]);
return sb.ToString();
}
private static byte[] DecodeBase32(string key)
{
int buffer = 0, bitsLeft = 0;
var bytes = new List(key.Length * 5 / 8);
foreach (char raw in key)
{
char c = char.ToUpperInvariant(raw);
if (c == ' ' || c == '-') continue;
int v = Base32Chars.IndexOf(c);
if (v < 0) continue;
buffer = (buffer << 5) | v;
bitsLeft += 5;
if (bitsLeft >= 8)
{
bitsLeft -= 8;
bytes.Add((byte)((buffer >> bitsLeft) & 0xFF));
}
}
return bytes.ToArray();
}
///
/// Parse application/x-www-form-urlencoded into a dictionary (UTF-8 assumed).
///
private static Dictionary ParseFormEncoded(string body)
{
var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(body)) return result;
var pairs = body.Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var pair in pairs)
{
var kv = pair.Split(new[] { '=' }, 2);
string key = Uri.UnescapeDataString(kv[0] ?? "");
string value = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : "";
result[key] = value;
}
return result;
}
}
}