Skip to content

FORS+C Overview

The primary signing scheme

FORS+C is NiceTry's primary signer: a standalone, hash-based few-time signature scheme. It is the FORS (Forest Of Random Subsets) primitive used inside SLH-DSA / SPHINCS+, with the "+C" grinding compression from Hülsing–Kudinov–Ronen–Yogev (2023), but with no XMSS hypertree on top. The public key is the compression of the FORS tree roots alone, which is what makes onchain verification cheap.

Everything here uses truncated Keccak-256 as the single hash primitive (N = 16, i.e. the first 16 bytes of a 32-byte Keccak output). The reference is src/Verifiers/ForsVerifier.sol.

FORS in plain English

Picture K boxes, each holding 2^A numbered slips, where each slip carries a 16-byte secret.

To sign a message:
  1. Hash the message into K small numbers, one per box.
  2. From box t, reveal the slip whose number matches the hash. That slip's secret becomes one component of the signature.
  3. Reveal all K chosen slips.

Why it's secure: the message dictates exactly which slip you reveal in each box. A forger wanting to sign a different message would need slips at different indices, which they don't have: every unrevealed slip is a fresh, independent random secret.

The Merkle tree part

Publishing all K · 2^A slip hashes as the public key would be huge (megabytes). Instead, each box gets a Merkle tree built over its slips, and the public key is the compressed hash of the K tree roots. When you reveal a slip, you also reveal its authentication path (A sibling hashes) so the verifier can recompute that tree's root and check it against the public commitment.

Few-time, not one-time

Sign once and you reveal K of the K · 2^A slips. Sign again with the same key and you reveal up to 2K. With enough reuse, a forger can mix-and-match revealed indices across signatures and forge a message that lands entirely on already-revealed slots.

This is what few-time means: unlike WOTS+C (one-time: any reuse breaks it immediately), FORS degrades gracefully. NiceTry exploits this as defense in depth. The intended discipline is one signature per key; if that ever slips (a dropped or replaced transaction), the failure mode is "weakened" rather than "broken." The exact degradation is in Parameters → Security.

The +C variant

Plain FORS sends K leaf-secrets plus K authentication paths. FORS+C drops the K-th tree entirely (its computation, its auth path, and its presence in the public-key compression) and replaces it with a grinding step:

  1. The signer iterates a 16-byte counter, recomputing the message digest each time, until the digest's top A-bit field is zero (equivalently: the K-th tree's would-be leaf index is forced to 0).
  2. The K-th tree is then never computed, opened, or transmitted.
  3. The signature carries the 16-byte counter instead of that tree's sk + auth_path.

The verifier simply checks the zero-bit constraint and compresses the surviving K−1 real roots into pkRoot. Grinding costs ~2^A digest attempts on average (small relative to building the trees), and at q = 1 the security bound is preserved.

A dedicated domain-separation byte (0xFD) keeps standalone FORS+C cryptographically distinct from spec SPHINCS+ and from NiceTry's SLH-DSA-Keccak family, so a digest from one scheme can never be replayed in another.

The five hash roles

FORS uses one primitive (truncated Keccak-256) in five roles. They are distinguished not by different functions but by the ADRS, a 32-byte typed address mixed into every hash input, which provides domain separation between leaves, nodes, trees, and the compression step.

RolePurposeInput shape
PRFsecret expansionskSeed ‖ ADRS
Fleaf hashpkSeed ‖ ADRS ‖ sk
Hinternal nodepkSeed ‖ ADRS ‖ left ‖ right
Troots compressionpkSeed ‖ ADRS_roots ‖ root_0 ‖ … ‖ root_{K-2}
Hmsgmessage digest (with grinding)pkSeed ‖ R ‖ digest ‖ DOM ‖ counter
  • PRF expands a single 16-byte skSeed into all K · 2^A per-leaf secrets, so one HD-derived seed reproduces an entire keypair and one seed-rotation produces a fresh one: no secret material is ever stored onchain.
  • R is a per-signature randomizer (R = PRF(skSeed, digest)) mixed into the message hash to prevent cross-signature collision attacks. It travels in the signature.
  • pkSeed is a public per-keypair seed, tweaked into F/H/T/Hmsg. It also travels in the signature.

The onchain public key is a 20-byte Ethereum address:

owner = last20( keccak256( pad32(pkSeed) ‖ pad32(pkRoot) ) )

That address is what SimpleAccount stores as owner and what the verifier returns from recover().

What the verifier does

Onchain, ForsVerifier.recover(sig, digest) runs the scheme in reverse:

  1. Read R, pkSeed, counter from the signature; recompute dVal = Hmsg(pkSeed, R, digest, counter).
  2. Check the +C grinding constraint (the K-th A-bit field of dVal is zero). If not, return address(0).
  3. For each of the K−1 = 25 real trees, recompute the revealed leaf from its sk fragment and climb the auth path to a root.
  4. Compress the 25 roots into pkRoot with a single T call.
  5. Return last20(keccak256(pad32(pkSeed) ‖ pad32(pkRoot))).

The account then compares that address to its stored owner. A malformed signature simply recovers a different (non-matching) address; the verifier only returns address(0) on a bad length or a failed grinding check.

Next

  • Parameters: the concrete K=26, A=5 set, signature layout, gas, and security under reuse.
  • Signer: the byte-level signing procedure for off-chain wallets.
  • ForsVerifier: the onchain verifier contract.