1package daemon23import (4 "context"5 "crypto/sha256"6 "encoding/base64"7 "encoding/hex"8 "encoding/json"9 "io"10 "net"11 "net/http"12 "net/http/httptest"13 "net/url"14 "strconv"15 "strings"16 "testing"17 "time"1819 "github.com/FelineStateMachine/tinyrelay/internal/blob"20 "github.com/FelineStateMachine/tinyrelay/internal/event"21 "github.com/FelineStateMachine/tinyrelay/internal/sites"22)2324// agentUpload sends one Blossom PUT /upload signed by secret through the25// tenant and returns the response.26func agentUpload(t *testing.T, tenant *Tenant, secret, body, typ string) *httptest.ResponseRecorder {27 t.Helper()28 sum := sha256.Sum256([]byte(body))29 token := event.Event{Kind: 24242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"t", "upload"}, {"x", hex.EncodeToString(sum[:])}, {"expiration", strconv.FormatInt(time.Now().Unix()+60, 10)}}, Content: "Authorize file storage"}30 if err := event.Sign(&token, secret); err != nil {31 t.Fatal(err)32 }33 raw, err := json.Marshal(token)34 if err != nil {35 t.Fatal(err)36 }37 r := httptest.NewRequest(http.MethodPut, "http://relay.test/upload", strings.NewReader(body))38 r.Header.Set("Authorization", "Nostr "+base64.RawURLEncoding.EncodeToString(raw))39 r.Header.Set("Content-Type", typ)40 w := httptest.NewRecorder()41 tenant.ServeHTTP(w, r)42 return w43}4445// TestAgentGrantGovernsUploads covers the blob door for agent keys: an46// active grant uploads as a member, a paused or revoked one is refused, a47// sites ttl stamps the claim and the maintenance sweep retires it, and an48// encrypted grant refuses plain files.49func TestAgentGrantGovernsUploads(t *testing.T) {50 _, tenant := testTenant(t)51 ctx := context.Background()52 owner := tenant.Policy().Owner53 agent, _ := event.PublicKey(testAgentSecret)54 if _, err := tenant.Execute(ctx, owner, "setpolicy", []json.RawMessage{json.RawMessage(`{"writes":"allowlist"}`)}); err != nil {55 t.Fatal(err)56 }57 if w := agentUpload(t, tenant, testAgentSecret, "stranger", "text/plain"); w.Code == http.StatusOK {58 t.Fatal("a key without a grant uploaded under allowlist writes")59 }60 now := time.Now().Unix()61 own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: agent})62 if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"sites", own, "ttl=2"})); err != nil {63 t.Fatal(err)64 }65 if w := agentUpload(t, tenant, testAgentSecret, "site file", "text/html"); w.Code != http.StatusOK {66 t.Fatalf("granted agent upload = %d %s", w.Code, w.Body.String())67 }68 sum := sha256.Sum256([]byte("site file"))69 hash := hex.EncodeToString(sum[:])70 var expires int6471 if err := tenant.store.DB().QueryRow("SELECT expires FROM blob_claims WHERE sha256=? AND uploader=?", hash, agent).Scan(&expires); err != nil || expires < now+2*86400 || expires > now+2*86400+60 {72 t.Fatalf("claim expires=%d err=%v, want about %d", expires, err, now+2*86400)73 }74 if entries, err := tenant.blobs.ListMetadata(ctx, agent); err != nil || len(entries) != 1 {75 t.Fatalf("agent list = %d entries, err=%v", len(entries), err)76 }77 if _, err := tenant.Execute(ctx, owner, "pauseagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {78 t.Fatal(err)79 }80 if w := agentUpload(t, tenant, testAgentSecret, "while paused", "text/html"); w.Code == http.StatusOK || !strings.Contains(w.Body.String(), "paused") {81 t.Fatalf("paused agent upload = %d %s", w.Code, w.Body.String())82 }83 if _, err := tenant.Execute(ctx, owner, "resumeagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {84 t.Fatal(err)85 }86 if w := agentUpload(t, tenant, testAgentSecret, "after resume", "text/html"); w.Code != http.StatusOK {87 t.Fatalf("resumed agent upload = %d %s", w.Code, w.Body.String())88 }89 if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {90 t.Fatal(err)91 }92 if w := agentUpload(t, tenant, testAgentSecret, "after revoke", "text/html"); w.Code == http.StatusOK {93 t.Fatalf("revoked agent upload = %d %s", w.Code, w.Body.String())94 }95 // The sweep removes the agent's files once the ttl lapses.96 if err := tenant.sweepExpiredBlobs(ctx, now+2*86400+61); err != nil {97 t.Fatal(err)98 }99 if _, _, err := tenant.blobs.Get(ctx, hash); err == nil {100 t.Fatal("expired agent upload still stored")101 }102 // A fresh grant with the encrypted flag refuses plain uploads.103 if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+1, now+3600, []string{"sites", "*", "ttl=1", "encrypted"})); err != nil {104 t.Fatal(err)105 }106 if w := agentUpload(t, tenant, testAgentSecret, "<html>plain</html>", "text/html"); w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "requires encrypted uploads") {107 t.Fatalf("plain upload under encrypted grant = %d %s", w.Code, w.Body.String())108 }109 if w := agentUpload(t, tenant, testAgentSecret, "\x8f\x1a\x9c\x03"+strings.Repeat("\xe2\x71\x05\x9b\x4c", 20), "application/octet-stream"); w.Code != http.StatusOK {110 t.Fatalf("ciphertext upload under encrypted grant = %d %s", w.Code, w.Body.String())111 }112}113114type blobOriginTransport func(*http.Request) (*http.Response, error)115116func (f blobOriginTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }117118func TestBlobMirrorAuthenticationBindsRequestAndFetchedContent(t *testing.T) {119 const content = "fetched encrypted bytes"120 sum := sha256.Sum256([]byte(content))121 hash := hex.EncodeToString(sum[:])122 for _, tc := range []struct {123 name, mode, action, scope string124 pathUpload, tamper bool125 want int126 }{127 {name: "mirror NIP98", mode: "nip98", want: 201},128 {name: "mirror NIP98 tampered JSON", mode: "nip98", tamper: true, want: 401},129 {name: "mirror Blossom upload verb", action: "upload", scope: hash, want: 201},130 {name: "mirror Blossom wrong verb", action: "mirror", scope: hash, want: 401},131 {name: "mirror Blossom wrong content", action: "upload", scope: strings.Repeat("a", 64), want: 401},132 {name: "remote NIP98", mode: "nip98", pathUpload: true, want: 201},133 {name: "remote Blossom", action: "upload", scope: hash, pathUpload: true, want: 201},134 } {135 t.Run(tc.name, func(t *testing.T) {136 _, tenant := testTenant(t)137 fetches := 0138 service, err := blob.New(context.Background(), blob.Config{139 Root: t.TempDir(), Store: tenant.store, PublicURL: tenant.publicURL,140 Authorize: tenant.authorizeBlob, ValidateUpload: tenant.validateBlobUpload,141 ResolveIP: func(context.Context, string) ([]net.IP, error) { return []net.IP{net.ParseIP("8.8.8.8")}, nil },142 HTTPClient: &http.Client{Transport: blobOriginTransport(func(*http.Request) (*http.Response, error) {143 fetches++144 return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, Body: io.NopCloser(strings.NewReader(content))}, nil145 })},146 })147 if err != nil {148 t.Fatal(err)149 }150 body := `{"url":"https://files.example/source"}`151 target := "http://relay.test/mirror"152 if tc.pathUpload {153 body = ""154 target = "http://relay.test/" + hash + "?url=" + url.QueryEscape("https://files.example/source")155 }156 r := httptest.NewRequest(http.MethodPut, target, strings.NewReader(body))157 if tc.mode == "nip98" {158 signRequest(t, r, body)159 } else {160 token := event.Event{Kind: 24242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"t", tc.action}, {"x", tc.scope}, {"expiration", strconv.FormatInt(time.Now().Unix()+60, 10)}}, Content: "Authorize file storage"}161 if err := event.Sign(&token, strings.Repeat("0", 63)+"1"); err != nil {162 t.Fatal(err)163 }164 raw, err := json.Marshal(token)165 if err != nil {166 t.Fatal(err)167 }168 r.Header.Set("Authorization", "Nostr "+base64.RawURLEncoding.EncodeToString(raw))169 }170 if tc.tamper {171 r.Body = io.NopCloser(strings.NewReader(`{"url":"https://files.example/altered"}`))172 }173 w := httptest.NewRecorder()174 service.Handler().ServeHTTP(w, r)175 if w.Code != tc.want {176 t.Fatalf("status=%d, want %d: %s", w.Code, tc.want, w.Body.String())177 }178 if (tc.tamper || tc.action == "mirror") && fetches != 0 {179 t.Fatal("unauthorized request fetched an origin")180 }181 })182 }183}184185func TestUploadURLQueryCannotBypassNIP98PayloadValidation(t *testing.T) {186 _, tenant := testTenant(t)187 r := httptest.NewRequest(http.MethodPut, "http://relay.test/upload?url=https://files.example/ignored", strings.NewReader("tampered body"))188 signRequest(t, r, "signed body")189 w := httptest.NewRecorder()190 tenant.ServeHTTP(w, r)191 if w.Code != http.StatusUnauthorized {192 t.Fatalf("tampered upload status=%d: %s", w.Code, w.Body.String())193 }194}