Reusable Rust implementation for client-side CKKS encryption.
This is the single canonical implementation package used directly by the Xous device and intended to be wrapped by future packages.
ckks/*— scheme-specific params, formats, encryption, and decryptionmod_arith— modular arithmetic (addmod,mulmod,powmod,invmod, etc.)ntt— number-theoretic transform with optional root-table precomputationfft— complex FFT for CKKS canonical embeddingopaque— little-endian buffer helperserror—Errorenum
This section walks through each building block (Setup, Key Generation, Encryption, Decryption). A complete code sample is provided at the end in Example.
Before encrypting, there is some one-time setup to be done.
- Parameters: Construct a
CkksParams(or its borrowed twinCkksParamsRef) describing the scheme — ring degree, RNS moduli, scale, and error bound. - Precomputation: A
PrecompConfigselects which optional precomputed tables are kept in caller-owned storage. This storage is separate from the scratch buffer passed toencrypt.PrecompConfig::MINIMALstores only the small per-prime modular multiplication constants. It keeps precomp storage tiny while avoiding some repeated setup work.PrecompConfig::FULLstores forward and inverse NTT root tables. It reduces arithmetic during encryption, but increases precomp storage size and root-table read traffic.PrecompRef::none()skips precomp storage entirely. It is useful for baseline measurement or extremely constrained environments, but most callers should chooseMINIMALorFULL.
- Context: A
CkksContextties the parameters, precomputation space, and public key together; it is the entry point forencrypt(and, with theallocfeature,decrypt_f64).
With the alloc feature enabled, the library provides a deterministic CKKS key pair generation function that creates a new secret key/public key pair from a seed.
At encryption time, in addition to buffers for the input values, the output ciphertext, and scratch space to work in, two values must be provided:
precision: the number of bits of precision after the decimal place needed on the values being encrypted. Currently the maximum value supported by the encryption library is approximately 40 bits.bound: This is an upper bound on the size of the values being encrypted. It should be a constant fixed before-hand and not e.g. computed by taking the max of the values, since the bound may be included in the ciphertext metadata and therefore must not leak information about the plaintext.
Each call to encrypt must also be supplied with a fresh, cryptographically random 32-byte seed. The seed-handling rules are non-trivial and security-critical; see the Randomness section below for the full requirements before writing production code.
With the alloc feature enabled, CkksContext::decrypt_f64 recovers the encoded vector of f64s from a ciphertext and a secret key. Pass the original value_count (the number of slots that were encoded) so the helper knows how many entries to return; values at indices beyond value_count are discarded.
use ucritair_client::{
CkksContext, CkksParams, CkksParamsRef, Precomp, PrecompConfig, PrecompLayout,
ckks::generate_key_pair,
};
// Choose parameters
const N: usize = 1024;
const NUM_PRIMES: usize = 2;
let owned = CkksParams::new(
N,
vec![2147352577, 2146959361],
/* scale */ 1 << 40,
/* error_bound */ 1,
)?;
let params = owned.as_ref();
// Set up precomputation storage. This is just a caller-owned `[u64]` buffer
// that will hold a small header plus any tables requested by the config.
// If `N` and `NUM_PRIMES` are not known at compile time, allocate the storage
// on the heap instead, e.g. `vec![0u64; PrecompLayout::words_for(...)]`.
let mut precomp_storage =
[0u64; PrecompLayout::words_for(PrecompConfig::MINIMAL, N, NUM_PRIMES)];
let precomp = Precomp::ensure(¶ms, PrecompConfig::MINIMAL, &mut precomp_storage)?;
// Generate a key pair (or load a pre-existing one).
// Both seeds below MUST come from a CSPRNG, must never be reused across
// calls, and must be independent of each other. See the Randomness section
// for the full reasoning.
let key_seed: [u8; 32] = rand::random();
let (pk_bytes, sk) = generate_key_pair(¶ms, &key_seed)?;
// Create CkksContext
let ctx = CkksContext::new(params, precomp).with_public_key(&pk_bytes)?;
// Encrypt a list of real values. Each call to `encrypt` MUST use a fresh seed.
let values: Vec<f64> = (0..16).map(|i| i as f64 / 4.0).collect();
let ciphertext_bytes = params.ciphertext_bytes()?;
let mut work = [0u64; CkksParamsRef::encrypt_work_words_for_n(N)];
let mut ciphertext = vec![0u8; ciphertext_bytes];
let encrypt_seed: [u8; 32] = rand::random();
ctx.encrypt(&values, /* precision */ 20, /* bound */ 4, &encrypt_seed, &mut work, &mut ciphertext)?;
// Decrypt with the secret key (requires the `alloc` feature).
let decoded = ctx.decrypt_f64(&sk, &ciphertext, values.len())?;This library is bring-your-own-entropy: every call to generate_key_pair and CkksContext::encrypt takes a 32-byte seed as input, and the library does not read from the OS or any ambient RNG itself. That keeps the library no_std-friendly, but it puts the following obligations on the caller. All three are required for the security claims of CKKS to hold; getting any of them wrong is a critical bug, not a stylistic issue.
A 32-byte seed only delivers 256 bits of security if it has 256 bits of entropy. In std builds the simplest way to produce one is the rand crate:
let seed: [u8; 32] = rand::random();rand::random() reads from a per-thread CSPRNG that is itself seeded from the OS entropy source. The rand crate must be at version 0.9 or higher for the rand::random() pattern shown above to be cryptographically secure; earlier versions made different choices about the default RNG and are not covered by this guarantee. Add the dependency to your own crate accordingly:
[dependencies]
rand = "0.9"For no_std callers, source the bytes from the platform TRNG (e.g. the Xous TRNG service) or from an in-process CSPRNG that is itself seeded from a true randomness source. Do not seed from a counter, timestamp, PID, or any other low-entropy value.
Reusing a seed across two calls to encrypt — even with different plaintexts and the same key — is catastrophic. The random masks u, e0, e1 used inside the encryption routine are deterministically derived from the seed, so two ciphertexts produced from the same seed share those masks, and the difference of the two ciphertexts directly leaks the difference of the two plaintexts. The IND-CPA security of CKKS depends on per-call freshness of these masks; there is no recovery from a seed reuse after the fact.
The safe pattern is one fresh rand::random() draw per encryption call. Never store an encrypt seed in a variable that lives across iterations, and never derive it from data you also hand to encrypt.
generate_key_pair and encrypt both expand their seed via ChaCha20 to derive sub-seeds for the underlying RLWE samples, and there is currently no domain separation between the two derivations. If the same 32-byte seed is fed to both, the ternary mask u used during encryption is byte-identical to the secret key s, which directly leaks information about s.
The safe pattern is one fresh CSPRNG draw per call:
let keygen_seed: [u8; 32] = rand::random();
let (pk, sk) = generate_key_pair(¶ms, &keygen_seed)?;
let encrypt_seed: [u8; 32] = rand::random();
ctx.encrypt(&values, 20, 4, &encrypt_seed, &mut work, &mut ciphertext)?;If you follow rule (2) you will already be drawing a fresh seed for every encryption, so this rule mostly amounts to "don't pass the key-generation seed back into encrypt later either".
params.num_primes()must be at most 256. Above that limit,generate_key_pairwill silently produce a degenerate public key whose later limbs collide with earlier ones, because the per-limb sub-seed is derived by single-byte-incrementing one byte of the key-generation seed. In practice all production CKKS parameter sets use far fewer than 256 primes, but if you are designing a new parameter set, keep this bound in mind.
ucritair-client operates on raw caller-owned buffers, which allows for flexible memory management depending on your environment:
- Stack allocation: When parameters are known at compile time, buffers can be sized and allocated on the stack (e.g., using
[u64; N]). This is ideal for constrained environments. - Heap allocation: When parameters are only known at runtime, buffers can be sized dynamically and backed by a
Vec<u64>.
Encryption uses one caller-provided scratch buffer, sized by CkksParamsRef::encrypt_work_words_for_n(n) or params.encrypt_work_bytes(). This scratch requirement is the same for PrecompConfig::MINIMAL and PrecompConfig::FULL.
Precomp storage is a separate caller-owned slice passed to Precomp::ensure. In simple terms, it is just the buffer that holds reusable setup data: a small header, plus whatever tables the chosen PrecompConfig asks for. Precomp::ensure fills or validates that buffer and returns a PrecompRef, which is only a borrowed view into the same storage. The storage must therefore outlive the CkksContext that uses the returned PrecompRef.
There is no special production PrecompStorage type. Tests may wrap this pattern in a helper with that name, but application code should pass its own stack array, heap Vec<u64>, or other memory-backed [u64] slice.
Building precomp separately keeps encryption latency and scratch usage predictable, lets callers choose placement for larger reusable tables, and allows the same validated precomp blob to be reused across many encryptions with the same parameters.
PrecompConfig only describes reusable stored tables: MINIMAL and FULL. For a true no-precomp path, pass PrecompRef::none() to CkksContext::new; this uses no precomp storage and makes encryption derive constants and roots on demand. Most callers should choose MINIMAL or FULL; MINIMAL is usually the storage-conscious baseline, while FULL is the table-heavy speed option.
For n = 65536, encryption scratch is always 768 KiB:
| config | encrypt scratch | precomp storage with 4 primes | precomp storage with 11 primes |
|---|---|---|---|
PrecompRef::none() |
768 KiB |
0 KiB |
0 KiB |
MINIMAL |
768 KiB |
< 1 KiB |
< 1 KiB |
FULL |
768 KiB |
~2 MiB |
~5.5 MiB |
Use FULL when precomp storage is reasonably cheap to store and read, especially when encrypting repeatedly with the same parameters. Use MINIMAL when the precomp footprint or root-table reads are the limiting factor. Use PrecompRef::none() only when even the small MINIMAL constants are not worth storing, or when you want a baseline that measures all setup work inside encryption. On devices where large table reads are slow, benchmark MINIMAL and FULL on the target memory path: FULL trades computation for table-read bandwidth, while MINIMAL trades table reads for computation.
std(default) — enablesallocand standard library support. Uses standard floating-point math.alloc— enables owned types likeCkksParams, key generation (generate_key_pair), and decryption helpers.- No features (
default-features = false) — provides an explicit-buffer encryption API only and falls back tolibmfor floating-point math, suitable forno_stdtargets without an allocator.
To run the tests:
cargo testTo run a simple example of encryption in examples/simple_encrypt.rs:
cargo run --release --example simple_encryptTo profile the encryption runtime and heap/stack usage in different parameter configurations:
cargo run --release --example profile -- [limbs-mode] [precomp-mode]Available options:
limbs-mode:minimal-limbs,full-limbs(default), orboth.precomp-mode:none,minimal,full, orboth(default;minimal+full).
Examples:
cargo run --release --example profile -- both both
cargo run --release --example profile -- full-limbs minimal