/**
 * Credential verification for `TERA_AUTH_MODE=password`.
 *
 * The rule this file exists to enforce is that **no plaintext password ever
 * comes out of the environment**. `TERA_AUTH_PASSWORD_HASH` carries a scrypt
 * digest and the parameters it was derived under, so an operator who leaks their
 * unit file, their `docker inspect` output or their shell history has leaked a
 * hash and not an account. A `TERA_AUTH_PASSWORD` that this module would happily
 * compare against is the version of this feature that must never exist.
 *
 * scrypt is `node:crypto`'s, which is why there is no new dependency: it is
 * memory-hard, it ships in the runtime, and the alternative is pulling argon2 or
 * bcrypt — a native build — into a repo whose acceptance test is `npm ci` on a
 * stranger's laptop.
 *
 * ## Generating a hash
 *
 * There is no script to run and nothing to install. Paste this, type the
 * password, press ctrl-D, and put the line it prints in
 * `TERA_AUTH_PASSWORD_HASH`:
 *
 * ```sh
 * node -e 'const c=require("node:crypto");let p="";process.stdin.setEncoding("utf8").on("data",d=>p+=d).on("end",()=>{const s=c.randomBytes(16),k=c.scryptSync(p.replace(/\n$/,""),s,32,{N:16384,r:8,p:1,maxmem:67108864});console.log(`scrypt$16384$8$1$${s.toString("base64url")}$${k.toString("base64url")}`)})'
 * ```
 *
 * It reads stdin rather than taking the password as an argument because an
 * argument lands in shell history and in anybody's `ps` output. A trailing
 * newline is stripped so that typing the password and pressing return produces
 * the same hash as piping it in.
 *
 * `session.test.ts` pins the output of exactly that command against the parser
 * below, so the two cannot drift apart without a test failing.
 */

import { randomBytes, scrypt, timingSafeEqual, createHash } from "node:crypto";

/** A parsed `TERA_AUTH_PASSWORD_HASH`, with the parameters it was made under. */
export interface ScryptHash {
  n: number;
  r: number;
  p: number;
  salt: Buffer;
  key: Buffer;
}

/**
 * Interactive-login parameters as of 2026: ~16 MiB and a few tens of
 * milliseconds. High enough that a leaked hash is not a wordlist away from a
 * password, low enough that the login endpoint is not itself a way to burn the
 * box's CPU. The chosen values are recorded *in* the hash, so raising them later
 * does not invalidate the hashes already in operators' unit files.
 */
const DEFAULT_PARAMS = { n: 16384, r: 8, p: 1 } as const;
const SALT_BYTES = 16;
const KEY_BYTES = 32;

/**
 * An upper bound on what a hash string is allowed to ask scrypt to allocate.
 * `TERA_AUTH_PASSWORD_HASH` is operator input rather than attacker input, but a
 * fat-fingered `N` should be a boot-time demotion and not a box that allocates
 * sixteen gigabytes on the first login attempt.
 */
const MAX_MEMORY_BYTES = 256 * 1024 * 1024;

/**
 * Longer than this and we do not even hash it. scrypt's cost is independent of
 * the password's length, so this is not about work — it is about not copying a
 * megabyte of request body into a key-derivation function on an unauthenticated
 * endpoint.
 */
export const MAX_PASSWORD_LENGTH = 256;

export function parseScryptHash(encoded: string): ScryptHash | null {
  const parts = encoded.split("$");
  if (parts.length !== 6) return null;
  const [scheme, nRaw, rRaw, pRaw, saltRaw, keyRaw] = parts;
  if (scheme !== "scrypt") return null;
  if (nRaw === undefined || rRaw === undefined || pRaw === undefined) return null;
  if (saltRaw === undefined || keyRaw === undefined) return null;

  const n = positiveInt(nRaw);
  const r = positiveInt(rRaw);
  const p = positiveInt(pRaw);
  if (n === null || r === null || p === null) return null;
  // scrypt requires N to be a power of two greater than one, and rejects
  // anything else at call time. Catching it here turns a runtime throw on the
  // login path into one line in `/api/v1/health`.
  if (n < 2 || (n & (n - 1)) !== 0) return null;
  if (memoryFor(n, r, p) > MAX_MEMORY_BYTES) return null;

  const salt = fromBase64Url(saltRaw);
  const key = fromBase64Url(keyRaw);
  if (salt === null || key === null) return null;
  if (salt.length < 8 || key.length < 16) return null;

  return { n, r, p, salt, key };
}

export function formatScryptHash(hash: ScryptHash): string {
  const salt = hash.salt.toString("base64url");
  const key = hash.key.toString("base64url");
  return `scrypt$${hash.n}$${hash.r}$${hash.p}$${salt}$${key}`;
}

/** Used by the tests and available to anyone who would rather not paste shell. */
export async function hashPassword(plaintext: string): Promise<string> {
  const { n, r, p } = DEFAULT_PARAMS;
  const salt = randomBytes(SALT_BYTES);
  const key = await derive(plaintext, salt, KEY_BYTES, { n, r, p });
  if (key === null) throw new Error("scrypt failed");
  return formatScryptHash({ n, r, p, salt, key });
}

/**
 * Derive and compare in constant time.
 *
 * The comparison is `timingSafeEqual` over two buffers that are the same length
 * by construction — the derived key's length is taken from the stored key — so
 * there is no early exit for an attacker to measure. The caller must still call
 * this even when it already knows the username is wrong; see `credentialsMatch`.
 */
export async function verifyPassword(hash: ScryptHash, supplied: string): Promise<boolean> {
  if (supplied.length > MAX_PASSWORD_LENGTH) return false;
  const derived = await derive(supplied, hash.salt, hash.key.length, hash);
  if (derived === null) return false;
  return timingSafeEqual(derived, hash.key);
}

/**
 * The whole credential check, and the reason it is one function rather than two
 * calls at the route: **a wrong username and a wrong password must be the same
 * event**. Both branches derive a key, both take the same path, and the two
 * answers are combined only at the end. An implementation that returns early on
 * an unknown username answers in a millisecond instead of fifty and hands an
 * attacker a user-enumeration oracle for free.
 */
export async function credentialsMatch(
  expectedUsername: string,
  hash: ScryptHash,
  suppliedUsername: string,
  suppliedPassword: string,
): Promise<boolean> {
  const usernameOk = timingSafeStringEqual(expectedUsername, suppliedUsername);
  const passwordOk = await verifyPassword(hash, suppliedPassword);
  return usernameOk && passwordOk;
}

/**
 * Compare two strings of unequal length without leaking the length.
 *
 * `timingSafeEqual` throws when the buffers differ in size, which would make the
 * *shape* of the failure depend on the input. Hashing both first gives two
 * 32-byte buffers whatever arrived, and a username is not secret enough for the
 * extra digest to be worth avoiding.
 */
export function timingSafeStringEqual(a: string, b: string): boolean {
  const left = createHash("sha256").update(a, "utf8").digest();
  const right = createHash("sha256").update(b, "utf8").digest();
  return timingSafeEqual(left, right);
}

// ---- Internals ------------------------------------------------------------

function derive(
  plaintext: string,
  salt: Buffer,
  length: number,
  params: { n: number; r: number; p: number },
): Promise<Buffer | null> {
  const { n, r, p } = params;
  return new Promise((resolve) => {
    // The callback form, not `scryptSync`: this runs on a login request, and
    // fifty milliseconds of synchronous key derivation is fifty milliseconds in
    // which the one server answers nobody else.
    scrypt(
      plaintext,
      salt,
      length,
      { N: n, r, p, maxmem: Math.max(32 * 1024 * 1024, memoryFor(n, r, p) * 2) },
      (err, derived) => resolve(err === null ? derived : null),
    );
  });
}

/** scrypt's working set, which is what `maxmem` is checked against. */
function memoryFor(n: number, r: number, p: number): number {
  return 128 * n * r * p;
}

function positiveInt(raw: string): number | null {
  if (!/^[0-9]{1,10}$/.test(raw)) return null;
  const value = Number(raw);
  return value > 0 ? value : null;
}

/**
 * `Buffer.from(s, "base64url")` never fails — it stops at the first character it
 * cannot use and returns what it had. Round-tripping is the only way to know the
 * string was really base64url, and a hash with a typo in it must be a boot-time
 * demotion rather than a password that can never be right.
 */
function fromBase64Url(raw: string): Buffer | null {
  if (raw === "") return null;
  const decoded = Buffer.from(raw, "base64url");
  return decoded.toString("base64url") === raw ? decoded : null;
}
