Chapter 47 — Practical Exercises#
Companion sheet for Chapter 47 — Hash-Based Signatures. Six exercises building from a one-line verifier to a state-reuse forgery.
Click “Click to show” on any solution cell to reveal a fully commented reference implementation.
Exercise 47.E1 — ★ Lamport verification#
Goal. Fill in lamport_verify(pk, msg, sig) so it returns True iff
the signature is valid.
Theory. §47.2 — Lamport one-time signatures.
import hashlib
def H(x): return hashlib.sha256(x).digest()
def bits(x):
out = []
for byte in x:
for j in range(8):
out.append((byte >> (7 - j)) & 1)
return out
def lamport_verify(pk, msg, sig):
'''pk[i] is a 2-element list [H(s_{i,0}), H(s_{i,1})]; sig[i] is the revealed s.'''
# TODO: hash each sig[i] and check that it matches pk[i][h_i] where
# h = bits(H(msg)). Return True if EVERY position checks out.
raise NotImplementedError('your turn')
# Self-test.
import os
sk = [[os.urandom(32), os.urandom(32)] for _ in range(256)]
pk = [[H(a), H(b)] for a, b in sk]
msg = b'hello world'
sig = [sk[i][b] for i, b in enumerate(bits(H(msg)))]
# assert lamport_verify(pk, msg, sig) is True
# assert lamport_verify(pk, b'hello WORLD', sig) is False
# print('E1 OK')
Exercise 47.E2 — ★★ Build a Merkle authentication path#
Goal. Given the level-by-level hashes of a binary Merkle tree
levels[0] = leaves, levels[1] = parents, ..., levels[-1] = [root], write
auth_path(levels, j) returning the \(h\) sibling hashes from leaf \(j\) up
to the root.
Theory. §47.4 — Merkle trees: one public key, many signatures.
def build_merkle(leaves):
levels = [leaves[:]]
while len(levels[-1]) > 1:
cur = levels[-1]
nxt = [H(cur[i] + cur[i + 1]) for i in range(0, len(cur), 2)]
levels.append(nxt)
return levels
def auth_path(levels, j):
'''Return the list of sibling hashes from leaf j up to the root.'''
# TODO: at each level (except the root), append the SIBLING hash to the
# path and update j -> j // 2. Use j ^ 1 to flip the last bit (= sibling
# index).
raise NotImplementedError('your turn')
def verify_merkle_path(root, leaf, j, path):
cur = leaf
for sib in path:
cur = H(cur + sib) if j % 2 == 0 else H(sib + cur)
j //= 2
return cur == root
# Test.
import os
leaves = [H(os.urandom(8)) for _ in range(8)]
levels = build_merkle(leaves)
root = levels[-1][0]
# for j in range(8):
# p = auth_path(levels, j)
# assert verify_merkle_path(root, leaves[j], j, p), f'leaf {j} failed'
# print('E2 OK')
Exercise 47.E3 — ★★★ The WOTS+ checksum#
Goal. Compute the Winternitz checksum digits given the message-hash digits.
Theory. §47.3 — Winternitz one-time signatures (WOTS+).
Why this matters. Without the checksum, an attacker can forge by incrementing a digit (which costs only further hashing). The checksum forces any digit increase to be matched by a decrease somewhere else, which is hard because the chain is preimage-resistant in that direction.
import math
W = 16 # Winternitz digit alphabet size (4 bits per digit)
WBITS = 4
N = 32 # SHA-256 output bytes
LEN1 = (8 * N + WBITS - 1) // WBITS # = 64 message-hash digits
LEN2 = (math.floor(math.log2(LEN1 * (W - 1))) // WBITS) + 1 # = 3 checksum digits
def msg_to_digits(msg_hash):
'''Split a 32-byte hash into 64 base-16 digits.'''
out, buf, bits_in_buf = [], 0, 0
for byte in msg_hash:
buf = (buf << 8) | byte; bits_in_buf += 8
while bits_in_buf >= WBITS:
bits_in_buf -= WBITS
out.append((buf >> bits_in_buf) & (W - 1))
return out
def wots_checksum(msg_digits):
'''Return the LEN2 checksum digits given the LEN1 message digits.'''
# TODO step 1: compute csum = sum_i (W - 1 - d_i) over message digits.
# TODO step 2: pack csum into LEN2 base-W digits. Use big-endian byte
# packing then call msg_to_digits on the bytes.
raise NotImplementedError('your turn')
# Sanity test: each message digit is W-1 -> checksum is zero.
# m = [W - 1] * LEN1
# c = wots_checksum(m)
# assert c[:LEN2] == [0] * LEN2, c
# # And: each digit zero -> checksum is its maximum.
# m0 = [0] * LEN1
# c0 = wots_checksum(m0)
# assert sum(c0[:LEN2]) > 0
# print('E3 OK')
Exercise 47.E4 — ★★★ Build a height-3 Merkle MSS#
Goal. Combine your Lamport from E1 + Merkle from E2 into a height-3
many-time signature scheme. Sign the \(j\)-th of 8 messages and produce a
single signature blob (ots_sig, ots_pk, auth_path) that the verifier
can check using only the published root.
Theory. §47.4 — Merkle trees: one public key, many signatures.
def lamport_keygen():
sk = [[os.urandom(32), os.urandom(32)] for _ in range(256)]
pk = [[H(a), H(b)] for a, b in sk]
return sk, pk
def lamport_sign(sk, msg):
return [sk[i][b] for i, b in enumerate(bits(H(msg)))]
def serialize_pk(pk):
return b''.join(b''.join(pair) for pair in pk)
def mss_keygen(h):
n = 1 << h
leaf_keys = [lamport_keygen() for _ in range(n)]
leaves = [H(serialize_pk(pk)) for _, pk in leaf_keys]
levels = build_merkle(leaves)
return leaf_keys, levels[-1][0], levels # secret leaf-keys + root + levels
def mss_sign(leaf_keys, levels, j, msg):
# TODO: produce the triple (ots_sig, ots_pk, path).
raise NotImplementedError('your turn')
def mss_verify(root, j, msg, blob):
ots_sig, ots_pk, path = blob
if not lamport_verify(ots_pk, msg, ots_sig): return False
leaf_hash = H(serialize_pk(ots_pk))
return verify_merkle_path(root, leaf_hash, j, path)
# Test on a height-3 tree, 8 leaves, sign leaf 5.
# leaf_keys, root, levels = mss_keygen(3)
# blob = mss_sign(leaf_keys, levels, 5, b'pay 100 PLN')
# assert mss_verify(root, 5, b'pay 100 PLN', blob)
# assert not mss_verify(root, 5, b'pay 999 PLN', blob)
# print('E4 OK')
Exercise 47.E5 — ★★★★ State-reuse forgery (24-bit hash)#
Goal. Demonstrate the state-reuse catastrophe with a 24-bit truncated hash. Sign two distinct messages with the same Lamport key, then forge a third message such that its signature is verified by the public key.
Theory. §47.7 — The state-reuse catastrophe.
DEMO_BITS = 24
def H_demo(x): return hashlib.sha256(x).digest()[: DEMO_BITS // 8]
def demo_keygen():
sk = [[os.urandom(32), os.urandom(32)] for _ in range(DEMO_BITS)]
pk = [[H(a), H(b)] for a, b in sk]
return sk, pk
def demo_sign(sk, msg):
h_bits = []
for byte in H_demo(msg):
for j in range(8): h_bits.append((byte >> (7 - j)) & 1)
return [sk[i][b] for i, b in enumerate(h_bits)]
def demo_verify(pk, msg, sig):
h_bits = []
for byte in H_demo(msg):
for j in range(8): h_bits.append((byte >> (7 - j)) & 1)
return all(H(sig[i]) == pk[i][b] for i, b in enumerate(h_bits))
def attack_after_two_sigs(pk, msg1, msg2, sig1, sig2):
'''Find a third message with a forged valid signature.'''
# TODO step 1: from (sig1, msg1) and (sig2, msg2) build a dictionary
# `known[(i, b)] = secret` for every revealed secret string.
# TODO step 2: brute-force a salted message until every position of its
# hash lands on a key that exists in `known`. Then assemble
# the forged signature.
raise NotImplementedError('your turn')
# sk, pk = demo_keygen()
# m1 = b'Pay 100 PLN to Alice.'
# m2 = b'Pay 999 PLN to Bob.'
# s1 = demo_sign(sk, m1); s2 = demo_sign(sk, m2)
# m3, s3 = attack_after_two_sigs(pk, m1, m2, s1, s2)
# print('forged for:', m3)
# assert demo_verify(pk, m3, s3)
# print('E5 OK')
Exercise 47.E6 — ★★★★★ Research: explore SLH-DSA-128s parameters#
Goal. Read FIPS 205 (NIST 2024) Algorithm 17 (slh_sign) and
Algorithm 18 (slh_verify). Write a one-page memo explaining how the
SLH-DSA-SHA2-128s parameter set fits its 7 856-byte signature size:
how many WOTS+ signatures, FORS commitments, hyper-tree levels.
Then implement the outermost hyper-tree layer: a single Merkle tree of height \(h_{prime} = h / d\) that signs the root of the layer below using WOTS+. Verify your byte counts against the spec.
Theory. §47.6 — SLH-DSA / SPHINCS+.
Parameter set SLH-DSA-SHA2-128s (FIPS 205 §10)
\(n = 16\), \(h = 63\), \(d = 7\), \(h_{prime} = 9\), \(a = 12\), \(k = 14\), \(w = 16\).
# (Open-ended.) Sketch your reasoning here, then build the outer hyper-tree
# layer. The exercise is intentionally underspecified -- design choices
# (recursive vs. iterative tree construction, in-memory layout, hash chain
# parametrization) are part of the project.