marmot.mx raw
1 // Package marmot implements the Marmot NIP-EE wire format over MLS.
2 // Wraps smesh.lol/web/common/mls for 1:1 DM groups and group messages.
3 //
4 // Event kinds:
5 // 443 — Key packages (MIP-00)
6 // 444 — Welcome rumors (MIP-02 inner event; gift-wrap skipped for interop)
7 // 445 — Group messages (MIP-03)
8 // 1059 — Gift wraps (NIP-59)
9 //
10 // Cipher suite: smesh MLS only supports 0x0003 (ChaCha20-Poly1305). Rust/MDK
11 // hardcodes 0x0001 (AES-128-GCM). Interop on KeyPackage/Welcome requires
12 // suite alignment; not currently resolved.
13 //
14 // Wire-width discipline: every wire-format integer in this package is held
15 // at its spec-exact width — NostrGroupData.Version is uint16, QUIC varints
16 // decode as uint64. Bare int never touches the wire. See CLAUDE.md
17 // "Integer widths on the wire" for the rule.
18 package marmot
19
20 import (
21 "errors"
22 "smesh.lol/web/common/crypto/chacha20poly1305"
23 "crypto/sha256"
24 "smesh.lol/web/common/helpers"
25 "smesh.lol/web/common/jsbridge/schnorr"
26 "smesh.lol/web/common/jsbridge/subtle"
27 "smesh.lol/web/common/mls"
28 "smesh.lol/web/common/nostr"
29 nosig "smesh.lol/web/common/nostr/sig"
30 )
31
32 // Nostr event kinds used by Marmot / NIP-EE. Explicit uint32 width —
33 // kinds are wire values (see CLAUDE.md "Integer widths on the wire").
34 const (
35 KindKeyPackage uint32 = 443
36 KindWelcome uint32 = 444
37 KindGroupMessage uint32 = 445
38 KindGiftWrap uint32 = 1059
39 KindKeyPackageRelays uint32 = 10051
40 )
41
42 // CipherSuite used by smesh — MLS suite 0x0003 (ChaCha20-Poly1305).
43 const CipherSuite = mls.CipherSuite0x0003
44
45 // Exporter parameters for kind 445 (MIP-03).
46 var (
47 exporterLabel = []byte("marmot")
48 exporterContext = []byte("group-event")
49 )
50
51 const exporterLength uint16 = 32
52
53 // --- QUIC varint (RFC 9000 §16) — used ONLY inside NostrGroupData. ---
54 //
55 // NOTE: QUIC varints and TLS presentation language length prefixes are two
56 // different conventions. MLS proper uses TLS (fixed u8/u16/u32). Marmot's
57 // 0xf2ee extension opts into QUIC varints for denser small-field packing.
58 // The boundary is: everything outside extensionData is TLS; the bytes inside
59 // the 0xf2ee extension are QUIC-varint. Do not mix.
60 //
61 // Wire-width discipline (see CLAUDE.md "Integer widths on the wire"):
62 // varint values are uint64 end to end. The 8-byte size class can encode
63 // values above 2^31, which would silently truncate in Moxie's int=int32.
64 // Any narrowing to int happens at the call site with an explicit overflow
65 // check, never inside the codec.
66
67 // VarintMax is the maximum value a QUIC varint can encode: 2^62 - 1.
68 const VarintMax uint64 = (uint64(1) << 62) - 1
69
70 // DecodeVarint parses one QUIC varint from b, returning (value, bytes consumed).
71 // Value is uint64 regardless of size class (1/2/4/8 bytes); the caller
72 // decides whether the context allows narrowing and does the overflow check.
73 func DecodeVarint(b []byte) (value uint64, n int, err error) {
74 if len(b) == 0 {
75 return 0, 0, errors.New("marmot: unexpected end of data")
76 }
77 switch b[0] >> 6 {
78 case 0:
79 return uint64(b[0] & 0x3f), 1, nil
80 case 1:
81 if len(b) < 2 {
82 return 0, 0, errors.New("marmot: truncated 2-byte varint")
83 }
84 return (uint64(b[0]&0x3f) << 8) | uint64(b[1]), 2, nil
85 case 2:
86 if len(b) < 4 {
87 return 0, 0, errors.New("marmot: truncated 4-byte varint")
88 }
89 v := (uint64(b[0]&0x3f) << 24) | (uint64(b[1]) << 16) |
90 (uint64(b[2]) << 8) | uint64(b[3])
91 return v, 4, nil
92 case 3:
93 if len(b) < 8 {
94 return 0, 0, errors.New("marmot: truncated 8-byte varint")
95 }
96 v := (uint64(b[0]&0x3f) << 56) | (uint64(b[1]) << 48) |
97 (uint64(b[2]) << 40) | (uint64(b[3]) << 32) |
98 (uint64(b[4]) << 24) | (uint64(b[5]) << 16) |
99 (uint64(b[6]) << 8) | uint64(b[7])
100 return v, 8, nil
101 }
102 return 0, 0, errors.New("marmot: varint prefix unreachable")
103 }
104
105 // EncodeVarint writes v as a QUIC varint into b, returning bytes written.
106 // Picks the smallest size class that fits v. Errors if v > VarintMax or
107 // b is too small for the required size class.
108 func EncodeVarint(v uint64, b []byte) (n int, err error) {
109 switch {
110 case v <= 63:
111 if len(b) < 1 {
112 return 0, errors.New("marmot: varint buffer too small")
113 }
114 b[0] = byte(v)
115 return 1, nil
116 case v <= 16383:
117 if len(b) < 2 {
118 return 0, errors.New("marmot: varint buffer too small")
119 }
120 b[0] = byte(0x40 | (v >> 8))
121 b[1] = byte(v)
122 return 2, nil
123 case v <= 1073741823:
124 if len(b) < 4 {
125 return 0, errors.New("marmot: varint buffer too small")
126 }
127 b[0] = byte(0x80 | (v >> 24))
128 b[1] = byte(v >> 16)
129 b[2] = byte(v >> 8)
130 b[3] = byte(v)
131 return 4, nil
132 case v <= VarintMax:
133 if len(b) < 8 {
134 return 0, errors.New("marmot: varint buffer too small")
135 }
136 b[0] = byte(0xc0 | (v >> 56))
137 b[1] = byte(v >> 48)
138 b[2] = byte(v >> 40)
139 b[3] = byte(v >> 32)
140 b[4] = byte(v >> 24)
141 b[5] = byte(v >> 16)
142 b[6] = byte(v >> 8)
143 b[7] = byte(v)
144 return 8, nil
145 }
146 return 0, errors.New("marmot: value exceeds QUIC varint max (2^62-1)")
147 }
148
149 // appendQuicVec appends a QUIC-varint length prefix followed by data.
150 // Internal helper; wraps EncodeVarint for the append-style callers.
151 func appendQuicVec(dst, data []byte) []byte {
152 var hdr [8]byte
153 n, err := EncodeVarint(uint64(len(data)), hdr[:])
154 if err != nil {
155 // Unreachable: len() returns int (int32), which always fits
156 // below VarintMax. Preserve the panic so a future type change
157 // surfaces the regression instead of silently corrupting output.
158 panic("marmot: appendQuicVec: " | err.Error())
159 }
160 dst = append(dst, hdr[:n]...)
161 return append(dst, data...)
162 }
163
164 // readQuicVec reads a QUIC-varint-prefixed byte vector.
165 // Rejects lengths that would not fit into int32 (Moxie's addressable range).
166 func readQuicVec(data []byte) (vec, rest []byte, err error) {
167 length, hdrLen, err := DecodeVarint(data)
168 if err != nil {
169 return nil, nil, err
170 }
171 if length > uint64(0x7fffffff) {
172 return nil, nil, errors.New("marmot: varint length exceeds int32")
173 }
174 n := int(length)
175 after := data[hdrLen:]
176 if len(after) < n {
177 return nil, nil, errors.New("marmot: data too short")
178 }
179 return after[:n], after[n:], nil
180 }
181
182 // --- NostrGroupData (extension 0xf2ee, MIP-01) ---
183
184 type NostrGroupData struct {
185 Version uint16
186 NostrGroupID [32]byte
187 Name string
188 Description string
189 AdminPubkeys [][]byte // 32-byte x-only keys
190 Relays []string
191 }
192
193 // NewNostrGroupData creates a NostrGroupData with a random group ID.
194 func NewNostrGroupData(name string, adminPub []byte, relays []string) *NostrGroupData {
195 var id [32]byte
196 subtle.RandomBytes(id[:])
197 return &NostrGroupData{
198 Version: 2,
199 NostrGroupID: id,
200 Name: name,
201 AdminPubkeys: [][]byte{adminPub},
202 Relays: relays,
203 }
204 }
205
206 // MarshalBytes serializes the NostrGroupData extension payload.
207 func (d *NostrGroupData) MarshalBytes() ([]byte, error) {
208 var buf []byte
209 // version (uint16 BE)
210 buf = append(buf, byte(d.Version>>8), byte(d.Version))
211 // nostr_group_id (fixed 32 bytes)
212 buf = append(buf, d.NostrGroupID[:]...)
213 // name
214 buf = appendQuicVec(buf, []byte(d.Name))
215 // description
216 buf = appendQuicVec(buf, []byte(d.Description))
217 // admin_pubkeys (concatenated 32-byte keys)
218 var adminData []byte
219 for _, pk := range d.AdminPubkeys {
220 if len(pk) != 32 {
221 return nil, errors.New("marmot: admin pubkey must be 32 bytes")
222 }
223 adminData = append(adminData, pk...)
224 }
225 buf = appendQuicVec(buf, adminData)
226 // relays (outer varint wrapping individually-prefixed URLs)
227 var relayBuf []byte
228 for _, r := range d.Relays {
229 relayBuf = appendQuicVec(relayBuf, []byte(r))
230 }
231 buf = appendQuicVec(buf, relayBuf)
232 // image_hash, image_key, image_nonce, image_upload_key — all empty
233 buf = appendQuicVec(buf, nil)
234 buf = appendQuicVec(buf, nil)
235 buf = appendQuicVec(buf, nil)
236 buf = appendQuicVec(buf, nil)
237 return buf, nil
238 }
239
240 // UnmarshalNostrGroupData parses the extension payload.
241 func UnmarshalNostrGroupData(data []byte) (*NostrGroupData, error) {
242 if len(data) < 34 {
243 return nil, errors.New("marmot: extension data too short")
244 }
245 d := &NostrGroupData{}
246 d.Version = (uint16(data[0]) << 8) | uint16(data[1])
247 copy(d.NostrGroupID[:], data[2:34])
248 rest := data[34:]
249
250 b, rest, err := readQuicVec(rest)
251 if err != nil {
252 return nil, errors.New("marmot: read name: " | err.Error())
253 }
254 d.Name = string(b)
255
256 b, rest, err = readQuicVec(rest)
257 if err != nil {
258 return nil, errors.New("marmot: read description: " | err.Error())
259 }
260 d.Description = string(b)
261
262 b, rest, err = readQuicVec(rest)
263 if err != nil {
264 return nil, errors.New("marmot: read admin_pubkeys: " | err.Error())
265 }
266 if len(b)%32 != 0 {
267 return nil, errors.New("marmot: admin_pubkeys length not multiple of 32")
268 }
269 for i := 0; i < len(b); i += 32 {
270 pk := []byte{:32}
271 copy(pk, b[i:i+32])
272 d.AdminPubkeys = append(d.AdminPubkeys, pk)
273 }
274
275 b, rest, err = readQuicVec(rest)
276 if err != nil {
277 return nil, errors.New("marmot: read relays: " | err.Error())
278 }
279 for len(b) > 0 {
280 var url []byte
281 url, b, err = readQuicVec(b)
282 if err != nil {
283 return nil, errors.New("marmot: read relay url: " | err.Error())
284 }
285 d.Relays = append(d.Relays, string(url))
286 }
287
288 _ = rest // skip image fields (unused for DMs)
289 return d, nil
290 }
291
292 // --- Group ID derivation ---
293
294 // DMGroupID computes the deterministic MLS group ID for a 1:1 DM:
295 // SHA256(sorted([pubA, pubB])). Both peers compute the same value.
296 func DMGroupID(pubA, pubB []byte) []byte {
297 var lo, hi []byte
298 if bytesLess(pubA, pubB) {
299 lo, hi = pubA, pubB
300 } else {
301 lo, hi = pubB, pubA
302 }
303 combined := []byte{:0:len(lo)+len(hi)}
304 combined = append(combined, lo...)
305 combined = append(combined, hi...)
306 h := sha256.Sum(combined)
307 return h[:]
308 }
309
310 func bytesLess(a, b []byte) bool {
311 n := len(a)
312 if len(b) < n {
313 n = len(b)
314 }
315 for i := 0; i < n; i++ {
316 if a[i] != b[i] {
317 return a[i] < b[i]
318 }
319 }
320 return len(a) < len(b)
321 }
322
323 // --- KeyPackage ---
324
325 // GenerateKeyPackage creates a new MLS key pair package for suite 0x0003
326 // using the given Nostr pubkey as the MLS credential identity.
327 // Supports LastResort (0x000a) and NostrGroupData (0xf2ee) per MIP-00.
328 func GenerateKeyPackage(nostrPub []byte, nowUnix int64) (*mls.KeyPairPackage, error) {
329 cred := mls.NewBasicCredential(nostrPub)
330 opts := &mls.KeyPackageOptions{
331 CapabilityExtensions: []mls.ExtensionType{
332 mls.ExtensionTypeLastResort,
333 mls.ExtensionTypeNostrGroupData,
334 },
335 KeyPackageExtensions: []mls.Extension{
336 mls.NewExtension(mls.ExtensionTypeLastResort, nil),
337 },
338 }
339 return mls.GenerateKeyPairPackageWithOptions(CipherSuite, cred, opts, nowUnix)
340 }
341
342 // BuildKeyPackageEvent builds an unsigned kind 443 event for a key package.
343 // The caller must set PubKey, compute ID, and sign before publishing.
344 // pubkeyHex is the hex-encoded Nostr public key.
345 func BuildKeyPackageEvent(kpp *mls.KeyPairPackage, pubkeyHex string, nowUnix int64, relays []string) (*nostr.Event, error) {
346 kpBytes := kpp.Public.RawBytes()
347 content := helpers.Base64Encode(kpBytes)
348
349 ref, err := kpp.Public.GenerateRef()
350 if err != nil {
351 return nil, errors.New("marmot: generate key package ref: " | err.Error())
352 }
353 refHex := helpers.HexEncode([]byte(ref))
354
355 tags := nostr.Tags{
356 nostr.Tag{"mls_protocol_version", "1.0"},
357 nostr.Tag{"mls_ciphersuite", "0x0003"},
358 nostr.Tag{"mls_extensions", "0x000a", "0xf2ee"},
359 nostr.Tag{"encoding", "base64"},
360 nostr.Tag{"i", refHex},
361 }
362 if len(relays) > 0 {
363 relayTag := nostr.Tag{"relays"}
364 relayTag = append(relayTag, relays...)
365 tags = append(tags, relayTag)
366 }
367
368 return &nostr.Event{
369 PubKey: pubkeyHex,
370 CreatedAt: nowUnix,
371 Kind: KindKeyPackage,
372 Tags: tags,
373 Content: content,
374 }, nil
375 }
376
377 // KeyPackageToEvent builds a signed kind 443 event for a key package.
378 // Content = base64(TLS-serialized KeyPackage). Signed by the caller's Nostr key.
379 func KeyPackageToEvent(kpp *mls.KeyPairPackage, seckey [32]byte, nowUnix int64, relays []string) (*nostr.Event, error) {
380 pubBytes, ok := schnorr.PubKeyFromSecKey(seckey[:])
381 if !ok {
382 return nil, errors.New("marmot: derive pubkey")
383 }
384 pubkeyHex := helpers.HexEncode(pubBytes)
385
386 ev, err := BuildKeyPackageEvent(kpp, pubkeyHex, nowUnix, relays)
387 if err != nil {
388 return nil, err
389 }
390 var aux [32]byte
391 subtle.RandomBytes(aux[:])
392 if !(*nosig.Event)(ev).Sign(seckey, aux) {
393 return nil, errors.New("marmot: sign key package event")
394 }
395 return ev, nil
396 }
397
398 // EventToKeyPackage extracts and parses the MLS key package from a kind 443 event.
399 func EventToKeyPackage(ev *nostr.Event) (*mls.KeyPackage, error) {
400 if ev.Kind != KindKeyPackage {
401 return nil, errors.New("marmot: expected kind 443")
402 }
403 content := []byte(ev.Content)
404 // base64 is the standard encoding per MIP-00.
405 if enc := ev.Tags.GetFirst("encoding"); enc != nil && enc.Value() == "base64" {
406 decoded := helpers.Base64Decode(ev.Content)
407 if decoded == nil {
408 return nil, errors.New("marmot: base64 decode key package")
409 }
410 content = decoded
411 }
412 kp, err := mls.UnmarshalRawKeyPackage(content)
413 if err != nil {
414 return nil, errors.New("marmot: unmarshal key package: " | err.Error())
415 }
416 return kp, nil
417 }
418
419 // --- Welcome / Group creation ---
420
421 // GroupState holds the MLS group plus the Nostr-specific group ID.
422 type GroupState struct {
423 Group *mls.Group
424 MLSGroupID []byte // from MLS group context
425 NostrGroupID []byte // from 0xf2ee extension (used as "h" tag value)
426 PeerPub []byte // x-only Nostr pubkey of peer (for DM)
427 }
428
429 // MarshalGroupState serializes a GroupState for persistence.
430 // Format: opaque_vec(group.Marshal()) || opaque_vec(NostrGroupID) || opaque_vec(PeerPub)
431 func MarshalGroupState(gs *GroupState) ([]byte, error) {
432 groupBytes, err := gs.Group.Marshal()
433 if err != nil {
434 return nil, errors.New("marmot: marshal group: " | err.Error())
435 }
436 // Use mls Writer via raw length-prefix encoding: [u16 len][data]
437 n := 2 + len(groupBytes) + 2 + len(gs.NostrGroupID) + 2 + len(gs.PeerPub)
438 b := []byte{:0:n}
439 writeU16Prefix := func(data []byte) {
440 l := len(data)
441 b = append(b, byte(l>>8), byte(l))
442 b = append(b, data...)
443 }
444 writeU16Prefix(groupBytes)
445 writeU16Prefix(gs.NostrGroupID)
446 writeU16Prefix(gs.PeerPub)
447 return b, nil
448 }
449
450 // UnmarshalGroupState restores a GroupState from bytes produced by MarshalGroupState.
451 func UnmarshalGroupState(raw []byte) (*GroupState, error) {
452 readU16Prefix := func(b []byte) ([]byte, []byte, bool) {
453 if len(b) < 2 {
454 return nil, b, false
455 }
456 l := int(b[0])<<8 | int(b[1])
457 b = b[2:]
458 if len(b) < l {
459 return nil, b, false
460 }
461 return b[:l], b[l:], true
462 }
463 groupBytes, rest, ok := readU16Prefix(raw)
464 if !ok {
465 return nil, errors.New("marmot: unmarshal group state: group bytes")
466 }
467 group, err := mls.UnmarshalGroup(groupBytes)
468 if err != nil {
469 return nil, errors.New("marmot: unmarshal group: " | err.Error())
470 }
471 nostrGroupID, rest, ok := readU16Prefix(rest)
472 if !ok {
473 return nil, errors.New("marmot: unmarshal group state: nostr group id")
474 }
475 peerPub, _, ok := readU16Prefix(rest)
476 if !ok {
477 return nil, errors.New("marmot: unmarshal group state: peer pub")
478 }
479 return &GroupState{
480 Group: group,
481 MLSGroupID: []byte(group.GroupID()),
482 NostrGroupID: nostrGroupID,
483 PeerPub: peerPub,
484 }, nil
485 }
486
487 // CreateDMGroup creates a 2-member MLS group with NostrGroupData extension
488 // and generates a Welcome for the peer. Advances past the initial commit so
489 // the creator can immediately encrypt.
490 func CreateDMGroup(selfKPP *mls.KeyPairPackage, peerKP *mls.KeyPackage, selfPub, peerPub []byte, name string, relays []string) (*GroupState, *mls.Welcome, error) {
491 groupID := mls.GroupID(DMGroupID(selfPub, peerPub))
492
493 ngd := NewNostrGroupData(name, selfPub, relays)
494 ngdBytes, err := ngd.MarshalBytes()
495 if err != nil {
496 return nil, nil, errors.New("marmot: marshal nostr group data: " | err.Error())
497 }
498 ngdExt := mls.NewExtension(mls.ExtensionTypeNostrGroupData, ngdBytes)
499
500 group, err := mls.CreateGroupWithOptions(groupID, selfKPP, &mls.GroupOptions{
501 Extensions: []mls.Extension{ngdExt},
502 })
503 if err != nil {
504 return nil, nil, errors.New("marmot: create group: " | err.Error())
505 }
506
507 welcome, commitMsg, err := group.CreateWelcome([]mls.KeyPackage{*peerKP})
508 if err != nil {
509 return nil, nil, errors.New("marmot: create welcome: " | err.Error())
510 }
511
512 // Creator advances their epoch by processing their own commit.
513 if _, _, err := group.UnmarshalAndProcessMessage(commitMsg); err != nil {
514 return nil, nil, errors.New("marmot: process own commit: " | err.Error())
515 }
516
517 return &GroupState{
518 Group: group,
519 MLSGroupID: []byte(group.GroupID()),
520 NostrGroupID: ngd.NostrGroupID[:],
521 PeerPub: peerPub,
522 }, welcome, nil
523 }
524
525 // JoinDMGroup joins a group from a Welcome, extracting the Nostr group ID
526 // from the 0xf2ee extension in the group context.
527 func JoinDMGroup(welcome *mls.Welcome, selfKPP *mls.KeyPairPackage, peerPub []byte, nowUnix int64) (*GroupState, error) {
528 group, err := mls.GroupFromWelcomeAt(welcome, selfKPP, nowUnix)
529 if err != nil {
530 return nil, errors.New("marmot: join from welcome: " | err.Error())
531 }
532 gs := &GroupState{
533 Group: group,
534 MLSGroupID: []byte(group.GroupID()),
535 PeerPub: peerPub,
536 }
537 if extData := group.FindGroupContextExtension(mls.ExtensionTypeNostrGroupData); extData != nil {
538 if ngd, err := UnmarshalNostrGroupData(extData); err == nil {
539 gs.NostrGroupID = ngd.NostrGroupID[:]
540 }
541 }
542 return gs, nil
543 }
544
545 // --- Welcome rumor (kind 444) ---
546
547 // WelcomeToRumor builds an unsigned kind 444 event carrying a base64-encoded
548 // Welcome. In production this is gift-wrapped (kind 1059); for interop tests
549 // the rumor is exchanged directly.
550 func WelcomeToRumor(welcome *mls.Welcome, senderPub []byte, nowUnix int64, kpEventID string, relays []string) *nostr.Event {
551 welcomeBytes := welcome.Bytes()
552 content := helpers.Base64Encode(welcomeBytes)
553
554 tags := nostr.Tags{
555 nostr.Tag{"encoding", "base64"},
556 }
557 if kpEventID != "" {
558 tags = append(tags, nostr.Tag{"e", kpEventID})
559 }
560 if len(relays) > 0 {
561 relayTag := nostr.Tag{"relays"}
562 relayTag = append(relayTag, relays...)
563 tags = append(tags, relayTag)
564 }
565
566 ev := &nostr.Event{
567 PubKey: helpers.HexEncode(senderPub),
568 CreatedAt: nowUnix,
569 Kind: KindWelcome,
570 Tags: tags,
571 Content: content,
572 }
573 // Unsigned rumor — compute ID but leave Sig empty.
574 (*nosig.Event)(ev).ComputeID()
575 return ev
576 }
577
578 // RumorToWelcome extracts the MLS Welcome from a kind 444 rumor event.
579 func RumorToWelcome(ev *nostr.Event) (*mls.Welcome, error) {
580 if ev.Kind != KindWelcome {
581 return nil, errors.New("marmot: expected kind 444")
582 }
583 content := []byte(ev.Content)
584 if enc := ev.Tags.GetFirst("encoding"); enc != nil && enc.Value() == "base64" {
585 decoded := helpers.Base64Decode(ev.Content)
586 if decoded == nil {
587 return nil, errors.New("marmot: base64 decode welcome")
588 }
589 content = decoded
590 }
591 welcome, err := mls.UnmarshalWelcome(content)
592 if err != nil {
593 return nil, errors.New("marmot: unmarshal welcome: " | err.Error())
594 }
595 return welcome, nil
596 }
597
598 // --- Group messages (kind 445) ---
599
600 // DeriveExporterSecret derives the ChaCha20-Poly1305 key for kind 445 events
601 // from an MLS group using MLS-Exporter("marmot", "group-event", 32).
602 func DeriveExporterSecret(group *mls.Group) ([]byte, error) {
603 return group.DeriveExporter(exporterLabel, exporterContext, exporterLength)
604 }
605
606 // MessageToEvent wraps an MLS ciphertext in a kind 445 event:
607 // content = base64(nonce[12] || chacha20poly1305.Seal(exporterSecret, nonce, mlsCiphertext, nil))
608 // Signed by a fresh ephemeral keypair.
609 func MessageToEvent(nostrGroupID, mlsCiphertext, exporterSecret []byte, nowUnix int64) (*nostr.Event, error) {
610 if len(exporterSecret) != 32 {
611 return nil, errors.New("marmot: exporter secret must be 32 bytes")
612 }
613 var key [32]byte
614 var nonce [12]byte
615 copy(key[:], exporterSecret)
616 subtle.RandomBytes(nonce[:])
617
618 ciphertext := chacha20poly1305.Seal(key, nonce, mlsCiphertext, nil)
619
620 raw := []byte{:0:12+len(ciphertext)}
621 raw = append(raw, nonce[:]...)
622 raw = append(raw, ciphertext...)
623 content := helpers.Base64Encode(raw)
624
625 // Fresh ephemeral keypair (MIP-03).
626 var ephSec [32]byte
627 subtle.RandomBytes(ephSec[:])
628
629 ev := &nostr.Event{
630 CreatedAt: nowUnix,
631 Kind: KindGroupMessage,
632 Tags: nostr.Tags{
633 nostr.Tag{"h", helpers.HexEncode(nostrGroupID)},
634 nostr.Tag{"encoding", "base64"},
635 },
636 Content: content,
637 }
638 var aux [32]byte
639 subtle.RandomBytes(aux[:])
640 if !(*nosig.Event)(ev).Sign(ephSec, aux) {
641 return nil, errors.New("marmot: sign group message")
642 }
643 return ev, nil
644 }
645
646 // EventToMessage decodes and decrypts the outer ChaCha20-Poly1305 layer
647 // from a kind 445 event, returning (nostrGroupID, mlsCiphertext).
648 func EventToMessage(ev *nostr.Event, exporterSecret []byte) (nostrGroupID, mlsCiphertext []byte, err error) {
649 if ev.Kind != KindGroupMessage {
650 return nil, nil, errors.New("marmot: expected kind 445")
651 }
652 if len(exporterSecret) != 32 {
653 return nil, nil, errors.New("marmot: exporter secret must be 32 bytes")
654 }
655 hTag := ev.Tags.GetFirst("h")
656 if hTag == nil {
657 return nil, nil, errors.New("marmot: missing 'h' tag")
658 }
659 nostrGroupID = helpers.HexDecode(hTag.Value())
660 if nostrGroupID == nil {
661 return nil, nil, errors.New("marmot: decode group ID")
662 }
663
664 raw := helpers.Base64Decode(ev.Content)
665 if raw == nil {
666 return nil, nil, errors.New("marmot: base64 decode content")
667 }
668 if len(raw) < 12 {
669 return nil, nil, errors.New("marmot: content too short")
670 }
671
672 var key [32]byte
673 var nonce [12]byte
674 copy(key[:], exporterSecret)
675 copy(nonce[:], raw[:12])
676
677 pt, ok := chacha20poly1305.Open(key, nonce, raw[12:], nil)
678 if !ok {
679 return nil, nil, errors.New("marmot: chacha20poly1305 open failed")
680 }
681 return nostrGroupID, pt, nil
682 }
683