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