Multiparty CKKS
Example source: examples/18_multiparty_ckks.py
This example holds two in-process party records representing two cryptographic parties, constructs collective public and evaluation keys, runs CKKS evaluation, and executes secret-dependent output operations. The tutorial follows its application-owned protocol state in execution order.
Each record retains one additive secret share, and the example never constructs or installs their aggregate secret. One process provides no trust-domain or process isolation between those slots.
The data and keys are synthetic and throwaway. The goal is to show how collective share messages become core FHElium keys and values that the standard evaluator can consume.
Tensor operation interface
fhelium.experimental.mpc is stateless tensor arithmetic. Its functions accept local shares, common-uniform tensors, raw protocol messages, core keys, and core ciphertexts. They return another raw message or a core FHElium PublicKey, evaluation key, Plaintext, or Ciphertext.
Collective key generation (CKG) produces the public key. Two-round relinearization-key generation (RKG) produces public evaluation material for three-to-two-component relinearization.
| Protocol value | Representation |
|---|---|
| Party share and RKG ephemeral | Level-zero QP SecretKey, NTT/Montgomery, [L_QP, N] |
| CKG common tensor and share | Contiguous Q NTT/Montgomery tensor [L_Q, N] |
| RKG/Galois common tensor | Contiguous QP tensor [D, L_QP, N] |
| Each RKG family or Galois share | QP NTT/Montgomery tensor [D, L_QP, N] |
| Protocol-3/4 coefficient input | Contiguous compact integer tensor [*batch, N] |
| Protocol-3 share | Active-Q coefficient/standard tensor [*batch, L_level, N] |
| Protocol-4 share | Tuple of two active-Q coefficient/standard tensors |
Here, N is the ring dimension and D is the stable hybrid key-digit count. The application assigns each local share to a cryptographic party, associates raw tensors with requests, and decides when the complete expected party set has contributed. The fhelium.experimental.mpc namespace creates no party, transport, coordinator, or persistent protocol object.
Current execution constraint
The current implementation accepts a local CPU or CUDA CkksEngine. The example defaults to Preset.slots8192_scale40_levels7_int64 for a small local run.
Security scope
The supported arithmetic scope is correctness for compatible values under honest ordered invocation. The current API provides no authentication, transcript binding, secure transport, malicious-party security, output-query control, reviewed output-error sampler, supported smudging/useful-precision parameter profile, privacy guarantee, or validated security composition for the collective-decryption and public-key-switch output operations. Use synthetic inputs, throwaway keys, and labeled correctness fixtures only.
Run the example from the repository root:
python examples/18_multiparty_ckks.py --preset slots8192-scale40-levels7-int64Follow the application-owned states
The example prints labels around stateless function calls. Its epoch takes this path:
The labels are application control state. A context, roster, or local share change begins another epoch. The detailed envelope, retry, duplicate, abort, and independent-process rules are intentionally left to Use multiparty CKKS.
1. Create two local shares
The example samples one compatible QP share for each local party record:
party_secret_shares = tuple(
mpc.sample_secret_share(engine) for _ in range(2)
)2
3
Both values have the same context, rows, dtype, and device, but remain distinct party-owned secrets. Nothing adds them or turns them into a collective SecretKey, including correctness checks.
2. Generate the collective public key
One-round collective key generation (CKG) creates the public encryption key for the epoch. Every party receives the same byte-identical Q common tensor and emits one contribution:
ckg_common_a = mpc.sample_common_uniform(engine, basis="Q")
ckg_shares = tuple(
mpc.ckg_share(engine, secret, ckg_common_a)
for secret in party_secret_shares
)
collective_public_key = mpc.aggregate_ckg(
engine, ckg_shares, ckg_common_a
)2
3
4
5
6
7
8
The result is a core Q PublicKey. Encryptors need this public value, not a party share. An independent deployment accepts one cached CKG message from every expected party before aggregation.
3. Generate a relinearization key in two rounds
Ciphertext multiplication introduces an
The RKG child request has its own application state:
The application creates one QP common tensor with a leading digit axis and one request-local ephemeral per party:
digit_count = engine.rns_layout.key_digit_count
rkg_common_a = mpc.sample_common_uniform(
engine, basis="QP", count=digit_count
)
rkg_ephemeral_by_party = tuple(
mpc.sample_secret_share(engine) for _ in party_secret_shares
)2
3
4
5
6
7
It passes the same party-local ephemeral to both rounds:
round1 = tuple(
mpc.rkg_round1_share(engine, secret, rkg_ephemeral_by_party[i], rkg_common_a)
for i, secret in enumerate(party_secret_shares)
)
aggregate_round1 = mpc.aggregate_rkg_round1(engine, round1)
round2 = tuple(
mpc.rkg_round2_share(engine, secret, rkg_ephemeral_by_party[i], aggregate_round1)
for i, secret in enumerate(party_secret_shares)
)
relinearization_key = mpc.aggregate_rkg_round2(
engine, round2, aggregate_round1
)2
3
4
5
6
7
8
9
10
11
12
13
Both rounds retain two separate message families. A delivery retry retransmits the byte-identical cached message. After an aborted RKG request, its replacement uses a new request identity and all-fresh request material; it never replaces one round while retaining randomness from another.
4. Generate rotation and conjugation keys
Rotation and conjugation apply Galois automorphisms, which transform the secret relation. Their evaluation keys switch each transformed relation back to the collective relation expected by later operations.
The example performs two independent one-round requests:
rotation_common_a = mpc.sample_common_uniform(
engine, basis="QP", count=digit_count
)
rotation_shares = tuple(
mpc.rotation_key_share(engine, secret, rotation_common_a, 1)
for secret in party_secret_shares
)
rotation_key = mpc.aggregate_rotation_key(
engine, rotation_shares, rotation_common_a, 1
)
conjugation_common_a = mpc.sample_common_uniform(
engine, basis="QP", count=digit_count
)
conjugation_shares = tuple(
mpc.conjugation_key_share(engine, secret, conjugation_common_a)
for secret in party_secret_shares
)
conjugation_key = mpc.aggregate_conjugation_key(
engine, conjugation_shares, conjugation_common_a
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Each request uses fresh QP common material with shape [D, L_QP, N]. The results are core key types with FHElium's complete level-zero key-digit layout.
5. Encrypt and evaluate normally
The synthetic message has real and imaginary components so rotation and conjugation are distinguishable. Encryption uses only the collective public key:
source = engine.encrypt_message(message, collective_public_key)Public evaluation then uses CkksEngine methods:
rotated = engine.rotate_with_key(source, rotation_key)
conjugated = engine.conjugate(source, conjugation_key)
transformed = engine.add(rotated, conjugated)
product = engine.multiply(
engine.coefficient_domain_to_ntt_domain(source),
engine.coefficient_domain_to_ntt_domain(source),
)
squared = engine.rescale_to_next_level(
engine.relinearize(product, relinearization_key)
)2
3
4
5
6
7
8
9
10
11
transformed is the two-component source for collective fusion. squared demonstrates consumption of the RKG result and becomes the public-key-switch source. The application retains the epoch association; a matching context_id alone does not establish collective lineage.
6. Fuse an unsafe collective-decryption output
Protocol 3 forms one secret-dependent share per party for an exact coefficient-domain two-component ciphertext. The example supplies opposite fixed coefficient impulses, which cancel for an arithmetic check but provide no smudging or privacy property.
p3_shares = tuple(
mpc.unsafe_collective_decryption_share(
engine,
transformed,
secret,
smudging_error_coefficients=p3_error_by_party[i],
)
for i, secret in enumerate(party_secret_shares)
)
plaintext = mpc.unsafe_fuse_collective_decryption(
engine, transformed, p3_shares
)
decoded = engine.decode(plaintext, is_real=False)2
3
4
5
6
7
8
9
10
11
12
13
Fusion returns a core approximate-coefficient Plaintext. The expected synthetic result is roll(message, +1) + conj(message). The example checks it without constructing an aggregate secret.
7. Switch an unsafe output to a destination key
Protocol 4 returns a ciphertext under a compatible destination key. The destination creates a throwaway pair; only its Q public key enters party share generation.
destination_secret = engine.create_secret_key(modulus_basis="QP")
destination_public = engine.create_public_key(destination_secret)
p4_shares = tuple(
mpc.unsafe_public_key_switch_share(
engine,
squared,
secret,
destination_public,
ephemeral_coefficients=p4_ephemeral_by_party[i],
smudging_error0_coefficients=p4_errors_by_party[i][0],
error1_coefficients=p4_errors_by_party[i][1],
)
for i, secret in enumerate(party_secret_shares)
)
recipient_ciphertext = mpc.unsafe_fuse_public_key_switch(
engine, squared, destination_public, p4_shares
)
recipient_decoded = engine.decrypt_message(
recipient_ciphertext, destination_secret, is_real=False
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
The fixed ternary ephemerals and opposite component errors are correctness fixtures, not fresh security randomness. Only the throwaway destination secret decrypts this output. The caller owns distribution choice, freshness, destination-key provenance, output authorization, and exposure accounting.
8. Inspect results and close
The example reports maximum and root-mean-square (RMS) error for the collective transform and the public-key-switched message**2 result, then moves its application label to CLOSED. Closing rejects later requests in the application workflow; it does not erase key tensors, and best-effort Python deletion does not guarantee device-memory zeroization.
Complete runnable source
#!/usr/bin/env python3
"""Multiparty CKKS with application-owned protocol state.
This runnable example emulates two logical party slots in one process on one
local CPU or CUDA engine. Each slot keeps its local additive secret share while
the application passes raw messages through collective key generation,
evaluation-key generation, and two secret-dependent output workflows. The
example validates arithmetic correctness with synthetic data, throwaway keys,
fixed output-error fixtures, and fixed PCKS ephemeral coefficients.
The single process provides no trust-domain or process isolation between the
two logical slots.
Authentication, transport, transcript binding, malicious-party security, a
reviewed output-error sampler, a supported smudging/useful-precision parameter
profile, a privacy guarantee, and representative precision analysis are not
provided by this example. The application never reconstructs the aggregate
secret.
"""
from __future__ import annotations
import argparse
import torch
from common import add_engine_args, error_stats, make_engine, print_table
from fhelium.experimental import mpc
def announce(state: str, detail: str) -> None:
"""Print one application-owned workflow state."""
print(f"[{state}] {detail}")
def fixed_impulse(
engine,
*,
coefficient: int,
value: int,
) -> torch.Tensor:
"""Return one fixed compact coefficient fixture on the engine device."""
result = torch.zeros(
engine.config.N,
dtype=engine.config.torch_dtype,
device=engine.device,
)
result[coefficient] = value
return result
def fixed_ternary(engine, *, shift: int) -> torch.Tensor:
"""Return one fixed compact ternary fixture on the engine device."""
values = (
torch.arange(
engine.config.N,
dtype=engine.config.torch_dtype,
device=engine.device,
)
% 3
) - 1
return torch.roll(values, shifts=shift).contiguous()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
add_engine_args(parser, default_preset="slots8192-scale40-levels7-int64")
args = parser.parse_args()
engine = make_engine(args)
print(
"Scope: arithmetic correctness with synthetic data, throwaway keys, "
"and fixed output fixtures."
)
print(
"Authentication, transcript security, reviewed output-error sampling, "
"a supported smudging/useful-precision parameter profile, and a privacy "
"guarantee are outside this example."
)
print(
"The application owns every state transition; FHElium MPC functions "
"remain stateless."
)
announce("CREATED", "the epoch descriptor and two-party roster are frozen")
# Application-owned transition: CREATED -> CKG_COLLECTING.
party_secret_shares = tuple(
mpc.sample_secret_share(engine) for _ in range(2)
)
announce(
"CKG_COLLECTING",
"two compatible QP additive shares exist; neither leaves its party slot",
)
print_table(
["party", "local secret shape", "context"],
[
[
party_index,
tuple(secret_share.data.shape),
f"{secret_share.context_id[:12]}...",
]
for party_index, secret_share in enumerate(party_secret_shares)
],
)
# Application-owned transition: CKG_COLLECTING -> PUBLIC_READY -> ACTIVE.
# Protocol 1 is one round: every party receives the same common `a`, emits
# one `b_i`, and the application aggregates only those public shares.
ckg_common_a = mpc.sample_common_uniform(engine, basis="Q")
ckg_shares = tuple(
mpc.ckg_share(engine, secret_share, ckg_common_a)
for secret_share in party_secret_shares
)
collective_public_key = mpc.aggregate_ckg(
engine,
ckg_shares,
ckg_common_a,
)
announce(
"PUBLIC_READY",
"one-round CKG produced a public Q encryption key",
)
announce("ACTIVE", "material and evaluation requests may now run")
# Application-owned RKG child state machine under ACTIVE.
# Protocol 2 retains one local ephemeral QP secret per party. The exact
# same objects are passed to that party's round-one and round-two calls.
digit_count = engine.rns_layout.key_digit_count
rkg_common_a = mpc.sample_common_uniform(
engine,
basis="QP",
count=digit_count,
)
rkg_ephemeral_by_party = tuple(
mpc.sample_secret_share(engine) for _ in party_secret_shares
)
rkg_round1_by_party = tuple(
mpc.rkg_round1_share(
engine,
secret_share,
rkg_ephemeral_by_party[party_index],
rkg_common_a,
)
for party_index, secret_share in enumerate(party_secret_shares)
)
announce("RKG:R1_LOCAL_CACHED", "every party cached its round-one tuple")
aggregate_round1 = mpc.aggregate_rkg_round1(
engine,
rkg_round1_by_party,
)
announce("RKG:R1_AGGREGATED", "both round-one families were aggregated")
rkg_round2_by_party = tuple(
mpc.rkg_round2_share(
engine,
secret_share,
rkg_ephemeral_by_party[party_index],
aggregate_round1,
)
for party_index, secret_share in enumerate(party_secret_shares)
)
announce("RKG:R2_LOCAL_CACHED", "every party cached its round-two tuple")
relinearization_key = mpc.aggregate_rkg_round2(
engine,
rkg_round2_by_party,
aggregate_round1,
)
announce("RKG:COMPLETE", "the relinearization key is an ordinary core key")
rotation_step = 1
rotation_common_a = mpc.sample_common_uniform(
engine,
basis="QP",
count=digit_count,
)
rotation_shares = tuple(
mpc.rotation_key_share(
engine,
secret_share,
rotation_common_a,
rotation_step,
)
for secret_share in party_secret_shares
)
rotation_key = mpc.aggregate_rotation_key(
engine,
rotation_shares,
rotation_common_a,
rotation_step,
)
announce("ROTATION:COMPLETE", "one exact rotation-key request completed")
conjugation_common_a = mpc.sample_common_uniform(
engine,
basis="QP",
count=digit_count,
)
conjugation_shares = tuple(
mpc.conjugation_key_share(
engine,
secret_share,
conjugation_common_a,
)
for secret_share in party_secret_shares
)
conjugation_key = mpc.aggregate_conjugation_key(
engine,
conjugation_shares,
conjugation_common_a,
)
announce("CONJUGATION:COMPLETE", "one conjugation-key request completed")
# Public evaluation while the collective epoch remains ACTIVE.
positions = torch.arange(engine.num_slots, dtype=torch.float64)
real = 0.008 * torch.sin(positions * 0.013) + 0.004 * torch.cos(
positions * 0.007
)
imag = 0.003 * torch.sin(positions * 0.011) - 0.002 * torch.cos(
positions * 0.005
)
message = torch.complex(real, imag)
source = engine.encrypt_message(message, collective_public_key)
announce(
"ACTIVE:ENCRYPTED_INPUT",
"an ordinary CKKS ciphertext was encrypted with the collective public key",
)
# Public evaluation consumes ordinary core keys and values.
rotated = engine.rotate_with_key(source, rotation_key)
conjugated = engine.conjugate(source, conjugation_key)
transformed = engine.add(rotated, conjugated)
source_ntt_left = engine.coefficient_domain_to_ntt_domain(source)
source_ntt_right = engine.coefficient_domain_to_ntt_domain(source)
product = engine.multiply(source_ntt_left, source_ntt_right)
squared = engine.rescale_to_next_level(
engine.relinearize(product, relinearization_key)
)
announce(
"ACTIVE:EVALUATED",
"rotation, conjugation, and relinearization keys were consumed by ordinary engine operations",
)
# Application-owned Protocol-3 child request under ACTIVE.
# These opposite fixed impulses cancel in the fused arithmetic. They are
# small deterministic correctness fixtures. They supply no smudging
# distribution or privacy property.
p3_error0 = fixed_impulse(engine, coefficient=0, value=1)
p3_error_by_party = (p3_error0, -p3_error0)
p3_shares = tuple(
mpc.unsafe_collective_decryption_share(
engine,
transformed,
secret_share,
smudging_error_coefficients=p3_error_by_party[party_index],
)
for party_index, secret_share in enumerate(party_secret_shares)
)
transformed_plaintext = mpc.unsafe_fuse_collective_decryption(
engine,
transformed,
p3_shares,
)
transformed_decoded = engine.decode(transformed_plaintext, is_real=False)
transformed_expected = torch.roll(
message, shifts=rotation_step
) + torch.conj(message)
transformed_error = error_stats(transformed_decoded, transformed_expected)
if transformed_error["max_abs"] >= 2e-5:
raise AssertionError(
"collective transform output exceeded the established key-switch "
f"correctness tolerance: {transformed_error['max_abs']:.3e}"
)
announce(
"P3:COMPLETE",
"unsafe collective shares fused to Plaintext and decoded without reconstructing the aggregate secret",
)
# Application-owned Protocol-4 child request under ACTIVE.
# The destination key pair is ordinary throwaway FHElium material. The
# fixed ternary ephemerals and opposite component errors below exercise PCKS
# arithmetic only. They supply neither fresh randomness nor a privacy
# property.
destination_secret = engine.create_secret_key(modulus_basis="QP")
destination_public = engine.create_public_key(destination_secret)
p4_error0 = fixed_impulse(engine, coefficient=0, value=1)
p4_error1 = fixed_impulse(engine, coefficient=1, value=-1)
p4_errors_by_party = (
(p4_error0, p4_error1),
(-p4_error0, -p4_error1),
)
p4_ephemeral_by_party = (
fixed_ternary(engine, shift=1),
fixed_ternary(engine, shift=2),
)
p4_shares = tuple(
mpc.unsafe_public_key_switch_share(
engine,
squared,
secret_share,
destination_public,
ephemeral_coefficients=p4_ephemeral_by_party[party_index],
smudging_error0_coefficients=p4_errors_by_party[party_index][0],
error1_coefficients=p4_errors_by_party[party_index][1],
)
for party_index, secret_share in enumerate(party_secret_shares)
)
recipient_ciphertext = mpc.unsafe_fuse_public_key_switch(
engine,
squared,
destination_public,
p4_shares,
)
recipient_decoded = engine.decrypt_message(
recipient_ciphertext,
destination_secret,
is_real=False,
)
recipient_expected = message.square()
recipient_error = error_stats(recipient_decoded, recipient_expected)
if recipient_error["max_abs"] >= 3e-5:
raise AssertionError(
"PCKS squared output exceeded the established multiplication "
f"correctness tolerance: {recipient_error['max_abs']:.3e}"
)
announce(
"P4:COMPLETE",
"unsafe PCKS produced a ciphertext decrypted only by the throwaway destination key",
)
print_table(
["output", "expected", "max_abs", "rms"],
[
[
"collective transform",
"roll(message, +1) + conj(message)",
f"{transformed_error['max_abs']:.3e}",
f"{transformed_error['rms']:.3e}",
],
[
"recipient PCKS",
"message**2",
f"{recipient_error['max_abs']:.3e}",
f"{recipient_error['rms']:.3e}",
],
],
)
print(
"Completed without constructing, installing, decrypting with, or "
"checking an aggregate secret."
)
announce("CLOSED", "the local synthetic epoch accepts no further requests")
if __name__ == "__main__":
main()2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
Continue with the complete operational guide
- Use multiparty CKKS covers epoch descriptors, message envelopes, cache-before-send, duplicate detection, retry and abort, freshness lifetimes, and mapping this local tuple to independent processes.
- Multiparty CKKS API provides exact signatures and value requirements.
- Key material lifecycle explains core key state and storage after aggregation.
- Evaluator operation transitions explains coefficient/NTT and standard/Montgomery evaluator states.