import { Buffer } from "node:buffer";
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
import jwt from "jsonwebtoken";
import { config } from "./config.js";
const scrypt = promisify(scryptCb);
/**
 * scrypt cost params. N=16384 (2^14) targets ~50–100 ms per verify on
 * modern hardware — slow enough to make brute-forcing expensive,
 * fast enough that an interactive login feels instant. r/p left at
 * the recommended defaults. Bump N when verifies start feeling fast.
 */
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const SCRYPT_KEYLEN = 64;
const SCRYPT_SALT_BYTES = 16;
const HASH_PREFIX = "scrypt";
/**
 * Constant-time string comparison. Pads the shorter buffer so timingSafeEqual
 * can run on equal-length inputs, then enforces the length check after-the-fact
 * so length isn't leaked through early-return timing.
 */
export function constantTimeStringEqual(presented, expected) {
    const a = Buffer.from(presented, "utf8");
    const b = Buffer.from(expected, "utf8");
    const len = Math.max(a.length, b.length);
    const aPadded = Buffer.alloc(len);
    const bPadded = Buffer.alloc(len);
    a.copy(aPadded);
    b.copy(bPadded);
    return timingSafeEqual(aPadded, bPadded) && a.length === b.length;
}
export function generateToken(opts) {
    if (config.auth.jwtSecret === undefined) {
        throw new Error("auth: cannot generate token — JWT_SECRET not configured");
    }
    const expiresIn = config.auth.jwtExpiresInSeconds;
    const token = jwt.sign({
        sub: "ui-user",
        mustChangePassword: opts.mustChangePassword,
    }, config.auth.jwtSecret, {
        algorithm: "HS256",
        expiresIn,
    });
    const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
    return { token, expiresAt };
}
/**
 * Verify a JWT and return the typed payload, or undefined on any failure
 * (bad signature, expired, missing JWT_SECRET, wrong subject).
 */
export function verifyToken(token) {
    if (config.auth.jwtSecret === undefined)
        return undefined;
    try {
        const decoded = jwt.verify(token, config.auth.jwtSecret, {
            algorithms: ["HS256"],
        });
        if (typeof decoded !== "object" || decoded === null)
            return undefined;
        if (decoded.sub !== "ui-user")
            return undefined;
        if (typeof decoded.iat !== "number" || typeof decoded.exp !== "number")
            return undefined;
        // mustChangePassword may be absent on tokens issued before this
        // field existed; treat absence as `false` so existing sessions
        // don't get force-rerouted to the change-password screen.
        const mustChangePassword = typeof decoded.mustChangePassword === "boolean"
            ? decoded.mustChangePassword
            : false;
        return { sub: "ui-user", iat: decoded.iat, exp: decoded.exp, mustChangePassword };
    }
    catch {
        // jsonwebtoken throws on malformed/expired/wrong-secret tokens.
        // Caller treats undefined as "no valid token" without
        // distinguishing why — clients can't act on the distinction
        // (and we don't want to leak which case applies to a brute-forcer).
        return undefined;
    }
}
export function verifyApiKey(presented) {
    const expected = config.auth.apiKey;
    if (expected === undefined)
        return false;
    return constantTimeStringEqual(presented, expected);
}
/**
 * Verify a presented password against either the on-disk hash (if
 * present) or the env UI_PASSWORD (fallback). The returned `source`
 * lets the caller decide whether to set `mustChangePassword` on the
 * issued token: `env` means the user is logging in with the
 * deployment-baked credential and (if `requirePasswordChange` is on)
 * must change it before doing anything else.
 *
 * Once a hash exists on disk, the env password is IGNORED — that
 * file is the canonical credential and should survive env-rotation
 * just like jwt-secret does.
 */
export async function verifyPasswordWithSource(presented) {
    const stored = readStoredHash();
    if (stored !== undefined) {
        const ok = await verifyAgainstStoredHash(presented, stored);
        return { ok, source: "stored" };
    }
    const envPw = config.auth.uiPassword;
    if (envPw !== undefined) {
        return { ok: constantTimeStringEqual(presented, envPw), source: "env" };
    }
    return { ok: false, source: "none" };
}
export function passwordConfigured() {
    return readStoredHash() !== undefined || config.auth.uiPassword !== undefined;
}
/**
 * Hash the new password and atomically replace the on-disk file.
 * Mode 0600 — only the Huiyu Pi process owner should be able to
 * read it. Atomic replace via tmp + rename so a crash mid-write
 * doesn't leave a half-written hash that locks the user out.
 */
export async function persistPassword(plain) {
    const encoded = await hashPassword(plain);
    const path = config.auth.passwordHashFile;
    const tmp = `${path}.tmp`;
    writeFileSync(tmp, `${encoded}\n`, { mode: 0o600 });
    chmodSync(tmp, 0o600);
    renameSync(tmp, path);
}
async function hashPassword(plain) {
    const salt = randomBytes(SCRYPT_SALT_BYTES);
    const hash = await scrypt(plain, salt, SCRYPT_KEYLEN);
    return [
        HASH_PREFIX,
        String(SCRYPT_N),
        String(SCRYPT_R),
        String(SCRYPT_P),
        salt.toString("base64"),
        hash.toString("base64"),
    ].join("$");
}
function parseHash(encoded) {
    const parts = encoded.split("$");
    if (parts.length !== 6)
        return undefined;
    if (parts[0] !== HASH_PREFIX)
        return undefined;
    const n = Number.parseInt(parts[1] ?? "", 10);
    const r = Number.parseInt(parts[2] ?? "", 10);
    const p = Number.parseInt(parts[3] ?? "", 10);
    if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p))
        return undefined;
    try {
        const salt = Buffer.from(parts[4] ?? "", "base64");
        const hash = Buffer.from(parts[5] ?? "", "base64");
        if (salt.length === 0 || hash.length === 0)
            return undefined;
        return { n, r, p, salt, hash };
    }
    catch {
        return undefined;
    }
}
function readStoredHash() {
    const path = config.auth.passwordHashFile;
    if (!existsSync(path))
        return undefined;
    try {
        const v = readFileSync(path, "utf8").trim();
        return v.length > 0 ? v : undefined;
    }
    catch {
        return undefined;
    }
}
function scryptWithOptions(password, salt, keylen, options) {
    return new Promise((resolve, reject) => {
        scryptCb(password, salt, keylen, options, (err, derived) => {
            if (err !== null) {
                reject(err);
                return;
            }
            resolve(derived);
        });
    });
}
async function verifyAgainstStoredHash(presented, encoded) {
    const parsed = parseHash(encoded);
    if (parsed === undefined)
        return false;
    // Honour the stored params (not our current constants) so older
    // hashes with different cost parameters still verify after a
    // params bump. New hashes always use the current SCRYPT_* values.
    const candidate = await scryptWithOptions(presented, parsed.salt, parsed.hash.length, {
        N: parsed.n,
        r: parsed.r,
        p: parsed.p,
    });
    if (candidate.length !== parsed.hash.length)
        return false;
    return timingSafeEqual(candidate, parsed.hash);
}
export function extractBearer(headerValue) {
    if (!headerValue)
        return undefined;
    const m = /^Bearer\s+(\S+)$/i.exec(headerValue);
    return m?.[1];
}
//# sourceMappingURL=auth.js.map