clone

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

feat/tinyagent
tinyrelay / protocol / auth / auth_test.go
auth_test.go 13.2K
1package auth23import (4 "crypto/sha256"5 "encoding/base64"6 "encoding/hex"7 "strings"8 "testing"9 "time"1011 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"12)1314func 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}2930func TestNIP98DeferredAllowsPayloadUntilBodyIsMaterialized(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.Unix(), "", [][]string{34 {"u", "https://relay.example/api"},35 {"method", "POST"},36 {"payload", sha256Hex("body")},37 })38 header := token(t, e)39 if _, err := validator.VerifyNIP98Deferred(header, "https://relay.example/api", "POST"); err != nil {40 t.Fatalf("deferred verification: %v", err)41 }42 if err := validator.ValidateBlobPayload(header, sha256Hex("body")); err != nil {43 t.Fatalf("validate streamed body: %v", err)44 }45 if err := validator.ValidateBlobPayload(header, sha256Hex("other")); err == nil {46 t.Fatal("accepted a different streamed body")47 }48 if _, err := validator.VerifyNIP98Deferred(header, "https://relay.example/api", "POST"); err == nil || !strings.Contains(err.Error(), "replay") {49 t.Fatalf("expected replay rejection, got %v", err)50 }51}5253func TestNIP98PayloadMatchesRequestBody(t *testing.T) {54 now := time.Unix(1_700_000_000, 0)55 for _, tc := range []struct {56 name, body, payload string57 wantErr bool58 }{59 {name: "empty without payload"},60 {name: "empty with matching payload", payload: sha256Hex("")},61 {name: "empty with wrong payload", payload: sha256Hex("body"), wantErr: true},62 {name: "body without payload", body: "body", wantErr: true},63 {name: "body with matching payload", body: "body", payload: sha256Hex("body")},64 {name: "body with wrong payload", body: "body", payload: sha256Hex("other"), wantErr: true},65 } {66 t.Run(tc.name, func(t *testing.T) {67 validator := NewValidator(func() time.Time { return now })68 tags := [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}}69 if tc.payload != "" {70 tags = append(tags, []string{"payload", tc.payload})71 }72 e := signedEvent(t, 27235, now.Unix(), "", tags)73 _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", tc.body)74 if (err != nil) != tc.wantErr || err != nil && !strings.Contains(err.Error(), "payload") {75 t.Fatalf("verify payload: %v, want error=%v", err, tc.wantErr)76 }77 })78 }79}8081func TestNIP98ExpiryAndPayload(t *testing.T) {82 now := time.Unix(1_700_000_000, 0)83 validator := NewValidator(func() time.Time { return now })84 e := signedEvent(t, 27235, now.Add(-61*time.Second).Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("hello")}})85 if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "expired") {86 t.Fatalf("expected expiry, got %v", err)87 }88 e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("other")}})89 if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "payload") {90 t.Fatalf("expected payload rejection, got %v", err)91 }92}9394func TestNIP98BindsSchemeQueryAndPath(t *testing.T) {95 now := time.Unix(1_700_000_000, 0)96 validator := NewValidator(func() time.Time { return now })97 for _, requested := range []string{98 "http://relay.example/api?a=1",99 "https://relay.example/other?a=1",100 "https://relay.example/api?a=2",101 "https://relay.example/api?a=1#fragment",102 "https://user:pass@relay.example/api?a=1",103 } {104 e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?a=1"}, {"method", "GET"}})105 if _, err := validator.VerifyNIP98(token(t, e), requested, "GET", ""); err == nil {106 t.Fatalf("accepted request URL %q", requested)107 }108 }109}110111func TestGRASP08ReusesRepositoryRootProofAcrossSmartHTTP(t *testing.T) {112 now := time.Unix(1_700_000_000, 0)113 validator := NewValidator(func() time.Time { return now })114 root := "https://relay.example/npub1owner/repo.git"115 e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}, {"payload", sha256Hex("different body")}})116 header := token(t, e)117 for _, request := range []string{root + "/info/refs?service=git-upload-pack", root + "/git-upload-pack"} {118 if _, err := validator.VerifyGRASP08(header, request); err != nil {119 t.Fatalf("verify reusable proof for %s: %v", request, err)120 }121 }122}123124func TestGRASP08RepositoryRootHandlesGitSubstringInSlug(t *testing.T) {125 got, ok := GRASP08RepositoryRoot("https://relay.example/npub1owner/foo.git-tools.git/info/refs?service=git-upload-pack")126 if !ok || got != "https://relay.example/npub1owner/foo.git-tools.git" {127 t.Fatalf("root=%q ok=%v", got, ok)128 }129}130131func TestGRASP08RejectsWrongRootMethodAndAgeWithUniformError(t *testing.T) {132 now := time.Unix(1_700_000_000, 0)133 validator := NewValidator(func() time.Time { return now })134 for _, tc := range []struct {135 name string136 tags [][]string137 at int64138 url string139 }{140 {"wrong root", [][]string{{"u", "https://relay.example/npub1owner/other.git"}, {"method", "GET"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},141 {"wrong method", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "POST"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},142 {"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"},143 } {144 t.Run(tc.name, func(t *testing.T) {145 _, err := validator.VerifyGRASP08(token(t, signedEvent(t, 27235, tc.at, "", tc.tags)), tc.url)146 if err != ErrGRASP08Unauthorized {147 t.Fatalf("error=%v, want uniform error", err)148 }149 })150 }151}152153func TestGRASP08RejectsUnsupportedGitEndpoint(t *testing.T) {154 now := time.Unix(1_700_000_000, 0)155 validator := NewValidator(func() time.Time { return now })156 e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}})157 for _, path := range []string{"/objects/pack/a", "/info/refs/extra", "/"} {158 if _, err := validator.VerifyGRASP08(token(t, e), "https://relay.example/npub1owner/repo.git"+path); err != ErrGRASP08Unauthorized {159 t.Fatalf("endpoint %s accepted with error %v", path, err)160 }161 }162}163164func TestBlossomTokenMayBeReusedAcrossHeadAndPut(t *testing.T) {165 now := time.Unix(1_700_000_000, 0)166 validator := NewValidator(func() time.Time { return now })167 e := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}})168 header := token(t, e)169 if _, err := validator.VerifyBlossom(header, "upload"); err != nil {170 t.Fatalf("verify first Blossom request: %v", err)171 }172 if _, err := validator.VerifyBlossom(header, "upload"); err != nil {173 t.Fatalf("verify reused Blossom request: %v", err)174 }175}176177func TestValidateBlobPayloadBindsBlossomAndNIP98Hashes(t *testing.T) {178 now := time.Unix(1_700_000_000, 0)179 validator := NewValidator(func() time.Time { return now })180 hash := sha256Hex("blob")181 blossom := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"x", hash}})182 if err := validator.ValidateBlobPayload(token(t, blossom), hash); err != nil {183 t.Fatalf("valid Blossom hash: %v", err)184 }185 if err := validator.ValidateBlobPayload(token(t, blossom), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "blob") {186 t.Fatalf("expected Blossom hash rejection, got %v", err)187 }188 nip98 := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/upload"}, {"method", "PUT"}, {"payload", hash}})189 if err := validator.ValidateBlobPayload(token(t, nip98), hash); err != nil {190 t.Fatalf("valid NIP98 hash: %v", err)191 }192 if err := validator.ValidateBlobPayload(token(t, nip98), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "payload") {193 t.Fatalf("expected NIP98 hash rejection, got %v", err)194 }195}196197func TestValidateBlossomPayloadBindsMirrorHash(t *testing.T) {198 now := time.Unix(1_700_000_000, 0)199 validator := NewValidator(func() time.Time { return now })200 hash := sha256Hex("blob")201 tokenEvent := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "mirror"}, {"expiration", "1700000300"}, {"x", hash}})202 header := token(t, tokenEvent)203 if err := validator.ValidateBlossomPayload(header, "mirror", hash); err != nil {204 t.Fatalf("valid mirror hash: %v", err)205 }206 if err := validator.ValidateBlossomPayload(header, "mirror", sha256Hex("other")); err == nil {207 t.Fatal("accepted mismatched mirror hash")208 }209}210211func TestVerifyBlossomRequestRequiresScopeAndServer(t *testing.T) {212 now := time.Unix(1_700_000_000, 0)213 validator := NewValidator(func() time.Time { return now })214 hash := sha256Hex("blob")215 valid := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}, {"x", hash}})216 if _, err := validator.VerifyBlossomRequest(token(t, valid), "upload", "relay.example", hash); err != nil {217 t.Fatalf("valid scoped Blossom token: %v", err)218 }219 wrongServer := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "other.example"}, {"x", hash}})220 if _, err := validator.VerifyBlossomRequest(token(t, wrongServer), "upload", "relay.example", hash); err == nil {221 t.Fatal("accepted token scoped to another server")222 }223 missingHash := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}})224 if _, err := validator.VerifyBlossomRequest(token(t, missingHash), "upload", "relay.example", ""); err == nil {225 t.Fatal("accepted upload token without x scope")226 }227}228229func TestVerifyBlossomRequestAllowsUnscopedGet(t *testing.T) {230 now := time.Unix(1_700_000_000, 0)231 validator := NewValidator(func() time.Time { return now })232 hash := sha256Hex("blob")233 e := signedEvent(t, 24242, now.Add(-24*time.Hour).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700086400"}})234 if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", hash); err != nil {235 t.Fatalf("unscoped GET token rejected: %v", err)236 }237}238239func TestVerifyBlossomRequestRejectsFutureCreatedAt(t *testing.T) {240 now := time.Unix(1_700_000_000, 0)241 validator := NewValidator(func() time.Time { return now })242 e := signedEvent(t, 24242, now.Add(time.Second).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700000300"}})243 if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", ""); err == nil || !strings.Contains(err.Error(), "future") {244 t.Fatalf("future token accepted: %v", err)245 }246}247248func TestChallengeManagerBindsRelayAndConsumesChallenge(t *testing.T) {249 now := time.Unix(1_700_000_000, 0)250 m := NewChallengeManager("wss://relay.example/tenant", func() time.Time { return now })251 challenge, err := m.Issue()252 if err != nil {253 t.Fatalf("issue challenge: %v", err)254 }255 e := signedEvent(t, 22242, now.Unix(), "", [][]string{{"relay", "wss://relay.example/tenant"}, {"challenge", challenge}})256 if err := m.Verify(e); err != nil {257 t.Fatalf("verify challenge: %v", err)258 }259 if err := m.Verify(e); err == nil || !strings.Contains(err.Error(), "challenge") {260 t.Fatalf("expected one-time challenge rejection, got %v", err)261 }262}263264func signedEvent(t *testing.T, kind int, createdAt int64, content string, tags [][]string) nostr.Event {265 t.Helper()266 e := nostr.Event{CreatedAt: createdAt, Kind: kind, Content: content, Tags: tags}267 if err := nostr.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {268 t.Fatalf("sign event: %v", err)269 }270 return e271}272273func token(t *testing.T, e nostr.Event) string {274 t.Helper()275 raw, err := nostr.Canonical(e)276 if err != nil {277 t.Fatalf("encode event: %v", err)278 }279 return "Nostr " + base64.StdEncoding.EncodeToString(raw)280}281282func sha256Hex(value string) string {283 sum := sha256.Sum256([]byte(value))284 return hex.EncodeToString(sum[:])285}286287func TestGRASP08RepositoryRootKeepsClientEscaping(t *testing.T) {288 now := time.Unix(1_700_000_000, 0)289 validator := NewValidator(func() time.Time { return now })290 root := "https://relay.example/npub1owner/a%3Ab.git"291 got, ok := GRASP08RepositoryRoot(root + "/info/refs?service=git-upload-pack")292 if !ok || got != root {293 t.Fatalf("root=%q ok=%v", got, ok)294 }295 e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}})296 if _, err := validator.VerifyGRASP08(token(t, e), root+"/git-upload-pack"); err != nil {297 t.Fatalf("verify proof for an escaped identifier: %v", err)298 }299}