Task 4 and the server half of 5: sealed challenges, bound proof of work
The seal covers nonce, issue time and difficulty, and is verified before the timestamp is read — until the signature holds, IssuedAt is a number the submitter chose, and checking an unverified timestamp is not a check. A test tampers with each field in turn. The proof of work binds the address into the preimage, which is the only reason the scheme is worth anything: unbound, one solve buys every address in a list. Two tests assert a solution transfers to neither another address nor another nonce. Lowercasing is ASCII-only on purpose. Go and JavaScript disagree on parts of Unicode, and a disagreement in the preimage is a silent pow_short that no visitor can diagnose and no log explains. spent_nonces replaces the scaffold's notes table. Nothing has shipped, so 0001_init is rewritten rather than followed by a migration that drops a table nobody ever had.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175aLTHqLrYkGe1skMV1rvq
7 files changed,
+407
−19
internal/movement/migrations/0001_init.sql+2 −2internal/movement/migrations/schema.sql+0 −5internal/movement/models.go+23 −12internal/movement/pow.go+75 −0internal/movement/pow_test.go+102 −0internal/movement/seal.go+107 −0internal/movement/seal_test.go+98 −0
diff --git a/internal/movement/migrations/0001_init.sql b/internal/movement/migrations/0001_init.sql| index c63a80d..ecc67a0 100644 |
| --- a/internal/movement/migrations/0001_init.sql |
| +++ b/internal/movement/migrations/0001_init.sql |
| @@ -1,2 +1,2 @@ |
| -CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime); |
| - |
| +CREATE TABLE `spent_nonces` (`nonce` text,`expires_at` datetime,PRIMARY KEY (`nonce`)); |
| +CREATE INDEX `idx_spent_nonces_expires_at` ON `spent_nonces`(`expires_at`); |
diff --git a/internal/movement/migrations/schema.sql b/internal/movement/migrations/schema.sql| deleted file mode 100644 |
| index 239e296..0000000 |
| --- a/internal/movement/migrations/schema.sql |
| +++ /dev/null |
| @@ -1,5 +0,0 @@ |
| --- Generated by rastrillo migration generate; DO NOT EDIT. |
| --- The schema every migration in this directory adds up to. |
| - |
| -CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime); |
| - |
diff --git a/internal/movement/models.go b/internal/movement/models.go| index 2497cad..5eb8416 100644 |
| --- a/internal/movement/models.go |
| +++ b/internal/movement/models.go |
| @@ -8,18 +8,29 @@ import "time" |
| // `rastrillo migration check` fails CI when it and the |
| // migrations disagree. |
| var Models = []any{ |
| - &Note{}, |
| + &SpentNonce{}, |
| } |
| -// Note is a starting example model — rename it, replace it, add more |
| -// beside it. Whatever it becomes, the rule stays the same: every query |
| -// touching user-owned rows goes through scope.Owned (SKILL.md) — reads |
| -// AND writes — and a request body is never bound onto a model directly |
| -// (explicit map[string]any + .Select allowlist). |
| -type Note struct { |
| - ID int64 |
| - Title string |
| - Body string |
| - CreatedAt time.Time |
| - UpdatedAt time.Time |
| +// SpentNonce records a challenge that has already been accepted. |
| +// |
| +// Nothing is written when a challenge is minted — only when one is spent. |
| +// Written at mint time, this table would take a row per page view, which |
| +// means a crawler, a link scanner or a browser prefetching links becomes a |
| +// stream of serialised writes against a single-writer SQLite file, starving |
| +// the one resource every signup needs. Written on acceptance, every row has |
| +// already cost somebody a proof-of-work solve, so the table is bounded by |
| +// work an attacker had to do rather than by traffic anyone can generate. |
| +// |
| +// Without it, a single solved challenge is replayable for its whole two-hour |
| +// window, and each replay sends another confirmation mail to whatever |
| +// address it names — a mail-bombing primitive pointed at a stranger's inbox, |
| +// sent from our own verified identity, with the sending reputation as the |
| +// collateral. |
| +// |
| +// It lives in the database rather than in memory because this process |
| +// hibernates as its normal state, and an in-memory set would forget |
| +// everything on the next wake. |
| +type SpentNonce struct { |
| + Nonce string `gorm:"primaryKey"` |
| + ExpiresAt time.Time `gorm:"index"` |
| } |
diff --git a/internal/movement/pow.go b/internal/movement/pow.go| new file mode 100644 |
| index 0000000..375af2b |
| --- /dev/null |
| +++ b/internal/movement/pow.go |
| @@ -0,0 +1,75 @@ |
| +package movement |
| + |
| +import ( |
| + "crypto/sha256" |
| + "math/bits" |
| + "strings" |
| +) |
| + |
| +// The proof of work is the price of submitting an address. The client |
| +// searches for a counter whose hash starts with enough zero bits; the server |
| +// runs that hash once and compares. |
| +// |
| +// The address is part of the preimage, and that is the whole point. An |
| +// unbound challenge is solved once and replayed against every address in a |
| +// list, so it costs an attacker one solve in total. Bound, it costs one solve |
| +// per address — which is exactly the axis a bulk signup attack scales along. |
| +// It is also why signing up needs JavaScript at all: the work cannot begin |
| +// until the visitor has typed the address it is bound to. |
| +// |
| +// What this does not do: 2^18 SHA-256 is under a millisecond on a commodity |
| +// GPU. Proof of work prices out casual and scripted abuse and nothing more. |
| +// The defence against a serious bulk attack is the persisted mail budget, |
| +// not this. |
| + |
| +// DefaultDifficulty is an estimate, not a measurement — roughly 262k |
| +// expected hashes. It must be re-set against real mid-range hardware before |
| +// launch, recording p95 and p99 rather than the mean: solve time is |
| +// geometric, so p99 is about 4.6x the average, and calibrating on the |
| +// average ships a form that hangs for one visitor in a hundred. |
| +const DefaultDifficulty = 18 |
| + |
| +// NormalizeEmail lowercases ASCII and nothing else. |
| +// |
| +// Go's strings.ToLower and JavaScript's toLowerCase disagree on parts of |
| +// Unicode, and the preimage has to be byte-identical on both sides. A |
| +// disagreement here is a silent pow_short that no visitor can diagnose and |
| +// no log explains, so the two implementations are kept deliberately dumb and |
| +// deliberately identical. |
| +func NormalizeEmail(s string) string { |
| + b := []byte(strings.TrimSpace(s)) |
| + for i, c := range b { |
| + if c >= 'A' && c <= 'Z' { |
| + b[i] = c + 32 |
| + } |
| + } |
| + return string(b) |
| +} |
| + |
| +// PowPreimage is the exact string both sides hash. pow.js builds the same |
| +// one; the browser test is what proves they agree. |
| +func PowPreimage(nonce, normalizedEmail, counter string) string { |
| + return nonce + ":" + normalizedEmail + ":" + counter |
| +} |
| + |
| +func leadingZeroBits(sum []byte) int { |
| + n := 0 |
| + for _, b := range sum { |
| + if b == 0 { |
| + n += 8 |
| + continue |
| + } |
| + return n + bits.LeadingZeros8(b) |
| + } |
| + return n |
| +} |
| + |
| +// VerifyPow checks one candidate. O(1) here, ~2^difficulty for whoever had |
| +// to find it. |
| +func VerifyPow(nonce, email, counter string, difficulty int) bool { |
| + if counter == "" { |
| + return false |
| + } |
| + sum := sha256.Sum256([]byte(PowPreimage(nonce, NormalizeEmail(email), counter))) |
| + return leadingZeroBits(sum[:]) >= difficulty |
| +} |
diff --git a/internal/movement/pow_test.go b/internal/movement/pow_test.go| new file mode 100644 |
| index 0000000..0215290 |
| --- /dev/null |
| +++ b/internal/movement/pow_test.go |
| @@ -0,0 +1,102 @@ |
| +package movement |
| + |
| +import ( |
| + "strconv" |
| + "testing" |
| +) |
| + |
| +// testDifficulty is low so the tests can brute-force a solution quickly. The |
| +// property being tested is the binding, not the cost. |
| +const testDifficulty = 10 |
| + |
| +func solve(t *testing.T, nonce, email string, difficulty int) string { |
| + t.Helper() |
| + for i := 0; i < 1<<24; i++ { |
| + c := strconv.Itoa(i) |
| + if VerifyPow(nonce, email, c, difficulty) { |
| + return c |
| + } |
| + } |
| + t.Fatalf("no solution found for %q at difficulty %d", email, difficulty) |
| + return "" |
| +} |
| + |
| +func TestValidSolutionVerifies(t *testing.T) { |
| + counter := solve(t, "nonce1", "alice@example.com", testDifficulty) |
| + if !VerifyPow("nonce1", "alice@example.com", counter, testDifficulty) { |
| + t.Fatal("a solution this package found must verify in this package") |
| + } |
| +} |
| + |
| +// This is ruling 3's only real defence, and the reason the scheme is worth |
| +// anything: a solution must not transfer. |
| +func TestSolutionDoesNotTransferToAnotherAddress(t *testing.T) { |
| + counter := solve(t, "nonce1", "alice@example.com", testDifficulty) |
| + |
| + if VerifyPow("nonce1", "bob@example.com", counter, testDifficulty) { |
| + t.Fatal("one solve must not buy a second address; that is the whole scheme") |
| + } |
| +} |
| + |
| +func TestSolutionDoesNotTransferToAnotherNonce(t *testing.T) { |
| + counter := solve(t, "nonce1", "alice@example.com", testDifficulty) |
| + |
| + if VerifyPow("nonce2", "alice@example.com", counter, testDifficulty) { |
| + t.Fatal("a solution must be bound to its own challenge") |
| + } |
| +} |
| + |
| +func TestShortSolutionIsRejected(t *testing.T) { |
| + counter := solve(t, "nonce1", "alice@example.com", testDifficulty) |
| + |
| + // The same counter, judged against a harder challenge. |
| + if VerifyPow("nonce1", "alice@example.com", counter, testDifficulty+10) { |
| + t.Fatal("a solution below the required difficulty must be refused") |
| + } |
| + if VerifyPow("nonce1", "alice@example.com", "", testDifficulty) { |
| + t.Fatal("an empty counter must be refused") |
| + } |
| +} |
| + |
| +// Case and surrounding space must not change the preimage, or a visitor who |
| +// typed their address with a capital would be told nothing and simply fail. |
| +func TestVerificationIsCaseAndSpaceInsensitive(t *testing.T) { |
| + counter := solve(t, "nonce1", "alice@example.com", testDifficulty) |
| + |
| + for _, variant := range []string{"Alice@Example.com", " alice@example.com ", "ALICE@EXAMPLE.COM"} { |
| + if !VerifyPow("nonce1", variant, counter, testDifficulty) { |
| + t.Errorf("%q should normalise to the same preimage", variant) |
| + } |
| + } |
| +} |
| + |
| +// pow.js does the same thing with a regex over A-Z. Anything cleverer here |
| +// would diverge from it on some input, and the divergence would be invisible. |
| +func TestNormalizeEmailTouchesASCIIOnly(t *testing.T) { |
| + if got := NormalizeEmail(" Alice@Example.COM "); got != "alice@example.com" { |
| + t.Errorf("NormalizeEmail = %q", got) |
| + } |
| + // A non-ASCII capital is left exactly as it is, on both sides. |
| + if got := NormalizeEmail("İX@e.com"); got != "İx@e.com" { |
| + t.Errorf("NormalizeEmail must not fold non-ASCII, got %q", got) |
| + } |
| +} |
| + |
| +func TestLeadingZeroBitsCounts(t *testing.T) { |
| + cases := []struct { |
| + in []byte |
| + want int |
| + }{ |
| + {[]byte{0xFF}, 0}, |
| + {[]byte{0x7F}, 1}, |
| + {[]byte{0x01}, 7}, |
| + {[]byte{0x00, 0xFF}, 8}, |
| + {[]byte{0x00, 0x0F}, 12}, |
| + {[]byte{0x00, 0x00}, 16}, |
| + } |
| + for _, c := range cases { |
| + if got := leadingZeroBits(c.in); got != c.want { |
| + t.Errorf("leadingZeroBits(%x) = %d, want %d", c.in, got, c.want) |
| + } |
| + } |
| +} |
diff --git a/internal/movement/seal.go b/internal/movement/seal.go| new file mode 100644 |
| index 0000000..4f2115b |
| --- /dev/null |
| +++ b/internal/movement/seal.go |
| @@ -0,0 +1,107 @@ |
| +package movement |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "time" |
| +) |
| + |
| +// A challenge is minted when a stance page renders and presented back when |
| +// the form is submitted. It carries a nonce, the moment it was issued, and |
| +// the difficulty the solver had to meet — all three sealed, so none of them |
| +// can be edited on the way back. |
| +// |
| +// Nothing is written to the database when one is minted. That is deliberate |
| +// and it is the second design this file has had: persisting a row per |
| +// challenge made every page view a serialised write against a single-writer |
| +// SQLite file, so a crawler, a link scanner or a prefetcher would have been |
| +// a denial of service against the one resource every signup needs. Nonces |
| +// are recorded as *spent* instead, on acceptance, where each row has already |
| +// cost an attacker a proof-of-work solve. |
| + |
| +const ( |
| + // MinAge is a claim about people, not throughput: nobody reads three |
| + // positions on AI ethics, picks one, and writes a sentence about |
| + // themselves in under three seconds. |
| + MinAge = 3 * time.Second |
| + |
| + // MaxAge bounds how long a minted challenge stays good, and therefore |
| + // how long a spent nonce must be remembered. |
| + MaxAge = 2 * time.Hour |
| +) |
| + |
| +// challengeKey derives the HMAC key for seals from the instance key. |
| +// |
| +// The instance key is shared with rastrillo/auth, which hashes it with a |
| +// label of its own. Deriving here with a different label is what stops a |
| +// token minted by one subsystem from ever verifying in the other. |
| +func challengeKey(instanceKey string) []byte { |
| + sum := sha256.Sum256([]byte("movement/challenge\x00" + instanceKey)) |
| + return sum[:] |
| +} |
| + |
| +// Challenge travels to the browser as four hidden fields and comes back the |
| +// same way. |
| +type Challenge struct { |
| + Nonce string |
| + IssuedAt int64 |
| + Difficulty int |
| + Seal string |
| +} |
| + |
| +func sealOf(key []byte, nonce string, issuedAt int64, difficulty int) string { |
| + m := hmac.New(sha256.New, key) |
| + fmt.Fprintf(m, "%s\x00%d\x00%d", nonce, issuedAt, difficulty) |
| + return hex.EncodeToString(m.Sum(nil)) |
| +} |
| + |
| +// NewChallenge mints one. The seal is a signature, not encryption: nothing |
| +// in a challenge needs to be secret from the person holding it, it only |
| +// needs to be impossible for them to alter. |
| +func NewChallenge(key []byte, now time.Time, difficulty int) Challenge { |
| + var raw [16]byte |
| + if _, err := rand.Read(raw[:]); err != nil { |
| + // crypto/rand failing is not a condition to degrade through: every |
| + // anti-abuse property below rests on this nonce being unguessable. |
| + panic("movement: crypto/rand unavailable") |
| + } |
| + nonce := hex.EncodeToString(raw[:]) |
| + issued := now.Unix() |
| + return Challenge{ |
| + Nonce: nonce, |
| + IssuedAt: issued, |
| + Difficulty: difficulty, |
| + Seal: sealOf(key, nonce, issued, difficulty), |
| + } |
| +} |
| + |
| +// VerifySeal reports why a challenge is unacceptable, and whether it is |
| +// acceptable at all. |
| +// |
| +// The order matters: the signature is checked before the timestamp, because |
| +// until the signature holds, IssuedAt is a number the submitter chose. A |
| +// check of an unverified timestamp is not a check. |
| +func VerifySeal(key []byte, c Challenge, now time.Time) (Reason, bool) { |
| + want := sealOf(key, c.Nonce, c.IssuedAt, c.Difficulty) |
| + if !hmac.Equal([]byte(want), []byte(c.Seal)) { |
| + return ReasonSealInvalid, false |
| + } |
| + age := now.Sub(time.Unix(c.IssuedAt, 0)) |
| + switch { |
| + case age < MinAge: |
| + return ReasonTooFast, false |
| + case age > MaxAge: |
| + return ReasonTooOld, false |
| + } |
| + return "", true |
| +} |
| + |
| +// Expiry is when a spent nonce stops needing to be remembered: a challenge |
| +// older than MaxAge is refused on age alone, so a record beyond that point |
| +// protects nothing. |
| +func (c Challenge) Expiry() time.Time { |
| + return time.Unix(c.IssuedAt, 0).Add(MaxAge) |
| +} |
diff --git a/internal/movement/seal_test.go b/internal/movement/seal_test.go| new file mode 100644 |
| index 0000000..ce23e72 |
| --- /dev/null |
| +++ b/internal/movement/seal_test.go |
| @@ -0,0 +1,98 @@ |
| +package movement |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func testKey() []byte { return challengeKey(strings.Repeat("k", 64)) } |
| + |
| +func TestFreshSealVerifies(t *testing.T) { |
| + key, now := testKey(), time.Now() |
| + c := NewChallenge(key, now, DefaultDifficulty) |
| + |
| + if reason, ok := VerifySeal(key, c, now.Add(10*time.Second)); !ok { |
| + t.Fatalf("a fresh seal must verify, got %q", reason) |
| + } |
| +} |
| + |
| +func TestSealRejectsOutsideItsWindow(t *testing.T) { |
| + key, now := testKey(), time.Now() |
| + c := NewChallenge(key, now, DefaultDifficulty) |
| + |
| + if r, _ := VerifySeal(key, c, now.Add(time.Second)); r != ReasonTooFast { |
| + t.Errorf("a submission one second after the page rendered is not a person: got %q", r) |
| + } |
| + if r, _ := VerifySeal(key, c, now.Add(3*time.Hour)); r != ReasonTooOld { |
| + t.Errorf("a three-hour-old challenge must be refused: got %q", r) |
| + } |
| +} |
| + |
| +// Every field is covered by the signature, so none of them can be edited on |
| +// the way back. Difficulty matters most: unsealed, a submitter would simply |
| +// lower it and skip the work. |
| +func TestSealCoversEveryField(t *testing.T) { |
| + key, now := testKey(), time.Now() |
| + at := now.Add(10 * time.Second) |
| + c := NewChallenge(key, now, DefaultDifficulty) |
| + |
| + tampered := map[string]Challenge{ |
| + "difficulty": {c.Nonce, c.IssuedAt, 1, c.Seal}, |
| + "issuedAt": {c.Nonce, c.IssuedAt - 3600, c.Difficulty, c.Seal}, |
| + "nonce": {"deadbeef", c.IssuedAt, c.Difficulty, c.Seal}, |
| + "seal": {c.Nonce, c.IssuedAt, c.Difficulty, strings.Repeat("0", 64)}, |
| + } |
| + for field, bad := range tampered { |
| + if r, ok := VerifySeal(key, bad, at); ok || r != ReasonSealInvalid { |
| + t.Errorf("editing %s must be caught as seal_invalid, got %q ok=%v", field, r, ok) |
| + } |
| + } |
| +} |
| + |
| +func TestSealFromAnotherKeyIsRefused(t *testing.T) { |
| + now := time.Now() |
| + c := NewChallenge(challengeKey("one"), now, DefaultDifficulty) |
| + |
| + if r, ok := VerifySeal(challengeKey("two"), c, now.Add(10*time.Second)); ok { |
| + t.Errorf("a seal from a different instance key must not verify, got %q", r) |
| + } |
| +} |
| + |
| +// The instance key is shared with rastrillo/auth, which labels its own |
| +// derivation. Using the raw key here would let a token minted for one |
| +// subsystem carry weight in the other. |
| +func TestChallengeKeyIsDerivedNotRaw(t *testing.T) { |
| + raw := strings.Repeat("k", 64) |
| + if bytes.Equal(challengeKey(raw), []byte(raw)) { |
| + t.Fatal("the challenge key must be derived from the instance key, not be it") |
| + } |
| + if bytes.Equal(challengeKey("a"), challengeKey("b")) { |
| + t.Fatal("different instance keys must derive different challenge keys") |
| + } |
| +} |
| + |
| +func TestNoncesAreUnique(t *testing.T) { |
| + key, now := testKey(), time.Now() |
| + seen := map[string]bool{} |
| + for i := 0; i < 1000; i++ { |
| + n := NewChallenge(key, now, DefaultDifficulty).Nonce |
| + if seen[n] { |
| + t.Fatalf("nonce %q issued twice", n) |
| + } |
| + seen[n] = true |
| + } |
| +} |
| + |
| +func TestExpiryIsIssueTimePlusMaxAge(t *testing.T) { |
| + key, now := testKey(), time.Now() |
| + c := NewChallenge(key, now, DefaultDifficulty) |
| + |
| + // A spent nonce only needs remembering while a challenge of its age |
| + // would still be accepted; past that, age alone refuses it. |
| + want := time.Unix(c.IssuedAt, 0).Add(MaxAge) |
| + if !c.Expiry().Equal(want) { |
| + t.Fatalf("Expiry = %v, want %v", c.Expiry(), want) |
| + } |
| +} |