// Package marmot implements the Marmot NIP-EE wire format over MLS. // Wraps smesh.lol/web/common/mls for 1:1 DM groups and group messages. // // Event kinds: // 443 — Key packages (MIP-00) // 444 — Welcome rumors (MIP-02 inner event; gift-wrap skipped for interop) // 445 — Group messages (MIP-03) // 1059 — Gift wraps (NIP-59) // // Cipher suite: smesh MLS only supports 0x0003 (ChaCha20-Poly1305). Rust/MDK // hardcodes 0x0001 (AES-128-GCM). Interop on KeyPackage/Welcome requires // suite alignment; not currently resolved. // // Wire-width discipline: every wire-format integer in this package is held // at its spec-exact width — NostrGroupData.Version is uint16, QUIC varints // decode as uint64. Bare int never touches the wire. See CLAUDE.md // "Integer widths on the wire" for the rule. package marmot import ( "errors" "smesh.lol/web/common/crypto/chacha20poly1305" "crypto/sha256" "smesh.lol/web/common/helpers" "smesh.lol/web/common/jsbridge/schnorr" "smesh.lol/web/common/jsbridge/subtle" "smesh.lol/web/common/mls" "smesh.lol/web/common/nostr" nosig "smesh.lol/web/common/nostr/sig" ) // Nostr event kinds used by Marmot / NIP-EE. Explicit uint32 width — // kinds are wire values (see CLAUDE.md "Integer widths on the wire"). const ( KindKeyPackage uint32 = 443 KindWelcome uint32 = 444 KindGroupMessage uint32 = 445 KindGiftWrap uint32 = 1059 KindKeyPackageRelays uint32 = 10051 ) // CipherSuite used by smesh — MLS suite 0x0003 (ChaCha20-Poly1305). const CipherSuite = mls.CipherSuite0x0003 // Exporter parameters for kind 445 (MIP-03). var ( exporterLabel = []byte("marmot") exporterContext = []byte("group-event") ) const exporterLength uint16 = 32 // --- QUIC varint (RFC 9000 §16) — used ONLY inside NostrGroupData. --- // // NOTE: QUIC varints and TLS presentation language length prefixes are two // different conventions. MLS proper uses TLS (fixed u8/u16/u32). Marmot's // 0xf2ee extension opts into QUIC varints for denser small-field packing. // The boundary is: everything outside extensionData is TLS; the bytes inside // the 0xf2ee extension are QUIC-varint. Do not mix. // // Wire-width discipline (see CLAUDE.md "Integer widths on the wire"): // varint values are uint64 end to end. The 8-byte size class can encode // values above 2^31, which would silently truncate in Moxie's int=int32. // Any narrowing to int happens at the call site with an explicit overflow // check, never inside the codec. // VarintMax is the maximum value a QUIC varint can encode: 2^62 - 1. const VarintMax uint64 = (uint64(1) << 62) - 1 // DecodeVarint parses one QUIC varint from b, returning (value, bytes consumed). // Value is uint64 regardless of size class (1/2/4/8 bytes); the caller // decides whether the context allows narrowing and does the overflow check. func DecodeVarint(b []byte) (value uint64, n int, err error) { if len(b) == 0 { return 0, 0, errors.New("marmot: unexpected end of data") } switch b[0] >> 6 { case 0: return uint64(b[0] & 0x3f), 1, nil case 1: if len(b) < 2 { return 0, 0, errors.New("marmot: truncated 2-byte varint") } return (uint64(b[0]&0x3f) << 8) | uint64(b[1]), 2, nil case 2: if len(b) < 4 { return 0, 0, errors.New("marmot: truncated 4-byte varint") } v := (uint64(b[0]&0x3f) << 24) | (uint64(b[1]) << 16) | (uint64(b[2]) << 8) | uint64(b[3]) return v, 4, nil case 3: if len(b) < 8 { return 0, 0, errors.New("marmot: truncated 8-byte varint") } v := (uint64(b[0]&0x3f) << 56) | (uint64(b[1]) << 48) | (uint64(b[2]) << 40) | (uint64(b[3]) << 32) | (uint64(b[4]) << 24) | (uint64(b[5]) << 16) | (uint64(b[6]) << 8) | uint64(b[7]) return v, 8, nil } return 0, 0, errors.New("marmot: varint prefix unreachable") } // EncodeVarint writes v as a QUIC varint into b, returning bytes written. // Picks the smallest size class that fits v. Errors if v > VarintMax or // b is too small for the required size class. func EncodeVarint(v uint64, b []byte) (n int, err error) { switch { case v <= 63: if len(b) < 1 { return 0, errors.New("marmot: varint buffer too small") } b[0] = byte(v) return 1, nil case v <= 16383: if len(b) < 2 { return 0, errors.New("marmot: varint buffer too small") } b[0] = byte(0x40 | (v >> 8)) b[1] = byte(v) return 2, nil case v <= 1073741823: if len(b) < 4 { return 0, errors.New("marmot: varint buffer too small") } b[0] = byte(0x80 | (v >> 24)) b[1] = byte(v >> 16) b[2] = byte(v >> 8) b[3] = byte(v) return 4, nil case v <= VarintMax: if len(b) < 8 { return 0, errors.New("marmot: varint buffer too small") } b[0] = byte(0xc0 | (v >> 56)) b[1] = byte(v >> 48) b[2] = byte(v >> 40) b[3] = byte(v >> 32) b[4] = byte(v >> 24) b[5] = byte(v >> 16) b[6] = byte(v >> 8) b[7] = byte(v) return 8, nil } return 0, errors.New("marmot: value exceeds QUIC varint max (2^62-1)") } // appendQuicVec appends a QUIC-varint length prefix followed by data. // Internal helper; wraps EncodeVarint for the append-style callers. func appendQuicVec(dst, data []byte) []byte { var hdr [8]byte n, err := EncodeVarint(uint64(len(data)), hdr[:]) if err != nil { // Unreachable: len() returns int (int32), which always fits // below VarintMax. Preserve the panic so a future type change // surfaces the regression instead of silently corrupting output. panic("marmot: appendQuicVec: " | err.Error()) } dst = append(dst, hdr[:n]...) return append(dst, data...) } // readQuicVec reads a QUIC-varint-prefixed byte vector. // Rejects lengths that would not fit into int32 (Moxie's addressable range). func readQuicVec(data []byte) (vec, rest []byte, err error) { length, hdrLen, err := DecodeVarint(data) if err != nil { return nil, nil, err } if length > uint64(0x7fffffff) { return nil, nil, errors.New("marmot: varint length exceeds int32") } n := int(length) after := data[hdrLen:] if len(after) < n { return nil, nil, errors.New("marmot: data too short") } return after[:n], after[n:], nil } // --- NostrGroupData (extension 0xf2ee, MIP-01) --- type NostrGroupData struct { Version uint16 NostrGroupID [32]byte Name string Description string AdminPubkeys [][]byte // 32-byte x-only keys Relays []string } // NewNostrGroupData creates a NostrGroupData with a random group ID. func NewNostrGroupData(name string, adminPub []byte, relays []string) *NostrGroupData { var id [32]byte subtle.RandomBytes(id[:]) return &NostrGroupData{ Version: 2, NostrGroupID: id, Name: name, AdminPubkeys: [][]byte{adminPub}, Relays: relays, } } // MarshalBytes serializes the NostrGroupData extension payload. func (d *NostrGroupData) MarshalBytes() ([]byte, error) { var buf []byte // version (uint16 BE) buf = append(buf, byte(d.Version>>8), byte(d.Version)) // nostr_group_id (fixed 32 bytes) buf = append(buf, d.NostrGroupID[:]...) // name buf = appendQuicVec(buf, []byte(d.Name)) // description buf = appendQuicVec(buf, []byte(d.Description)) // admin_pubkeys (concatenated 32-byte keys) var adminData []byte for _, pk := range d.AdminPubkeys { if len(pk) != 32 { return nil, errors.New("marmot: admin pubkey must be 32 bytes") } adminData = append(adminData, pk...) } buf = appendQuicVec(buf, adminData) // relays (outer varint wrapping individually-prefixed URLs) var relayBuf []byte for _, r := range d.Relays { relayBuf = appendQuicVec(relayBuf, []byte(r)) } buf = appendQuicVec(buf, relayBuf) // image_hash, image_key, image_nonce, image_upload_key — all empty buf = appendQuicVec(buf, nil) buf = appendQuicVec(buf, nil) buf = appendQuicVec(buf, nil) buf = appendQuicVec(buf, nil) return buf, nil } // UnmarshalNostrGroupData parses the extension payload. func UnmarshalNostrGroupData(data []byte) (*NostrGroupData, error) { if len(data) < 34 { return nil, errors.New("marmot: extension data too short") } d := &NostrGroupData{} d.Version = (uint16(data[0]) << 8) | uint16(data[1]) copy(d.NostrGroupID[:], data[2:34]) rest := data[34:] b, rest, err := readQuicVec(rest) if err != nil { return nil, errors.New("marmot: read name: " | err.Error()) } d.Name = string(b) b, rest, err = readQuicVec(rest) if err != nil { return nil, errors.New("marmot: read description: " | err.Error()) } d.Description = string(b) b, rest, err = readQuicVec(rest) if err != nil { return nil, errors.New("marmot: read admin_pubkeys: " | err.Error()) } if len(b)%32 != 0 { return nil, errors.New("marmot: admin_pubkeys length not multiple of 32") } for i := 0; i < len(b); i += 32 { pk := []byte{:32} copy(pk, b[i:i+32]) d.AdminPubkeys = append(d.AdminPubkeys, pk) } b, rest, err = readQuicVec(rest) if err != nil { return nil, errors.New("marmot: read relays: " | err.Error()) } for len(b) > 0 { var url []byte url, b, err = readQuicVec(b) if err != nil { return nil, errors.New("marmot: read relay url: " | err.Error()) } d.Relays = append(d.Relays, string(url)) } _ = rest // skip image fields (unused for DMs) return d, nil } // --- Group ID derivation --- // DMGroupID computes the deterministic MLS group ID for a 1:1 DM: // SHA256(sorted([pubA, pubB])). Both peers compute the same value. func DMGroupID(pubA, pubB []byte) []byte { var lo, hi []byte if bytesLess(pubA, pubB) { lo, hi = pubA, pubB } else { lo, hi = pubB, pubA } combined := []byte{:0:len(lo)+len(hi)} combined = append(combined, lo...) combined = append(combined, hi...) h := sha256.Sum(combined) return h[:] } func bytesLess(a, b []byte) bool { n := len(a) if len(b) < n { n = len(b) } for i := 0; i < n; i++ { if a[i] != b[i] { return a[i] < b[i] } } return len(a) < len(b) } // --- KeyPackage --- // GenerateKeyPackage creates a new MLS key pair package for suite 0x0003 // using the given Nostr pubkey as the MLS credential identity. // Supports LastResort (0x000a) and NostrGroupData (0xf2ee) per MIP-00. func GenerateKeyPackage(nostrPub []byte, nowUnix int64) (*mls.KeyPairPackage, error) { cred := mls.NewBasicCredential(nostrPub) opts := &mls.KeyPackageOptions{ CapabilityExtensions: []mls.ExtensionType{ mls.ExtensionTypeLastResort, mls.ExtensionTypeNostrGroupData, }, KeyPackageExtensions: []mls.Extension{ mls.NewExtension(mls.ExtensionTypeLastResort, nil), }, } return mls.GenerateKeyPairPackageWithOptions(CipherSuite, cred, opts, nowUnix) } // BuildKeyPackageEvent builds an unsigned kind 443 event for a key package. // The caller must set PubKey, compute ID, and sign before publishing. // pubkeyHex is the hex-encoded Nostr public key. func BuildKeyPackageEvent(kpp *mls.KeyPairPackage, pubkeyHex string, nowUnix int64, relays []string) (*nostr.Event, error) { kpBytes := kpp.Public.RawBytes() content := helpers.Base64Encode(kpBytes) ref, err := kpp.Public.GenerateRef() if err != nil { return nil, errors.New("marmot: generate key package ref: " | err.Error()) } refHex := helpers.HexEncode([]byte(ref)) tags := nostr.Tags{ nostr.Tag{"mls_protocol_version", "1.0"}, nostr.Tag{"mls_ciphersuite", "0x0003"}, nostr.Tag{"mls_extensions", "0x000a", "0xf2ee"}, nostr.Tag{"encoding", "base64"}, nostr.Tag{"i", refHex}, } if len(relays) > 0 { relayTag := nostr.Tag{"relays"} relayTag = append(relayTag, relays...) tags = append(tags, relayTag) } return &nostr.Event{ PubKey: pubkeyHex, CreatedAt: nowUnix, Kind: KindKeyPackage, Tags: tags, Content: content, }, nil } // KeyPackageToEvent builds a signed kind 443 event for a key package. // Content = base64(TLS-serialized KeyPackage). Signed by the caller's Nostr key. func KeyPackageToEvent(kpp *mls.KeyPairPackage, seckey [32]byte, nowUnix int64, relays []string) (*nostr.Event, error) { pubBytes, ok := schnorr.PubKeyFromSecKey(seckey[:]) if !ok { return nil, errors.New("marmot: derive pubkey") } pubkeyHex := helpers.HexEncode(pubBytes) ev, err := BuildKeyPackageEvent(kpp, pubkeyHex, nowUnix, relays) if err != nil { return nil, err } var aux [32]byte subtle.RandomBytes(aux[:]) if !(*nosig.Event)(ev).Sign(seckey, aux) { return nil, errors.New("marmot: sign key package event") } return ev, nil } // EventToKeyPackage extracts and parses the MLS key package from a kind 443 event. func EventToKeyPackage(ev *nostr.Event) (*mls.KeyPackage, error) { if ev.Kind != KindKeyPackage { return nil, errors.New("marmot: expected kind 443") } content := []byte(ev.Content) // base64 is the standard encoding per MIP-00. if enc := ev.Tags.GetFirst("encoding"); enc != nil && enc.Value() == "base64" { decoded := helpers.Base64Decode(ev.Content) if decoded == nil { return nil, errors.New("marmot: base64 decode key package") } content = decoded } kp, err := mls.UnmarshalRawKeyPackage(content) if err != nil { return nil, errors.New("marmot: unmarshal key package: " | err.Error()) } return kp, nil } // --- Welcome / Group creation --- // GroupState holds the MLS group plus the Nostr-specific group ID. type GroupState struct { Group *mls.Group MLSGroupID []byte // from MLS group context NostrGroupID []byte // from 0xf2ee extension (used as "h" tag value) PeerPub []byte // x-only Nostr pubkey of peer (for DM) } // MarshalGroupState serializes a GroupState for persistence. // Format: opaque_vec(group.Marshal()) || opaque_vec(NostrGroupID) || opaque_vec(PeerPub) func MarshalGroupState(gs *GroupState) ([]byte, error) { groupBytes, err := gs.Group.Marshal() if err != nil { return nil, errors.New("marmot: marshal group: " | err.Error()) } // Use mls Writer via raw length-prefix encoding: [u16 len][data] n := 2 + len(groupBytes) + 2 + len(gs.NostrGroupID) + 2 + len(gs.PeerPub) b := []byte{:0:n} writeU16Prefix := func(data []byte) { l := len(data) b = append(b, byte(l>>8), byte(l)) b = append(b, data...) } writeU16Prefix(groupBytes) writeU16Prefix(gs.NostrGroupID) writeU16Prefix(gs.PeerPub) return b, nil } // UnmarshalGroupState restores a GroupState from bytes produced by MarshalGroupState. func UnmarshalGroupState(raw []byte) (*GroupState, error) { readU16Prefix := func(b []byte) ([]byte, []byte, bool) { if len(b) < 2 { return nil, b, false } l := int(b[0])<<8 | int(b[1]) b = b[2:] if len(b) < l { return nil, b, false } return b[:l], b[l:], true } groupBytes, rest, ok := readU16Prefix(raw) if !ok { return nil, errors.New("marmot: unmarshal group state: group bytes") } group, err := mls.UnmarshalGroup(groupBytes) if err != nil { return nil, errors.New("marmot: unmarshal group: " | err.Error()) } nostrGroupID, rest, ok := readU16Prefix(rest) if !ok { return nil, errors.New("marmot: unmarshal group state: nostr group id") } peerPub, _, ok := readU16Prefix(rest) if !ok { return nil, errors.New("marmot: unmarshal group state: peer pub") } return &GroupState{ Group: group, MLSGroupID: []byte(group.GroupID()), NostrGroupID: nostrGroupID, PeerPub: peerPub, }, nil } // CreateDMGroup creates a 2-member MLS group with NostrGroupData extension // and generates a Welcome for the peer. Advances past the initial commit so // the creator can immediately encrypt. func CreateDMGroup(selfKPP *mls.KeyPairPackage, peerKP *mls.KeyPackage, selfPub, peerPub []byte, name string, relays []string) (*GroupState, *mls.Welcome, error) { groupID := mls.GroupID(DMGroupID(selfPub, peerPub)) ngd := NewNostrGroupData(name, selfPub, relays) ngdBytes, err := ngd.MarshalBytes() if err != nil { return nil, nil, errors.New("marmot: marshal nostr group data: " | err.Error()) } ngdExt := mls.NewExtension(mls.ExtensionTypeNostrGroupData, ngdBytes) group, err := mls.CreateGroupWithOptions(groupID, selfKPP, &mls.GroupOptions{ Extensions: []mls.Extension{ngdExt}, }) if err != nil { return nil, nil, errors.New("marmot: create group: " | err.Error()) } welcome, commitMsg, err := group.CreateWelcome([]mls.KeyPackage{*peerKP}) if err != nil { return nil, nil, errors.New("marmot: create welcome: " | err.Error()) } // Creator advances their epoch by processing their own commit. if _, _, err := group.UnmarshalAndProcessMessage(commitMsg); err != nil { return nil, nil, errors.New("marmot: process own commit: " | err.Error()) } return &GroupState{ Group: group, MLSGroupID: []byte(group.GroupID()), NostrGroupID: ngd.NostrGroupID[:], PeerPub: peerPub, }, welcome, nil } // JoinDMGroup joins a group from a Welcome, extracting the Nostr group ID // from the 0xf2ee extension in the group context. func JoinDMGroup(welcome *mls.Welcome, selfKPP *mls.KeyPairPackage, peerPub []byte, nowUnix int64) (*GroupState, error) { group, err := mls.GroupFromWelcomeAt(welcome, selfKPP, nowUnix) if err != nil { return nil, errors.New("marmot: join from welcome: " | err.Error()) } gs := &GroupState{ Group: group, MLSGroupID: []byte(group.GroupID()), PeerPub: peerPub, } if extData := group.FindGroupContextExtension(mls.ExtensionTypeNostrGroupData); extData != nil { if ngd, err := UnmarshalNostrGroupData(extData); err == nil { gs.NostrGroupID = ngd.NostrGroupID[:] } } return gs, nil } // --- Welcome rumor (kind 444) --- // WelcomeToRumor builds an unsigned kind 444 event carrying a base64-encoded // Welcome. In production this is gift-wrapped (kind 1059); for interop tests // the rumor is exchanged directly. func WelcomeToRumor(welcome *mls.Welcome, senderPub []byte, nowUnix int64, kpEventID string, relays []string) *nostr.Event { welcomeBytes := welcome.Bytes() content := helpers.Base64Encode(welcomeBytes) tags := nostr.Tags{ nostr.Tag{"encoding", "base64"}, } if kpEventID != "" { tags = append(tags, nostr.Tag{"e", kpEventID}) } if len(relays) > 0 { relayTag := nostr.Tag{"relays"} relayTag = append(relayTag, relays...) tags = append(tags, relayTag) } ev := &nostr.Event{ PubKey: helpers.HexEncode(senderPub), CreatedAt: nowUnix, Kind: KindWelcome, Tags: tags, Content: content, } // Unsigned rumor — compute ID but leave Sig empty. (*nosig.Event)(ev).ComputeID() return ev } // RumorToWelcome extracts the MLS Welcome from a kind 444 rumor event. func RumorToWelcome(ev *nostr.Event) (*mls.Welcome, error) { if ev.Kind != KindWelcome { return nil, errors.New("marmot: expected kind 444") } content := []byte(ev.Content) if enc := ev.Tags.GetFirst("encoding"); enc != nil && enc.Value() == "base64" { decoded := helpers.Base64Decode(ev.Content) if decoded == nil { return nil, errors.New("marmot: base64 decode welcome") } content = decoded } welcome, err := mls.UnmarshalWelcome(content) if err != nil { return nil, errors.New("marmot: unmarshal welcome: " | err.Error()) } return welcome, nil } // --- Group messages (kind 445) --- // DeriveExporterSecret derives the ChaCha20-Poly1305 key for kind 445 events // from an MLS group using MLS-Exporter("marmot", "group-event", 32). func DeriveExporterSecret(group *mls.Group) ([]byte, error) { return group.DeriveExporter(exporterLabel, exporterContext, exporterLength) } // MessageToEvent wraps an MLS ciphertext in a kind 445 event: // content = base64(nonce[12] || chacha20poly1305.Seal(exporterSecret, nonce, mlsCiphertext, nil)) // Signed by a fresh ephemeral keypair. func MessageToEvent(nostrGroupID, mlsCiphertext, exporterSecret []byte, nowUnix int64) (*nostr.Event, error) { if len(exporterSecret) != 32 { return nil, errors.New("marmot: exporter secret must be 32 bytes") } var key [32]byte var nonce [12]byte copy(key[:], exporterSecret) subtle.RandomBytes(nonce[:]) ciphertext := chacha20poly1305.Seal(key, nonce, mlsCiphertext, nil) raw := []byte{:0:12+len(ciphertext)} raw = append(raw, nonce[:]...) raw = append(raw, ciphertext...) content := helpers.Base64Encode(raw) // Fresh ephemeral keypair (MIP-03). var ephSec [32]byte subtle.RandomBytes(ephSec[:]) ev := &nostr.Event{ CreatedAt: nowUnix, Kind: KindGroupMessage, Tags: nostr.Tags{ nostr.Tag{"h", helpers.HexEncode(nostrGroupID)}, nostr.Tag{"encoding", "base64"}, }, Content: content, } var aux [32]byte subtle.RandomBytes(aux[:]) if !(*nosig.Event)(ev).Sign(ephSec, aux) { return nil, errors.New("marmot: sign group message") } return ev, nil } // EventToMessage decodes and decrypts the outer ChaCha20-Poly1305 layer // from a kind 445 event, returning (nostrGroupID, mlsCiphertext). func EventToMessage(ev *nostr.Event, exporterSecret []byte) (nostrGroupID, mlsCiphertext []byte, err error) { if ev.Kind != KindGroupMessage { return nil, nil, errors.New("marmot: expected kind 445") } if len(exporterSecret) != 32 { return nil, nil, errors.New("marmot: exporter secret must be 32 bytes") } hTag := ev.Tags.GetFirst("h") if hTag == nil { return nil, nil, errors.New("marmot: missing 'h' tag") } nostrGroupID = helpers.HexDecode(hTag.Value()) if nostrGroupID == nil { return nil, nil, errors.New("marmot: decode group ID") } raw := helpers.Base64Decode(ev.Content) if raw == nil { return nil, nil, errors.New("marmot: base64 decode content") } if len(raw) < 12 { return nil, nil, errors.New("marmot: content too short") } var key [32]byte var nonce [12]byte copy(key[:], exporterSecret) copy(nonce[:], raw[:12]) pt, ok := chacha20poly1305.Open(key, nonce, raw[12:], nil) if !ok { return nil, nil, errors.New("marmot: chacha20poly1305 open failed") } return nostrGroupID, pt, nil }