FORS¶
src/fors.zig — FIPS 205 §8, Algorithms 14–17.
Concepts: chapter 6.
Sizes¶
pub const md_bytes: usize = (k * a + 7) / 8; // packed k×a bits
pub const signature_bytes: usize = k * (a + 1) * n;
md_bytes is the FORS share of the H_msg output — see
digest parsing.
| Set | k |
a |
md_bytes |
FORS signature |
|---|---|---|---|---|
| 128s | 14 | 12 | 21 | 2,912 B |
| 128f | 33 | 6 | 25 | 3,696 B |
| 256s | 22 | 14 | 39 | 10,560 B |
skGen — §8.1, Algorithm 14¶
var sk_adrs = adrs.*;
sk_adrs.setType(.fors_prf);
sk_adrs.setKeyPairAddress(adrs.getKeyPairAddress());
sk_adrs.setTreeIndex(idx);
Hash.prf(sk_seed, pk_seed, &sk_adrs, out);
.fors_prf is the derivation type, distinct from .fors_tree used when the
value is consumed. setTreeIndex(idx) takes a global leaf index across the
whole forest, not an index within one tree — see the leaf computation in sign.
node — §8.2, Algorithm 15¶
Structurally identical to xmss_node, with one
important difference in the base case:
if (z == 0) {
var sk: [n]u8 = undefined;
defer std.crypto.secureZero(u8, &sk); // ← secret
skGen(sk_seed, pk_seed, adrs, i, &sk);
adrs.setTreeHeight(0);
adrs.setTreeIndex(i);
Hash.f(pk_seed, adrs, &sk, out);
} else {
var lnode: [n]u8 = undefined;
var rnode: [n]u8 = undefined;
node(sk_seed, 2 * i, z - 1, pk_seed, adrs, &lnode);
node(sk_seed, 2 * i + 1, z - 1, pk_seed, adrs, &rnode);
adrs.setTreeHeight(z);
adrs.setTreeIndex(i);
Hash.h(pk_seed, adrs, &lnode, &rnode, out);
}
A FORS leaf is F over one derived secret. An XMSS leaf is T_len over a
whole WOTS+ public key — len chains of up to w-1 steps each, ~500 hashes. A
FORS leaf is two hash calls: one PRF, one F. FORS trees are therefore far
cheaper per leaf than XMSS trees of the same height, which is why a can be as
large as 14 while h' stays at 9.
sk is scrubbed here but revealed in sign. Same value, different lifetime.
When node computes an internal value the secret is an intermediate that must
not persist. When sign reveals a leaf it becomes part of the signature — public
by definition. Two call sites, two correct behaviours, and the comments in the
source say which is which.
This is the library's most secret-adjacent code
skGen and node's base case are where SK.seed is expanded into leaf
secrets, making them the primary target of the
ctgrind harness — which taints
SK.seed and runs fors_node under Valgrind for both hash families. A timing
leak here would be a leak of key material, not of a public index.
sign — §8.3, Algorithm 16¶
var indices: [k]u32 = undefined;
util.base_2b(md, a_bits, &indices); // k indices of a bits each
const block = (a + 1) * n;
for (0..k) |i| {
const base = i * block;
const tree: u32 = @intCast(i);
const leaf = tree * two_pow_a + indices[i];
// The revealed secret goes straight into the signature buffer.
skGen(sk_seed, pk_seed, adrs, leaf, out_sig[base..][0..n]);
for (0..a) |j| {
const s = (indices[i] >> @as(u5, @intCast(j))) ^ 1;
const sib = tree * (two_pow_a >> @as(u5, @intCast(j))) + s;
node(sk_seed, sib, @intCast(j), pk_seed, adrs, out_sig[base + (1 + j) * n ..][0..n]);
}
}
Global indexing across the forest. leaf = tree * 2^a + indices[i] — the k
trees share one flat index space, so tree 3's leaf 7 and tree 4's leaf 7 get
different addresses and therefore different secrets. The sibling computation
tree * (2^a >> j) + s applies the same offsetting at height j, where the tree
has 2^a >> j nodes.
The revealed secret is written directly to out_sig. No temporary, no
scrubbing — it is signature material. Contrast node's base case above.
Layout is k blocks of (1 + a) values: the leaf secret, then a
authentication nodes bottom-up.
pkFromSig — §8.4, Algorithm 17¶
For each tree: hash the revealed secret to a leaf, fold the auth path, collect the
root. Then compress all k roots:
// per tree i: node = F(revealed secret), then fold a auth nodes
// roots[i] = the reconstructed root of tree i
var pk_adrs = adrs.*;
pk_adrs.setType(.fors_roots);
pk_adrs.setKeyPairAddress(adrs.getKeyPairAddress());
Hash.t_l(pk_seed, &pk_adrs, &roots, out_pk);
.fors_roots is a fourth distinct address type, so compressing k FORS roots
cannot be confused with any other multi-block compression in the scheme.
Returns the reconstructed FORS public key. The top level does not compare it to anything — it hands it to the hypertree as the message to certify.
Constant-time notes¶
The indices derived from md are public: md comes from H_msg, and a
verifier recomputes it from R and the message, both in the signature. So
branching and indexing on indices is safe, and the loop structure of sign
leaks nothing an attacker does not already hold.
What is secret is sk_seed and the derived leaf values at unrevealed
positions. Those flow only through PRF and F, never into a branch condition or
a memory index.
Tests in this module¶
nodeatz = 1checked by hand againstH(F(sk_0), F(sk_1)), independent of the recursion.- Round-trip: signing then reconstructing recovers the FORS public key.
- Negatives: tampering a revealed secret or an auth node changes the result.
- Distinctness: the same leaf index in different trees yields different secrets — verifying the global-index offsetting.