// Tests for src/shared/vault.js: the Argon2id + XSalsa20-Poly1305 encryption
// that protects recovery phrases and private keys at rest.
//
// The properties that matter here are the ones whose failure is silent. A
// vault that decrypts under the wrong password, that hands back plaintext from
// a ciphertext an attacker edited, that reuses a nonce, or that leaves the
// recovery phrase readable somewhere in the stored blob all look exactly like
// a working vault from the UI. So each test below asserts a negative: the
// thing that must not happen.
//
// Cost: every encrypt and decrypt runs one Argon2id pwhash at the production
// interactive parameters, which the module hardcodes. The parameters are not
// weakened or overridden anywhere in this file — they are pinned by the "key
// derivation cost" tests, since they are the vault's only defence against an
// offline attack on a stolen blob. The suite is kept inside script/test's
// 30-second budget by sharing one encrypted fixture across the tamper cases
// instead of re-encrypting per test.

const sodium = require("libsodium-wrappers-sumo");
const {
    encryptWithPassword,
    decryptWithPassword,
} = require("../src/shared/vault");

// A publicly known development phrase. Never fund it.
const SECRET = "test test test test test test test test test test test junk";
const PASSWORD = "correct horse battery staple";
const WRONG_PASSWORD = "correct horse battery stapl";

const SALT_BYTES = 16;
const NONCE_BYTES = 24;
const POLY1305_TAG_BYTES = 16;

const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;

function b64decode(s) {
    return sodium.from_base64(s);
}

// A shallow copy with one field replaced, so the shared fixture is never
// mutated by a tamper test.
function withField(blob, field, value) {
    return { ...blob, [field]: value };
}

// Flip the low bit of one byte of a base64-encoded field.
function flipByte(b64, index) {
    const bytes = b64decode(b64);
    bytes[index] ^= 0x01;
    return sodium.to_base64(bytes);
}

let vault;

beforeAll(async () => {
    await sodium.ready;
    vault = await encryptWithPassword(SECRET, PASSWORD);
});

describe("stored blob shape", () => {
    test("is exactly the documented { salt, nonce, ciphertext }", () => {
        expect(Object.keys(vault).sort()).toEqual([
            "ciphertext",
            "nonce",
            "salt",
        ]);
    });

    test("every field is a base64 string", () => {
        for (const field of ["salt", "nonce", "ciphertext"]) {
            expect(typeof vault[field]).toBe("string");
            expect(vault[field]).toMatch(BASE64);
        }
    });

    test("salt and nonce are full length", () => {
        expect(b64decode(vault.salt)).toHaveLength(SALT_BYTES);
        expect(b64decode(vault.nonce)).toHaveLength(NONCE_BYTES);
    });

    test("ciphertext carries a Poly1305 authentication tag", () => {
        expect(b64decode(vault.ciphertext)).toHaveLength(
            SECRET.length + POLY1305_TAG_BYTES,
        );
    });

    test("the blob survives JSON storage unchanged", async () => {
        const stored = JSON.parse(JSON.stringify(vault));

        await expect(decryptWithPassword(stored, PASSWORD)).resolves.toBe(
            SECRET,
        );
    });
});

describe("no plaintext leakage", () => {
    test("the secret does not appear in the serialized vault", () => {
        const serialized = JSON.stringify(vault);

        expect(serialized).not.toContain(SECRET);
        for (const word of new Set(SECRET.split(" "))) {
            expect(serialized).not.toContain(word);
        }
    });

    test("the ciphertext bytes do not contain the secret bytes", () => {
        const bytes = Buffer.from(b64decode(vault.ciphertext));

        expect(bytes.includes(Buffer.from(SECRET, "utf8"))).toBe(false);
        // Not even the first word, which would betray an unencrypted prefix.
        expect(bytes.includes(Buffer.from("test test", "utf8"))).toBe(false);
    });

    test("the password does not appear in the serialized vault", () => {
        expect(JSON.stringify(vault)).not.toContain(PASSWORD);
    });
});

describe("round trip", () => {
    test("decrypts back to the original secret", async () => {
        await expect(decryptWithPassword(vault, PASSWORD)).resolves.toBe(
            SECRET,
        );
    });

    test("survives a non-ASCII plaintext byte for byte", async () => {
        const unicode = "recovery phrase é中文\u{1f600}";

        const blob = await encryptWithPassword(unicode, PASSWORD);

        await expect(decryptWithPassword(blob, PASSWORD)).resolves.toBe(
            unicode,
        );
    });

    test("an empty password still round-trips and is not a bypass", async () => {
        const blob = await encryptWithPassword(SECRET, "");

        await expect(decryptWithPassword(blob, "")).resolves.toBe(SECRET);
        // An empty password must not act as a skeleton key on other vaults,
        // nor may a real password open an empty-password vault.
        await expect(decryptWithPassword(vault, "")).rejects.toThrow();
        await expect(decryptWithPassword(blob, PASSWORD)).rejects.toThrow();
    });
});

describe("fresh salt and nonce", () => {
    test("two encryptions of the same plaintext differ in all three fields", async () => {
        const second = await encryptWithPassword(SECRET, PASSWORD);

        expect(second.salt).not.toBe(vault.salt);
        expect(second.nonce).not.toBe(vault.nonce);
        expect(second.ciphertext).not.toBe(vault.ciphertext);
        await expect(decryptWithPassword(second, PASSWORD)).resolves.toBe(
            SECRET,
        );
    });
});

describe("key derivation cost", () => {
    // Argon2id's opslimit and memlimit are the whole of the vault's resistance
    // to an offline attack on a stolen blob, and lowering them breaks nothing
    // any other test here can see — the suite merely runs faster. So pin them
    // directly, both to libsodium's INTERACTIVE constants and to the absolute
    // values those constants must keep meaning.
    const INTERACTIVE_OPSLIMIT = 2;
    const INTERACTIVE_MEMLIMIT = 64 * 1024 * 1024;

    test("the interactive constants still mean 2 passes over 64 MiB", () => {
        expect(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE).toBe(
            INTERACTIVE_OPSLIMIT,
        );
        expect(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE).toBe(
            INTERACTIVE_MEMLIMIT,
        );
        // The floor these must never quietly be swapped for: _MIN is one pass
        // over 8 KiB, an 8192x reduction in memory cost.
        expect(sodium.crypto_pwhash_OPSLIMIT_MIN).toBeLessThan(
            INTERACTIVE_OPSLIMIT,
        );
        expect(sodium.crypto_pwhash_MEMLIMIT_MIN).toBeLessThan(
            INTERACTIVE_MEMLIMIT,
        );
    });

    test("a key derived at the interactive parameters opens the vault", () => {
        // Independent of any spy, and of the module's own code path: derive
        // the key here from the vault's published salt at the interactive cost
        // and open its ciphertext directly. A vault whose key came from any
        // other opslimit, memlimit or Argon2id variant yields a different key
        // and cannot be opened this way.
        const key = sodium.crypto_pwhash(
            sodium.crypto_secretbox_KEYBYTES,
            PASSWORD,
            b64decode(vault.salt),
            INTERACTIVE_OPSLIMIT,
            INTERACTIVE_MEMLIMIT,
            sodium.crypto_pwhash_ALG_ARGON2ID13,
        );
        const opened = sodium.crypto_secretbox_open_easy(
            b64decode(vault.ciphertext),
            b64decode(vault.nonce),
            key,
        );

        expect(sodium.to_string(opened)).toBe(SECRET);
    });

    test.each([
        [
            "encrypt",
            async () => {
                await encryptWithPassword(SECRET, PASSWORD);
            },
        ],
        [
            "decrypt",
            async () => {
                await decryptWithPassword(vault, PASSWORD);
            },
        ],
    ])("%s derives exactly one key at the interactive cost", async (_, run) => {
        const spy = jest.spyOn(sodium, "crypto_pwhash");
        try {
            await run();

            expect(spy).toHaveBeenCalledTimes(1);
            const [keyBytes, , salt, opslimit, memlimit, alg] =
                spy.mock.calls[0];
            expect(keyBytes).toBe(sodium.crypto_secretbox_KEYBYTES);
            expect(salt).toHaveLength(SALT_BYTES);
            expect(opslimit).toBe(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE);
            expect(memlimit).toBe(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE);
            expect(alg).toBe(sodium.crypto_pwhash_ALG_ARGON2ID13);
        } finally {
            spy.mockRestore();
        }
    });
});

describe("wrong password", () => {
    test("is rejected, and rejects cleanly", async () => {
        // rejects.toThrow asserts a rejected promise, not a synchronous throw
        // and not an unhandled rejection: the caller can catch this.
        await expect(
            decryptWithPassword(vault, WRONG_PASSWORD),
        ).rejects.toThrow();
    });

    test("returns no plaintext, not even partially", async () => {
        const result = await decryptWithPassword(vault, WRONG_PASSWORD).catch(
            (err) => err,
        );

        expect(result).toBeInstanceOf(Error);
        expect(String(result)).not.toContain("test");
    });

    test("the empty password is rejected on a password-protected vault", async () => {
        await expect(decryptWithPassword(vault, "")).rejects.toThrow();
    });
});

describe("tampering", () => {
    test("a flipped ciphertext bit is rejected by the auth tag", async () => {
        const tampered = withField(
            vault,
            "ciphertext",
            flipByte(vault.ciphertext, 0),
        );

        await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
    });

    test("a flipped bit in the authentication tag itself is rejected", async () => {
        const tagStart = b64decode(vault.ciphertext).length - 1;
        const tampered = withField(
            vault,
            "ciphertext",
            flipByte(vault.ciphertext, tagStart),
        );

        await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
    });

    test("a flipped nonce bit is rejected", async () => {
        const tampered = withField(vault, "nonce", flipByte(vault.nonce, 0));

        await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
    });

    test("a flipped salt bit is rejected", async () => {
        const tampered = withField(vault, "salt", flipByte(vault.salt, 0));

        await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
    });

    test("a truncated ciphertext is rejected", async () => {
        const bytes = b64decode(vault.ciphertext);
        const tampered = withField(
            vault,
            "ciphertext",
            sodium.to_base64(bytes.slice(0, bytes.length - 4)),
        );

        await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
    });

    test("a ciphertext shorter than the auth tag is rejected", async () => {
        const tampered = withField(
            vault,
            "ciphertext",
            sodium.to_base64(b64decode(vault.ciphertext).slice(0, 4)),
        );

        await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
    });

    test("a truncated nonce is rejected", async () => {
        const tampered = withField(
            vault,
            "nonce",
            sodium.to_base64(b64decode(vault.nonce).slice(0, NONCE_BYTES - 1)),
        );

        await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
    });

    test("a ciphertext from another vault is rejected", async () => {
        const other = await encryptWithPassword("a different secret", PASSWORD);
        const spliced = withField(vault, "ciphertext", other.ciphertext);

        await expect(decryptWithPassword(spliced, PASSWORD)).rejects.toThrow();
    });

    test("a missing field is rejected rather than decrypted", async () => {
        for (const field of ["salt", "nonce", "ciphertext"]) {
            const broken = { ...vault };
            delete broken[field];

            await expect(
                decryptWithPassword(broken, PASSWORD),
            ).rejects.toThrow();
        }
    });
});
