mirror of
https://github.com/gnh1201/welsonjs.git
synced 2025-10-25 10:01:16 +00:00
Refactor TFA tool to TwoFactorAuth and update TOTP client
Renamed ResourceTools/Tfa.cs to ResourceTools/TwoFactorAuth.cs and updated all references accordingly. Enhanced TwoFactorAuth with improved key handling, error responses, and form parsing. Updated totp.js to use the new local HTTP API endpoints for key generation and OTP calculation, removing legacy JSON-RPC code.
This commit is contained in:
parent
8ff12c9883
commit
dfc18cfd7d
|
|
@ -62,7 +62,7 @@ namespace WelsonJS.Launcher
|
|||
_tools.Add(new ResourceTools.ChromiumDevTools(this, _httpClient));
|
||||
_tools.Add(new ResourceTools.DnsQuery(this, _httpClient));
|
||||
_tools.Add(new ResourceTools.CitiQuery(this, _httpClient));
|
||||
_tools.Add(new ResourceTools.Tfa(this, _httpClient));
|
||||
_tools.Add(new ResourceTools.TwoFactorAuth(this, _httpClient));
|
||||
_tools.Add(new ResourceTools.Whois(this, _httpClient));
|
||||
|
||||
// Register the prefix
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
// Tfa.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;
|
||||
|
||||
namespace WelsonJS.Launcher.ResourceTools
|
||||
{
|
||||
public class Tfa : IResourceTool
|
||||
{
|
||||
private readonly ResourceServer Server;
|
||||
private readonly HttpClient _httpClient;
|
||||
private const string Prefix = "tfa/";
|
||||
private const string Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
public Tfa(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)
|
||||
{
|
||||
await Task.Delay(0);
|
||||
|
||||
string endpoint = path.Substring(Prefix.Length);
|
||||
|
||||
if (endpoint.Equals("pubkey"))
|
||||
{
|
||||
Server.ServeResource(context, GetPubKey(), "text/plain", 200);
|
||||
return;
|
||||
}
|
||||
|
||||
Server.ServeResource(context);
|
||||
}
|
||||
|
||||
public int GetOtp(string key)
|
||||
{
|
||||
byte[] binaryKey = DecodeBase32(key.Replace(" ", ""));
|
||||
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30;
|
||||
byte[] timestampBytes = BitConverter.GetBytes(timestamp);
|
||||
Array.Reverse(timestampBytes); // Ensure big-endian order
|
||||
|
||||
using (var hmac = new HMACSHA1(binaryKey))
|
||||
{
|
||||
byte[] hash = hmac.ComputeHash(timestampBytes);
|
||||
int offset = hash[hash.Length - 1] & 0xF;
|
||||
|
||||
int otp = ((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return otp % 1000000; // Ensure 6-digit OTP
|
||||
}
|
||||
}
|
||||
|
||||
public string GetPubKey()
|
||||
{
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
{
|
||||
var key = new char[16];
|
||||
var randomBytes = new byte[16];
|
||||
rng.GetBytes(randomBytes);
|
||||
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
key[i] = Base32Chars[randomBytes[i] % Base32Chars.Length];
|
||||
}
|
||||
|
||||
return string.Join(" ", Enumerable.Range(0, 4).Select(i => new string(key, i * 4, 4)));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] DecodeBase32(string key)
|
||||
{
|
||||
int buffer = 0, bitsLeft = 0;
|
||||
var binaryKey = new List<byte>();
|
||||
|
||||
foreach (char c in key)
|
||||
{
|
||||
int value = Base32Chars.IndexOf(c);
|
||||
if (value < 0) continue; // Ignore invalid characters
|
||||
|
||||
buffer = (buffer << 5) + value;
|
||||
bitsLeft += 5;
|
||||
if (bitsLeft >= 8)
|
||||
{
|
||||
bitsLeft -= 8;
|
||||
binaryKey.Add((byte)((buffer >> bitsLeft) & 0xFF));
|
||||
}
|
||||
}
|
||||
return binaryKey.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute a 6-digit TOTP from a Base32 secret.
|
||||
/// </summary>
|
||||
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<byte>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse application/x-www-form-urlencoded into a dictionary (UTF-8 assumed).
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> ParseFormEncoded(string body)
|
||||
{
|
||||
var result = new Dictionary<string, string>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
<Compile Include="ResourceTools\Completion.cs" />
|
||||
<Compile Include="ResourceTools\ChromiumDevTools.cs" />
|
||||
<Compile Include="ResourceTools\DnsQuery.cs" />
|
||||
<Compile Include="ResourceTools\Tfa.cs" />
|
||||
<Compile Include="ResourceTools\TwoFactorAuth.cs" />
|
||||
<Compile Include="ResourceTools\Whois.cs" />
|
||||
<Compile Include="EnvForm.cs">
|
||||
<SubType>Form</SubType>
|
||||
|
|
|
|||
51
lib/totp.js
51
lib/totp.js
|
|
@ -3,47 +3,38 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// https://github.com/gnh1201/welsonjs
|
||||
//
|
||||
// SECURITY NOTICE
|
||||
// Due to potential security issues, the Public API URL is not provided. If you need to request access, please refer to the project's contact information.
|
||||
// You can download the server-side script that implements this functionality from the link below:
|
||||
// https://github.com/gnh1201/caterpillar
|
||||
// To use this feature, you’ll need software that can generate Time-based OTPs (HMACSHA1 algorithm, supporting 16- or 32-character keys).
|
||||
// If the WelsonJS Launcher is running, you can access this feature right away.
|
||||
//
|
||||
var JsonRpc2 = require("lib/jsonrpc2");
|
||||
var HTTP = require("lib/http");
|
||||
|
||||
var TFA_API_BASE_URL = "http://localhost:3000/tfa";
|
||||
|
||||
function getPubKey() {
|
||||
var rpc = JsonRpc2.create(JsonRpc2.DEFAULT_JSONRPC2_URL);
|
||||
var result = rpc.invoke("relay_invoke_method", {
|
||||
"callback": "load_script",
|
||||
"requires": [
|
||||
"https://scriptas.catswords.net/class.tfa.php"
|
||||
],
|
||||
"args": [
|
||||
"$tfa = new tfa(); return $tfa->getPubKey()"
|
||||
]
|
||||
}, "");
|
||||
|
||||
return result.data;
|
||||
var response = HTTP.create()
|
||||
.open("GET", TFA_API_BASE_URL + "/pubkey")
|
||||
.send()
|
||||
.responseBody
|
||||
;
|
||||
return response.trim();
|
||||
}
|
||||
|
||||
function getOtp(pubkey) {
|
||||
var rpc = JsonRpc2.create(JsonRpc2.DEFAULT_JSONRPC2_URL);
|
||||
var result = rpc.invoke("relay_invoke_method", {
|
||||
"callback": "load_script",
|
||||
"requires": [
|
||||
"https://scriptas.catswords.net/class.tfa.php"
|
||||
],
|
||||
"args": [
|
||||
"$tfa = new tfa(); return $tfa->getOtp('" + pubkey + "')"
|
||||
]
|
||||
}, "");
|
||||
|
||||
return result.data;
|
||||
var response = HTTP.create()
|
||||
.setRequestBody({
|
||||
"secret": pubkey
|
||||
})
|
||||
.open("POST", TFA_API_BASE_URL + "/tfa/otp")
|
||||
.send()
|
||||
.responseBody
|
||||
;
|
||||
return response.trim();
|
||||
}
|
||||
|
||||
exports.getPubKey = getPubKey;
|
||||
exports.getOtp = getOtp;
|
||||
|
||||
exports.VERSIONINFO = "TOTP Client (totp.js) version 0.1.8";
|
||||
exports.VERSIONINFO = "Time-based OTP client (totp.js) version 1.0";
|
||||
exports.AUTHOR = "gnh1201@catswords.re.kr";
|
||||
exports.global = global;
|
||||
exports.require = global.require;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user