Merge pull request #321 from gnh1201/dev
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
Deploy Jekyll with GitHub Pages dependencies preinstalled / build (push) Has been cancelled
Deploy Jekyll with GitHub Pages dependencies preinstalled / deploy (push) Has been cancelled

TOTP (Time-based OTP) with 32 characters key
This commit is contained in:
Namhyeon Go 2025-08-22 23:28:01 +09:00 committed by GitHub
commit cc08f7b362
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 244 additions and 142 deletions

View File

@ -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

View File

@ -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();
}
}
}

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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, youll 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 + "/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.1";
exports.AUTHOR = "gnh1201@catswords.re.kr";
exports.global = global;
exports.require = global.require;
@ -51,6 +42,7 @@ exports.require = global.require;
/*
// Example:
var TOTP = require("lib/totp");
console.log(TOTP.getPubKey()); // get public key. e.g. 6Y4R 3AQN 4TTV CEQT
console.log(TOTP.getOtp('6Y4R 3AQN 4TTV CEQT')); // get OTP code. e.g. 317884
console.log(TOTP.getPubKey()); // get public key. e.g. ih6vdfuh75ugdcmruaexhfh3miiwdqhx
console.log(TOTP.getOtp('ih6vdfuh75ugdcmruaexhfh3miiwdqhx')); // get OTP code. (32 characters key) e.g. 774372
console.log(TOTP.getOtp('6Y4R 3AQN 4TTV CEQT')); // get OTP code. (16 characters key) e.g. 317884
*/