| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 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" |
| 18 | |
| 19 | "github.com/FelineStateMachine/tinyrelay/internal/blob" |
| 20 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 21 | "github.com/FelineStateMachine/tinyrelay/internal/sites" |
| 22 | ) |
| 23 | |
| 24 | // agentUpload sends one Blossom PUT /upload signed by secret through the |
| 25 | // tenant and returns the response. |
| 26 | func 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 w |
| 43 | } |
| 44 | |
| 45 | // TestAgentGrantGovernsUploads covers the blob door for agent keys: an |
| 46 | // active grant uploads as a member, a paused or revoked one is refused, a |
| 47 | // sites ttl stamps the claim and the maintenance sweep retires it, and an |
| 48 | // encrypted grant refuses plain files. |
| 49 | func TestAgentGrantGovernsUploads(t *testing.T) { |
| 50 | _, tenant := testTenant(t) |
| 51 | ctx := context.Background() |
| 52 | owner := tenant.Policy().Owner |
| 53 | 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 int64 |
| 71 | 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 | } |
| 113 | |
| 114 | type blobOriginTransport func(*http.Request) (*http.Response, error) |
| 115 | |
| 116 | func (f blobOriginTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| 117 | |
| 118 | func 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 string |
| 124 | pathUpload, tamper bool |
| 125 | want int |
| 126 | }{ |
| 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 := 0 |
| 138 | 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))}, nil |
| 145 | })}, |
| 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 | } |
| 184 | |
| 185 | func 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 | } |