giftwrap.go raw

   1  package bridge
   2  
   3  import (
   4  	"crypto/rand"
   5  	"encoding/binary"
   6  	"fmt"
   7  	"time"
   8  
   9  	"next.orly.dev/pkg/nostr/crypto/encryption"
  10  	"next.orly.dev/pkg/nostr/crypto/keys"
  11  	"next.orly.dev/pkg/nostr/encoders/event"
  12  	"next.orly.dev/pkg/nostr/encoders/hex"
  13  	"next.orly.dev/pkg/nostr/encoders/tag"
  14  	"next.orly.dev/pkg/nostr/interfaces/signer"
  15  	"next.orly.dev/pkg/lol/log"
  16  )
  17  
  18  const (
  19  	kindSeal      uint16 = 13
  20  	kindDM        uint16 = 14
  21  	kindGiftWrap  uint16 = 1059
  22  )
  23  
  24  // UnwrappedDM holds the result of unwrapping a NIP-17 gift-wrapped DM.
  25  type UnwrappedDM struct {
  26  	SenderPubHex string
  27  	Content      string
  28  }
  29  
  30  // unwrapGiftWrap decrypts a kind 1059 gift-wrapped event to extract the
  31  // inner kind 14 DM. NIP-17 structure: 1059 (gift wrap) → 13 (seal) → 14 (DM).
  32  //
  33  // Layer 1: Gift wrap is NIP-44 encrypted with ephemeral_key + recipient_key.
  34  // Layer 2: Seal is NIP-44 encrypted with sender_key + recipient_key.
  35  // The sender's real pubkey is on the seal (kind 13), not the gift wrap.
  36  func unwrapGiftWrap(ev *event.E, sign signer.I) (*UnwrappedDM, error) {
  37  	if ev.Kind != kindGiftWrap {
  38  		return nil, fmt.Errorf("expected kind %d, got %d", kindGiftWrap, ev.Kind)
  39  	}
  40  
  41  	// Layer 1: Decrypt gift wrap using bridge secret + gift wrap pubkey (ephemeral)
  42  	convKey, err := encryption.GenerateConversationKey(sign.Sec(), ev.Pubkey)
  43  	if err != nil {
  44  		return nil, fmt.Errorf("gift wrap ECDH: %w", err)
  45  	}
  46  	sealJSON, err := encryption.Decrypt(convKey, string(ev.Content))
  47  	if err != nil {
  48  		return nil, fmt.Errorf("gift wrap decrypt: %w", err)
  49  	}
  50  
  51  	// Parse the seal event (kind 13)
  52  	var seal event.E
  53  	if err := seal.UnmarshalJSON([]byte(sealJSON)); err != nil {
  54  		return nil, fmt.Errorf("parse seal: %w", err)
  55  	}
  56  	if seal.Kind != kindSeal {
  57  		return nil, fmt.Errorf("expected seal kind %d, got %d", kindSeal, seal.Kind)
  58  	}
  59  
  60  	// Layer 2: Decrypt seal using bridge secret + seal pubkey (real sender)
  61  	sealConvKey, err := encryption.GenerateConversationKey(sign.Sec(), seal.Pubkey)
  62  	if err != nil {
  63  		return nil, fmt.Errorf("seal ECDH: %w", err)
  64  	}
  65  	innerJSON, err := encryption.Decrypt(sealConvKey, string(seal.Content))
  66  	if err != nil {
  67  		return nil, fmt.Errorf("seal decrypt: %w", err)
  68  	}
  69  
  70  	// Parse the inner DM event (kind 14)
  71  	var inner event.E
  72  	if err := inner.UnmarshalJSON([]byte(innerJSON)); err != nil {
  73  		return nil, fmt.Errorf("parse inner DM: %w", err)
  74  	}
  75  	if inner.Kind != kindDM {
  76  		return nil, fmt.Errorf("expected DM kind %d, got %d", kindDM, inner.Kind)
  77  	}
  78  
  79  	return &UnwrappedDM{
  80  		SenderPubHex: hex.Enc(seal.Pubkey),
  81  		Content:      string(inner.Content),
  82  	}, nil
  83  }
  84  
  85  // wrapGiftWrap creates a NIP-17 gift-wrapped DM (kind 14 → 13 → 1059).
  86  // The outer gift wrap uses an ephemeral key for sender privacy.
  87  func wrapGiftWrap(
  88  	recipientPubHex string, content string, sign signer.I,
  89  ) (*event.E, error) {
  90  	recipientPub, err := hex.Dec(recipientPubHex)
  91  	if err != nil {
  92  		return nil, fmt.Errorf("decode recipient pubkey: %w", err)
  93  	}
  94  
  95  	now := time.Now().Unix()
  96  
  97  	// Step 1: Create inner DM (kind 14)
  98  	inner := &event.E{
  99  		Content:   []byte(content),
 100  		CreatedAt: now,
 101  		Kind:      kindDM,
 102  		Tags: tag.NewS(
 103  			tag.NewFromAny("p", recipientPubHex),
 104  		),
 105  	}
 106  	if err := inner.Sign(sign); err != nil {
 107  		return nil, fmt.Errorf("sign inner DM: %w", err)
 108  	}
 109  
 110  	innerJSON, err := inner.MarshalJSON()
 111  	if err != nil {
 112  		return nil, fmt.Errorf("marshal inner DM: %w", err)
 113  	}
 114  
 115  	// Step 2: Create seal (kind 13) — encrypt inner with sender + recipient
 116  	sealConvKey, err := encryption.GenerateConversationKey(sign.Sec(), recipientPub)
 117  	if err != nil {
 118  		return nil, fmt.Errorf("seal ECDH: %w", err)
 119  	}
 120  	sealCiphertext, err := encryption.Encrypt(sealConvKey, innerJSON, nil)
 121  	if err != nil {
 122  		return nil, fmt.Errorf("seal encrypt: %w", err)
 123  	}
 124  
 125  	seal := &event.E{
 126  		Content:   []byte(sealCiphertext),
 127  		CreatedAt: randomizeTimestamp(now),
 128  		Kind:      kindSeal,
 129  		Tags:      tag.NewS(), // no tags on seal
 130  	}
 131  	if err := seal.Sign(sign); err != nil {
 132  		return nil, fmt.Errorf("sign seal: %w", err)
 133  	}
 134  
 135  	sealJSON, err := seal.MarshalJSON()
 136  	if err != nil {
 137  		return nil, fmt.Errorf("marshal seal: %w", err)
 138  	}
 139  
 140  	// Step 3: Create gift wrap (kind 1059) — encrypt seal with ephemeral + recipient
 141  	ephSecret, err := keys.GenerateSecretKey()
 142  	if err != nil {
 143  		return nil, fmt.Errorf("generate ephemeral key: %w", err)
 144  	}
 145  	ephSigner, err := keys.SecretBytesToSigner(ephSecret)
 146  	if err != nil {
 147  		return nil, fmt.Errorf("create ephemeral signer: %w", err)
 148  	}
 149  	defer ephSigner.Zero()
 150  
 151  	wrapConvKey, err := encryption.GenerateConversationKey(ephSecret, recipientPub)
 152  	if err != nil {
 153  		return nil, fmt.Errorf("gift wrap ECDH: %w", err)
 154  	}
 155  	wrapCiphertext, err := encryption.Encrypt(wrapConvKey, sealJSON, nil)
 156  	if err != nil {
 157  		return nil, fmt.Errorf("gift wrap encrypt: %w", err)
 158  	}
 159  
 160  	giftWrap := &event.E{
 161  		Content:   []byte(wrapCiphertext),
 162  		CreatedAt: randomizeTimestamp(now),
 163  		Kind:      kindGiftWrap,
 164  		Tags: tag.NewS(
 165  			tag.NewFromAny("p", recipientPubHex),
 166  		),
 167  	}
 168  	if err := giftWrap.Sign(ephSigner); err != nil {
 169  		return nil, fmt.Errorf("sign gift wrap: %w", err)
 170  	}
 171  
 172  	log.D.F("created NIP-17 gift wrap for %s (ephemeral pubkey %s)",
 173  		recipientPubHex, hex.Enc(ephSigner.Pub()))
 174  
 175  	return giftWrap, nil
 176  }
 177  
 178  // randomizeTimestamp adds a random offset of +/- 2 days for NIP-59 privacy.
 179  // Uses crypto/rand to prevent timestamp correlation attacks.
 180  func randomizeTimestamp(base int64) int64 {
 181  	const fourDays = 4 * 24 * 60 * 60
 182  	const twoDays = 2 * 24 * 60 * 60
 183  	var buf [8]byte
 184  	if _, err := rand.Read(buf[:]); err != nil {
 185  		// Fallback: use base timestamp unrandomized rather than
 186  		// using a zero buffer which would produce a predictable offset.
 187  		return base
 188  	}
 189  	n := int64(binary.LittleEndian.Uint64(buf[:]))
 190  	if n < 0 {
 191  		n = -n
 192  	}
 193  	offset := n%fourDays - twoDays
 194  	return base + offset
 195  }
 196