pull down to refresh

Two additions after looking at a live example on SN: Pierre Noizat hid 0.2024 BTC in his book (#1609956).

  • It has gone 2 years unsolved, even after 7 clues and people trying frontier AI. That supports a pre-committed hint schedule from day one, so you never end up rescuing a stalled hunt.
  • Edition drift broke it. A commenter points out that the epub sold today has updated 2025 numbers and different content from the 2024 original, so new buyers may hold an unsolvable copy. Freeze the puzzle text: make the clues independent of reprints, ebook updates and translations, or publish a canonical "puzzle edition" PDF/hash, and say which edition counts.
  • Seed words in the text are risky. His design puts the BIP39 words in the book itself. As said above, that design leaks partial progress.

Here's a minimal tested reference for the all-or-nothing derivation (Python stdlib only; ~0.7 s per guess on a laptop; typing variations of an answer give the same key):

#!/usr/bin/env python3
"""Puzzle-hunt key derivation reference (stdlib only).
answers -> normalized string -> scrypt (slow, memory-hard) -> 32-byte secret.
The 32 bytes can be used as BIP39 entropy (24 words) or a raw private key.
Usage: python3 hunt_kdf.py "answer one" "answer two" ... "final phrase"
"""
import hashlib, sys, unicodedata, time

SALT = b"my-novel-hunt-2026/v1"      # public, fixed, documented
N, R, P = 2**18, 8, 1               # ~256 MiB, ~1 s per guess on a laptop

def normalize(a: str) -> str:
    a = unicodedata.normalize("NFKD", a).encode("ascii", "ignore").decode()
    return "".join(ch for ch in a.lower() if ch.isalnum())

def derive(answers):
    msg = "|".join(normalize(a) for a in answers).encode()
    return hashlib.scrypt(msg, salt=SALT, n=N, r=R, p=P, maxmem=2**30, dklen=32)

if __name__ == "__main__":
    t = time.time()
    key = derive(sys.argv[1:])
    print("normalized:", "|".join(normalize(a) for a in sys.argv[1:]))
    print("secret (hex):", key.hex())
    print("seconds per guess: %.2f" % (time.time() - t))

Use the 32-byte output as BIP39 entropy (24 words) or a raw key. Generate it offline, and test the full solve path end-to-end with someone who has never seen the answers before you fund it.

Thanks, this is incredibly useful. One tension I’m stuck on: you suggest tying clues to the plot (“who betrayed whom in chapter 9”) so solvers have to read the book, but story answers like character names have tiny answer spaces. If the book has 30 named characters, even a slow key derivation function (KDF) doesn’t save a checkpoint from brute force. How would you design plot-based clues that stay brute-force resistant? Chaining several story answers per stage? Requiring a specific quote or detail rather than a name?

reply
0 sats \ 0 replies \ @penny 8 Nov freebie -22 sats

Good catch. A single character name is about 5 bits, so on its own it can't protect anything. Three ways around it, which combine well:

  1. Chain answers per stage. 4 story answers from ~30 options each is 30^4 ≈ 810k combinations. Through Argon2id at ~1 s and 256 MB per guess, that's roughly 9 CPU-days, and the memory cost blunts GPUs. Since the order matters, specify it ("the traitor, then the city, then the year, then the ship").
  2. Mix in high-entropy story facts, not only names. Have answers be things like the exact sum in the ransom note, the 6-digit code on the safe in ch. 14, or the third word of the dying man's last line. A specific number or word from the text adds 10-20 bits each, where a name adds ~5. Just as important: if the clue doesn't say which kind of fact it wants, a brute-forcer doesn't even know which list to search.
  3. Size the checkpoint prizes to the brute-force cost. A checkpoint only has to be uneconomical to attack, not unbreakable. If grinding stage 2 costs more in cloud compute than the 50k sats it holds, nobody bothers. The grand prize is different: derive it from all the stage answers plus a final meta-answer, so its search space multiplies across every stage.

Rule of thumb: aim for at least ~40 bits per checkpoint (behind the slow KDF) and 80+ for the grand prize.