tinyrelayfile
tinyrelay/protocol/auth/auth_test.go/

protocol/auth/auth_test.go raw
1package auth
2
3import (
4 "crypto/sha256"
5 "encoding/base64"
6 "encoding/hex"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
12)
13
14func TestNIP98ValidatesRequestAndRejectsReplay(t *testing.T) {
15 now := time.Unix(1_700_000_000, 0)
16 validator := NewValidator(func() time.Time { return now })
17 e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", ""}})
18 // A non-empty request body must carry its exact SHA-256 tag.
19 e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", sha256Hex("hello")}})
20 header := token(t, e)
21 got, err := validator.VerifyNIP98(header, "https://RELAY.example/api?ignored=1", "post", "hello")
22 if err != nil || got.PubKey == "" {
23 t.Fatalf("verify valid token: %v", err)
24 }
25 if _, err := validator.VerifyNIP98(header, "https://relay.example/api?ignored=1", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "replay") {
26 t.Fatalf("expected replay rejection, got %v", err)
27 }
28}
29
30func TestNIP98ExpiryAndPayload(t *testing.T) {
31 now := time.Unix(1_700_000_000, 0)
32 validator := NewValidator(func() time.Time { return now })
33 e := signedEvent(t, 27235, now.Add(-61*time.Second).Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("hello")}})
34 if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "expired") {
35 t.Fatalf("expected expiry, got %v", err)
36 }
37 e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("other")}})
38 if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "payload") {
39 t.Fatalf("expected payload rejection, got %v", err)
40 }
41}
42
43func TestNIP98BindsSchemeQueryAndPath(t *testing.T) {
44 now := time.Unix(1_700_000_000, 0)
45 validator := NewValidator(func() time.Time { return now })
46 for _, requested := range []string{
47 "http://relay.example/api?a=1",
48 "https://relay.example/other?a=1",
49 "https://relay.example/api?a=2",
50 "https://relay.example/api?a=1#fragment",
51 "https://user:pass@relay.example/api?a=1",
52 } {
53 e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?a=1"}, {"method", "GET"}})
54 if _, err := validator.VerifyNIP98(token(t, e), requested, "GET", ""); err == nil {
55 t.Fatalf("accepted request URL %q", requested)
56 }
57 }
58}
59
60func TestGRASP08ReusesRepositoryRootProofAcrossSmartHTTP(t *testing.T) {
61 now := time.Unix(1_700_000_000, 0)
62 validator := NewValidator(func() time.Time { return now })
63 root := "https://relay.example/npub1owner/repo.git"
64 e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}, {"payload", sha256Hex("different body")}})
65 header := token(t, e)
66 for _, request := range []string{root + "/info/refs?service=git-upload-pack", root + "/git-upload-pack"} {
67 if _, err := validator.VerifyGRASP08(header, request); err != nil {
68 t.Fatalf("verify reusable proof for %s: %v", request, err)
69 }
70 }
71}
72
73func TestGRASP08RepositoryRootHandlesGitSubstringInSlug(t *testing.T) {
74 got, ok := GRASP08RepositoryRoot("https://relay.example/npub1owner/foo.git-tools.git/info/refs?service=git-upload-pack")
75 if !ok || got != "https://relay.example/npub1owner/foo.git-tools.git" {
76 t.Fatalf("root=%q ok=%v", got, ok)
77 }
78}
79
80func TestGRASP08RejectsWrongRootMethodAndAgeWithUniformError(t *testing.T) {
81 now := time.Unix(1_700_000_000, 0)
82 validator := NewValidator(func() time.Time { return now })
83 for _, tc := range []struct {
84 name string
85 tags [][]string
86 at int64
87 url string
88 }{
89 {"wrong root", [][]string{{"u", "https://relay.example/npub1owner/other.git"}, {"method", "GET"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},
90 {"wrong method", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "POST"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},
91 {"expired", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}}, now.Add(-61 * time.Second).Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},
92 } {
93 t.Run(tc.name, func(t *testing.T) {
94 _, err := validator.VerifyGRASP08(token(t, signedEvent(t, 27235, tc.at, "", tc.tags)), tc.url)
95 if err != ErrGRASP08Unauthorized {
96 t.Fatalf("error=%v, want uniform error", err)
97 }
98 })
99 }
100}
101
102func TestGRASP08RejectsUnsupportedGitEndpoint(t *testing.T) {
103 now := time.Unix(1_700_000_000, 0)
104 validator := NewValidator(func() time.Time { return now })
105 e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}})
106 for _, path := range []string{"/objects/pack/a", "/info/refs/extra", "/"} {
107 if _, err := validator.VerifyGRASP08(token(t, e), "https://relay.example/npub1owner/repo.git"+path); err != ErrGRASP08Unauthorized {
108 t.Fatalf("endpoint %s accepted with error %v", path, err)
109 }
110 }
111}
112
113func TestBlossomTokenMayBeReusedAcrossHeadAndPut(t *testing.T) {
114 now := time.Unix(1_700_000_000, 0)
115 validator := NewValidator(func() time.Time { return now })
116 e := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}})
117 header := token(t, e)
118 if _, err := validator.VerifyBlossom(header, "upload"); err != nil {
119 t.Fatalf("verify first Blossom request: %v", err)
120 }
121 if _, err := validator.VerifyBlossom(header, "upload"); err != nil {
122 t.Fatalf("verify reused Blossom request: %v", err)
123 }
124}
125
126func TestValidateBlobPayloadBindsBlossomAndNIP98Hashes(t *testing.T) {
127 now := time.Unix(1_700_000_000, 0)
128 validator := NewValidator(func() time.Time { return now })
129 hash := sha256Hex("blob")
130 blossom := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"x", hash}})
131 if err := validator.ValidateBlobPayload(token(t, blossom), hash); err != nil {
132 t.Fatalf("valid Blossom hash: %v", err)
133 }
134 if err := validator.ValidateBlobPayload(token(t, blossom), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "blob") {
135 t.Fatalf("expected Blossom hash rejection, got %v", err)
136 }
137 nip98 := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/upload"}, {"method", "PUT"}, {"payload", hash}})
138 if err := validator.ValidateBlobPayload(token(t, nip98), hash); err != nil {
139 t.Fatalf("valid NIP98 hash: %v", err)
140 }
141 if err := validator.ValidateBlobPayload(token(t, nip98), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "payload") {
142 t.Fatalf("expected NIP98 hash rejection, got %v", err)
143 }
144}
145
146func TestValidateBlossomPayloadBindsMirrorHash(t *testing.T) {
147 now := time.Unix(1_700_000_000, 0)
148 validator := NewValidator(func() time.Time { return now })
149 hash := sha256Hex("blob")
150 tokenEvent := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "mirror"}, {"expiration", "1700000300"}, {"x", hash}})
151 header := token(t, tokenEvent)
152 if err := validator.ValidateBlossomPayload(header, "mirror", hash); err != nil {
153 t.Fatalf("valid mirror hash: %v", err)
154 }
155 if err := validator.ValidateBlossomPayload(header, "mirror", sha256Hex("other")); err == nil {
156 t.Fatal("accepted mismatched mirror hash")
157 }
158}
159
160func TestVerifyBlossomRequestRequiresScopeAndServer(t *testing.T) {
161 now := time.Unix(1_700_000_000, 0)
162 validator := NewValidator(func() time.Time { return now })
163 hash := sha256Hex("blob")
164 valid := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}, {"x", hash}})
165 if _, err := validator.VerifyBlossomRequest(token(t, valid), "upload", "relay.example", hash); err != nil {
166 t.Fatalf("valid scoped Blossom token: %v", err)
167 }
168 wrongServer := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "other.example"}, {"x", hash}})
169 if _, err := validator.VerifyBlossomRequest(token(t, wrongServer), "upload", "relay.example", hash); err == nil {
170 t.Fatal("accepted token scoped to another server")
171 }
172 missingHash := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}})
173 if _, err := validator.VerifyBlossomRequest(token(t, missingHash), "upload", "relay.example", ""); err == nil {
174 t.Fatal("accepted upload token without x scope")
175 }
176}
177
178func TestVerifyBlossomRequestAllowsUnscopedGet(t *testing.T) {
179 now := time.Unix(1_700_000_000, 0)
180 validator := NewValidator(func() time.Time { return now })
181 hash := sha256Hex("blob")
182 e := signedEvent(t, 24242, now.Add(-24*time.Hour).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700086400"}})
183 if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", hash); err != nil {
184 t.Fatalf("unscoped GET token rejected: %v", err)
185 }
186}
187
188func TestVerifyBlossomRequestRejectsFutureCreatedAt(t *testing.T) {
189 now := time.Unix(1_700_000_000, 0)
190 validator := NewValidator(func() time.Time { return now })
191 e := signedEvent(t, 24242, now.Add(time.Second).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700000300"}})
192 if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", ""); err == nil || !strings.Contains(err.Error(), "future") {
193 t.Fatalf("future token accepted: %v", err)
194 }
195}
196
197func TestChallengeManagerBindsRelayAndConsumesChallenge(t *testing.T) {
198 now := time.Unix(1_700_000_000, 0)
199 m := NewChallengeManager("wss://relay.example/tenant", func() time.Time { return now })
200 challenge, err := m.Issue()
201 if err != nil {
202 t.Fatalf("issue challenge: %v", err)
203 }
204 e := signedEvent(t, 22242, now.Unix(), "", [][]string{{"relay", "wss://relay.example/tenant"}, {"challenge", challenge}})
205 if err := m.Verify(e); err != nil {
206 t.Fatalf("verify challenge: %v", err)
207 }
208 if err := m.Verify(e); err == nil || !strings.Contains(err.Error(), "challenge") {
209 t.Fatalf("expected one-time challenge rejection, got %v", err)
210 }
211}
212
213func signedEvent(t *testing.T, kind int, createdAt int64, content string, tags [][]string) nostr.Event {
214 t.Helper()
215 e := nostr.Event{CreatedAt: createdAt, Kind: kind, Content: content, Tags: tags}
216 if err := nostr.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
217 t.Fatalf("sign event: %v", err)
218 }
219 return e
220}
221
222func token(t *testing.T, e nostr.Event) string {
223 t.Helper()
224 raw, err := nostr.Canonical(e)
225 if err != nil {
226 t.Fatalf("encode event: %v", err)
227 }
228 return "Nostr " + base64.StdEncoding.EncodeToString(raw)
229}
230
231func sha256Hex(value string) string {
232 sum := sha256.Sum256([]byte(value))
233 return hex.EncodeToString(sum[:])
234}
235
236func TestGRASP08RepositoryRootKeepsClientEscaping(t *testing.T) {
237 now := time.Unix(1_700_000_000, 0)
238 validator := NewValidator(func() time.Time { return now })
239 root := "https://relay.example/npub1owner/a%3Ab.git"
240 got, ok := GRASP08RepositoryRoot(root + "/info/refs?service=git-upload-pack")
241 if !ok || got != root {
242 t.Fatalf("root=%q ok=%v", got, ok)
243 }
244 e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}})
245 if _, err := validator.VerifyGRASP08(token(t, e), root+"/git-upload-pack"); err != nil {
246 t.Fatalf("verify proof for an escaped identifier: %v", err)
247 }
248}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs