Skip to content

Chained scheme: pad strings to a length bucket to hide exact length #104

Closed

Description

@coderdan

Problem

Every ciphertext of the chained scheme (OreAes128Bit6Chained, #83) reveals the exact UTF-8 byte length of its plaintext. The header stores the block count, and for strings the count is ceil(8n/6), which is strictly increasing in n. The ciphertext size gives the same number: 4 + 25*count + 16 bytes for a full ciphertext and 4 + 17*count for a left. The right half alone is count * 8 bytes, so a future right-only storage format would still leak it.

This leak is per row and needs no comparison or query. In short, low-entropy columns (country codes, status values, first names), values are often unique by length or close to it, and length plus common-prefix length is a strong signal for frequency analysis. The review of #83 raised it; #83 documents it in the leakage profile.

Resolution

The original proposal (Padmé buckets, and a 65th pad symbol below every real symbol) was wrong on both counts. The comments below record the analysis; this section records what landed, on branch feat/chained-padding.

No pad symbol is needed. Strings are padded with NUL bytes. A NUL byte decomposes to zero 6-bit blocks, the same zero fill the decomposition already applies to its last partial block, so order and equality hold for every string that does not end in NUL:

  • Order. Where the shorter of two strings ends, the longer one has a non-zero bit somewhere in its remaining bytes, because its last byte is not NUL. The shorter string has zeros there (padding), so it sorts first, as "shorter sorts first" requires. Where two strings differ within their common length, both carry real bits and the MSB-first packing decides it.
  • Equality. Equal strings have equal lengths, so they pad to the same length and the same blocks.

The PRP domain, the right blocks and the comparator are unchanged. Pad blocks cannot be told apart from a string's own blocks, because each block's PRP is keyed by its position and prefix. The padded encryptors refuse a string that ends in NUL (OreError::TrailingNul), since padded it would encrypt the same as the string without it; Postgres text and jsonb cannot hold NUL. The raw 6-bit symbol encryptors (encrypt_var, encrypt_left_var) cannot be padded this way, since a trailing zero symbol is a real value there, and had no users beyond tests; they are test-only now, so strings are the scheme's only public plaintext.

Padmé was not used. It was prototyped and set aside. Padmé was designed for kilobyte-to-gigabyte messages; at database string lengths its buckets are near-exact (below 9 bytes it changes nothing, 9 to 31 bytes round only to an even length, 32 to 63 to a multiple of 4), so above a floor it is barely better than no padding. The simulation in the comment below put an attacker's chance of guessing an email's exact length at 53% under Padmé with a 16-byte floor, against 11% under powers of two at about 1.4× the blocks.

What landed

  • A Padding policy on OreAes128Bit6Chained, chosen at init_with_padding; init uses the default.
    • PowerOfTwo { min_bytes }, the default with min_bytes: 16. A string up to min_bytes long pads to min_bytes; a longer one pads to the next power of two, so the default buckets are 16, 32, 64, 128, … bytes. A string that would pad past MAX_STR_BYTES is refused (TooManyBlocks), so the default accepts strings up to 32768 bytes.
    • Fixed { max_bytes }: every string pads to max_bytes, hiding the length entirely; longer strings are refused (ExceedsPadding). For short, bounded, low-entropy columns.
    • None: no padding; the ciphertext reveals the exact length.
  • Wire format: a 3-byte policy tag (kind byte, big-endian u16) follows the 4-byte common header, so a full ciphertext is 7 + 25*count + 16 bytes and a left is 7 + 17*count. The parser and the comparators reject unknown kinds, policies no cipher could be initialised with, and block counts the tagged policy cannot produce. The comparators return None for two ciphertexts made under different policies, since an unpadded and a padded copy of one string have different blocks and would otherwise compare unequal. A column's stored values and its queries must use one policy; changing it means re-encrypting the column.
  • Verification: unit, exhaustive small-alphabet and property tests under every policy; pinned vectors for the default, unpadded and fixed policies, including strings on both sides of the 16-, 32- and 64-byte bucket edges; Kani proofs that the rules never shrink a length, are idempotent and monotone; a libFuzzer target (chained_encrypt_str) for arbitrary string pairs; the ct-taint gate green on arm64 and amd64, with one new accepted branch (the trailing-NUL check, a single out-of-line branch that returns a constant on each path).

Leakage profile now: the length bucket, plus the order and common-prefix length that comparisons reveal. Stored full ciphertexts still compare offline (#99), which reveals common-prefix lengths and so a lower bound on each value's length; in the simulation this raised the guess rate by 2 to 7 points.

Not in scope, possible later: a configurable growth ratio (1.5), explicit bucket boundaries fitted to a column (needs a larger header tag), right-only storage (#99).

This lands before 0.9.0 freezes the chained wire format; 0.9.0-rc.1 marks it as not frozen.

Activity

  1. coderdan commented on Oct 15, 2026

    @coderdan
    ContributorAuthor

    Update: padding needs no new symbol, and Padmé is probably the wrong default

    1. A 65-value domain is not needed for strings

    The constraint section above says the pad symbol must sort below all 64 real symbols. That is only true for arbitrary bytes. If a string is padded with NUL bytes, the padding decomposes to zero 6-bit symbols. This is the same zero fill the decomposition already applies to its last partial block. It keeps both order and equality for every string that does not end in NUL:

    • Order: where the shorter of two strings ends, the longer one has a non-zero bit within its next block, because its next byte isn't NUL. So "shorter sorts first" still holds.
    • Equality: equal strings have equal lengths, so they pad to the same length.

    "a" and "a\0" collide only because "a\0" ends in NUL. A padded encryptor must refuse such strings; Postgres text and jsonb cannot hold NUL anyway. I checked this against a model of the decomposition and the comparator: there were no mismatches with byte order over 200k random and prefix pairs.

    So padding needs no change to the PRP domain, the CMAC derivation, the right blocks or the comparator. Padding blocks can't be identified in a ciphertext, because each block's PRP is keyed by its position and prefix.

    The padding rule is free to choose. Any rule that always pads a given string to the same length, and never shrinks it, preserves order and equality. The padded length doesn't have to depend only on the string's length.

    Decisions so far:

    • Policy tag in the header. A 3-byte tag after the common 4-byte header records the padding policy. The comparators return None when two ciphertexts were made under different policies. Without this, an unpadded "abc" and a padded "abc" would compare unequal. A column's stored values and its queries must use the same policy.
    • Pad by default. Raw symbol sequences (encrypt_var) are never padded, because a trailing zero symbol is a real value there.

    A prototype exists on a local branch (feat/chained-padding, not pushed). It implements a Padding policy (Padme { min_bytes }, Fixed { max_bytes }, None), the header tag, and a TrailingNul refusal, with property tests and new pinned vectors. It also passes the ct-taint gate on arm64 and amd64: the trailing-NUL check is a single out-of-line branch with one suppression. The unpadded and raw-symbol vectors are the previous bytes with only the tag inserted.

    2. Which padding rule?

    Padmé was designed for files and messages from kilobytes to gigabytes. In that range, keeping overhead under 12% matters, and leaking O(log log L) bits is small. Database strings are mostly under 64 bytes, and there Padmé's buckets are very narrow:

    • below 9 bytes, lengths are unchanged;
    • from 9 to 31 bytes, they round up to an even number;
    • from 32 to 63 bytes, they round to a multiple of 4.

    A 16-byte floor hides short strings, but above the floor Padmé is barely better than no padding.

    I simulated the candidate rules on four sample columns: first names (1,308), English words (20k), synthetic emails (20k) and multi-word phrases (20k), built from the /usr/share/dict word lists. Two measures:

    • Cost: padded blocks divided by unpadded blocks.
    • Guess rate: the chance an attacker guesses a value's exact length from the ciphertext size.
    Policy Emails (mean 25 B): cost / guess Words (mean 9.5 B): cost / guess Phrases (mean 12 B): cost / guess
    None 1.00× / 100% 1.00× / 100% 1.00× / 100%
    Padmé, 16-byte floor 1.02× / 53% 1.69× / 15% 1.33× / 20%
    Multiple of 8, 16-byte floor 1.14× / 18% 1.38× / 27% 1.32× / 22%
    Powers of 2, 16-byte floor 1.36× / 11% 1.71× / 15% 1.40× / 19%
    ×1.5 buckets, 16-byte floor 1.20× / 16% 1.70× / 15% 1.36× / 19%
    2 buckets fitted to the column 1.28× / 11% 1.42× / 20% 1.28× / 20%
    4 buckets fitted to the column 1.10× / 21% 1.14× / 34% 1.10× / 35%
    Pad to the column maximum 1.84× / 7% 2.45× / 14% 1.91× / 16%
    Pseudorandom length per value 1.22× / 17% 1.52× / 21% 1.37× / 23%

    On first names, every policy with a 16-byte floor puts all values in one bucket (2.9× cost, 26% guess rate, which is just the most common length).

    Offline comparison doesn't undo the padding. Stored full ciphertexts can be compared with each other offline, and this reveals common-prefix lengths. Those give a lower bound on each value's length. I added that to the attacker in the simulation, and it raises the guess rate by only 2–7 points. For example, emails with powers of 2 go from 11% to 14%, and emails with Padmé from 53% to 55%. Right-only storage (#99) would remove even this.

    Options considered and set aside:

    • Random padding. Degabriele (CCS 2021) shows Gaussian padding beats uniform padding when the same message is encrypted repeatedly with fresh randomness. That doesn't apply here: a string must always pad to the same length, or equality breaks. A pseudorandom length fixed per value is possible (last row of the table), but it was both costlier and leakier than buckets, because it produces many distinct sizes.
    • Round up to a multiple of a constant (as TLS and Signal do). With lengths that vary widely, it still leaks a lot.
    • Buckets fitted to the column's data (in the spirit of Hore, Mehrotra and Tsudik's bucketization, VLDB 2004, and Bost and Fouque's distribution-aware padding, ePrint 2017/1060). These give the best trade-off between cost and leakage. But the boundaries have to come from sample data, and they become per-column configuration. They're a good later option, not a default.

    Recommendation

    1. Default: powers-of-2 buckets with a 16-byte floor (16, 32, 64, 128, …), not Padmé. Each bucket leaks about log₂ L, more than Padmé in theory. But in the length ranges that matter for database columns, the buckets are much wider, and they're easy to explain.
    2. Keep Fixed { max_bytes } for short, bounded, low-entropy columns such as codes, statuses and names. It hides the length completely.
    3. Possibly later: a configurable growth ratio (1.5 or 2), and explicit bucket boundaries fitted to a column. The explicit boundaries need a larger header tag than 3 bytes.

    This must land before 0.9.0 freezes the chained wire format. 0.9.0-rc.1 marks it as not frozen. Parked for now; we'll come back to it.

    References:

  2. changed the title [-]Chained scheme: pad strings to hide exact length (Padmé)[/-] [+]Chained scheme: pad strings to a length bucket to hide exact length[/+] on Oct 16, 2026
  3. self-assigned this
    on Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions