carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package movementtest
4
5import (
6 "crypto/sha256"
7 "encoding/hex"
8 "fmt"
9 "io"
10 "log/slog"
11 "net/http"
12 "path/filepath"
13 "testing"
14
15 "github.com/carlosframework/rastrillo/db"
16 "github.com/carlosframework/rastrillo/harness"
17 "github.com/chromedp/cdproto/runtime"
18 "github.com/chromedp/chromedp"
19
20 movement "amadan.net/carlos/movement/internal/movement"
21)
22
23// awaitPromise makes Evaluate wait for the expression's promise instead of
24// handing back a pending object. Every script here uses dynamic import, which
25// is asynchronous by nature. chromedp exports no such option of its own.
26func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
27 return p.WithAwaitPromise(true)
28}
29
30func goSHA256(b []byte) []byte {
31 sum := sha256.Sum256(b)
32 return sum[:]
33}
34
35// This is the gate the whole proof-of-work scheme rests on.
36//
37// A Go-only test proves the verifier and nothing else. The failure it cannot
38// see is the one that actually happens: the browser and the server building
39// a different preimage from the same inputs — a stray Unicode case fold, a
40// different separator, a counter formatted another way — so every honest
41// visitor's solution is rejected as too short, with no error they can read
42// and nothing in the logs to explain it. The only way to know the two agree
43// is to run the shipped module in a real browser and check its answer here.
44//
45// It imports powcore.js, which is exactly what the Worker runs.
46
47func powRig(t *testing.T) *harness.Rig {
48 t.Helper()
49 return harness.New(t, func(origin string) http.Handler {
50 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
51 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
52 if err != nil {
53 t.Fatalf("db.Open: %v", err)
54 }
55 t.Cleanup(func() { d.Close() })
56 movement.SetLogger(logger)
57 env := testEnv()
58 env.Origin = origin
59 mux, err := movement.App(d, env)
60 if err != nil {
61 t.Fatalf("App: %v", err)
62 }
63 return mux
64 })
65}
66
67// browserSolve runs the shipped solver in the page and returns its counter.
68const browserSolve = `(async () => {
69 const m = await import("/static/powcore.js");
70 return m.solve(%q, %q, %d, null);
71})()`
72
73func TestBrowserSolutionSatisfiesGoVerifier(t *testing.T) {
74 rig := powRig(t)
75 rig.Run(chromedp.Navigate(rig.Origin + "/"))
76
77 // Low difficulty: the property under test is agreement, not cost. A
78 // realistic 18 bits would make this test take as long as a real signup
79 // for no extra information.
80 const difficulty = 12
81 const nonce = "0f1e2d3c4b5a69788796a5b4c3d2e1f0"
82 const email = "Alice@Example.COM"
83
84 var counter string
85 rig.Run(chromedp.Evaluate(
86 fmt.Sprintf(browserSolve, nonce, email, difficulty),
87 &counter,
88 awaitPromise,
89 ))
90
91 if counter == "" {
92 t.Fatal("the browser returned no counter")
93 }
94 if !movement.VerifyPow(nonce, email, counter, difficulty) {
95 t.Fatalf("the browser solved %q for %q but Go rejects it: the two sides "+
96 "are building different preimages", counter, email)
97 }
98}
99
100// The address is inside the hash, and this is what that buys. If the binding
101// were ever dropped — or built differently on the two sides — one solve would
102// buy every address in a list.
103func TestBrowserSolutionDoesNotTransfer(t *testing.T) {
104 rig := powRig(t)
105 rig.Run(chromedp.Navigate(rig.Origin + "/"))
106
107 const difficulty = 12
108 const nonce = "0f1e2d3c4b5a69788796a5b4c3d2e1f0"
109
110 var counter string
111 rig.Run(chromedp.Evaluate(
112 fmt.Sprintf(browserSolve, nonce, "alice@example.com", difficulty),
113 &counter,
114 awaitPromise,
115 ))
116
117 if movement.VerifyPow(nonce, "bob@example.com", counter, difficulty) {
118 t.Fatal("a solution the browser found for one address verified for another")
119 }
120}
121
122// The two implementations of SHA-256 must agree byte for byte, not merely
123// agree about which counters pass. This catches a divergence directly rather
124// than waiting for one to show up as a rejected signup.
125func TestBrowserSHA256MatchesGo(t *testing.T) {
126 rig := powRig(t)
127 rig.Run(chromedp.Navigate(rig.Origin + "/"))
128
129 inputs := []string{
130 "",
131 "a",
132 "abc",
133 "the quick brown fox jumps over the lazy dog",
134 // 55, 56 and 64 bytes: the padding boundaries, where a transcribed
135 // SHA-256 goes wrong if it is going to.
136 "5555555555555555555555555555555555555555555555555555555",
137 "66666666666666666666666666666666666666666666666666666666",
138 "6666666666666666666666666666666666666666666666666666666666666666",
139 }
140
141 const digestJS = `(async () => {
142 const m = await import("/static/sha256.js");
143 const b = new TextEncoder().encode(%q);
144 return m.hex(m.sha256(b, b.length));
145 })()`
146
147 for _, in := range inputs {
148 var got string
149 rig.Run(chromedp.Evaluate(fmt.Sprintf(digestJS, in), &got, awaitPromise))
150
151 want := hex.EncodeToString(goSHA256([]byte(in)))
152 if got != want {
153 t.Errorf("sha256(%q):\n browser %s\n go %s", in, got, want)
154 }
155 }
156}
157