Skip to content

Limits and refusals

Everything the library refuses, and why a refusal was chosen over a plausible number.

The general rule

A silent zero is worse than an exception. A NaN turned into a credible zero and carried into a sum is indistinguishable from a real zero — and a gap in a feature column is ordinary input, not an exotic case.

The key

condition what happens
modulus shorter than 2048 bits refused: below NIST SP 800-57 for 112 bits
modulus longer than 8192 bits refused: denial-of-service guard
(p−1)/2 or (q−1)/2 not prime refused: safe primes are required
\|p − q\| small refused: factors by Fermat's method
p = q refused: no CRT split exists

The upper bound is not about security, it is about denial of service. Building a peer key runs with the GIL released, and until it returns the interpreter executes nothing at all, signal handlers included.

A foreign modulus is cut off by raw byte count, before any arithmetic. Previously n² was computed before the check: 64 megabytes from a peer bought 4.07 seconds of complete deafness. Now the refusal does not look at the content at all, and its cost does not depend on the input length — which is asserted by a test rather than promised.

The plaintext

condition what happens
NaN, ±inf refused
overflow when scaled refused
magnitude above the key bound refused

The bound on a value is n/2 shrunk by 2^20 to reserve headroom for a sum. With a key of 2048 bits or more that is 2^2026, while the largest encodable f64 is about 2^1024 — so in practice this check cannot fire. It stands anyway: key length and scale are configurable, and a defence against one combination is not a defence against another.

Integers

encrypt_integers, multiply_integers and multiply_integers_public take Python ints, and for them the plaintext bound is reachable:

condition what happens
not an integer (float, str, None, …) TypeError, naming the position
\|x\| > n/2^21 refused — never reduced modulo n
a scalar of zero refused, as on the float paths
\|k\| ≥ 2^scalar_bits (secret path) refused — never run wider
scalar_bits outside 1..plaintext_bound_bits refused
product scale past 1e18 refused

An integer too long for the bound is refused by its bit length before its digits are copied.

The rounding rule

condition what happens
rounding not "half_away" or "half_even" refused
NaN, ±inf under any rule refused, with the same text
a bound on an encoded value the same under every rule

Strict parsing

check_blobs(pub, blobs, scale_pow10) refuses a blob whose header is not the expected scale, that is empty or has no ciphertext, whose ciphertext starts with a zero byte, is longer than n² takes, or is not below n².

The sum

condition what happens
empty batch refused
more than 2^20 terms per call refused
ciphertext outside [1, n²) refused, with the term's index
mixed scales in a batch refused, naming both scales

The cap on terms is not a guarantee

It is per call. The result of add_many can be fed into a second call and the counter starts over; two lawful calls give 2^40 terms, and no check will see it — the ciphertext of a sum is indistinguishable from the ciphertext of a term.

What really keeps sums in the group is the thousand bits of headroom between the key bound and what is encodable from f64.

What is NOT checked

That a ciphertext was made under this key. It does not follow from n alone. A foreign ciphertext will usually lead to a refusal on decryption, but not always.

That a ciphertext is invertible. add_many checks only the range: the value n falls inside [1, n²), yet gcd(n, n²) ≠ 1, and a sum containing it is spoiled. The refusal then arrives later, at the key holder, with no address on it. This is deliberate: a gcd per term would cost more than the operation itself.

That the modulus is not poisoned. Length and oddness are checked — partial public key validation per NIST SP 800-56B, which is what everyone does. A smoothness probe gives no guarantee at any bound: at B = 2^20 it costs 25 minutes and certifies 2^10 of work.

That the scale byte is authentic. It is not authenticated, and flipping one byte yields a plausible wrong number. This adds nothing for an active attacker — Paillier is malleable anyway — but the scale mechanism defends against oversight, not against edits on the wire. A caller that knows which scale a blob must carry can say so with check_blobs and have any other refused.