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