main.go raw
1 package main
2
3 import (
4 "bytes"
5 "crypto/rand"
6 "encoding/base64"
7 "encoding/hex"
8 "encoding/json"
9 "flag"
10 "fmt"
11 "io"
12 "net/http"
13 "os"
14 "strings"
15 "time"
16
17 "next.orly.dev/pkg/nostr/crypto/ec/schnorr"
18 "next.orly.dev/pkg/nostr/crypto/ec/secp256k1"
19 "next.orly.dev/pkg/nostr/encoders/bech32encoding"
20 "next.orly.dev/pkg/nostr/interfaces/signer/p8k"
21 "github.com/minio/sha256-simd"
22 )
23
24 const (
25 // BlossomAuthKind is the Nostr event kind for Blossom authorization (BUD-01)
26 BlossomAuthKind = 24242
27 )
28
29 var (
30 relayURL = flag.String("url", "http://localhost:3334", "Relay base URL")
31 nsec = flag.String("nsec", "", "Nostr private key (nsec format). If empty, generates a new key")
32 blobSize = flag.Int("size", 1024, "Size of test blob in bytes")
33 verbose = flag.Bool("v", false, "Verbose output")
34 noAuth = flag.Bool("no-auth", false, "Skip authentication (test anonymous uploads)")
35 )
36
37 // BlossomDescriptor represents a blob descriptor returned by the server
38 type BlossomDescriptor struct {
39 URL string `json:"url"`
40 SHA256 string `json:"sha256"`
41 Size int64 `json:"size"`
42 Type string `json:"type,omitempty"`
43 Uploaded int64 `json:"uploaded"`
44 PublicKey string `json:"public_key,omitempty"`
45 Tags [][]string `json:"tags,omitempty"`
46 }
47
48 func main() {
49 flag.Parse()
50
51 fmt.Println("đ¸ Blossom Test Tool")
52 fmt.Println("===================")
53
54 // Get or generate keypair (only if auth is enabled)
55 var sec, pub []byte
56 var err error
57
58 if !*noAuth {
59 sec, pub, err = getKeypair()
60 if err != nil {
61 fmt.Fprintf(os.Stderr, "Error getting keypair: %v\n", err)
62 os.Exit(1)
63 }
64
65 pubkey, _ := schnorr.ParsePubKey(pub)
66 npubBytes, _ := bech32encoding.PublicKeyToNpub(pubkey)
67 fmt.Printf("Using identity: %s\n", string(npubBytes))
68 } else {
69 fmt.Printf("Testing anonymous uploads (no authentication)\n")
70 }
71 fmt.Printf("Relay URL: %s\n\n", *relayURL)
72
73 // Generate random test data
74 testData := make([]byte, *blobSize)
75 if _, err := rand.Read(testData); err != nil {
76 fmt.Fprintf(os.Stderr, "Error generating test data: %v\n", err)
77 os.Exit(1)
78 }
79
80 // Calculate SHA256
81 hash := sha256.Sum256(testData)
82 hashHex := hex.EncodeToString(hash[:])
83
84 fmt.Printf("đĻ Generated %d bytes of random data\n", *blobSize)
85 fmt.Printf(" SHA256: %s\n\n", hashHex)
86
87 // Step 1: Upload blob
88 fmt.Println("đ¤ Step 1: Uploading blob...")
89 descriptor, err := uploadBlob(sec, pub, testData)
90 if err != nil {
91 fmt.Fprintf(os.Stderr, "â Upload failed: %v\n", err)
92 os.Exit(1)
93 }
94 fmt.Printf("â
Upload successful!\n")
95 fmt.Printf(" URL: %s\n", descriptor.URL)
96 fmt.Printf(" SHA256: %s\n", descriptor.SHA256)
97 fmt.Printf(" Size: %d bytes\n\n", descriptor.Size)
98
99 // Step 2: Fetch blob
100 fmt.Println("đĨ Step 2: Fetching blob...")
101 fetchedData, err := fetchBlob(hashHex)
102 if err != nil {
103 fmt.Fprintf(os.Stderr, "â Fetch failed: %v\n", err)
104 os.Exit(1)
105 }
106 fmt.Printf("â
Fetch successful! Retrieved %d bytes\n", len(fetchedData))
107
108 // Verify data matches
109 if !bytes.Equal(testData, fetchedData) {
110 fmt.Fprintf(os.Stderr, "â Data mismatch! Retrieved data doesn't match uploaded data\n")
111 os.Exit(1)
112 }
113 fmt.Printf("â
Data verification passed - hashes match!\n\n")
114
115 // Step 3: Delete blob
116 fmt.Println("đī¸ Step 3: Deleting blob...")
117 if err := deleteBlob(sec, pub, hashHex); err != nil {
118 fmt.Fprintf(os.Stderr, "â Delete failed: %v\n", err)
119 os.Exit(1)
120 }
121 fmt.Printf("â
Delete successful!\n\n")
122
123 // Step 4: Verify deletion
124 fmt.Println("đ Step 4: Verifying deletion...")
125 if err := verifyDeleted(hashHex); err != nil {
126 fmt.Fprintf(os.Stderr, "â Verification failed: %v\n", err)
127 os.Exit(1)
128 }
129 fmt.Printf("â
Blob successfully deleted - returns 404 as expected\n\n")
130
131 fmt.Println("đ All tests passed! Blossom service is working correctly.")
132 }
133
134 func getKeypair() (sec, pub []byte, err error) {
135 if *nsec != "" {
136 // Decode provided nsec
137 var secKey *secp256k1.SecretKey
138 secKey, err = bech32encoding.NsecToSecretKey(*nsec)
139 if err != nil {
140 return nil, nil, fmt.Errorf("invalid nsec: %w", err)
141 }
142 sec = secKey.Serialize()
143 } else {
144 // Generate new keypair
145 sec = make([]byte, 32)
146 if _, err := rand.Read(sec); err != nil {
147 return nil, nil, fmt.Errorf("failed to generate key: %w", err)
148 }
149 fmt.Println("âšī¸ No key provided, generated new keypair")
150 }
151
152 // Derive public key using p8k signer
153 var signer *p8k.Signer
154 if signer, err = p8k.New(); err != nil {
155 return nil, nil, fmt.Errorf("failed to create signer: %w", err)
156 }
157 if err = signer.InitSec(sec); err != nil {
158 return nil, nil, fmt.Errorf("failed to initialize signer: %w", err)
159 }
160 pub = signer.Pub()
161
162 return sec, pub, nil
163 }
164
165 // createAuthEvent creates a Blossom authorization event (kind 24242)
166 func createAuthEvent(sec, pub []byte, action, hash string) (string, error) {
167 now := time.Now().Unix()
168
169 // Build tags based on action
170 tags := [][]string{
171 {"t", action},
172 }
173
174 // Add x tag for DELETE and GET actions
175 if hash != "" && (action == "delete" || action == "get") {
176 tags = append(tags, []string{"x", hash})
177 }
178
179 // All Blossom auth events require expiration tag (BUD-01)
180 expiry := now + 300 // Event expires in 5 minutes
181 tags = append(tags, []string{"expiration", fmt.Sprintf("%d", expiry)})
182
183 pubkeyHex := hex.EncodeToString(pub)
184
185 // Create event ID
186 eventJSON, err := json.Marshal([]interface{}{
187 0,
188 pubkeyHex,
189 now,
190 BlossomAuthKind,
191 tags,
192 "",
193 })
194 if err != nil {
195 return "", fmt.Errorf("failed to marshal event for ID: %w", err)
196 }
197
198 eventHash := sha256.Sum256(eventJSON)
199 eventID := hex.EncodeToString(eventHash[:])
200
201 // Sign the event using p8k signer
202 signer, err := p8k.New()
203 if err != nil {
204 return "", fmt.Errorf("failed to create signer: %w", err)
205 }
206 if err = signer.InitSec(sec); err != nil {
207 return "", fmt.Errorf("failed to initialize signer: %w", err)
208 }
209
210 sig, err := signer.Sign(eventHash[:])
211 if err != nil {
212 return "", fmt.Errorf("failed to sign event: %w", err)
213 }
214 sigHex := hex.EncodeToString(sig)
215
216 // Create event JSON (signed)
217 event := map[string]interface{}{
218 "id": eventID,
219 "pubkey": pubkeyHex,
220 "created_at": now,
221 "kind": BlossomAuthKind,
222 "tags": tags,
223 "content": "",
224 "sig": sigHex,
225 }
226
227 // Marshal to JSON for Authorization header
228 authJSON, err := json.Marshal(event)
229 if err != nil {
230 return "", fmt.Errorf("failed to marshal auth event: %w", err)
231 }
232
233 if *verbose {
234 fmt.Printf(" Auth event: %s\n", string(authJSON))
235 }
236
237 return string(authJSON), nil
238 }
239
240 func uploadBlob(sec, pub, data []byte) (*BlossomDescriptor, error) {
241 // Create request
242 url := strings.TrimSuffix(*relayURL, "/") + "/blossom/upload"
243 req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
244 if err != nil {
245 return nil, err
246 }
247
248 // Set headers
249 req.Header.Set("Content-Type", "application/octet-stream")
250
251 // Add authorization if not disabled
252 if !*noAuth && sec != nil && pub != nil {
253 authEvent, err := createAuthEvent(sec, pub, "upload", "")
254 if err != nil {
255 return nil, err
256 }
257 // Base64-encode the auth event as per BUD-01
258 authEventB64 := base64.StdEncoding.EncodeToString([]byte(authEvent))
259 req.Header.Set("Authorization", "Nostr "+authEventB64)
260 }
261
262 if *verbose {
263 fmt.Printf(" PUT %s\n", url)
264 fmt.Printf(" Content-Length: %d\n", len(data))
265 }
266
267 // Send request
268 client := &http.Client{Timeout: 30 * time.Second}
269 resp, err := client.Do(req)
270 if err != nil {
271 return nil, err
272 }
273 defer resp.Body.Close()
274
275 // Read response
276 body, err := io.ReadAll(resp.Body)
277 if err != nil {
278 return nil, err
279 }
280
281 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
282 reason := resp.Header.Get("X-Reason")
283 if reason == "" {
284 reason = string(body)
285 }
286 return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, reason)
287 }
288
289 // Parse descriptor
290 var descriptor BlossomDescriptor
291 if err := json.Unmarshal(body, &descriptor); err != nil {
292 return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(body))
293 }
294
295 return &descriptor, nil
296 }
297
298 func fetchBlob(hash string) ([]byte, error) {
299 url := strings.TrimSuffix(*relayURL, "/") + "/blossom/" + hash
300
301 if *verbose {
302 fmt.Printf(" GET %s\n", url)
303 }
304
305 resp, err := http.Get(url)
306 if err != nil {
307 return nil, err
308 }
309 defer resp.Body.Close()
310
311 if resp.StatusCode != http.StatusOK {
312 body, _ := io.ReadAll(resp.Body)
313 return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
314 }
315
316 return io.ReadAll(resp.Body)
317 }
318
319 func deleteBlob(sec, pub []byte, hash string) error {
320 // Create request
321 url := strings.TrimSuffix(*relayURL, "/") + "/blossom/" + hash
322 req, err := http.NewRequest(http.MethodDelete, url, nil)
323 if err != nil {
324 return err
325 }
326
327 // Add authorization if not disabled
328 if !*noAuth && sec != nil && pub != nil {
329 authEvent, err := createAuthEvent(sec, pub, "delete", hash)
330 if err != nil {
331 return err
332 }
333 // Base64-encode the auth event as per BUD-01
334 authEventB64 := base64.StdEncoding.EncodeToString([]byte(authEvent))
335 req.Header.Set("Authorization", "Nostr "+authEventB64)
336 }
337
338 if *verbose {
339 fmt.Printf(" DELETE %s\n", url)
340 }
341
342 // Send request
343 client := &http.Client{Timeout: 30 * time.Second}
344 resp, err := client.Do(req)
345 if err != nil {
346 return err
347 }
348 defer resp.Body.Close()
349
350 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
351 body, _ := io.ReadAll(resp.Body)
352 reason := resp.Header.Get("X-Reason")
353 if reason == "" {
354 reason = string(body)
355 }
356 return fmt.Errorf("server returned %d: %s", resp.StatusCode, reason)
357 }
358
359 return nil
360 }
361
362 func verifyDeleted(hash string) error {
363 url := strings.TrimSuffix(*relayURL, "/") + "/blossom/" + hash
364
365 if *verbose {
366 fmt.Printf(" GET %s (expecting 404)\n", url)
367 }
368
369 resp, err := http.Get(url)
370 if err != nil {
371 return err
372 }
373 defer resp.Body.Close()
374
375 if resp.StatusCode == http.StatusOK {
376 return fmt.Errorf("blob still exists (expected 404, got 200)")
377 }
378
379 if resp.StatusCode != http.StatusNotFound {
380 return fmt.Errorf("unexpected status code: %d (expected 404)", resp.StatusCode)
381 }
382
383 return nil
384 }
385