carlos / movement Public

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
Paul Campbell cdb4c0e4f0190ef38db91c1da232ff20813ae863 parent 9785ecd
7 files changed, +407 −19
  • internal/movement/migrations/0001_init.sql +2 −2
  • internal/movement/migrations/schema.sql +0 −5
  • internal/movement/models.go +23 −12
  • internal/movement/pow.go +75 −0
  • internal/movement/pow_test.go +102 −0
  • internal/movement/seal.go +107 −0
  • internal/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)
+ }
+}