RubikFHE: Solving a Rubik's Cube Under CKKS

RubikFHE: Solving a Rubik's Cube Under CKKS

November 12, 2026 · 7 min read

RubikFHE solves a Rubik’s cube that the server never sees. The client encrypts the colors of the 54 stickers under CKKS, the server evaluates a fixed circuit on the ciphertexts, and the client decrypts a sequence of moves that solves its cube. This article only describes that circuit: how solving a cube becomes a branch-free computation, how it is laid out in the slots, and where the bootstrappings go.

A solver without branches

Usual solvers search. Thistlethwaite, Kociemba or IDA* all look up the current state in a table and choose the next move from the result. Under encryption the server can neither index a table with an encrypted value nor branch on one: whatever it computes, it computes for every possible cube, and the circuit must have the same shape for all of them.

RubikFHE uses a stabilizer chain of the cube group instead. It tracks 18 cubies in a fixed order: 11 edges (UR, UF, UL, UB, DR, DF, DL, DB, FR, FL, BL), then 7 corners (URF, UFL, ULB, UBR, DFR, DLF, DBL). The last edge and the last corner need no stage: once the others are home, they are too. Stage ii brings cubie ii home without disturbing the cubies placed before it.

When stage ii starts, its cubie is at one of ∣Ωi∣|\Omega_i| oriented locations: 24 for the first edge (12 positions, 2 orientations each), 22 for the second, down to 4 for the last tracked edge, then 24, 21, …, 3 for the corners. For each location aa there is a public correction ci,ac_{i,a}, a move word that sends the cubie home and fixes every cubie placed before it. The product of the orbit sizes is the order of the cube group, 43 252 003 274 489 856 00043\,252\,003\,274\,489\,856\,000, so every legal cube is solved by exactly one sequence of 18 corrections.

The corrections are computed once, in the clear, with Schreier–Sims. Each one is the shortest word of at most six moves when there is one, and otherwise comes from a plaintext solver. All the words of stage ii are padded with no-op tokens to the longest of them, LiL_i, which ranges from 3 for the first edges to 33. The output is always 378 tokens, each a code from 0 (no-op) to 18 (one of the 18 face turns).

Write the location of cubie rr as a one-hot vector xr∈{0,1}24x_r \in \{0,1\}^{24}, and let s=xis = x_i, restricted to the orbit, be the selector of stage ii. The stage computes

ti,ℓ=∑asa⋅code(ci,a)ℓ,xr′=∑asa Pi,a xr(r>i),t_{i,\ell} = \sum_{a} s_a \cdot \mathrm{code}(c_{i,a})_\ell, \qquad x_r' = \sum_{a} s_a \, P_{i,a}\, x_r \quad (r > i),

where Pi,aP_{i,a} is the 24×2424 \times 24 permutation matrix of ci,ac_{i,a} acting on edge or corner locations. Exactly one sas_a equals 1, so the stage emits the word the plaintext solver would pick and applies the matching correction to every later cubie. The tokens are linear in the selector and the update is bilinear in the selector and the state: one multiplicative level per stage, with no comparison and no lookup.

Encoding the stickers

Cubie rr sits at oriented location xx exactly when the first facelet of xx shows the cubie’s first color and the next facelet shows its second color. Two adjacent stickers, read in the cubie’s reference order, are enough to identify an edge or a corner and its orientation. So xr[x]=u⋅vx_r[x] = u \cdot v, where uu and vv are one-hot sticker bits that the client copies to public positions. The client encrypts these two ciphertexts and does no cube reasoning; the server’s first operation is their product.

One stage in the slots

With N=216N = 2^{16}, a ciphertext has 2152^{15} slots, split into 32 blocks of 32×3232 \times 32. Each tracked cubie owns a block: its 24-entry vector is stored along one axis and copied along the other. Edges take the even blocks and corners the odd ones, so the 18 cubies fit in 22 blocks and the whole state is a single ciphertext. A stage then runs:

  1. A mask isolates the block of cubie ii, and an inner sum copies it into all 32 blocks.
  2. A linear transform with public diagonals turns the selector into the transition matrix S[y→z]=∑asa[πa(y)=z]S[y \to z] = \sum_a s_a [\pi_a(y) = z] in every block, where πa\pi_a is the action of ci,ac_{i,a} on edges in the edge blocks and on corners in the corner blocks.
  3. One product with the state puts S[y→z]⋅xr[y]S[y \to z] \cdot x_r[y] at position (y,z)(y, z) of block rr.
  4. Folding along yy with rotations and additions leaves xr′[z]x_r'[z] along the other axis. A mask and a replication restore the block layout. The two axes swap roles at every stage, so the circuit never needs a transpose, and the same fold already contains the next selector.

The tokens are the replicated selector times a plaintext of codes, summed into one token ciphertext per orientation. A stage costs a plaintext product for the gather (24 bits, the precision of the public plaintexts), a ciphertext product (35 bits, the scale) and a mask (24 bits): 83 bits of modulus.

The bootstrapping schedule

edges · stages 0–10 corners · stages 11–17 cubie tokens u·v UR 0 3 UF 1 4 UL 2 4 UB 3 4 B1 DR 4 5 DF 5 6 DL 6 30 DB 7 25 B2 FR 8 32 FL 9 33 BL 10 27 URF 11 30 B3 UFL 12 32 ULB 13 30 UBR 14 30 DFR 15 29 B4 DLF 16 26 DBL 17 28 524 489 133 bits at 133 bits (scale 35 + reserve 98), the state is bootstrapped each bootstrapping · S2C-first, functional SlotsToCoeffs slots → coeffs dense → sparse h = 1024 → 32 ModUp 1009-bit modulus sparse → dense h = 32 → 1024 CoeffsToSlots coeffs → slots EvalMod + LUT 0 → 0, 1 → 1 in: 133 bits, noisy 0/1 values out: 489 bits, values rounded back to 0/1
The server's side of a solve. One product u·v decodes the stickers, then each stage places one cubie and emits its padded move word (token counts below). The state loses 83 bits per stage; four functional bootstrappings, B1 to B4, restore it and round its noisy one-hot values back to 0 and 1. The last stage only emits tokens.

A fresh ciphertext has 524 bits of modulus, and the product u⋅vu \cdot v leaves 489. The first stage also pays 24 bits to isolate its selector, so after four stages 133 bits remain: the 35 bits of the scale plus the 98 bits the bootstrapping needs to read its input. Stage 4 cannot run on that, so the state is bootstrapped. A refreshed ciphertext has 489 bits again, sized for exactly four more stages. The bootstrappings therefore fall before stages 4, 8, 12 and 16: four per solve, each on a single ciphertext since the whole state lives in one. The last stage only emits tokens, a single plaintext product, so the final segment needs no fifth bootstrapping.

The selector and the state carry the same error into a bilinear map, so the error on the one-hot values roughly doubles at every stage, and accumulates. Under the secure parameters, the state error grows from about 3⋅10−73 \cdot 10^{-7} after the first stage to 3⋅10−43 \cdot 10^{-4} by the fourth. A bootstrapping that only restores the modulus would carry this error forward. Each bootstrapping here also rounds the values back to 0 and 1, which squares the error: the state starts every new segment at about 3⋅10−63 \cdot 10^{-6}.

Four stages per bootstrapping is a memory choice as much as a noise one. It keeps the working modulus of the bootstrapping at 1009 bits, below about 1060 bits, where every bootstrapping key needs three gadget digits instead of two. That saves a third of the key memory, which is worth more than the extra bootstrappings that narrower ciphertexts require.

Getting the moves back

A final fold collects the tokens at fixed positions of the two output ciphertexts. The client decrypts them, rounds 378 slots to integers (the worst error is about 2⋅10−32 \cdot 10^{-3}, far from the 1/21/2 that would flip a code), drops the no-ops and merges consecutive turns of the same face. On the server, every cube costs the same 18 stages and 4 bootstrappings, including an already solved one, which simply decrypts to 378 no-ops.