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