Cryptographic Chiplets
Use Hekate's cryptographic chiplets in Rust, from Keccak, SHA-256 and AES to RSA, ML-KEM and ML-DSA, or write your own: zero-knowledge tables called over buses.
On this page
A chiplet is an AIR table, or a pipeline of them, that proves one operation, such as a Keccak-f[1600] permutation, an AES block or an ML-DSA signature check, and answers calls from your table over a LogUp bus. Hekate ships chiplets for hashing, encryption, signatures and key exchange, and base chiplets for integer arithmetic, memory and modular exponentiation, and any table you write can be packaged the same way and plugged in next to them. This page opens the Keccak chiplet to show how one is built, covers the three ways to plug a chiplet into a program, proves a SHA3-256 preimage with one, and builds a chiplet of your own on top of Hekate's integer arithmetic.
§ The chiplets
Each chiplet proves a stated operation and leaves the rest of the statement to your program. Every crate is open source under AGPL-3.0-only, and none has been audited.
| Chiplet | Crate | Proves |
|---|---|---|
| Keccak-f[1600] | hekate-keccak | the permutation; host helpers run SHA3-256, SHA3-512, SHAKE128 and SHAKE256 |
| SHA-256 | hekate-sha2 | the 64 rounds of the FIPS 180-4 compression function, 1, 2, 4, 8 or 16 per row |
| AES-128 and AES-256 | hekate-aes | block encryption with its key schedule, on a round table and an S-box table |
| RSA-2048 PKCS#1 v1.5 | hekate-rsa | s^65537 mod N == PKCS1-v1_5(H), over the modexp and SHA-256 chiplets |
| ML-KEM | hekate-pqc | KeyGen, Encaps and Decaps of FIPS 203 at all three parameter sets |
| ML-DSA | hekate-pqc | signature verification of FIPS 204 at all three parameter sets |
| Integer arithmetic | hekate-gadgets | 32- or 64-bit ADD, SUB, AND, XOR, NOT and LT |
| Modexp | hekate-gadgets | s^65537 mod N for a 2048-bit N |
| RAM and ROM | hekate-gadgets | reads that return the last write; fetches that match the ROM table row for row |
§ Inside a chiplet: Keccak-f[1600]
A chiplet is an AIR table like your own: columns, constraints, fixed columns and the answering end of a bus, written once and reused.
The Keccak chiplet proves one permutation per block of 25 rows: 24 rows that each apply one round, and an output row. Its state, 1600 bits, sits in 25 committed columns of 64 bits, and its constraints read the same state as 1600 bit columns.
A round costs little in a binary field, where adding is XOR:
- θ XORs each bit with two column parities: sums, no products.
- ρ and π rotate and move lanes, which only decides which bit column a constraint reads.
- χ computes
a + (1 + b) · cfor three neighbouring bits, the one product in the round. - ι adds the round constant as a coefficient, chosen by fixed columns that mark the round number.
Each of the 1600 bits of the next row gets one constraint that applies the whole round, switched on by a fixed column on the 24 round rows. A second fixed column marks rows 0 and 24 of each block. On those rows a constraint ties every 64-bit lane to its 64 bits, and the bus endpoint answers with the 25 lanes and the emit rank, which pairs the block's input and output with one caller's request and response, as LogUp Buses explains.
Integer arithmetic is the hard case for a binary field, where 2 + 3 is 1. The modexp chiplet behind RSA carries an
integer x as G^x for a generator G of the field's multiplicative group: adding two integers multiplies two field
elements. Its column sums stay below the group order, 2^128 − 1, past which the exponent would wrap and the relation
would prove nothing.
§ Calling a chiplet
Your table calls a chiplet by sending the call's values on the chiplet's service bus, on rows that a fixed selector marks.
KeccakChiplet::service() describes the bus: 25 lanes and the emit rank. cx.call(&KeccakChiplet::service(), &lanes, selector) makes your table the requester: on every row where selector is 1, the 25 lane columns go out as one
message. A call is two messages, the input state on one row and the output state 24 rows later, and
KeccakChiplet::host_selector_shape marks exactly those rows. The chiplet answers with the two ends of a block, and
the ordered bus pairs the t-th message on each side.
| Chiplet | Service and plug-in | One call in your table |
|---|---|---|
| Keccak-f[1600] | KeccakChiplet::service(), mounted | input lanes on row 25k, output lanes on row 25k + 24 |
| SHA-256 | CpuSha256Block::connect, mounted or attached | (state_in, block) -> state_out on one row per block |
| AES | link_service() and key_service(), a pipeline | the whitened input and the key on row 2b, the ciphertext on row 2b + 1 |
| ML-KEM, ML-DSA | mlkem::service() or mldsa::service(), a pipeline | one 32-bit word per row: the inputs, then the outputs |
| Integer arithmetic | IntArithmeticChiplet::service(), attached | (a, b, result, opcode) on one row |
Keccak and AES ship the selector shapes their calls need: KeccakChiplet::host_selector_shape, and
host_selector_shape with host_key_selector_shape. For SHA-256 you fix a selector column and pass it to
CpuSha256Block::connect, and the post-quantum services take a selector that is 1 on every word row.
§ Mount, attach, or attach a pipeline
A chiplet joins a program in one of three ways, and the choice decides which table its rows live in.
cx.mount(def)puts the chiplet's columns into your table, row for row, and its constraints run on your rows. It fits when the chiplet's rows line up with yours: a Keccak host puts callkon rows25kand25k + 24, exactly where blockkruns. The trace appends the chiplet's columns to yours.cx.attach(def)gives the chiplet a table of its own, with its own height and ZeroCheck, and the bus is the only link. It fits when the chiplet needs a different number of rows: one row per arithmetic operation, or64 / rounds_per_rowrows per SHA-256 block. The trace goes intoProgramWitness::with_chiplets, one per attached table, in attach order.cx.attach_namespaced(namespace, defs, external)attaches a pipeline of tables and renames every bus they use tonamespace::id, except the ones listed inexternal, which stay open to your table. Two pipelines that use the same internal bus id cannot trade messages. AES runs as a round table and an S-box table, ML-DSA as six tables, and ML-KEM as six or seven.
One shared table is as tall as its tallest part, and every padding row is encoded, hashed and proven. Attaching saves that padding whenever the chiplet and your table need different heights.
§ Packed columns
A chiplet can commit packed columns and let its constraints read bits, and your own tables can do the same.
Circuit::expand_bits(count, storage) commits count columns of storage, from B8 to B64, and gives the
constraints one column per bit; Circuit::reuse_pass_through reads the same committed columns whole, as Keccak's bus
key does. A Bit column takes one byte per row and a B64 column eight: as 25 lanes, Keccak's state takes 200 bytes
per row, and as bit columns it would take 1,600. The proof still reaches single bits, through ring switching, which
PIOP Protocol describes.
§ Example: prove a hash preimage
This program proves that its prover knows a 32-byte secret whose SHA3-256 digest is public, with one call to the Keccak chiplet.
SHA3-256 absorbs a message into the first 136 bytes of the 200-byte state, the rate, and pads it. A 32-byte message
fits one block: lanes 0 to 3 hold the secret, lane 4 starts the padding with 0x06, lane 16 ends it with 0x80 in its
top byte, and lanes 17 to 24, the capacity, start at zero. One permutation later, the digest is the first 32 bytes of
the state, lanes 0 to 3 of the output.
The program takes three files. Start from the crates in Installation and
add hekate-keccak and rand.
1use Config;
2use errors;
3use KeccakChiplet;
4use Block128;
5use ChipletDef;
6use ;
7use define_columns;
8
9pub const SECRET_LANES: usize = 4;
10pub const OUTPUT_ROW: usize = BLOCK_ROWS - 1;
11
12pub type F = Block128;
13
14define_columns!
20
21
26
27
51
52
1use crate;
2use errors;
3use ;
4use ;
5use ;
6
7
1
2
3
4use Config;
5use DefaultHasher;
6use Transcript;
7use sha3_256;
8use program_id;
9use ;
10use prove;
11use preflight;
12use HekateVerifier;
13use ;
14use ;
15use Error;
16use generate_trace;
17
18const LABEL: & = b"sha3-preimage";
19
20
build_programcalls the Keccak service from the 25 lanes, fixes the selector to rows 0 and 24, and mounts the chiplet. It pins lanes 4 to 24 of the input row to the padding and the zero capacity, and publishes lanes 0 to 3 of the output row. The secret lanes stay free.generate_tracewrites the call's input on row 0 and its output on row 24, sets the selector on both, and appends the chiplet's columns fromgenerate_keccak_trace, wheremountplaced them.mainhashes a random secret withsha3_256, which returns the digest and the one permutation call. It publishes the digest as four lanes, checks the trace with preflight, proves and verifies.
cargo run prints the digest, a new one on every run, and the verifier accepts the proof. The verifier learns the
digest and nothing more about the secret.
The pins make the digest a SHA3-256 digest. Without them, the published lanes would be the permutation of any input the
prover chose. A longer message takes more calls, each input the previous output with the next block XORed into the
rate: Keccak-f[1600] proves a 200-byte message that way. The secret sits in a plain array here;
production code keeps it in a type that wipes itself, as TraceBuilder::new_secret does for the trace.
§ Traces come from the host
Each chiplet crate ships the host code that computes its operation and builds its trace, and the same calls fill your table.
- Keccak.
sha3_256,sha3_512,shake128andshake256return the output and every permutation call, andgenerate_keccak_tracebuilds the chiplet's columns from the call inputs. - SHA-256.
pad_messagesplits the message into blocks,CpuSha256Block::writefills a host row, andSha256Chiplet::tracebuilds the chiplet's table. - AES.
expand_keyandexpand_key_256derive the round keys, andgenerate_tracesbuilds the round table and the S-box table from the calls. - ML-KEM and ML-DSA. The pipeline's
tracereturns the words your table sends and the trace of every table in the pipeline. - Integer arithmetic.
generate_arithmetic_tracebuilds the chiplet's table from a list of operations.
The chiplet traces and the witness buffers behind them wipe themselves when dropped.
§ What a chiplet proves
A chiplet proves its operation, and your program states what the operation means. Hekate's rule for the post-quantum tables puts it this way:
A table is a function. Each table proves
outputs = f(inputs)for every call on its service bus, the way the Keccak chiplet provesout = keccak-f(in). Every input enters on the bus and every output leaves on it. A table publishes nothing: the host program decides what is public, what stays hidden and what is chained to what.
| Chiplet | Your program's part |
|---|---|
| Keccak-f[1600] | padding, the chaining of calls into a sponge, and which lanes are public |
| SHA-256 | padding and the chaining of blocks; CpuSha256Block adds the feed-forward on your rows |
| AES | the mode, binding the key to the rest of the statement, and which blocks are public |
| RSA-2048 | the FIPS 180-4 padding of the message, which the statement leaves to its caller |
| ML-KEM, ML-DSA | which words are public: hashes, ciphertexts, keys or the validity bit |
| Integer arithmetic | what the numbers mean; the chiplet proves only each operation's result |
Primitives states the proof of each cryptographic chiplet in full, with what stays outside it.
§ Chiplets that call chiplets
Chiplets compose over the same buses: a statement can call several chiplets, and the tables of a pipeline call each other.
The RSA statement's host table chains SHA-256 one block per row and calls two chiplets, modexp and SHA-256. On the last
block row it compares the modexp output with the RFC 8017 encoding of the digest. Inside the post-quantum pipelines
the tables call each other and hash through Keccak, and the hekate-pqc README maps ML-KEM's encapsulation onto
them:
FIPS 203 Encaps_internal(ek, m) table
──────────────────────────────────────────── ───────────────
h ← H(ek); (K, r) ← G(m ‖ h) ctrl, Keccak
t̂ ← ByteDecode12(ek); μ ← Decompress1(m) Codec
 ← SampleNTT(ρ); y, e1, e2 ← CBD(r) Sampler, Keccak
ŷ ← NTT(y); s ← e2 + μ NTT
û ← Âᵀ∘ŷ; v̂ ← t̂ᵀ∘ŷ PolyArith
u ← NTT⁻¹(û) + e1; v ← NTT⁻¹(v̂) + s NTT
c1 ← Compress_du(u); c2 ← Compress_dv(v) Codec
Values travel between those tables over internal buses, and attach_namespaced keeps them inside the pipeline; your
table trades 32-bit words with the control table over the one external bus.
§ A chiplet of your own
Any table can be a chiplet. Declare a service, answer it from a table you write, and ship the host code that fills the table: the engine plugs in a chiplet of yours, one of Hekate's or one from another crate in the same way.
§ What a chiplet ships
Each of Hekate's chiplets ships the same parts, and a chiplet of yours needs them too. hekate-rsa is the exception:
it ships a whole statement, Pkcs1Statement, built on two of them.
- A service for each bus it answers. The bus id, its kind and its key slots, declared once as a
Service; AES answers on two. The caller's request and the chiplet's answer both derive from it. RAM answers in address order, which aServicecannot express, and declares both of its endpoints by hand, a shape LogUp Buses lists. - Its tables. One
ChipletDefper table.ChipletDef::from_airtakes anyAir: a table written withCircuitand compiled, or a type that implementsAirby hand, asKeccakChipletdoes. - Host code. Functions that compute the operation and build the trace of every table, in the order the caller attaches them.
- The caller's half, where it helps. A selector shape, such as
KeccakChiplet::host_selector_shape, or a block of host columns with its ownconnect, such asCpuSha256Block.
LogUp Buses builds a small one, a table that answers range checks. The example below builds one that calls a chiplet of Hekate's.
§ Example: the highest bid
This program proves that the highest of four private 32-bit bids is a public number. The comparison runs in a chiplet
of your own, Max32, which asks the integer arithmetic chiplet of hekate-gadgets whether one number is below the
other.
Start from the crates in Installation and add hekate-gadgets and
rand. The chiplet sits in a module of its own, the way it would sit in a crate of its own.
1use errors;
2use ;
3use ;
7use ;
8use ChipletDef;
9use ;
10use ;
11use ;
12
13pub const BUS_ID: &str = "max32";
14
15type F = Block128;
16
17define_columns!
27
28
40
41
83
84
113
114
1use cratemax;
2use Config;
3use errors;
4use ;
5use ;
6use ;
7use ;
8
9pub type F = Block128;
10
11define_columns!
19
20
23
24
56
57
69
70
1
2
3
4use Config;
5use DefaultHasher;
6use Transcript;
7use program_id;
8use ;
9use prove;
10use preflight;
11use HekateVerifier;
12use ;
13use ;
14use Error;
15
16const LABEL: & = b"highest-bid";
17
18
cargo run prints highest bid 17500 and the verifier accepts the proof.
- The service.
max::servicedeclares the busmax32: two inputs, the output and the emit rank. The host table requests on it, andMax32answers. - The comparison.
Max32sendsA,B,LTand the LT opcode, held in a fixed column, to Hekate's integer chiplet, which answers only whenLTis the true comparison: 1 whenA < B, 0 otherwise.max_selectthen makesMAXequalA + LT · (A + B), which isBwhenLTis 1 andAwhen it is 0, since a value added to itself cancels in a binary field. - The pipeline.
max::defsreturns two tables,Max32and the integer chiplet it calls, and the host attaches both under the namespacemax. Inside it, the integer chiplet's bus becomesmax::int_arith_link, andmax32, listed as external, stays open to the host. An integer chiplet that the program attaches for work of its own stays apart. - The host table.
max_callslists each row's call: the best bid up to that row and the new bid.carry_besthands each row'sBESTto the next row'sPREV, a boundary startsPREVat 0, and the row after the last bid publishesPREV, the highest bid. Each row asksMax32for the larger ofPREVandBID, and the answer must beBEST. - The witness.
max::tracesbuilds both tables from the same calls, in the ordermax::defslists them, which is the orderwith_chipletsexpects.
The bids are free here: the proof says that some four 32-bit numbers have 17500 as their largest. A sealed-bid auction
would also bind each bid to the commitment its bidder published, a Keccak hash of the bid and a random nonce, with one
Keccak call per bid as in the preimage example. A hash of the bid alone hides
nothing: 2^32 guesses recover it.
Make Max32 lie: in max::traces, write a.min(b) to MAX and u32::from(a >= b) to LT. The constraint
max_select still holds, since MAX follows LT, and preflight reports
PREFLIGHT: 0 constraint, 0 boundary, 0 fixed column, 2 bus: max32, where the host expects the larger bid, and
max::int_arith_link, where Hekate's integer chiplet holds the true comparison.
§ Gadgets: chiplets and atoms
hekate-gadgets holds Hekate's base parts, in two forms.
- Base chiplets, called like any other and mounted or attached: integer arithmetic, RAM, ROM and modexp, from the table at the top.
- Atoms, constraint helpers that run inside your own table:
add_carry_chainandsub_borrow_chainfor n-bit addition and subtraction over bit columns,range_checkforvalue < bound,mul_constandschoolbook_mulfor multiplication,mod_addandmod_reductionfor arithmetic modulo a constant, and the exp-form chains behind modexp. The post-quantum tables build their arithmetic from them.
The two cost in different places. Calling a chiplet adds one bus endpoint to your table, however many rows call it,
and the work runs in the chiplet's columns, at a height of their own when attached. An atom runs the work in your
table, in bit columns and constraints on every row, with no bus. Max32 could compare with sub_borrow_chain, whose
last borrow is 1 exactly when A < B, and drop the integer chiplet for bit columns of its own.
§ Chiplets from other crates
A chiplet from another crate plugs in like Hekate's: add the crate, call its service, attach or mount its tables and pass its traces. Its constraints come with it, and your statement is only as sound as they are.
- Audit it. The verifier checks the constraints a chiplet declares and nothing more. A chiplet that leaves one out proves less than its name says: Shapes the honest prover never builds tells how Hekate's own Keccak and AES chiplets once computed every round correctly and still did not prove their ciphers.
- Read its function. A chiplet proves
outputs = f(inputs)for each call. What is public and how calls chain stay with your program, as What a chiplet proves lists for Hekate's chiplets. - Pin it. Every table you attach or mount enters the program id: its columns, constraints, buses and fixed columns. A release that changes any of them changes the id, and a verifier that pins the audited id, as Verifier Logic does, rejects the changed program.
- Namespace it. Bus ids are global, and two chiplets that use one id trade messages. Attach a chiplet that brings
tables of its own under a namespace, as the example attaches
max.
§ Next steps
- LogUp Buses: the bus every chiplet answers on, and a range-check table of your own.
- Primitives: each chiplet's statement, its public values, and a complete example.
- Your First ZK Program: Keccak mounted and integer arithmetic attached in one program.
- Shapes the honest prover never builds: chiplet constraints that computed every round correctly and still did not prove the cipher.