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