mirror of
https://github.com/gnh1201/welsonjs.git
synced 2025-12-07 14:53:40 +00:00
Merge pull request #321 from gnh1201/dev
TOTP (Time-based OTP) with 32 characters key
This commit is contained in:
commit
cc08f7b362
|
|
@ -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>
|
||||
|
|
|
|||
56
lib/totp.js
56
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 + "/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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user