/**
 * Extra keystore hardening tests.
 *
 * Covers audit findings from `.test-audit/
 *   - CLI-1  (CRITICAL, dup #11) — scrypt KDF parameter floor is not enforced.
 *     A keystore file with dangerously weak N/r/p parameters loads successfully,
 *     defeating the whole point of scrypt memory-hardness.
 *
 * The existing `keystore.test.ts` only covers the happy path (encrypt → decrypt,
 * wrong passphrase throws, wire format stability). It has no negative tests for
 * parameter choice. This file adds those.
 *
 * NOTE (PROD-BUG): as of audit, `decryptKeystore` reads `kdfparams.n / r / p`
 * verbatim and derives the key with whatever values the (untrusted) keystore
 * file provides. There is no minimum-cost gate. Until a floor check lands in
 * `src/keystore.ts`, the "refuse weak" assertions stay RED — see
 * `.test-audit/.
 */

import { describe, it, expect, afterEach } from 'vitest';
import {
  encryptKeystore,
  decryptKeystore,
  isEncryptedKeystore,
  _setScryptN,
  type EncryptedKeystore,
} from '../src/keystore.js';

const PRIVKEY = 'deadbeef'.repeat(8); // 32 bytes
const PASSPHRASE = 'correct horse battery staple';

// scrypt N must be a power of two; 2**15 is the existing production floor for
// the non-test code path — 2**18 is the real prod value, lowered via `_setScryptN`
// for tests to avoid OOM on small CI workers.
const SAFE_N = 2 ** 15;

// Weak params. If the decrypt path does not floor-check, these still "work".
const WEAK_N = 2 ** 8; // toy value
const WEAK_R = 1;
const WEAK_P = 1;

afterEach(() => {
  // Restore test-friendly N so subsequent tests in this file don't pay the
  // 128 MB prod cost.
  _setScryptN(SAFE_N);
});

function withKdfParams(
  ks: EncryptedKeystore,
  overrides: Partial<EncryptedKeystore['crypto']['kdfparams']>,
): EncryptedKeystore {
  return {
    ...ks,
    crypto: {
      ...ks.crypto,
      kdfparams: { ...ks.crypto.kdfparams, ...overrides },
    },
  };
}

describe('keystore wire format is stable', () => {
  it('emits a V1 keystore that type-guards as EncryptedKeystore', async () => {
    _setScryptN(SAFE_N);
    const ks = await encryptKeystore(PRIVKEY, PASSPHRASE);
    expect(isEncryptedKeystore(ks)).toBe(true);
    expect(ks.version).toBe(1);
    expect(ks.crypto.cipher).toBe('aes-256-gcm');
    expect(ks.crypto.kdf).toBe('scrypt');
    expect(ks.crypto.kdfparams.dklen).toBe(32);
    // Salt, iv, tag are hex strings of the expected lengths
    expect(ks.crypto.kdfparams.salt).toMatch(/^[0-9a-f]{64}$/);
    expect(ks.crypto.iv).toMatch(/^[0-9a-f]{24}$/);
    expect(ks.crypto.tag).toMatch(/^[0-9a-f]{32}$/);
    expect(ks.id).toMatch(/^[0-9a-f]{16}$/);
  });
});

describe('CLI-1 — scrypt KDF parameter floor (PROD-BUG: not enforced)', () => {
  it('refuses to decrypt a keystore whose advertised N is below 2**15 (minimum scrypt cost)', async () => {
    _setScryptN(SAFE_N);
    const ks = await encryptKeystore(PRIVKEY, PASSPHRASE);

    // Re-encrypt using a toy N so we can faithfully construct a forged
    // "weak" keystore (same ciphertext+IV+tag would not decrypt if we just
    // mutated kdfparams because the derived key would differ).
    _setScryptN(WEAK_N);
    const weakKs = await encryptKeystore(PRIVKEY, PASSPHRASE);
    _setScryptN(SAFE_N);

    // Sanity: this really is a weak keystore — the advertised N is the one we
    // encrypted with.
    expect(weakKs.crypto.kdfparams.n).toBe(WEAK_N);

    // Sanity: `decryptKeystore` actually reads `kdfparams.n` when deriving
    // (regression guard against the historical bug where the loader
    // ignored the file's advertised cost factor and always used the
    // module-global `SCRYPT_N`). To prove the param is honoured without
    // tripping the brand-new "weak keystore" gate, tamper with a value
    // that's STILL above the production floor (2^15) but different from
    // what the keystore was actually encrypted with — different N →
    // different derived key → GCM auth failure → "Decryption failed".
    const STRONG_BUT_DIFFERENT_N = 2 ** 16;
    expect(STRONG_BUT_DIFFERENT_N).toBeGreaterThan(SAFE_N);
    await expect(
      decryptKeystore(withKdfParams(ks, { n: STRONG_BUT_DIFFERENT_N }), PASSPHRASE),
    ).rejects.toThrow(/Decryption failed/);

    // PROD-BUG: the below call SHOULD throw "KDF parameters below minimum"
    // (or any rejection tied to the cost floor). Instead it returns the
    // plaintext — which means any attacker who can write a keystore file
    // can force an O(1)-to-brute-force KDF. See issue #11.
    //
    // This assertion stays RED until `decryptKeystore` enforces N >= 2**15,
    // r >= 8, p >= 1. Leaving red-on-purpose.
    await expect(decryptKeystore(weakKs, PASSPHRASE)).rejects.toThrow(
      /KDF parameters below minimum|scrypt cost too low|weak keystore/i,
    );
  });

  it('refuses to decrypt a keystore whose r factor is below 8 (memory-hardness)', async () => {
    _setScryptN(SAFE_N);
    const ks = await encryptKeystore(PRIVKEY, PASSPHRASE);

    // We cannot actually re-derive with r=1 via the public API (r is fixed
    // at 8 in the module), but an attacker could hand-craft such a file.
    // Assert that the loader refuses it BEFORE attempting a derive — a
    // correct implementation rejects at the kdfparams validation step and
    // never calls scryptSync with a pathological r.
    const tampered = withKdfParams(ks, { r: WEAK_R });

    // PROD-BUG: decryptKeystore has no param-floor check, so it simply
    // re-derives with r=1, gets the wrong key, and throws the generic
    // "Decryption failed — wrong passphrase or corrupted keystore" error.
    // That's the wrong error *class*: the true reason is "unsafe KDF
    // parameters", and the file should be rejected up-front.
    await expect(decryptKeystore(tampered, PASSPHRASE)).rejects.toThrow(
      /KDF parameters below minimum|scrypt r too low|weak keystore/i,
    );
  });

  it('refuses to decrypt a keystore whose p factor is below 1 (parallelism floor)', async () => {
    _setScryptN(SAFE_N);
    const ks = await encryptKeystore(PRIVKEY, PASSPHRASE);

    // p=0 is definitionally nonsense — but the loader currently passes it
    // straight through to scryptSync, which throws a low-level
    // ERR_OUT_OF_RANGE from Node's OpenSSL binding. The loader should
    // catch and replace that with a clear "weak keystore" error.
    const tampered = withKdfParams(ks, { p: 0 });

    await expect(decryptKeystore(tampered, PASSPHRASE)).rejects.toThrow(
      /KDF parameters below minimum|scrypt p too low|weak keystore/i,
    );
  });

  it('refuses to decrypt a keystore with a short salt (<16 bytes)', async () => {
    _setScryptN(SAFE_N);
    const ks = await encryptKeystore(PRIVKEY, PASSPHRASE);
    // 8 zero bytes — eligible for precomputed rainbow attacks.
    const tampered = withKdfParams(ks, { salt: '00'.repeat(8) });

    // PROD-BUG (dup #11): the loader currently happily derives from any
    // salt length and lets GCM decide if the plaintext matches. A weak
    // salt should be rejected up front.
    await expect(decryptKeystore(tampered, PASSPHRASE)).rejects.toThrow(
      /salt too short|weak keystore|KDF parameters below minimum/i,
    );
  });

  it('refuses to decrypt a keystore with a wrong dklen (must equal 32 for AES-256)', async () => {
    _setScryptN(SAFE_N);
    const ks = await encryptKeystore(PRIVKEY, PASSPHRASE);
    const tampered = withKdfParams(ks, { dklen: 16 });

    // A dklen != 32 is structurally incompatible with aes-256-gcm and
    // should produce a crisp validation error (NOT a generic "Decryption
    // failed" after a silent key-length mismatch).
    await expect(decryptKeystore(tampered, PASSPHRASE)).rejects.toThrow(
      /dklen must be 32|invalid dklen|weak keystore/i,
    );
  });
});

describe('isEncryptedKeystore — structural guard', () => {
  it('rejects objects that look keystore-shaped but are not version 1', () => {
    expect(isEncryptedKeystore({ version: 2, crypto: {} })).toBe(false);
    expect(isEncryptedKeystore({ version: 'v1', crypto: {} })).toBe(false);
    expect(isEncryptedKeystore(null)).toBe(false);
    expect(isEncryptedKeystore('keystore')).toBe(false);
  });

  it('accepts the canonical shape', () => {
    expect(
      isEncryptedKeystore({
        version: 1,
        crypto: { cipher: 'aes-256-gcm' },
        id: 'x',
      }),
    ).toBe(true);
  });
});
