Upload files to "tests/scramble"
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user