group.go raw

   1  package marmot
   2  
   3  import (
   4  	"crypto/sha256"
   5  	"fmt"
   6  	"sort"
   7  
   8  	"github.com/emersion/go-mls"
   9  )
  10  
  11  // cipherSuite is the MLS cipher suite used by Marmot. Ed25519 signing with
  12  // X25519 DHKEM, AES-128-GCM encryption, and SHA-256 hashing.
  13  var cipherSuite = mls.CipherSuiteMLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
  14  
  15  // GroupState holds the MLS group state for a 1:1 DM conversation.
  16  type GroupState struct {
  17  	GroupID  []byte
  18  	PeerPub  []byte // 32-byte x-only Nostr pubkey of the peer
  19  	group    *mls.Group
  20  	mlsBytes []byte // serialized MLS state for persistence
  21  }
  22  
  23  // DMGroupID derives a deterministic group ID for a 1:1 DM between two pubkeys.
  24  // The ID is SHA256(sort(pubA, pubB)) so both sides compute the same value.
  25  func DMGroupID(pubA, pubB []byte) []byte {
  26  	pair := [2][]byte{pubA, pubB}
  27  	sort.Slice(pair[:], func(i, j int) bool {
  28  		for k := 0; k < len(pair[i]) && k < len(pair[j]); k++ {
  29  			if pair[i][k] != pair[j][k] {
  30  				return pair[i][k] < pair[j][k]
  31  			}
  32  		}
  33  		return len(pair[i]) < len(pair[j])
  34  	})
  35  	h := sha256.New()
  36  	h.Write(pair[0])
  37  	h.Write(pair[1])
  38  	return h.Sum(nil)
  39  }
  40  
  41  // CreateDMGroup creates a new 2-member MLS group and generates a Welcome
  42  // message for the peer. Returns the group state, the welcome (to send to peer),
  43  // and the serialized commit message.
  44  func CreateDMGroup(selfKPP *mls.KeyPairPackage, peerKP *mls.KeyPackage, selfPub, peerPub []byte) (*GroupState, *mls.Welcome, []byte, error) {
  45  	groupID := DMGroupID(selfPub, peerPub)
  46  
  47  	group, err := mls.CreateGroup(groupID, selfKPP)
  48  	if err != nil {
  49  		return nil, nil, nil, fmt.Errorf("mls create group: %w", err)
  50  	}
  51  
  52  	welcome, commitMsg, err := group.CreateWelcome([]mls.KeyPackage{*peerKP})
  53  	if err != nil {
  54  		return nil, nil, nil, fmt.Errorf("mls create welcome: %w", err)
  55  	}
  56  
  57  	// The creator must process the commit to advance their own epoch
  58  	if _, err := group.UnmarshalAndProcessMessage(commitMsg); err != nil {
  59  		return nil, nil, nil, fmt.Errorf("process own commit: %w", err)
  60  	}
  61  
  62  	gs := &GroupState{
  63  		GroupID: groupID,
  64  		PeerPub: peerPub,
  65  		group:   group,
  66  	}
  67  	return gs, welcome, welcome.Bytes(), nil
  68  }
  69  
  70  // JoinDMGroup joins a group from a received Welcome message.
  71  func JoinDMGroup(welcome *mls.Welcome, selfKPP *mls.KeyPairPackage, peerPub []byte) (*GroupState, error) {
  72  	group, err := mls.GroupFromWelcome(welcome, selfKPP)
  73  	if err != nil {
  74  		return nil, fmt.Errorf("mls join from welcome: %w", err)
  75  	}
  76  
  77  	// Derive the group ID from the MLS group
  78  	gs := &GroupState{
  79  		PeerPub: peerPub,
  80  		group:   group,
  81  	}
  82  	return gs, nil
  83  }
  84  
  85  // Encrypt encrypts a plaintext message within the MLS group.
  86  func (gs *GroupState) Encrypt(plaintext []byte) ([]byte, error) {
  87  	if gs.group == nil {
  88  		return nil, fmt.Errorf("group not initialized")
  89  	}
  90  	return gs.group.CreateApplicationMessage(plaintext)
  91  }
  92  
  93  // Decrypt decrypts a ciphertext message received from the MLS group.
  94  func (gs *GroupState) Decrypt(ciphertext []byte) ([]byte, error) {
  95  	if gs.group == nil {
  96  		return nil, fmt.Errorf("group not initialized")
  97  	}
  98  	plaintext, err := gs.group.UnmarshalAndProcessMessage(ciphertext)
  99  	if err != nil {
 100  		return nil, fmt.Errorf("mls decrypt: %w", err)
 101  	}
 102  	return plaintext, nil
 103  }
 104