mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
fix: support steam totp code generation and formatting
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
This commit is contained in:
committed by
Shuai
parent
010cda972c
commit
b5d58f1aa8
@@ -18,7 +18,9 @@ const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
|||||||
const failedIconHosts = new Set<string>();
|
const failedIconHosts = new Set<string>();
|
||||||
|
|
||||||
function formatTotp(code: string): string {
|
function formatTotp(code: string): string {
|
||||||
if (!code || code.length < 6) return code;
|
if (!code) return code;
|
||||||
|
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
||||||
|
if (code.length < 6) return code;
|
||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,9 @@ export function maskSecret(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatTotp(code: string): string {
|
export function formatTotp(code: string): string {
|
||||||
if (!code || code.length < 6) return code;
|
if (!code) return code;
|
||||||
|
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
||||||
|
if (code.length < 6) return code;
|
||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,19 +167,31 @@ export async function decryptStr(cipherString: string | null | undefined, encKey
|
|||||||
return new TextDecoder().decode(plain);
|
return new TextDecoder().decode(plain);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractTotpSecret(raw: string): string {
|
function normalizeTotpSecret(secret: string): string {
|
||||||
if (!raw) return '';
|
return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTotpConfig(raw: string): { secret: string; steam: boolean } {
|
||||||
|
if (!raw) return { secret: '', steam: false };
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if (!s) return '';
|
if (!s) return { secret: '', steam: false };
|
||||||
if (/^otpauth:\/\//i.test(s)) {
|
if (/^otpauth:\/\//i.test(s)) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(s);
|
const u = new URL(s);
|
||||||
return (u.searchParams.get('secret') || '').toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
||||||
|
const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
|
||||||
|
const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
|
||||||
|
const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
|
||||||
|
return { secret: normalizeTotpSecret(u.searchParams.get('secret') || ''), steam };
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return { secret: '', steam: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
return { secret: normalizeTotpSecret(s), steam: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTotpSecret(raw: string): string {
|
||||||
|
return parseTotpConfig(raw).secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
function base32ToBytes(input: string): Uint8Array {
|
function base32ToBytes(input: string): Uint8Array {
|
||||||
@@ -202,7 +214,7 @@ function base32ToBytes(input: string): Uint8Array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function calcTotpNow(rawSecret: string): Promise<{ code: string; remain: number } | null> {
|
export async function calcTotpNow(rawSecret: string): Promise<{ code: string; remain: number } | null> {
|
||||||
const secret = extractTotpSecret(rawSecret);
|
const { secret, steam } = parseTotpConfig(rawSecret);
|
||||||
if (!secret) return null;
|
if (!secret) return null;
|
||||||
const keyBytes = base32ToBytes(secret);
|
const keyBytes = base32ToBytes(secret);
|
||||||
if (!keyBytes.length) return null;
|
if (!keyBytes.length) return null;
|
||||||
@@ -221,6 +233,15 @@ export async function calcTotpNow(rawSecret: string): Promise<{ code: string; re
|
|||||||
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
|
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
|
||||||
const offset = hs[hs.length - 1] & 0x0f;
|
const offset = hs[hs.length - 1] & 0x0f;
|
||||||
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
|
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
|
||||||
const code = (bin % 1000000).toString().padStart(6, '0');
|
let code = (bin % 1000000).toString().padStart(6, '0');
|
||||||
|
if (steam) {
|
||||||
|
const chars = '23456789BCDFGHJKMNPQRTVWXY';
|
||||||
|
let value = bin;
|
||||||
|
code = '';
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
code += chars[value % chars.length];
|
||||||
|
value = Math.floor(value / chars.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
return { code, remain };
|
return { code, remain };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user