Overview
The Paillier cryptosystem is a probabilistic, asymmetric encryption scheme with a remarkable property: arithmetic can be performed directly on encrypted data — no decryption required. The server performing the computation never sees the underlying values.
This implementation exposes a set of CLI commands under the crypto topic that let you
generate keys, encrypt and decrypt integers and strings, and perform addition, subtraction, and
scalar multiplication on ciphertext.
Add, subtract, and multiply (by a scalar) without decrypting.
Keys use 2048-bit primes, matching NIST minimum for asymmetric encryption.
Each encryption of the same value produces a different ciphertext.
Keys are stored as human-readable JSON with BigInteger fields.
How the Paillier Cryptosystem Works
Key Components
Paillier encryption uses four mathematical values. Two form the public key (safe to share) and three form the private key (must be kept secret).
| Symbol | Name | Visibility | Description |
|---|---|---|---|
n |
Modulus | Public | Product of two large primes p × q. All arithmetic is performed mod n². |
g |
Generator | Public | Fixed as n + 1 in this implementation (a common simplification). |
λ |
Lambda | Private | lcm(p-1, q-1). Used as the decryption exponent. |
μ |
Mu | Private | Modular inverse of λ mod n. Scales the result during decryption. |
Encryption
To encrypt a plaintext integer m, a fresh random value r (coprime with n)
is chosen. The ciphertext is:
Because r is random and discarded, encrypting the same m twice produces
different ciphertexts — this is the probabilistic nature of the scheme.
Because g = n + 1, the term gm simplifies to
1 + m·n (mod n²).
Results of homomorphic operations are re-randomised as well: each output is multiplied by a fresh
rⁿ (mod n²). No output ciphertext equals an input ciphertext, so a result cannot be
linked to its inputs by comparing ciphertexts.
Decryption
Given the private key, the original plaintext is recovered in two steps. First, the L-function
removes the randomness; then μ rescales the result:
Homomorphic Addition
Multiplying two ciphertexts together in the encrypted domain produces the ciphertext of their plaintext sum:
Homomorphic Subtraction
Multiplication by the modular inverse of a ciphertext computes the difference:
Homomorphic Scalar Multiplication
Raising a ciphertext to an integer power multiplies the plaintext by that scalar:
Note: this is multiplication by a known plaintext scalar, not by another encrypted value. Fully encrypted multiplication requires a different (more expensive) scheme.
Key Generation
The generate-key command creates a fresh Paillier key pair and writes two JSON files:
a public key (safe to distribute) and a private key (keep secret).
Public key file (public.json)
Contains only N (the modulus). Safe to share freely.
Private key file (private.json)
Contains N, Lambda, and Mu. Written with owner-only
Unix permissions (600) and must never be shared.
Numeric Operations
Encrypt a number
Takes a non-negative integer and a public key file. Writes the ciphertext to a JSON file.
Decrypt a number
Reads an encrypted number file and private key, writes the plaintext to stdout. If decryption fails
(wrong key, malformed or out-of-range ciphertext, a ciphertext from a different key, or an invalid value), it prints
Error: Decryption failed: input is invalid or was not encrypted with this key. and exits
with code 1. A missing or unreadable file reports its own specific error.
Homomorphic Operations on Encrypted Numbers
These commands operate entirely on ciphertext. No private key is needed and the plaintext values are never revealed to the process performing the computation.
Outputs are re-randomised: each one is multiplied by a fresh rⁿ (mod n²), so no output
ciphertext equals an input ciphertext. multiply --scalar 0 yields a fresh Enc(0), and
--scalar 1 yields a fresh re-encryption of the input. Both are indistinguishable from any
other ciphertext.
add pbtk crypto add
Multiplies the two ciphertexts together. Decrypting the result yields a + b.
subtract pbtk crypto subtract
Multiplies the first ciphertext by the modular inverse of the second.
multiply pbtk crypto multiply
Raises the ciphertext to the power of a known plaintext scalar k.
String Operations
Strings are encrypted character-by-character: each Unicode code point is independently encrypted as an integer using the Paillier scheme. The result is a JSON array of encrypted numbers, one per character. This representation enables position-aware operations on the ciphertext without decryption.
Every encrypted string is padded to exactly 100 entries: the real characters followed by encrypted NUL (U+0000) padding. The padding is removed on decryption, so it is invisible in the plaintext.
replace or substring knows the --start, --length and
replacement they supplied, since those are plaintext inputs. Plaintext is read from files or stdin
rather than command-line arguments so it does not appear in the process list or shell history.
encrypt-string
Encrypts a UTF-8 string as an array of encrypted Unicode code points. The plaintext is read from the
file given by --string-file, or from stdin when the value is -. Exactly one
trailing line ending (\n or \r\n) is stripped. The string may contain at most
100 Unicode characters, and the NUL character (U+0000) is rejected because it is reserved for padding.
The output always contains exactly 100 encrypted entries. On success it prints
Encrypted string written to '<path>'.
decrypt-string
Decrypts all 100 entries and removes every NUL, so padding does not appear in the output. If
decryption fails (wrong key, malformed or out-of-range ciphertext, a ciphertext from a different key, or an invalid character), it prints
Error: Decryption failed: input is invalid or was not encrypted with this key. and exits
with code 1. A missing or unreadable file reports its own specific error.
replace
Overwrites characters at a known position in an encrypted string without decrypting it.
The replacement is read from the file given by --replacement-file, or from stdin when the
value is -. Exactly one trailing line ending is stripped, and the NUL character is not
allowed. The replacement is encrypted with the same public key (recovered from the ciphertext
metadata) and substituted into the array at the specified index.
The range is checked against the encrypted array length (100 for padded strings), so writing past the
real end writes into the padding and succeeds. For example, replacing at --start 7 with
X in an encrypted hello decrypts to helloX. Every position in the
output gets a fresh ciphertext: replaced positions are newly encrypted and all others are
re-randomised, so the output cannot be linked to the input by comparing ciphertexts. Replace rejects an
empty encrypted string.
substring
Slices an encrypted string by position, returning a new encrypted array of the selected range.
Each entry in the slice is re-randomised, and the result is padded with fresh encrypted NUL back to
100 entries. Omitting --length takes the slice to the end of the array, including padding.
No private key is needed. On success it prints Encrypted substring written to '<path>'.
Substring rejects an empty encrypted string.
Command Reference
| Command | Required options | Optional options | Output |
|---|---|---|---|
crypto generate-key |
--public-key-file --private-key-file |
— | Two JSON files (public + private key) |
crypto encrypt |
--number --public-key-file --out-file |
— | Encrypted number JSON file |
crypto decrypt |
--in-file --private-key-file |
— | Plaintext integer (stdout) |
crypto add |
--in-file1 --in-file2 --out-file |
— | Encrypted sum JSON file |
crypto subtract |
--in-file1 --in-file2 --out-file |
— | Encrypted difference JSON file |
crypto multiply |
--in-file --scalar --out-file |
— | Encrypted product JSON file |
crypto encrypt-string |
--string-file --public-key-file --out-file |
— | Encrypted string JSON file (100 encrypted entries: code points plus NUL padding) |
crypto decrypt-string |
--in-file --private-key-file |
— | Plaintext string (stdout) |
crypto replace |
--in-file --start --replacement-file --out-file |
— | Updated encrypted string JSON file (100 entries, all re-randomised) |
crypto substring |
--in-file --start --out-file |
--length (defaults to end of string) |
Encrypted substring JSON file (padded to 100 entries) |
Constraints and Limits
| Constraint | Value | Reason |
|---|---|---|
| Minimum key size | 2048 bits | NIST minimum recommendation for asymmetric security |
| Plaintext range (numbers) | 0 ≤ m < n | Paillier requires the message to be smaller than the modulus |
| Plaintext integers | Non-negative only | Paillier works in Z/nZ; negative values are not natively supported |
| Max string length | 100 Unicode characters; always padded to 100 entries (each file ≈ 190 KB) | Each character requires one full ciphertext; the fixed 100-entry size limits file size and hides the true length |
| NUL character | Not allowed (U+0000) in plaintext or replacement | Reserved for padding; decryption removes every NUL |
| String character set | Full Unicode code points, except U+0000 | Characters are stored as Unicode scalar values, supporting any language |
| Scalar multiplier | Non-negative integer | Negative scalars not supported; multiply by 0 produces a fresh Enc(0), and multiply by 1 a fresh re-encryption |
| Input validation | All characters share one public key N (≥ 2048 bits); each ciphertext satisfies 0 < c < n² and is coprime to n; at most 100 entries | Malformed or mismatched inputs are rejected before any computation. replace and substring reject an empty encrypted string |
| Decryption errors | Error: Decryption failed: input is invalid or was not encrypted with this key. (exit 1) |
Applies to wrong key, malformed or out-of-range ciphertext, a ciphertext from a different key, and invalid characters. Missing or unreadable files keep their specific error |
| Integrity | Not provided | Paillier ciphertexts are malleable: a well-formed ciphertext that has been modified decrypts to a different value without an error. Use a separate signature or MAC if you need tamper detection |
| Compatibility | Unpadded files written by previous versions still decrypt | replace and substring on those files produce padded 100-entry output |