API¶
The paillier module. Everything it exposes is listed here.
generate_keypair(bits=3072)¶
Returns (PublicKey, SecretKey).
Generates two safe primes of bits/2 each and validates the
resulting key. Keys shorter than 2048 bits are not generated: NIST
SP 800-57 requires that length for 112-bit security.
It costs seconds and has a huge spread — anywhere from 1 to 10 seconds at 2048 bits on the same machine. It is a search: safe primes are rare, and the time to a hit is random.
pub, sec = paillier.generate_keypair(2048)
PublicKey¶
PublicKey.from_n(raw)¶
Builds a public key from the modulus alone — the thing that came
over the wire. hs is derived right here, from n, rather than
accepted from outside.
It costs about 0.03 s at 3072 bits, because the table of powers is built along with the key. Do this once per session.
Refuses an even modulus, one that is too short, and one that is too long. The length is cut off by raw byte count before any arithmetic: the modulus arrives from a peer, and 64 megabytes instead of a key must not cost the process anything.
modulus_bytes()¶
The modulus in bytes, most significant first. The only thing that travels to a peer.
exponent_bits¶
The length of the random exponent in bits — half the modulus length. Exposed so it can be checked by number rather than by guesswork.
plaintext_bound_bits¶
The largest accepted plaintext in magnitude, in bits: n/2, shrunk to
reserve headroom for a sum.
encrypt_many(pub, values, scale_pow10=8)¶
Encrypts a list of numbers, returns a list of blobs.
Runs across all cores. Encrypting one at a time in a Python loop throws away a factor of six.
scale_pow10 is the power-of-ten exponent, from 0 to 18. It travels in
every blob as the first byte. See
Encoding.
Refuses NaN, infinities, numbers that overflow f64 once scaled, and
numbers outside the key's admissible range.
add_many(pub, blobs)¶
Adds ciphertexts, returns a single blob.
Refuses: an empty batch; a batch longer than 2^20; a ciphertext
outside [1, n²); a batch that mixes scales.
The 2^20 cap is not a guarantee
It is per call, and the result can be fed into a second call. What
actually keeps sums in the group is the gap between the key floor
(2^2026) and what is encodable from f64 at all (about 2^1024):
a thousand bits short of overflow.
add_blocks(pub, blobs, blocks)¶
Sums MANY blocks over one array of ciphertexts, in parallel over the
blocks. blocks is a list of index lists into blobs; the result is one
blob per block, in the order the blocks were given.
Equivalent to [add_many(pub, [blobs[i] for i in block]) for block in
blocks], byte for byte. It is faster on the shape it was written for,
because the Python loop holds the GIL and re-parses every blob once per
block that names it.
Refuses: an empty block; a block longer than 2^20; an index past
the end of blobs; a block that mixes scales; a ciphertext outside
[1, n²). An empty LIST of blocks is not an error — it is no sums, which
is a different thing from an empty sum.
A ciphertext that no block names is never read, so it is never refused either. That is what makes the equivalence above hold for refusals as well as for values: the loop would not have read it.
Per block, not per call
Every limit and every refusal add_many applies to one sum, this
applies to each block separately. Two blocks at two different scales
are two sums and are allowed; two scales inside ONE block are not.
A repeated index is added again
[0, 0, 0] is three times that term. Indices are positions, not a
set, and nothing here deduplicates them.
multiply_many(pub, blobs, scalars, *, scalar_scale_pow10=None)¶
Multiplies each ciphertext by a known scalar — E(x) → E(k·x) — and
returns a list of blobs, paired with the input by position.
The scale of a product is the sum of the two scales, and the header
of the returned blob says so, so decrypt divides by the right thing
without being told. scalar_scale_pow10 defaults to the scale of the
ciphertext, so the ordinary case is 8 + 8 = 16.
Refuses: a length mismatch between blobs and scalars; a scalar that
is NaN or infinite; a scalar that encodes to zero; a scalar past
the fixed exponent width; a product scale above 1e18; a ciphertext
outside [1, n²) or not invertible.
Multiplication does not compose at the default
A second multiplication would be 16 + 16 = 32 — refused. Chaining
means lowering scalar_scale_pow10 on both calls.
For the same reason a product cannot be added to an ordinary
ciphertext: add_many refuses a mixed batch. Encrypt the companions
at the product's scale — encrypt_many(pub, values, scale_pow10=16).
A scalar that encodes to zero is refused, an exact zero included
E(x)^0 is the constant 1: the value is destroyed and the result
becomes a two-byte blob anyone can recognise. Where the scalar is a
share or a weight, a vanishing one means "this bucket is empty", and
publishing that as a distinctive blob is a leak rather than a result.
Encrypt a zero if a zero is what is wanted.
The exponent runs at a FIXED width
The scalar is usually the caller's own secret, so this takes the secret-exponent path, as decryption does. That hides the exponent's value but not its length — and its length is the magnitude of the scalar. So the exponent is offset to a constant width and the offset divided out afterwards, at the cost of a second exponentiation.
The consequence is the refusal above: a scalar that does not fit the
fixed width is rejected rather than run at a wider exponent. At the
default scale that admits |k| ≤ 1.8e11.
The sign is hidden too, and by construction rather than by care: the offset makes every exponent positive, so both exponentiations and the inversion run unconditionally. There is no branch on the sign to observe.
An earlier draft of this page claimed the opposite — that a negative scalar costs an inversion and a positive one does not. That described a naive implementation, not this one, and it was caught by review rather than by a test.
Do NOT transmit a raw product if the scalar is secret
The output is E(x)^k and nothing else — a deterministic function of
the input ciphertext and the scalar. Anyone who sees BOTH the input
and the output recovers k by trying candidates: n is public, so
each guess costs one exponentiation, and a scalar that is a share or
a small weight has few candidates worth trying.
Which means the fixed-width construction above protects a side door
while this one stands open. It is worth having — timing is available
to a neighbour on the same machine, who may never see the
ciphertexts — but it is not what keeps k secret from someone
watching the wire.
Use rerandomize before transmitting, or
sum the products with something the other side did not send. The
analytics exchange this feature was built for does the latter, which
is why the library does not do it for you: it cannot know which of
your ciphertexts are about to leave.
multiply_many_public(pub, blobs, scalars, *, scalar_scale_pow10=None)¶
The same product as
multiply_many,
computed for a caller who states that the scalar is not a secret.
Byte-for-byte identical results. The same refusals, plus one of its own. About twelve times faster, and one property fewer.
Which one to reach for¶
multiply_many keeps the exponent's width constant, at the cost of two
exponentiations and a modular inversion per product. That is right when
the scalar is what the exchange conceals — an analytics bucket share,
say.
This one runs a single windowed exponentiation. Reach for it when the scalar is the multiplying party's own data, never leaves it, and neither do the products. Vertical linear training is the case it was added for: the passive party multiplies encrypted residuals by its own features and sends only their masked sum.
What is traded, measured¶
The time depends on the scalar's magnitude. On a 2048-bit key:
| exponent | this function | multiply_many |
|---|---|---|
| 1 bit | 0.0046 ms | ~2.1 ms |
| 64 bits | 0.3200 ms | ~2.2 ms |
One observation separates a small scalar from a large one at about 93% accuracy; twenty-seven reach 99%. If the party receiving the answer must not learn the magnitudes, this is the wrong function — and that party is usually the one being defended against, so the question is worth asking rather than assuming.
Typical shape, batches of 200 at ~29-bit exponents: 0.181 ms against 2.193 ms, a factor of 12.1.
Bounds¶
|k| < 2^53 after encoding, and this range is narrower than
multiply_many's,
not wider. That function refuses an encoded |k| ≥ 2^64; 2^53 is below
2^64 at every scale. Measured across 3000 combinations of scale and
magnitude: 108 inputs this path refuses and the other accepts, none the
other way round.
The two bounds differ in kind. 2^64 was a timing requirement — every
exponent had to be the same width. 2^53 is where an f64 stops holding
every integer, so past it the value multiplied in is not the value you
named: 10^20 arrives as 1.9999999999999997e20. multiply_many rounds
there silently; this refuses.
At the default scale of 1e8 that means |k| ≤ 9.0e7. A unix timestamp
(1.7e9), a population count, a revenue figure — all accepted by
multiply_many, all refused here. The way through is a lower
scalar_scale_pow10, trading fractional precision for range: at 1e2 the
ceiling is 9.0e13.
The plaintext space (2^2027 at a 2048-bit key) never binds either way, and nothing wraps silently: every out-of-range path ends in a refusal.
Refusals¶
Everything multiply_many refuses, plus:
- a ciphertext sharing a factor with
n— for every scalar.pow_modreports this only when the exponent is negative, because only then does it need the inverse. Left to it, the same input was accepted on positive scalars and rejected on negative ones. The check is explicit here.
rerandomize(pub, blobs)¶
Same plaintext, different bytes. Each ciphertext is multiplied by a fresh encryption of zero.
Runs across all cores. Costs about one encryption per ciphertext, because that is what it is: one exponentiation at the full exponent length.
Refuses an empty blob, an unknown scale exponent, and a ciphertext
outside [1, n²). The scale byte passes through unchanged.
Why this is a separate call and not a flag
The homomorphic operations are deterministic: add_many of the same
terms gives the same bytes, a one-term sum gives its input back
verbatim, multiply_many gives exactly E(x)^k. Whoever knows the
inputs can confirm a guess about the operation by recomputing it.
That only matters when the result LEAVES. Inside a computation the property buys nothing and costs an encryption apiece — so it is the caller who applies it, at the point where they know what is being sent, which the library cannot know.
A flag was considered. On by default it charges every caller for something most do not need; off by default it is left off exactly where it costs most.
It hides WHICH ciphertext, not the value
Re-randomising does nothing against a party that can decrypt. If the recipient of your result holds the private key, they read the plaintext, and this changes nothing about that.
decrypt(sec, blob)¶
Returns a float. The scale is taken from the blob itself.
Refuses an empty blob, an unknown scale exponent, and a value that is not a ciphertext under this key.
There is no 'this ciphertext under this key' check
Nor can there be: it does not follow from n alone. A ciphertext
made under a different key will usually lead to a refusal — but not
always.
decrypt_many(sec, blobs)¶
Decrypts a batch across all cores and returns the plaintexts as decimal strings, in the order given.
Strings, not floats, because the values this is for are aggregates: a
homomorphic sum passes 2^53 on an ordinary batch, and above that an
f64 holds only the even integers. decrypt would return a number that
is not flagged, not out of range and not implausible — merely a different
one.
Refuses a blob carrying a non-zero scale, and a value that is not a ciphertext under this key. The scale is refused rather than divided out: dividing is what puts the rounding back.
Use decrypt for a magnitude
A scaled value is a magnitude, and a magnitude is what decrypt
returns. This one is for callers that will add, compare or re-encode
the result, where a double rounding through binary64 stops being the
identity well below 2^53.
__version__¶
The package version, taken from Cargo.toml at build time rather than
typed in a second place.
Blob format¶
[1 byte: scale exponent] [ciphertext, most significant byte first]
The ciphertext is written at minimal length, not padded to a fixed
width: measured {512: 2, 513: 398} over four hundred encryptions with
a 2048-bit key. You cannot slice the buffer at a constant stride.