package com.peachpasswords.sync import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test class SyncPaddingTest { @Test fun round_trips_payloads_of_every_shape() { for (size in listOf(0, 1, 5, 251, 252, 253, 1000, 70_000)) { val payload = ByteArray(size) { ((it * 31) and 0xFF).toByte() } assertArrayEquals(payload, SyncPadding.unpad(SyncPadding.pad(payload))) } } @Test fun small_control_frames_collapse_to_one_indistinguishable_size() { // A relay must not be able to tell a HELLO from a DONE by frame size. val hello = ("{\"type\":\"SYNC_HELLO\",\"vaultId\":\"" + "a".repeat(64) + "\"}") .toByteArray(Charsets.UTF_8) val done = "{\"type\":\"SYNC_DONE\"}".toByteArray(Charsets.UTF_8) assertEquals(SyncPadding.pad(hello).size, SyncPadding.pad(done).size) assertEquals(256, SyncPadding.pad(done).size) } @Test fun bucket_ladder_matches_the_browser_kat() { // Pinned against peach-browser sync-padding.test.ts. A mismatch here // means the two platforms cannot read each other's frames at all. val cases = listOf( 0 to 256, 252 to 256, 253 to 512, 1024 to 2048, 65_532 to 65_536, 65_533 to 131_072, 200_000 to 262_144, ) for ((payload, expected) in cases) { assertEquals(expected, SyncPadding.bucket(4 + payload)) assertEquals(expected, SyncPadding.pad(ByteArray(payload)).size) } } @Test fun large_frame_overhead_stays_bounded() { val twelveMiB = 12 * 1024 * 1024 val padded = SyncPadding.bucket(twelveMiB) assertTrue(padded >= twelveMiB) assertTrue(padded - twelveMiB < 64 * 1024) } @Test fun rejects_a_frame_declaring_more_payload_than_it_carries() { val forged = ByteArray(256) forged[0] = 0; forged[1] = 0; forged[2] = 0x27; forged[3] = 0x0F.toByte() // 9999 var threw = false try { SyncPadding.unpad(forged) } catch (_: IllegalArgumentException) { threw = true } assertTrue(threw) } @Test fun rejects_a_frame_shorter_than_its_length_header() { var threw = false try { SyncPadding.unpad(ByteArray(3)) } catch (_: IllegalArgumentException) { threw = true } assertTrue(threw) } // ───────────────────────────────────────────────────────────────────── // E7b cross-platform KAT — mirrors peach-browser sync-padding.test.ts // `describe("sync frame chunking (E7b)")`. Pinned against the browser: // either side drifting breaks the wire silently on real hardware. // ───────────────────────────────────────────────────────────────────── @Test fun max_noise_plaintext_bytes_matches_the_crypto_core_limit() { // crypto-core caps a Noise ciphertext at 65535 bytes; minus the 16-byte // Poly1305 tag that is 65519 plaintext. If this constant drifts, the // chunking layer accepts chunks the crypto core will reject. assertEquals(65519, MAX_NOISE_PLAINTEXT_BYTES) } @Test fun the_bucket_ladder_produces_frames_above_the_noise_limit_the_bug() { // A 32 KiB payload rounds up to a 64 KiB padded frame, which exceeds // the Noise transport limit. Without chunking, the crypto core // refused these with "Noise plaintext exceeds transport limit" while // the receiver waited for a frame that never arrived — the exact // failure this layer exists to fix. Pinning the condition reachable // means a future tightening of the ladder is caught here, not on a // real device in someone's pocket. val padded = SyncPadding.pad(ByteArray(32 * 1024)) assertTrue(padded.size > MAX_NOISE_PLAINTEXT_BYTES) } @Test fun the_continuation_marker_is_the_byte_the_chunking_protocol_uses() { // 0x01 is unambiguous against a real padded frame: the first byte of // the 4-byte big-endian length header for any payload under 64 KiB // is 0x00, and the marker is itself the FIRST byte of the chunk — // not a slot inside the padded frame. Must match the browser KAT. assertEquals(0x01.toByte(), FRAME_CONTINUATION_MARKER) } }