Verifying a Signed Document
This is the central attestation pattern: prove “I know a document D whose ECDSA signature under the public key pk is valid, and whose content satisfies predicate P,” without revealing D. Everything else on this site — field choice, witness layout, circuit compilation — composes into this one pattern. TPM2 quotes, Intel TDX reports, JWT bodies, and signed mdoc credentials all fit this shape; only the predicate P and the byte-parsing details change.
The three pieces
- SHA-256 gadget. Hashes the document in-circuit and exposes its 256-bit digest as a wire. See SHA Gadget.
- ECDSA gadget. Checks the signature identity in the curve’s base field. Takes
(pk_x, pk_y)as public,(r, s, intermediate points)as witness, and consumes the digest from the SHA gadget. See ECDSA Gadget. - User predicate. Whatever property of the document you care about (attribute disclosure, range check, fresh timestamp). Composes out of the primitives in Circuits / Logic.
What the prover computes off-circuit
The prover does the heavy elliptic-curve work outside the circuit and feeds the intermediate points in as witness. Two calls carry the weight:
FlatSHA256Witness::transform_and_witness_message(...)— turns raw bytes into padded blocks plus the 48+64+64 intermediate round witnesses per block that the SHA gadget consumes. Seecircuits/sha/flatsha256_witness.h.VerifyCircuit<LogicCircuit, Field, EC>::Witness— the inner struct that holds the off-circuit ECDSA witness. The prover fills it with: the point(rx, ry)on the curve, the scalar inversesrx_inv,s_inv, andpk_inv, the 8-element precomputed sum tablepre[8]that holds the x- and y-coordinates of{G+pk, G+R, R+pk, G+R+pk}in the fixed order[GPK_X, GPK_Y, GR_X, GR_Y, RPK_X, RPK_Y, GRPK_X, GRPK_Y], thenkBitssingle-field-element mux indicesbi[kBits]— each selecting the table entry for that bit-step — andkBits-1intermediate projective triples(int_x[i], int_y[i], int_z[i])that slice the bit-loop for depth reduction. There is nobos_coster_shamircall; the circuit performs triple scalar multiplication via a precomputed 4-point sum table indexed bit-by-bit in a double-and-add loop. Seecircuits/ecdsa/verify_circuit.h.
Neither gadget re-derives this work in-circuit. The circuit only checks the identity the witness implies — the prover’s expensive off-circuit arithmetic is the whole point of moving to ZK.
The base-field / scalar-field split
ECDSA lives in two fields that happen to share the same curve. r and s are scalars modulo the curve order n; the coordinate arithmetic (rx, ry) = g·(e/s) + pk·(r/s) happens modulo the curve’s base prime p. p and n are the same bit-length but different numbers. The circuit handles this by:
- Running all gates in the base field (
Fp256forsecp256r1). That is whatFlatSHA256CircuitandVerifyCircuitboth assume. - Treating
randsas integers the circuit range-checks againstnrather than as native scalar-field elements. The range check is a bit-by-bit comparison against a public constant (the curve order), expressed throughcircuits/logic/bit_adder.h. - Requiring the prover to witness
s^{-1}in the base field and checking consistency gate-by-gate.
The consequence for callers: you do not pick the scalar field separately from the base field. Picking Fp256 fixes both.
Wiring it together
The sketch below mirrors lib/zk/zk_test.cc and lib/circuits/mdoc/mdoc_signature.h:
// 1. Instantiate the gate library.
QuadCircuit<Fp256Base> Q(fp256);
CompilerBackend<Fp256Base> cbk(&Q);
Logic<Fp256Base, CompilerBackend<Fp256Base>> lc(&cbk, fp256);
// 2. Declare public inputs: one, pk_x, pk_y, the expected digest, and
// whatever else your predicate needs.
auto one = lc.eltw_input();
auto pkx = lc.eltw_input();
auto pky = lc.eltw_input();
// The expected 256-bit digest is a public input declared as a v256 of bits.
// (Each bit of the digest is one lc.input() call; see flatsha256_circuit_test.cc.)
v256 digest_pub = declare_v256_public(lc);
// ... other public inputs ...
Q.private_input();
// 3. Declare witness inputs: padded message, SHA round witnesses, ECDSA
// witness (rx, ry, pre[8], inverses, bi[kBits], int_{x,y,z}[kBits-1]).
auto msg = declare_padded_message(lc, /* max_bytes */ 512);
FlatSHA256Circuit<...>::BlockWitness sha_w[8];
for (auto& bw : sha_w) bw.input(lc);
VerifyCircuit<Logic<Fp256Base, CompilerBackend<Fp256Base>>,
Fp256Base, P256>::Witness ecdsa_w;
ecdsa_w.input(lc);
// 4. Instantiate the gadgets (they hold a reference to the Logic instance).
FlatSHA256Circuit<Logic<Fp256Base, CompilerBackend<Fp256Base>>,
BitPlucker</* ... */>> sha(lc);
VerifyCircuit<Logic<Fp256Base, CompilerBackend<Fp256Base>>,
Fp256Base, P256> ecdsa(lc, p256, n256_order);
// 5. Assert SHA-256(msg) == digest_pub in-circuit.
// assert_message_hash is void; digest_pub is the required target wire.
sha.assert_message_hash(/* max */ 8, sha_w[0].nb /* placeholder */,
msg, digest_pub, sha_w);
// 6. Verify the signature against the same digest wire.
ecdsa.verify_signature3(pkx, pky, lc.as_scalar(digest_pub), ecdsa_w);
// 7. Apply the user predicate to the document.
assert_predicate(lc, msg);
// 8. Compile.
auto circuit = Q.mkcircuit(/* nc */ 1);The declare_* helpers above are not real APIs — they are placeholders for the sequence of lc.eltw_input() calls that declare each gadget’s expected witness shape. See flatsha256_circuit_test.cc and verify_circuit_test.cc for the concrete declarations. The gadget instantiation pattern (FlatSHA256Circuit sha(lc); sha.assert_message_hash(...)) follows circuits/mdoc/mdoc_hash.h, which composes both gadgets the same way.
What the user predicate looks like
assert_predicate(lc, msg) is everything that is specific to your use case. A few recurring shapes:
- Attribute disclosure. Prove the document, parsed as a structured blob, contains a field with an expected value. See Parsing Structured Bytes.
- Range check. Prove a numeric field (age, timestamp, measurement register) falls in a public range.
circuits/logic/bit_adder.hcarries the comparison primitives. - Freshness. Prove a timestamp field is ≥ a public
nowvalue. Same bit-adder primitives.
The predicate is the only circuit-level knob you actually own; the SHA and ECDSA pieces above are constants.
Attestation-shape examples
- TPM2 quote. Document is a
TPMS_ATTESTblob;pkis the AIK; predicate asserts the PCR digest matches an expected measurement. The SHA gadget hashes the entireTPMS_ATTEST; the ECDSA gadget verifies its signature. - Intel TDX TDREPORT / TDQUOTE. Document is the TDQUOTE body;
pkis the PCK or quoting-enclave key; predicate asserts MRTD / RTMR fields match expected values. - JWT. Document is the base64url header.payload;
pkis an ES256 key; predicate asserts specific claims in the payload.
In each case the pattern is unchanged — only the parsing in the predicate changes.
Related
- SHA Gadget (Reference)
- ECDSA Gadget (Reference)
- Elliptic Curves (Reference) — the off-circuit side of the ECDSA witness.
- Parsing Structured Bytes in Circuit — how the user predicate actually parses the document.
- mdoc Case Study — a fully realised instance of this pattern.