| 1 | //go:build browser |
| 2 | |
| 3 | package movementtest |
| 4 | |
| 5 | import ( |
| 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. |
| 26 | func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { |
| 27 | return p.WithAwaitPromise(true) |
| 28 | } |
| 29 | |
| 30 | func 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 | |
| 47 | func 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. |
| 68 | const browserSolve = `(async () => { |
| 69 | const m = await import("/static/powcore.js"); |
| 70 | return m.solve(%q, %q, %d, null); |
| 71 | })()` |
| 72 | |
| 73 | func 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. |
| 103 | func 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. |
| 125 | func 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 | |