package mls // MLS Group state machine (RFC 9420 §11, §12). // High-level API: create, join, commit, encrypt, decrypt. import "errors" type pendingProposal struct { ref proposalRef proposal *proposal sender leafIndex } // Group is the high-level MLS group state. type Group struct { tree ratchetTree groupContext groupContext interimTranscriptHash []byte pskSecret []byte epochSecret []byte initSecret []byte myLeafIndex leafIndex privTree []hpkePrivateKey signaturePriv signaturePrivateKey pendingProposals []pendingProposal } // Epoch returns the current MLS epoch. func (g *Group) Epoch() uint64 { return g.groupContext.epoch } // GroupID returns the MLS group ID. func (g *Group) GroupID() GroupID { return g.groupContext.groupID } // Members returns the credential identity bytes of every occupied leaf in // the ratchet tree, in leaf-index order. Blank leaves (removed members) are // skipped. For basic credentials in Smesh's use this is the member's nostr // pubkey. Callers use this for p-tag fan-out on kind 444/445 events. func (g *Group) Members() [][]byte { var out [][]byte n := g.tree.numLeaves() for li := leafIndex(0); li < leafIndex(n); li++ { ln := g.tree.getLeaf(li) if ln == nil { continue } if ln.credential.credentialType != credentialTypeBasic { continue } id := []byte{:len(ln.credential.identity)} copy(id, ln.credential.identity) out = append(out, id) } return out } // ExporterSecret derives the exporter secret from the current epoch. func (g *Group) ExporterSecret() ([]byte, error) { return g.groupContext.cipherSuite.deriveSecret(g.epochSecret, secretLabelExporter) } // DeriveExporter derives keying material via MLS exporter (RFC 9420 §8). func (g *Group) DeriveExporter(label, context []byte, length uint16) ([]byte, error) { exporterSecret, err := g.ExporterSecret() if err != nil { return nil, err } return deriveExporter(g.groupContext.cipherSuite, exporterSecret, label, context, length) } // GroupContextExtensions returns the group context extensions. func (g *Group) GroupContextExtensions() []extension { return g.groupContext.extensions } // FindGroupContextExtension returns extension data by type, or nil. func (g *Group) FindGroupContextExtension(t extensionType) []byte { return findExtensionData(g.groupContext.extensions, t) } // --- Serialization --- // Marshal serializes the full Group state for local persistence. // Output contains sensitive key material — encrypt at rest. func (g *Group) Marshal() ([]byte, error) { var w Writer g.groupContext.marshal(&w) g.tree.marshal(&w) w.writeOpaqueVec(g.interimTranscriptHash) w.writeOpaqueVec(g.pskSecret) w.writeOpaqueVec(g.epochSecret) w.writeOpaqueVec(g.initSecret) w.addUint32(uint32(g.myLeafIndex)) w.writeOpaqueVec([]byte(g.signaturePriv)) w.writeVector(len(g.privTree), func(w *Writer, i int) { w.writeOpaqueVec([]byte(g.privTree[i])) }) return w.bytes() } // UnmarshalGroup restores a Group from bytes produced by Marshal. func UnmarshalGroup(raw []byte) (*Group, error) { r := newReader(raw) g := &Group{} if err := g.groupContext.unmarshal(&r); err != nil { return nil, err } if err := g.tree.unmarshal(&r); err != nil { return nil, err } var ok bool g.interimTranscriptHash, ok = r.readOpaqueVec() if !ok { return nil, errUnexpectedEOF } g.pskSecret, ok = r.readOpaqueVec() if !ok { return nil, errUnexpectedEOF } g.epochSecret, ok = r.readOpaqueVec() if !ok { return nil, errUnexpectedEOF } g.initSecret, ok = r.readOpaqueVec() if !ok { return nil, errUnexpectedEOF } v, ok := r.readUint32() if !ok { return nil, errUnexpectedEOF } g.myLeafIndex = leafIndex(v) sigPriv, ok := r.readOpaqueVec() if !ok { return nil, errUnexpectedEOF } g.signaturePriv = signaturePrivateKey(sigPriv) err := r.readVector(func(r *Reader) error { k, ok := r.readOpaqueVec() if !ok { return errUnexpectedEOF } g.privTree = append(g.privTree, hpkePrivateKey(k)) return nil }) if err != nil { return nil, err } return g, nil } // --- Group creation --- // GroupOptions configures group creation. type GroupOptions struct { Extensions []extension } // CreateGroup creates a new single-member group at epoch 0. func CreateGroup(groupID GroupID, kpp *KeyPairPackage) (*Group, error) { return CreateGroupWithOptions(groupID, kpp, nil) } // CreateGroupWithOptions creates a new group with custom extensions. func CreateGroupWithOptions(groupID GroupID, kpp *KeyPairPackage, opts *GroupOptions) (*Group, error) { cs := kpp.Public.cipherSuite tree := ratchetTree([]*node{:1}) tree.add(&kpp.Public.leafNode) privTree := []hpkePrivateKey{:len(tree)} privTree[0] = kpp.Private.EncryptionKey treeHash, err := tree.computeRootTreeHash(cs) if err != nil { return nil, err } confirmedTranscriptHash := []byte{:cs.HashSize()} epochSecret := cs.randomBytes(cs.ExtractSize()) var ctxExts []extension if opts != nil { ctxExts = opts.Extensions } ctx := groupContext{ version: kpp.Public.version, cipherSuite: cs, groupID: groupID, epoch: 0, treeHash: treeHash, confirmedTranscriptHash: confirmedTranscriptHash, extensions: ctxExts, } confirmationTag, err := ctx.signConfirmationTag(epochSecret) if err != nil { return nil, err } interimTH, err := nextInterimTranscriptHash(cs, confirmedTranscriptHash, confirmationTag) if err != nil { return nil, err } pskSecret, err := extractPSKSecret(cs, nil, nil) if err != nil { return nil, err } initSecret, err := cs.deriveSecret(epochSecret, secretLabelInit) if err != nil { return nil, err } return &Group{ tree: tree, privTree: privTree, myLeafIndex: 0, signaturePriv: kpp.Private.SignatureKey, groupContext: ctx, interimTranscriptHash: interimTH, pskSecret: pskSecret, epochSecret: epochSecret, initSecret: initSecret, }, nil } // --- Join from Welcome --- // GroupFromWelcome creates a group from a Welcome message. func GroupFromWelcome(welcome *Welcome, kpp *KeyPairPackage) (*Group, error) { ref, err := kpp.Public.GenerateRef() if err != nil { return nil, err } gs, err := welcome.decryptGroupSecrets(ref, kpp.Private.InitKey) if err != nil { return nil, err } if !gs.verifySingleReinitOrBranchPSK() { return nil, errors.New("mls: more than one reinit/branch PSK") } if len(gs.psks) != 0 { return nil, errors.New("mls: group secret PSKs not supported") } return groupFromSecrets(welcome, kpp, gs, 0) } // GroupFromWelcomeAt is like GroupFromWelcome but with explicit time for lifetime verification. func GroupFromWelcomeAt(welcome *Welcome, kpp *KeyPairPackage, nowUnix int64) (*Group, error) { ref, err := kpp.Public.GenerateRef() if err != nil { return nil, err } gs, err := welcome.decryptGroupSecrets(ref, kpp.Private.InitKey) if err != nil { return nil, err } if !gs.verifySingleReinitOrBranchPSK() { return nil, errors.New("mls: more than one reinit/branch PSK") } if len(gs.psks) != 0 { return nil, errors.New("mls: group secret PSKs not supported") } return groupFromSecrets(welcome, kpp, gs, nowUnix) } func groupFromSecrets(welcome *Welcome, kpp *KeyPairPackage, gs *groupSecrets, nowUnix int64) (*Group, error) { cs := welcome.cipherSuite pskSecret, err := extractPSKSecret(cs, gs.psks, nil) if err != nil { return nil, err } gi, err := welcome.decryptGroupInfo(gs.joinerSecret, pskSecret) if err != nil { return nil, err } rawTree := findExtensionData(gi.extensions, extensionTypeRatchetTree) if rawTree == nil { return nil, errors.New("mls: missing ratchet tree") } var tree ratchetTree if err := unmarshalRaw(rawTree, &tree); err != nil { return nil, err } signerNode := tree.getLeaf(gi.signer) if signerNode == nil { return nil, errors.New("mls: signer node is blank") } if !gi.verifySignature(signerNode.signatureKey) { return nil, errors.New("mls: group info signature verification failed") } if !gi.verifyConfirmationTag(gs.joinerSecret, pskSecret) { return nil, errors.New("mls: confirmation tag verification failed") } if gi.groupContext.cipherSuite != cs { return nil, errors.New("mls: group info cipher suite mismatch") } if err := tree.verifyIntegrity(&gi.groupContext, nowUnix); err != nil { return nil, err } ctx := gi.groupContext epochSecret, err := ctx.extractEpochSecret(gs.joinerSecret, pskSecret) if err != nil { return nil, err } initSecret, err := cs.deriveSecret(epochSecret, secretLabelInit) if err != nil { return nil, err } interimTH, err := nextInterimTranscriptHash(cs, ctx.confirmedTranscriptHash, gi.confirmationTag) if err != nil { return nil, err } myLI, ok := tree.findLeaf(&kpp.Public.leafNode) if !ok { return nil, errors.New("mls: cannot find my leaf node in tree") } privTree := []hpkePrivateKey{:len(tree)} privTree[int(myLI.nodeIndex())] = kpp.Private.EncryptionKey if gs.pathSecret != nil { ancestor := commonAncestor(myLI.nodeIndex(), gi.signer.nodeIndex()) if err := processPathSecret(cs, tree, privTree, gs.pathSecret, ancestor); err != nil { return nil, err } } return &Group{ tree: tree, groupContext: ctx, interimTranscriptHash: interimTH, pskSecret: pskSecret, epochSecret: epochSecret, initSecret: initSecret, myLeafIndex: myLI, privTree: privTree, signaturePriv: kpp.Private.SignatureKey, }, nil } func processPathSecret(cs CipherSuite, tree ratchetTree, privTree []hpkePrivateKey, pathSecret []byte, ni nodeIndex) error { nodePriv, err := nodePrivFromPathSecret(cs, pathSecret, tree.get(ni).encryptionKey()) if err != nil { return err } privTree[int(ni)] = nodePriv for { var ok bool ni, ok = tree.numLeaves().parent(ni) if !ok { break } pathSecret, err = cs.deriveSecret(pathSecret, []byte("path")) if err != nil { return err } nodePriv, err = nodePrivFromPathSecret(cs, pathSecret, tree.get(ni).encryptionKey()) if err != nil { return err } privTree[int(ni)] = nodePriv } return nil } // --- Message processing --- // UnmarshalAndProcessMessage decodes and processes an MLS message. // Returns decrypted application data if applicable. func (g *Group) UnmarshalAndProcessMessage(raw []byte) (plaintext []byte, selfSent bool, err error) { var msg mlsMessage if err := unmarshalRaw(raw, &msg); err != nil { return nil, false, err } switch msg.wireFormat { case wireFormatMLSPublicMessage: return nil, false, g.processPublicMessage(msg.publicMessage) case wireFormatMLSPrivateMessage: return g.processPrivateMessage(msg.privateMessage) default: return nil, false, errors.New("mls: unsupported wire format") } } func (g *Group) processPublicMessage(pubMsg *publicMessage) error { authContent, err := g.verifyPublicMessage(pubMsg) if err != nil { return err } switch authContent.content.contentType { case contentTypeProposal: return g.processProposal(authContent) case contentTypeCommit: return g.processCommit(authContent, nil, nil, 0) case contentTypeApplication: return errors.New("mls: application content must be encrypted") default: return errors.New("mls: unsupported content type") } } func (g *Group) verifyPublicMessage(pubMsg *publicMessage) (*authenticatedContent, error) { if !pubMsg.content.groupID.equal(g.groupContext.groupID) { return nil, errors.New("mls: group ID mismatch") } if pubMsg.content.epoch != g.groupContext.epoch { return nil, errors.New("mls: epoch mismatch") } if pubMsg.content.sender.senderType != senderTypeMember { return nil, errors.New("mls: unsupported sender type") } senderLI := pubMsg.content.sender.leafIndex senderNode := g.tree.getLeaf(senderLI) if senderNode == nil { return nil, errors.New("mls: blank sender leaf node") } authContent := pubMsg.authenticatedContent() if !authContent.verifySignature(senderNode.signatureKey, &g.groupContext) { return nil, errors.New("mls: public message signature verification failed") } membershipKey, err := g.groupContext.cipherSuite.deriveSecret(g.epochSecret, secretLabelMembership) if err != nil { return nil, err } if !pubMsg.verifyMembershipTag(membershipKey, &g.groupContext) { return nil, errors.New("mls: membership tag verification failed") } return authContent, nil } func (g *Group) processPrivateMessage(privMsg *privateMessage) ([]byte, bool, error) { cs := g.groupContext.cipherSuite if !privMsg.groupID.equal(g.groupContext.groupID) { return nil, false, errors.New("mls: group ID mismatch") } if privMsg.epoch != g.groupContext.epoch { return nil, false, errors.New("mls: epoch mismatch") } senderDataSecret, err := cs.deriveSecret(g.epochSecret, secretLabelSenderData) if err != nil { return nil, false, err } sd, err := privMsg.decryptSenderData(cs, senderDataSecret) if err != nil { return nil, false, err } encSecret, err := cs.deriveSecret(g.epochSecret, secretLabelEncryption) if err != nil { return nil, false, err } secTree, err := deriveSecretTree(cs, g.tree.numLeaves(), encSecret) if err != nil { return nil, false, err } label := ratchetLabelFromContentType(privMsg.contentType) secret, err := secTree.deriveRatchetRoot(cs, sd.leafIndex.nodeIndex(), label) if err != nil { return nil, false, err } // Ratchet to the right generation for secret.generation != sd.generation { secret, err = secret.deriveNext(cs) if err != nil { return nil, false, err } } privContent, err := privMsg.decryptContent(cs, secret, sd.reuseGuard) if err != nil { return nil, false, err } signerNode := g.tree.getLeaf(sd.leafIndex) if signerNode == nil { return nil, false, errors.New("mls: signer node is blank") } authContent := privMsg.authenticatedContent(sd, privContent) if !authContent.verifySignature(signerNode.signatureKey, &g.groupContext) { return nil, false, errors.New("mls: private message signature verification failed") } selfSent := sd.leafIndex == g.myLeafIndex switch authContent.content.contentType { case contentTypeProposal: return nil, false, g.processProposal(authContent) case contentTypeCommit: return nil, false, g.processCommit(authContent, nil, nil, 0) case contentTypeApplication: return authContent.content.applicationData, selfSent, nil default: return nil, false, errors.New("mls: unsupported content type") } } func (g *Group) processProposal(authContent *authenticatedContent) error { ref, err := authContent.generateProposalRef(g.groupContext.cipherSuite) if err != nil { return err } g.pendingProposals = append(g.pendingProposals, pendingProposal{ ref: ref, proposal: authContent.content.proposal, sender: authContent.content.sender.leafIndex, }) return nil } func (g *Group) processCommit(authContent *authenticatedContent, pskIDs []preSharedKeyID, psks [][]byte, nowUnix int64) error { cs := g.groupContext.cipherSuite senderLI := authContent.content.sender.leafIndex cmt := authContent.content.commit proposals, senders, err := resolveProposals(cmt.proposals, senderLI, g.pendingProposals) if err != nil { return err } if err := verifyProposalList(proposals, senders, senderLI); err != nil { return err } for _, prop := range proposals { if prop.proposalType == proposalTypeAdd { if err := prop.add.keyPackage.verify(&g.groupContext); err != nil { return err } } } if proposalListNeedsPath(proposals) && cmt.path == nil { return errors.New("mls: commit missing required update path") } newCtx := g.groupContext newCtx.epoch++ newTree := g.tree.copy() newTree.apply(proposals, senders) newPrivTree := []hpkePrivateKey{:len(newTree)} for i := range g.tree { if i < len(newPrivTree) { newPrivTree[i] = g.privTree[i] } } commitSecret := []byte{:cs.ExtractSize()} if cmt.path != nil { if cmt.path.leafNode.leafNodeSource != leafNodeSourceCommit { return errors.New("mls: commit path leaf source must be commit") } senderNode := newTree.getLeaf(senderLI) sigKeys, encKeys := newTree.keys() delete(sigKeys, string(senderNode.signatureKey)) err := cmt.path.leafNode.verify(&leafNodeVerifyOptions{ cipherSuite: cs, groupID: g.groupContext.groupID, leafIndex: senderLI, supportedCreds: newTree.supportedCreds(), signatureKeys: sigKeys, encryptionKeys: encKeys, nowUnix: nowUnix, }) if err != nil { return err } for _, upNode := range cmt.path.nodes { if encKeys[string(upNode.encryptionKey)] { return errors.New("mls: update path encryption key already in tree") } } if err := newTree.mergeUpdatePath(cs, senderLI, cmt.path); err != nil { return err } newCtx.treeHash, err = newTree.computeRootTreeHash(cs) if err != nil { return err } commitSecret, err = newTree.decryptPathSecrets(cs, &newCtx, senderLI, g.myLeafIndex, cmt.path, newPrivTree) if err != nil { return err } } else { newCtx.treeHash, err = newTree.computeRootTreeHash(cs) if err != nil { return err } } newCtx.confirmedTranscriptHash, err = authContent.confirmedTranscriptHashInput().hashValue(cs, g.interimTranscriptHash) if err != nil { return err } newInterimTH, err := nextInterimTranscriptHash(cs, newCtx.confirmedTranscriptHash, authContent.auth.confirmationTag) if err != nil { return err } newJoinerSecret, err := newCtx.extractJoinerSecret(g.initSecret, commitSecret) if err != nil { return err } newPSKSecret, err := extractPSKSecret(cs, pskIDs, psks) if err != nil { return err } newEpochSecret, err := newCtx.extractEpochSecret(newJoinerSecret, newPSKSecret) if err != nil { return err } newInitSecret, err := cs.deriveSecret(newEpochSecret, secretLabelInit) if err != nil { return err } g.tree = newTree g.privTree = newPrivTree g.groupContext = newCtx g.interimTranscriptHash = newInterimTH g.pskSecret = newPSKSecret g.epochSecret = newEpochSecret g.initSecret = newInitSecret g.pendingProposals = nil return nil } func resolveProposals(propOrRefs []proposalOrRef, senderLI leafIndex, pending []pendingProposal) ([]proposal, []leafIndex, error) { var proposals []proposal var senders []leafIndex for _, por := range propOrRefs { switch por.typ { case proposalOrRefTypeProposal: proposals = append(proposals, *por.proposal) senders = append(senders, senderLI) case proposalOrRefTypeReference: found := false for _, pp := range pending { if pp.ref.equal(por.reference) { found = true proposals = append(proposals, *pp.proposal) senders = append(senders, pp.sender) break } } if !found { return nil, nil, errors.New("mls: proposal reference not found") } } } return proposals, senders, nil } // --- Welcome creation --- // CreateWelcome creates a Welcome message inviting new members. // Returns the Welcome and the raw commit message for existing members. func (g *Group) CreateWelcome(keyPkgs []KeyPackage) (*Welcome, []byte, error) { cs := g.groupContext.cipherSuite proposals := []proposal{:len(keyPkgs)} propOrRefs := []proposalOrRef{:len(keyPkgs)} for i, kp := range keyPkgs { proposals[i] = proposal{ proposalType: proposalTypeAdd, add: &add{keyPackage: kp}, } propOrRefs[i] = proposalOrRef{ typ: proposalOrRefTypeProposal, proposal: &proposals[i], } } cmt := commit{proposals: propOrRefs} newCtx := g.groupContext newCtx.epoch++ newTree := g.tree.copy() senders := []leafIndex{:len(proposals)} for i := range senders { senders[i] = g.myLeafIndex } newTree.apply(proposals, senders) var err error newCtx.treeHash, err = newTree.computeRootTreeHash(cs) if err != nil { return nil, nil, err } commitSecret := []byte{:cs.ExtractSize()} pskSecret, err := extractPSKSecret(cs, nil, nil) if err != nil { return nil, nil, err } fc := framedContent{ groupID: g.groupContext.groupID, epoch: g.groupContext.epoch, sender: sender{ senderType: senderTypeMember, leafIndex: g.myLeafIndex, }, contentType: contentTypeCommit, commit: &cmt, } // Sign as private message (default) privContent, err := signPrivateMessageContent(cs, g.signaturePriv, &fc, &g.groupContext) if err != nil { return nil, nil, err } authContent := privContent.authenticatedContent(&fc) authData := &privContent.auth newCtx.confirmedTranscriptHash, err = authContent.confirmedTranscriptHashInput().hashValue(cs, g.interimTranscriptHash) if err != nil { return nil, nil, err } joinerSecret, err := newCtx.extractJoinerSecret(g.initSecret, commitSecret) if err != nil { return nil, nil, err } epochSecret, err := newCtx.extractEpochSecret(joinerSecret, pskSecret) if err != nil { return nil, nil, err } confirmationTag, err := newCtx.signConfirmationTag(epochSecret) if err != nil { return nil, nil, err } authData.confirmationTag = confirmationTag rawTree, err := marshalRaw(newTree) if err != nil { return nil, nil, err } newGI := groupInfo{ groupContext: newCtx, confirmationTag: confirmationTag, signer: g.myLeafIndex, extensions: []extension{ { extensionType: extensionTypeRatchetTree, extensionData: rawTree, }, }, } if err := newGI.sign(g.signaturePriv); err != nil { return nil, nil, err } encGI, err := newGI.encrypt(joinerSecret, pskSecret) if err != nil { return nil, nil, err } gSec := groupSecrets{joinerSecret: joinerSecret} encSecrets := []encryptedGroupSecrets{:len(keyPkgs)} for i, kp := range keyPkgs { ref, err := kp.GenerateRef() if err != nil { return nil, nil, err } encGS, err := gSec.encrypt(cs, kp.initKey, encGI) if err != nil { return nil, nil, err } encSecrets[i] = encryptedGroupSecrets{ newMember: ref, encryptedGroupSecrets: *encGS, } } rawMsg, err := g.encryptPrivateMessage(&fc, privContent) if err != nil { return nil, nil, err } return &Welcome{ cipherSuite: cs, secrets: encSecrets, encryptedGroupInfo: encGI, }, rawMsg, nil } // --- Application messages --- // CreateApplicationMessage encrypts application data for the group. func (g *Group) CreateApplicationMessage(data []byte) ([]byte, error) { cs := g.groupContext.cipherSuite fc := framedContent{ groupID: g.groupContext.groupID, epoch: g.groupContext.epoch, sender: sender{ senderType: senderTypeMember, leafIndex: g.myLeafIndex, }, contentType: contentTypeApplication, applicationData: data, } privContent, err := signPrivateMessageContent(cs, g.signaturePriv, &fc, &g.groupContext) if err != nil { return nil, err } return g.encryptPrivateMessage(&fc, privContent) } func (g *Group) encryptPrivateMessage(fc *framedContent, privContent *privateMessageContent) ([]byte, error) { cs := g.groupContext.cipherSuite sd, err := newSenderData(g.myLeafIndex, 0) if err != nil { return nil, err } encSecret, err := cs.deriveSecret(g.epochSecret, secretLabelEncryption) if err != nil { return nil, err } secTree, err := deriveSecretTree(cs, g.tree.numLeaves(), encSecret) if err != nil { return nil, err } label := ratchetLabelFromContentType(fc.contentType) secret, err := secTree.deriveRatchetRoot(cs, g.myLeafIndex.nodeIndex(), label) if err != nil { return nil, err } senderDataSecret, err := cs.deriveSecret(g.epochSecret, secretLabelSenderData) if err != nil { return nil, err } privMsg, err := encryptPrivateMessage(cs, secret, senderDataSecret, fc, privContent, sd) if err != nil { return nil, err } return marshalRaw(&mlsMessage{ version: protocolVersionMLS10, wireFormat: wireFormatMLSPrivateMessage, privateMessage: privMsg, }) } // signPublicMessageMembershipTag signs and wraps a public message. func (g *Group) signPublicMessageMembershipTag(pubMsg *publicMessage) ([]byte, error) { cs := g.groupContext.cipherSuite membershipKey, err := cs.deriveSecret(g.epochSecret, secretLabelMembership) if err != nil { return nil, err } if err := pubMsg.signMembershipTag(cs, membershipKey, &g.groupContext); err != nil { return nil, err } return marshalRaw(&mlsMessage{ version: protocolVersionMLS10, wireFormat: wireFormatMLSPublicMessage, publicMessage: pubMsg, }) }