clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

test(auth): fuzz signed request bindings and relay actors

Dami committed 8e02874
Exercise NIP-98 and Blossom proof parsing and binding with valid signed seeds. Check NIP-42 proof mutations, connection boundaries, multiple identities and denied-write isolation.
commit
8e028746d76883d0ed66942e1c609cfc14d3b102
parent
2a2536aeeabc6e926c74762eedeea540fbda5935
author
Dami <dami.etoile@gmail.com>
2 files changed, +250 -0
Aprotocol/auth/fuzz_test.go117++++++++++++++++++++++++++
Atinyrelay/auth_fuzz_test.go133++++++++++++++++++++++++++++++
A protocol/auth/fuzz_test.go +117 -0
@@ -0,0 +1,117 @@
1+package auth2+3+import (4+ "encoding/base64"5+ "strings"6+ "testing"7+ "time"8+9+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"10+)11+12+// FuzzNIP98Boundary sends arbitrary authorization inputs through the complete13+// decoder and verifier. Invalid proofs are expected; panics are not.14+func FuzzNIP98Boundary(f *testing.F) {15+ f.Add("", "https://relay.example/", "GET", "")16+ f.Add("Nostr !!!", "https://relay.example/api", "POST", "body")17+ f.Add("nIp98 AA==", "not a URL", "", "\x00")18+ secret := strings.Repeat("0", 63) + "1"19+ proof := nostr.Event{CreatedAt: 1_700_000_000, Kind: 27235, Tags: [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}}, Content: "seed"}20+ if err := nostr.Sign(&proof, secret); err != nil {21+ f.Fatal(err)22+ }23+ raw, err := nostr.Canonical(proof)24+ if err != nil {25+ f.Fatal(err)26+ }27+ header := "Nostr " + base64.RawStdEncoding.EncodeToString(raw)28+ f.Add(header, "https://relay.example/api", "POST", "")29+ f.Fuzz(func(t *testing.T, header, rawURL, method, body string) {30+ validator := NewValidator(func() time.Time { return time.Unix(1_700_000_000, 0) })31+ _, err := validator.VerifyNIP98(header, rawURL, method, body)32+ if header == "Nostr "+base64.RawStdEncoding.EncodeToString(raw) && rawURL == "https://relay.example/api" && method == "POST" && body == "" && err != nil {33+ t.Fatalf("valid seeded proof rejected: %v", err)34+ }35+ })36+}37+38+func FuzzNIP98Binding(f *testing.F) {39+ secret := strings.Repeat("0", 63) + "1"40+ proof := nostr.Event{CreatedAt: 1_700_000_000, Kind: 27235, Tags: [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", hashBytes([]byte("body"))}}, Content: "binding seed"}41+ if err := nostr.Sign(&proof, secret); err != nil {42+ f.Fatal(err)43+ }44+ raw, err := nostr.Canonical(proof)45+ if err != nil {46+ f.Fatal(err)47+ }48+ header := "Nostr " + base64.RawStdEncoding.EncodeToString(raw)49+ f.Add("https://relay.example/api", "POST", "body")50+ f.Add("https://relay.example/other", "POST", "body")51+ f.Add("https://relay.example/api", "GET", "body")52+ f.Add("https://relay.example/api", "POST", "other")53+ f.Fuzz(func(t *testing.T, rawURL, method, body string) {54+ validator := NewValidator(func() time.Time { return time.Unix(1_700_000_000, 0) })55+ _, err := validator.VerifyNIP98(header, rawURL, method, body)56+ if rawURL == "https://relay.example/api" && method == "POST" && body == "body" && err != nil {57+ t.Fatalf("valid binding rejected: %v", err)58+ }59+ if (rawURL != "https://relay.example/api" || method != "POST" || body != "body") && err == nil {60+ t.Fatal("accepted mismatched NIP-98 binding")61+ }62+ })63+}64+65+// FuzzBlossomBoundary covers the alternate authorization scheme and its66+// server/blob scope parameters.67+func FuzzBlossomBoundary(f *testing.F) {68+ f.Add("", "upload", "https://relay.example", "")69+ f.Add("Blossom !!!", "delete", "relay", "hash")70+ secret := strings.Repeat("0", 63) + "1"71+ proof := nostr.Event{CreatedAt: 1_700_000_000, Kind: 24242, Tags: [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "https://relay.example"}, {"x", strings.Repeat("a", 64)}}, Content: "seed"}72+ if err := nostr.Sign(&proof, secret); err != nil {73+ f.Fatal(err)74+ }75+ raw, err := nostr.Canonical(proof)76+ if err != nil {77+ f.Fatal(err)78+ }79+ header := "Nostr " + base64.RawStdEncoding.EncodeToString(raw)80+ f.Add(header, "upload", "https://relay.example", strings.Repeat("a", 64))81+ f.Fuzz(func(t *testing.T, header, action, server, blobHash string) {82+ validator := NewValidator(func() time.Time { return time.Unix(1_700_000_000, 0) })83+ _, err := validator.VerifyBlossomRequest(header, action, server, blobHash)84+ if header == "Nostr "+base64.RawStdEncoding.EncodeToString(raw) && action == "upload" && server == "https://relay.example" && blobHash == strings.Repeat("a", 64) && err != nil {85+ t.Fatalf("valid seeded authorization rejected: %v", err)86+ }87+ })88+}89+90+func FuzzBlossomBinding(f *testing.F) {91+ secret := strings.Repeat("0", 63) + "1"92+ blobHash := strings.Repeat("a", 64)93+ proof := nostr.Event{CreatedAt: 1_700_000_000, Kind: 24242, Tags: [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "https://relay.example"}, {"x", blobHash}}, Content: "binding seed"}94+ if err := nostr.Sign(&proof, secret); err != nil {95+ f.Fatal(err)96+ }97+ raw, err := nostr.Canonical(proof)98+ if err != nil {99+ f.Fatal(err)100+ }101+ header := "Nostr " + base64.RawStdEncoding.EncodeToString(raw)102+ f.Add("upload", "https://relay.example", blobHash)103+ f.Add("delete", "https://relay.example", blobHash)104+ f.Add("upload", "https://other.example", blobHash)105+ f.Add("upload", "https://relay.example", strings.Repeat("b", 64))106+ f.Fuzz(func(t *testing.T, action, server, hash string) {107+ validator := NewValidator(func() time.Time { return time.Unix(1_700_000_000, 0) })108+ _, err := validator.VerifyBlossomRequest(header, action, server, hash)109+ if action == "upload" && server == "https://relay.example" && hash == blobHash && err != nil {110+ t.Fatalf("valid binding rejected: %v", err)111+ }112+ mismatch := action != "upload" || server != "" && server != "https://relay.example" || hash != "" && hash != blobHash113+ if mismatch && err == nil {114+ t.Fatal("accepted mismatched Blossom binding")115+ }116+ })117+}
A tinyrelay/auth_fuzz_test.go +133 -0
@@ -0,0 +1,133 @@
1+package tinyrelay_test2+3+import (4+ "context"5+ "encoding/json"6+ "strings"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"11+ "github.com/coder/websocket"12+)13+14+func authProof(t *testing.T, secret, relay, challenge string, created int64) nostr.Event {15+ t.Helper()16+ return signedEvent(t, secret, 22242, created, [][]string{{"relay", relay}, {"challenge", challenge}}, "")17+}18+19+func connectAuthClient(t *testing.T, httpURL string) *wireClient {20+ t.Helper()21+ conn, _, err := websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(httpURL, "http"), nil)22+ if err != nil {23+ t.Fatal(err)24+ }25+ c := &wireClient{t: t, conn: conn}26+ t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "test complete") })27+ msg := c.expect("AUTH", 2)28+ if err := json.Unmarshal(msg[1], &c.challenge); err != nil {29+ t.Fatal(err)30+ }31+ return c32+}33+34+func TestStandaloneNIP42RejectsProofMutations(t *testing.T) {35+ _, _, c := openRelay(t, true)36+ secret, err := nostr.GenerateKey()37+ if err != nil {38+ t.Fatal(err)39+ }40+ now := time.Now().Unix()41+ cases := []struct {42+ name string43+ proof nostr.Event44+ }{45+ {name: "challenge", proof: authProof(t, secret, "ws://relay.example", "wrong", now)},46+ {name: "relay", proof: authProof(t, secret, "ws://other.example", c.challenge, now)},47+ {name: "expired", proof: authProof(t, secret, "ws://relay.example", c.challenge, now-3600)},48+ {name: "future", proof: authProof(t, secret, "ws://relay.example", c.challenge, now+3600)},49+ }50+ for _, tc := range cases {51+ t.Run(tc.name, func(t *testing.T) {52+ c.send("AUTH", tc.proof)53+ reply := c.expect("OK", 4)54+ assertAccepted(t, reply, false)55+ probe := signedEvent(t, secret, 1, time.Now().Unix(), nil, "unauthenticated probe")56+ c.send("EVENT", probe)57+ probeReply := c.expect("OK", 4)58+ assertAccepted(t, probeReply, false)59+ })60+ }61+ valid := authProof(t, secret, "ws://relay.example", c.challenge, now)62+ c.send("AUTH", valid)63+ assertAccepted(t, c.expect("OK", 4), true)64+ // Repeating the proof on the same connection is idempotent. NIP-42 permits65+ // authenticating more than one key on a connection, so the relay cannot66+ // consume the event globally.67+ c.send("AUTH", valid)68+ replay := c.expect("OK", 4)69+ assertAccepted(t, replay, true)70+}71+72+func TestStandaloneNIP42ProofCannotCrossConnections(t *testing.T) {73+ _, httpServer, first := openRelay(t, true)74+ secret, err := nostr.GenerateKey()75+ if err != nil {76+ t.Fatal(err)77+ }78+ proof := authProof(t, secret, "ws://relay.example", first.challenge, time.Now().Unix())79+ second := connectAuthClient(t, httpServer.URL)80+ second.send("AUTH", proof)81+ assertAccepted(t, second.expect("OK", 4), false)82+}83+84+func TestStandaloneAuthenticatedActorsCannotPublishAsEachOther(t *testing.T) {85+ _, httpServer, first := openRelay(t, true)86+ secrets := make([]string, 12)87+ for i := range secrets {88+ secret, err := nostr.GenerateKey()89+ if err != nil {90+ t.Fatal(err)91+ }92+ secrets[i] = secret93+ }94+ for i, secret := range secrets {95+ if i > 0 {96+ first = connectAuthClient(t, httpServer.URL)97+ }98+ auth := authProof(t, secret, "ws://relay.example", first.challenge, time.Now().Unix())99+ first.send("AUTH", auth)100+ assertAccepted(t, first.expect("OK", 4), true)101+ own := signedEvent(t, secret, 1, time.Now().Unix(), nil, "actor")102+ publish(t, first, own)103+ foreign := signedEvent(t, secrets[(i+1)%len(secrets)], 1, time.Now().Unix(), nil, "foreign")104+ first.send("EVENT", foreign)105+ assertAccepted(t, first.expect("OK", 4), false)106+ }107+}108+109+func FuzzStandaloneAuthTagParsing(f *testing.F) {110+ f.Add("wrong", "ws://relay.example")111+ f.Add("", "ws://other.example")112+ f.Add("\x00\xff", "not-a-url")113+ f.Fuzz(func(t *testing.T, challenge, relay string) {114+ if len(challenge) > 128 || len(relay) > 256 {115+ t.Skip()116+ }117+ _, _, c := openRelay(t, true)118+ secret, err := nostr.GenerateKey()119+ if err != nil {120+ t.Fatal(err)121+ }122+ proof := authProof(t, secret, relay, challenge, time.Now().Unix())123+ c.send("AUTH", proof)124+ reply := c.expect("OK", 4)125+ accepted := challenge == c.challenge && relay == "ws://relay.example"126+ assertAccepted(t, reply, accepted)127+ if !accepted {128+ probe := signedEvent(t, secret, 1, time.Now().Unix(), nil, "unauthenticated probe")129+ c.send("EVENT", probe)130+ assertAccepted(t, c.expect("OK", 4), false)131+ }132+ })133+}