diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceServer.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceServer.cs index b9907ca..c144195 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceServer.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceServer.cs @@ -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 diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceTools/Tfa.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceTools/Tfa.cs deleted file mode 100644 index da6a406..0000000 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceTools/Tfa.cs +++ /dev/null @@ -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(); - - 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(); - } - } -} \ No newline at end of file diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceTools/TwoFactorAuth.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceTools/TwoFactorAuth.cs new file mode 100644 index 0000000..e7094e2 --- /dev/null +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ResourceTools/TwoFactorAuth.cs @@ -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); + } + + /// + /// 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; + } + } +} diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj b/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj index 5a9f91d..0de377b 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj @@ -93,7 +93,7 @@ - + Form diff --git a/lib/totp.js b/lib/totp.js index 884864c..8acac05 100644 --- a/lib/totp.js +++ b/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;