import { describe, expect, test } from "bun:test"; import { StringScrambler } from "../../src/utils/stringtool"; describe("StringScrambler", () => { const PASSPHRASE = "ctf-test-passphrase"; const SALT = "test-salt-value"; describe("round-trip with same instance", () => { const scrambler = new StringScrambler(PASSPHRASE, SALT); test.each([ ["empty string", ""], ["single char", "x"], ["two chars", "AB"], ["short ascii", "hello"], ["ascii with punctuation", "Hello, World!"], ["mixed symbols", "longer string with numbers 123 and symbols !@#"], ["whitespace only", " \t "], ["control chars", "line1\nline2\tend"], ["unicode latin", "café résumé"], ["unicode emoji", "rocket 🚀 and 👍🏽"], ["cjk", "中文字符串"], ["long string", "The quick brown fox jumps over the lazy dog. ".repeat(20)], ["binary-like bytes", String.fromCharCode(...Array.from({ length: 256 }, (_, i) => i))], ])("should round-trip %s", (_label, value) => { const encoded = scrambler.encode(value); const decoded = scrambler.decode(encoded); expect(decoded).toBe(value); }); }); describe("round-trip across separate instances with same passphrase+ salt", () => { test("should decode with a different instance using the same parameters", () => { const a = new StringScrambler(PASSPHRASE, SALT); const b = new StringScrambler(PASSPHRASE, SALT); const value = "cross-instance secret payload"; expect(b.decode(a.encode(value))).toBe(value); }); test("should round-trip multiple values across instances", () => { const a = new StringScrambler(PASSPHRASE, SALT); const b = new StringScrambler(PASSPHRASE, SALT); for (const value of ["one", "two", "three 🎉", "中文"]) { expect(b.decode(a.encode(value))).toBe(value); } }); }); describe("encoding properties", () => { const scrambler = new StringScrambler(PASSPHRASE, SALT); test("should produce a valid base64 string", () => { expect(scrambler.encode("hello world")).toMatch( /^[A-Za-z0-9+/]+=*$/, ); }); test("should produce different ciphertext for the same plaintext", () => { const value = "same plaintext"; const e1 = scrambler.encode(value); const e2 = scrambler.encode(value); expect(e1).not.toBe(e2); expect(scrambler.decode(e1)).toBe(value); expect(scrambler.decode(e2)).toBe(value); }); test("should have a 16-byte encrypted nonce prefix", () => { const value = "abcdef"; const encoded = scrambler.encode(value); const buf = Buffer.from(encoded, "base64"); expect(buf.length).toBe(16 + Buffer.byteLength(value, "utf8")); }); test("nonce should differ between encodings", () => { const b1 = Buffer.from(scrambler.encode("a"), "base64").subarray(0, 16); const b2 = Buffer.from(scrambler.encode("a"), "base64").subarray(0, 16); expect(b1.equals(b2)).toBe(false); }); test("nonce bytes should be non-zero and not the raw nonce", () => { const encoded = scrambler.encode("test"); const nonceBytes = Buffer.from(encoded, "base64").subarray(0, 16); // Encrypted nonce should not be trivially identifiable const allZero = nonceBytes.every((b) => b === 0); expect(allZero).toBe(false); }); }); describe("CBC diffusion", () => { const scrambler = new StringScrambler(PASSPHRASE, SALT); test("changing one byte of plaintext changes subsequent ciphertext bytes", () => { const a = Buffer.from(scrambler.encode("A".repeat(16)), "base64").subarray(16); const b = Buffer.from(scrambler.encode("B" + "A".repeat(15)), "base64").subarray(16); // With CBC chaining, the first byte differs AND subsequent bytes propagate let diffCount = 0; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) diffCount++; } expect(diffCount).toBeGreaterThan(1); }); test("changing one byte mid-string changes all subsequent bytes", () => { const prefix = "AAAA"; const a = Buffer.from( scrambler.encode(prefix + "AAAA"), "base64", ).subarray(16); const b = Buffer.from( scrambler.encode(prefix + "BBBB"), "base64", ).subarray(16); let diffCount = 0; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) diffCount++; } expect(diffCount).toBeGreaterThan(1); }); }); describe("multi-round", () => { const scrambler = new StringScrambler(PASSPHRASE, SALT); test("three rounds still round-trip correctly", () => { const value = "multi-round verification string"; expect(scrambler.decode(scrambler.encode(value))).toBe(value); }); test("ciphertext size equals plaintext size plus nonce", () => { for (const value of ["", "a", "hello world", "x".repeat(1000)]) { const encoded = scrambler.encode(value); const buf = Buffer.from(encoded, "base64"); expect(buf.length).toBe(16 + Buffer.byteLength(value, "utf8")); } }); }); describe("isolation between parameters", () => { test("should NOT decode correctly with a different passphrase", () => { const a = new StringScrambler("pass-one", SALT); const b = new StringScrambler("pass-two", SALT); const encoded = a.encode("top secret"); expect(b.decode(encoded)).not.toBe("top secret"); }); test("should NOT decode correctly with a different salt", () => { const a = new StringScrambler(PASSPHRASE, "salt-one"); const b = new StringScrambler(PASSPHRASE, "salt-two"); const encoded = a.encode("top secret"); expect(b.decode(encoded)).not.toBe("top secret"); }); test("different salts produce completely different ciphertexts", () => { const a = new StringScrambler(PASSPHRASE, "salt-A"); const b = new StringScrambler(PASSPHRASE, "salt-B"); const e1 = a.encode("same input"); const e2 = b.encode("same input"); expect(e1).not.toBe(e2); }); }); describe("default (random) parameters", () => { test("should round-trip within a single instance", () => { const scrambler = new StringScrambler(); const value = "self-contained secret"; expect(scrambler.decode(scrambler.encode(value))).toBe(value); }); test("should not be decodable by a fresh default instance", () => { const a = new StringScrambler(); const b = new StringScrambler(); expect(b.decode(a.encode("hello"))).not.toBe("hello"); }); test("default salt and explicit salt produce different encodings", () => { const a = new StringScrambler(PASSPHRASE); const b = new StringScrambler(PASSPHRASE, SALT); expect(a.encode("test")).not.toBe(b.encode("test")); }); }); });