码桶

发现社区成员的开源项目

_totp.js5.2 KB
// TOTP (RFC 6238) implementation using Web Crypto API only — no npm dependencies.

// ── Base32 helpers ──────────────────────────────────────────────────────────

const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

/**
 * Encode a Uint8Array to a base32 string (RFC 4648, no padding).
 */
export function base32Encode(bytes) {
    let bits = 0;
    let value = 0;
    let output = '';

    for (let i = 0; i < bytes.length; i++) {
        value = (value << 8) | bytes[i];
        bits += 8;

        while (bits >= 5) {
            bits -= 5;
            output += BASE32_ALPHABET[(value >>> bits) & 0x1f];
        }
    }

    if (bits > 0) {
        output += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];
    }

    return output;
}

/**
 * Decode a base32 string to a Uint8Array.
 */
export function base32Decode(str) {
    // Strip padding and normalise to uppercase
    const input = str.replace(/=+$/, '').toUpperCase();
    const lookup = {};
    for (let i = 0; i < BASE32_ALPHABET.length; i++) {
        lookup[BASE32_ALPHABET[i]] = i;
    }

    let bits = 0;
    let value = 0;
    const output = [];

    for (let i = 0; i < input.length; i++) {
        const v = lookup[input[i]];
        if (v === undefined) {
            throw new Error(`Invalid base32 character: ${input[i]}`);
        }
        value = (value << 5) | v;
        bits += 5;

        if (bits >= 8) {
            bits -= 8;
            output.push((value >>> bits) & 0xff);
        }
    }

    return new Uint8Array(output);
}

// ── TOTP core ───────────────────────────────────────────────────────────────

/**
 * Generate a cryptographically random 20-byte secret and return it as a
 * base32-encoded string (suitable for authenticator apps).
 */
export function generateTOTPSecret() {
    const bytes = crypto.getRandomValues(new Uint8Array(20));
    return base32Encode(bytes);
}

/**
 * Compute an HMAC-SHA1 based TOTP code per RFC 6238.
 *
 * @param {string}  secret  Base32-encoded shared secret.
 * @param {number} [time]   Unix timestamp in seconds (defaults to now).
 * @returns {Promise<string>} 6-digit zero-padded TOTP code.
 */
export async function generateTOTPCode(secret, time) {
    const period = 30;
    const t = time !== undefined ? time : Math.floor(Date.now() / 1000);
    const counter = Math.floor(t / period);

    // Convert counter to 8-byte big-endian buffer
    const counterBuf = new ArrayBuffer(8);
    const view = new DataView(counterBuf);
    // DataView.setBigUint64 may not be available in all runtimes, so set
    // the two 32-bit halves manually.
    view.setUint32(0, Math.floor(counter / 0x100000000));
    view.setUint32(4, counter & 0xffffffff);

    // Import the secret as an HMAC-SHA1 key
    const keyBytes = base32Decode(secret);
    const key = await crypto.subtle.importKey(
        'raw',
        keyBytes,
        { name: 'HMAC', hash: 'SHA-1' },
        false,
        ['sign']
    );

    const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new Uint8Array(counterBuf)));

    // Dynamic truncation (RFC 4226 section 5.4)
    const offset = hmac[hmac.length - 1] & 0x0f;
    const binary =
        ((hmac[offset] & 0x7f) << 24) |
        ((hmac[offset + 1] & 0xff) << 16) |
        ((hmac[offset + 2] & 0xff) << 8) |
        (hmac[offset + 3] & 0xff);

    const otp = binary % 1000000;
    return otp.toString().padStart(6, '0');
}

/**
 * Verify a user-supplied TOTP code against the shared secret.
 * Allows +/- 1 time-step window to accommodate clock drift.
 *
 * @param {string} secret  Base32-encoded shared secret.
 * @param {string} code    6-digit code submitted by the user.
 * @returns {Promise<boolean>}
 */
export async function verifyTOTP(secret, code) {
    if (!code || code.length !== 6) return false;

    const now = Math.floor(Date.now() / 1000);
    const period = 30;

    for (let i = -1; i <= 1; i++) {
        const t = now + i * period;
        const expected = await generateTOTPCode(secret, t);
        if (timingSafeEqual(code, expected)) {
            return true;
        }
    }
    return false;
}

/**
 * Constant-time string comparison to mitigate timing attacks.
 */
function timingSafeEqual(a, b) {
    if (a.length !== b.length) return false;
    let result = 0;
    for (let i = 0; i < a.length; i++) {
        result |= a.charCodeAt(i) ^ b.charCodeAt(i);
    }
    return result === 0;
}

/**
 * Build an otpauth:// URI for provisioning authenticator apps.
 *
 * @param {string} secret   Base32 encoded secret.
 * @param {string} username Account name shown in the authenticator.
 * @param {string} issuer   Issuer label (e.g. "CF-DNS-Manager").
 * @returns {string} otpauth:// URI
 */
export function generateOTPAuthURI(secret, username, issuer) {
    const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(username)}`;
    const params = new URLSearchParams({
        secret,
        issuer,
        algorithm: 'SHA1',
        digits: '6',
        period: '30'
    });
    return `otpauth://totp/${label}?${params.toString()}`;
}