Verifying the SPHINCS- Post-Quantum Signature Verifier

Specifying the SPHINCS- on-chain verifiers in Lean 4 and reducing their correctness to a small, named trust surface

1

On every public key, message, and signature, the Verity model performs the same SPHINCS- checks and reaches the same verdict: accept, reject, or revert.

2

The verifier accepts only if the +C grinding is present: the WOTS digit-sum hits its target and the forced FORS index is zero.

For the C13 keccak verifier this is machine-checked end to end: Lean's interpreter executes the transcribed assembly and the kernel checks that it computes the spec, resting on Lean's logic plus three named, documented residual assembly facts (section 5). The SHA-2 variant carries one explicit bridge assumption plus an opaque SHA-256 primitives constant.

This post is the result of a sidequest our friend Nico handed us. He spent the past few months working on SPHINCS-, a post-quantum signature scheme and wrote a family of on-chain verifier contracts for it. Our job was to take the Solidity source code, and for each verifier we modeled, reduce the claim “this contract matches the SPHINCS- algorithm” to the smallest assumptions possible. Using verity, we proved implementation-correctness against the algorithm (cryptographic unforgeability being a separate task). Here is how we did it.

What we verified

Two verifier contracts from lfglabs-dev/SPHINCS-, authored by Nicolas Consigny: SPHINCs-C13Asm.sol (keccak256, WOTS+C/FORS+C) and SLH-DSA-SHA2-128-24verifier.sol (SHA-256, FIPS-aligned). The repository also ships live C7 and C9 verifiers; those are not modeled or verified here, and the earlier C12 verifier (whose model a previous revision of this work covered) has been retired to legacy/ along with its model. Every claim below links to a specific line, pinned to commit e62d833 (current main).

1. What is SPHINCS-?

A digital signature lets you prove you authorized a message without revealing your secret. You hold a private key and publish a public key; only the key holder can produce a signature that the public key accepts. On Ethereum the on-chain code runs only the verify half. Today's wallets use ECDSA, whose security a large quantum computer would break, which is the motivation for post-quantum signatures.

SPHINCS+ (standardized by NIST as SLH-DSA, FIPS 205) rests on one assumption only: that a hash function is hard to invert and collision-resistant. It combines three pieces. WOTS+ is a one-time signature: a message digit aia_i says how far the signer already hashed a secret value, and the verifier hashes it the remaining steps, checking that it lands on the published public value cw1ai(σi)=pkic^{\,w-1-a_i}(\sigma_i) = pk_i (where cc is one hash step). XMSS is a Merkle tree of such keys; stacking trees into a hypertree reaches many signatures per key. FORS is a few-time signature that signs the message digest itself.

A full signature is a guided climb, from the message digest up to the one root the verifier already holds:

message digestFORS reconstructionFORS public keyWOTS+ at layer 0XMSS root0auth paths, layers 1d1root=? pkRoot\begin{array}{cl}\textsf{message digest} & \\[2pt]\big\downarrow & \text{FORS reconstruction} \\[2pt]\textsf{FORS public key} & \\[2pt]\big\downarrow & \text{WOTS}^{+}\text{ at layer } 0 \\[2pt]\textsf{XMSS root}_0 & \\[2pt]\big\downarrow & \text{auth paths, layers } 1\ldots d{-}1 \\[2pt]\textsf{root} & \overset{?}{=}\ \textit{pkRoot}\end{array}

The “minus”: smaller signatures via grinding

Hash-based signatures are large, which is expensive on-chain. SPHINCS- shrinks them with a grinding trick, the +C in WOTS+C and FORS+C. Rather than transmit a checksum, the signer does a small proof-of-work: it searches for a randomizer so the WOTS digits hit a fixed target and one FORS index is forced to zero.

i=1ai  =  Tandidxk1  =  0(T=208 for C13)\sum_{i=1}^{\ell} a_i \;=\; T \qquad\text{and}\qquad \mathrm{idx}_{k-1} \;=\; 0 \qquad (T = 208 \text{ for C13})

Those constants no longer travel in the signature. Verification depends on it: the verifier must re-check both equalities and refuse the signature on a mismatch. A contract has two ways to refuse: revert, which undoes the whole transaction, or return false, the ordinary “this signature did not check out” answer. The two are observably different to callers, so the spec keeps them distinct, and the exact revert-versus-reject boundary is one of the properties we prove about the model. (One known divergence between the model and the live source on this exact boundary is disclosed in the assumptions section.)

2. Two verifiers, one shape

We modeled two of the live verifier contracts. They share one entry point, verify(pkSeed, pkRoot, message, sig) → bool, and differ in hash, address encoding, and grinding rules.

VariantHashGrindingSource
C13keccak256WOTS+C, FORS+C.sol
SLH-DSA-SHA2SHA-256plain (NIST).sol

Each contract is a single hand-written assembly block tuned for gas, exactly where a subtle masking or index bug hides.

C13 is also the first verifier in the family built on the FIPS 205 §11.2.2 uncompressed 32-byte address layout, keeping the keccak hash while aligning the ADRS encoding with the NIST reference. Concretely, every FORS hash is keyed by the position of this message in the hypertree: the digest's 22-bit index splits into a tree half and a leaf half (idxTree0 = htIdx >> 11, idxLeaf0 = htIdx & 0x7FF), and the per-level climb address folds the FORS tree number into the index word. The model and the spec are proved to agree at those real, per-message digits, not at a fixed placeholder.

The keccak verifier (C13) is fully proved against the executable model, up to three named residual assembly facts. The SHA-2 variant is modeled and specified to the same standard, but its model-to-spec step is a named assumption (its SHA-256 precompile call sits outside the pure fragment our interpreter covers), so treat its guarantees as the less settled of the two.

3. The specification, in three layers

You prove a contract correct against a specification: an independent description of what it should do. The risk is a spec so close to the code that both share one bug. We split it into three layers, each owning a different concern, and prove each layer refines the one above. Here “B refines A” just means everything B does is already allowed by A, so proving the lower layer can never contradict the upper one.

Model.leanEVM: offsets, masks, revertsrefines    (proved, keccak variants)verifyByteslength, key canonicality, parsingequal    (proved)verifyParsedthe algorithm\begin{array}{cl}\textsf{Model.lean} & \text{EVM: offsets, masks, reverts} \\[2pt]\big\downarrow & \text{refines}\;\;(\textit{proved, keccak variants}) \\[2pt]\textsf{verifyBytes} & \text{length, key canonicality, parsing} \\[2pt]\big\Vert & \text{equal}\;\;(\textit{proved}) \\[2pt]\textsf{verifyParsed} & \text{the algorithm} \end{array}

The top layer verifyBytes owns everything observable from outside the EVM and reduces a valid input to verifyParsed, the algorithm. Their combined contract is a single three-outcome function, where \bot denotes a revert:

verifySpec  =  {if length, key, or parse is invalidverifyParsedotherwise\textsf{verifySpec} \;=\; \begin{cases}\bot & \text{if length, key, or parse is invalid} \\\textsf{verifyParsed} & \text{otherwise}\end{cases}
Why the outcome type is Option Bool, not Bool

A Solidity verifier has three observable outcomes: true, false, or revert. Folding revert into false would hide a real, observable difference.

So the spec returns Option Bool: none models a revert, and the two some values model the normal returns. See the three-way HyperResult outcome.

4. Six properties a correct verifier must enforce

For each property we give the rule, its spec definition, and the Solidity line that enforces it. A bug in any one is exploitable, or a denial of service against honest users. The spec is parametric over a Primitives package (hash, address encoding, signature parsing); for the keccak verifier that package is instantiated concretely, routed through the same pure Keccak function the interpreter evaluates, so nothing about the hashing is taken on faith in the implementation-correctness theorem. The spec link in each row is machine-checked; the .sol link is the Solidity line whose Lean transcription the proofs execute (section 5).

  1. 1

    Signature shape

    Exactly the right components at the right sizes: the randomizer, the FORS leaves and authentication paths, and one WOTS+ block plus Merkle path per hypertree layer.

    signatureShapeOk

  2. 2

    Public-key canonicality

    Both verifiers pack 16-byte hashes into 32-byte words and require the unused low bytes to be zero: pkSeed and pkRoot must each equal themselves masked by N_MASK, otherwise the contract reverts with “Invalid public key.” The C13 guard (added in review C13-V-f1, src/SPHINCs-C13Asm.sol) closes a previously silent dead branch: a non-top-aligned pkRoot can never equal the always-masked recomputed root, so every signature would quietly verify false, bricking the account. The guard fails loudly instead, and the C13 model transcribes it.

    publicKeyOkC13 .sol L60-L66

  3. 3

    FORS forced-zeroidxk1=0\mathrm{idx}_{k-1} = 0

    For the +C variants the verifier recomputes the last FORS tree index; a non-zero value means the signature did not pay its proof-of-work and is refused.

    forcedZeroOkC13 .sol L106

  4. 4

    WOTS+C grinding targetiai=T\textstyle\sum_i a_i = T

    The recovered chain digits must sum to the agreed target (208 for C13); a wrong sum means a forged or malformed signature and is refused. The proofs run the model's digit-sum loop and show it fails exactly when the spec-side grinding predicate fails.

    wotsGrindingFailsC13 .sol L212

  5. 5

    Hypertree index arithmeticleaf=idxmod2h,    idxidx/2h\mathrm{leaf} = \mathrm{idx} \bmod 2^{h\prime},\;\; \mathrm{idx} \leftarrow \lfloor \mathrm{idx} / 2^{h\prime} \rfloor

    The low bits pick the leaf in the current tree; shifting them off yields the next tree’s index. Off-by-one here silently accepts signatures from the wrong tree.

    foldHypertreeAux

  6. 6

    Final acceptanceClimb()=pkRoot\textsf{Climb}(\dots) = \textit{pkRoot}

    After the full climb the recovered root must equal the public root. The contract returns true exactly when they match, and only then.

    verifyParsedC13 .sol L265

5. From Solidity to a theorem

Rebuilding the assembly in Verity

Model.lean transcribes each contract's assembly block into Verity's EDSL statement by statement, keeping the same scratch-memory addresses, the same digest bit-extraction masks, the same loop bounds, the branchless Merkle swap, and the final mstore(0x00, valid); return(0x00, 0x20). Where the Solidity drops to raw Yul (revert(0, 0) and the Error(string) guards), the model uses typed unsafe-Yul fragments that carry an explicit proof obligation instead of an opaque escape hatch.

For the keccak verifier the transcription is not decoration: the proofs execute it. Verity ships a small interpreter for its statement language, and the theorems run c13VerifyBody through it, statement by statement, on a symbolic input. To keep that tractable the body is cut at natural seams, the digest computation, the index split, the FORS reconstruction, the hypertree climb, and each segment is pinned to the body by a definitional equation (a rfl that Lean checks by computing). Edit one opcode of the transcription and those equations stop type-checking: a transcription bug breaks the build.

Inside each segment, loop invariants carry the spec values through every iteration: the 19-level FORS climbs, the 43 WOTS chains, the two hypertree layers. And because the spec's hash package is instantiated with the same pure Keccak function the interpreter calls, “the model's keccak” and “the spec's keccak” are literally one definition; there is no axiom asserting they agree.

What is proved, and what is assumed

Everything from the raw bytes up to the algorithm is machine-checked, resting only on Lean's core logic. For C13 the step below that, the model computes exactly verifyBytes, is a theorem too, established by running the transcribed body through the interpreter. Its proof leans on three named residual assembly facts, each a small, local statement documented in place; the SHA-2 variant keeps its bridge as an explicit assumption. The exact axioms are listed in the trust-surface disclosure below.

TheoremEstablishesStatus
verifyBytes_eq_verifySpecbyte boundary = algorithm (by case analysis)Proven
byteVerifier_refines_speclifts it to any semanticsProven
verifyBytes_isSome_iffexact revert boundaryProven
c13_refines_byte_specthe interpreted model body computes verifyBytesProven (named framing facts)
c13_refines_specmodel refines the algorithmProven (same facts)
slhDsaSha2_128_24_refines_specSHA-2 model refines the algorithmProven modulo its named bridge assumption

The full build contains no sorry, though that alone proves little: what matters is which axioms each theorem rests on, and Lean prints that list exactly. The three residual facts are statements of the same kind as hundreds of lemmas the build does check: that the WOTS chain loop's entry bindings hold at a named cutpoint state of the layer climb, and the same for its layer-1 and revert-branch twins. Each is recorded in place and none hides a new mathematical idea. Work-in-progress proofs discharging them live on the wip/axiom-discharge branch (PR #9); they have not yet compiled to completion (the monolithic attempt exceeded a 32 GB memory cap), so the three declarations remain axioms on main.

The exact trust surface (#print axioms)
c13_refines_spec depends on axioms:
  [propext, Classical.choice, Quot.sound,             -- Lean logic
   c13_ok_..._lightweight_chain_inputs_layer0,         -- WOTS loop entry facts
   c13_ok_..._lightweight_chain_cells_residual_layer1, -- layer-1 twin
   c13_reverted_..._lightweight_chain_cells_residual]  -- revert-branch twin

slhDsaSha2_128_24_refines_spec depends on axioms:
  [propext, Quot.sound,
   slhDsaSha2_128_24_Primitives,                       -- opaque SHA-2 semantics
   slhDsaSha2_128_24_refines_byte_spec]                -- named bridge assumption

Note what is absent from the keccak list: no opaque primitives package and no bridge axiom. The hash semantics are a concrete Lean function shared by spec and interpreter, and the model-to-bytes step is a theorem. byteVerifier_refines_spec depends only on Lean's logic: the algorithmic core carries no domain assumptions at all.

Assumptions

The entire trust surface is small and named. These are the only things a reader must take on faith.

  • keccak / SHA-2 collision resistancestandard hash assumption, security only

    every hash-based scheme

    Cryptographic security reduces to the hash being hard to invert and collision-resistant; this is what SPHINCS+ itself is built on. It is not needed for the implementation-correctness theorems above: those say the contract computes the algorithm, whatever the hash's strength.

  • Residual assembly facts (C13: 3)accepted obligations, documented in place

    verity/SphincsMinusVerifiers/Proofs.lean

    Three named axioms in Proofs.lean: c13_ok_beforeAuthOff_wotsPk_lightweight_chain_inputs_layer0, c13_ok_beforeAuthOff_wotsPk_lightweight_chain_cells_residual_layer1, and c13_reverted_layer0_beforeAuthOff_wotsPk_lightweight_chain_cells_residual. Each pins the WOTS chain loop's entry bindings at a named cutpoint state of the layer climb (layer 0, layer 1, and the revert branch). All higher-level facts that used to be assumed are theorems composed from these. Discharge is in progress on the wip/axiom-discharge branch (PR #9). The proof is per-variant: it covers C13's concrete parameters, not a generic parameterized family.

  • Source-to-model transcription fidelitymanual review assumption, all variants

    verity/SphincsMinusVerifiers/Model.lean

    The models are hand-transcribed from the Solidity assembly and are never compiled into the contracts or replayed by Foundry, so correspondence rests on review. One divergence is known: on a failed grinding check (forced-zero index or WOTS digit sum) the model and spec revert, while the live contract, after a later review pass (C13-V-f2), returns false for caller uniformity. Both behaviors refuse the signature; no input is accepted by one and not the other, but the revert-versus-reject boundary proved about the model does not match the deployed source on those two branches until the transcription is re-aligned.

  • slhDsaSha2_128_24_refines_byte_spec (+ its Primitives)SHA-2 variant only, named bridge assumption

    verity/SphincsMinusVerifiers/Proofs.lean

    The SHA-2 verifier's model-to-bytes step and its opaque SHA-256/parse semantics remain explicit assumptions: its hashing goes through the SHA-256 precompile, which sits outside the pure fragment the interpreter covers. The keccak verifiers carry neither assumption.

Reproduce it

The proof is checked by Lean 4's kernel. If any step had a gap, the build would fail.

Verify it yourself
git clone https://github.com/lfglabs-dev/SPHINCS-
cd SPHINCS-/verity
git checkout e62d833a3ad70740ef6d3669b37aace6cc652f2a
./scripts/build.sh SphincsMinusVerifiers   # caps Lean at 2 workers; ~16 GB RAM suffices

A successful build means every theorem type-checks against its stated axioms. See the proof inventory and blockers.

Learn more

New to this? What is a formal proof? explains the idea in two minutes. The reusable layered spec also required a small addition to Verity itself (typed unsafe-Yul fragments), landed upstream in verity#1942.

More research