• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LukaJCB / ts-mls / 20944655171

13 Jan 2026 04:25AM UTC coverage: 95.199% (-0.5%) from 95.727%
20944655171

Pull #200

github

web-flow
Merge eec989c7f into 6f65b753e
Pull Request #200: Use CiphersuiteId instead of CiphersuiteName for internal values

412 of 421 branches covered (97.86%)

Branch coverage included in aggregate %.

193 of 208 new or added lines in 35 files covered. (92.79%)

6 existing lines in 3 files now uncovered.

2364 of 2495 relevant lines covered (94.75%)

72670.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.3
/src/processMessages.ts
1
import { AuthenticatedContentCommit } from "./authenticatedContent.js"
2
import {
3
  ClientState,
4
  addHistoricalReceiverData,
5
  applyProposals,
6
  nextEpochContext,
7
  processProposal,
8
  throwIfDefined,
9
  validateLeafNodeCredentialAndKeyUniqueness,
10
  validateLeafNodeUpdateOrCommit,
11
} from "./clientState.js"
12
import { GroupActiveState } from "./groupActiveState.js"
13
import { applyUpdatePathSecret } from "./createCommit.js"
14
import { CiphersuiteImpl } from "./crypto/ciphersuite.js"
15
import { Kdf, deriveSecret } from "./crypto/kdf.js"
16
import { verifyConfirmationTag } from "./framedContent.js"
17
import { GroupContext } from "./groupContext.js"
18
import { acceptAll, IncomingMessageAction, IncomingMessageCallback } from "./incomingMessageAction.js"
19
import { initializeEpoch } from "./keySchedule.js"
20
import { MlsPrivateMessage, MlsPublicMessage } from "./message.js"
21
import { unprotectPrivateMessage } from "./messageProtection.js"
22
import { unprotectPublicMessage } from "./messageProtectionPublic.js"
23
import { CryptoVerificationError, InternalError, ValidationError } from "./mlsError.js"
24
import { pathToRoot } from "./pathSecrets.js"
25
import { PrivateKeyPath, mergePrivateKeyPaths, toPrivateKeyPath } from "./privateKeyPath.js"
26
import { PrivateMessage } from "./privateMessage.js"
27
import { emptyPskIndex, PskIndex } from "./pskIndex.js"
28
import { PublicMessage } from "./publicMessage.js"
29
import { findBlankLeafNodeIndex, RatchetTree, addLeafNode } from "./ratchetTree.js"
30
import { createSecretTree } from "./secretTree.js"
31
import { getSenderLeafNodeIndex, Sender, senderTypes } from "./sender.js"
32
import { treeHashRoot } from "./treeHash.js"
33
import {
34
  LeafIndex,
35
  leafToNodeIndex,
36
  leafWidth,
37
  NodeIndex,
38
  nodeToLeafIndex,
39
  root,
40
  toLeafIndex,
41
  toNodeIndex,
42
} from "./treemath.js"
43
import { UpdatePath, applyUpdatePath } from "./updatePath.js"
44
import { addToMap } from "./util/addToMap.js"
45
import { WireformatName, wireformats } from "./wireformat.js"
46
import { zeroOutUint8Array } from "./util/byteArray.js"
47
import { contentTypes } from "./contentType.js"
48

49
/** @public */
50
export type ProcessMessageResult =
51
  | {
52
      kind: "newState"
53
      newState: ClientState
54
      actionTaken: IncomingMessageAction
55
      consumed: Uint8Array[]
56
    }
57
  | { kind: "applicationMessage"; message: Uint8Array; newState: ClientState; consumed: Uint8Array[] }
58

59
/**
60
 * Process private message and apply proposal or commit and return the updated ClientState or return an application message
61
 *
62
 * @public
63
 */
64
export async function processPrivateMessage(
65
  state: ClientState,
66
  pm: PrivateMessage,
67
  pskSearch: PskIndex,
68
  cs: CiphersuiteImpl,
69
  callback: IncomingMessageCallback = acceptAll,
70
): Promise<ProcessMessageResult> {
71
  if (pm.epoch < state.groupContext.epoch) {
8,990✔
72
    const receiverData = state.historicalReceiverData.get(pm.epoch)
171✔
73

74
    if (receiverData !== undefined) {
171✔
75
      const result = await unprotectPrivateMessage(
152✔
76
        receiverData.senderDataSecret,
77
        pm,
78
        receiverData.secretTree,
79
        receiverData.ratchetTree,
80
        receiverData.groupContext,
81
        state.clientConfig.keyRetentionConfig,
82
        cs,
83
      )
84

85
      const newHistoricalReceiverData = addToMap(state.historicalReceiverData, pm.epoch, {
152✔
86
        ...receiverData,
87
        secretTree: result.tree,
88
      })
89

90
      const newState = { ...state, historicalReceiverData: newHistoricalReceiverData }
152✔
91

92
      if (result.content.content.contentType === contentTypes.application) {
152✔
93
        return {
133✔
94
          kind: "applicationMessage",
95
          message: result.content.content.applicationData,
96
          newState,
97
          consumed: result.consumed,
98
        }
99
      } else {
100
        throw new ValidationError("Cannot process commit or proposal from former epoch")
19✔
101
      }
102
    } else {
103
      throw new ValidationError("Cannot process message, epoch too old")
19✔
104
    }
105
  }
106

107
  const result = await unprotectPrivateMessage(
8,819✔
108
    state.keySchedule.senderDataSecret,
109
    pm,
110
    state.secretTree,
111
    state.ratchetTree,
112
    state.groupContext,
113
    state.clientConfig.keyRetentionConfig,
114
    cs,
115
  )
116

117
  const updatedState = { ...state, secretTree: result.tree }
8,762✔
118

119
  if (result.content.content.contentType === contentTypes.application) {
8,762✔
120
    return {
5,833✔
121
      kind: "applicationMessage",
122
      message: result.content.content.applicationData,
123
      newState: updatedState,
124
      consumed: result.consumed,
125
    }
126
  } else if (result.content.content.contentType === contentTypes.commit) {
2,929✔
127
    const { newState, actionTaken, consumed } = await processCommit(
2,853✔
128
      updatedState,
129
      result.content as AuthenticatedContentCommit,
130
      "mls_private_message",
131
      pskSearch,
132
      callback,
133
      cs,
134
    ) //todo solve with types
135
    return {
2,853✔
136
      kind: "newState",
137
      newState,
138
      actionTaken,
139
      consumed: [...result.consumed, ...consumed],
140
    }
141
  } else {
142
    const action = callback({
76✔
143
      kind: "proposal",
144
      proposal: {
145
        proposal: result.content.content.proposal,
146
        senderLeafIndex: getSenderLeafNodeIndex(result.content.content.sender),
147
      },
148
    })
149
    if (action === "reject")
76✔
150
      return {
19✔
151
        kind: "newState",
152
        newState: updatedState,
153
        actionTaken: action,
154
        consumed: result.consumed,
155
      }
156
    else
157
      return {
57✔
158
        kind: "newState",
159
        newState: await processProposal(updatedState, result.content, result.content.content.proposal, cs.hash),
160
        actionTaken: action,
161
        consumed: result.consumed,
162
      }
163
  }
164
}
165

166
/** @public */
167
export interface NewStateWithActionTaken {
168
  newState: ClientState
169
  actionTaken: IncomingMessageAction
170
  consumed: Uint8Array[]
171
}
172

173
/** @public */
174
export async function processPublicMessage(
175
  state: ClientState,
176
  pm: PublicMessage,
177
  pskSearch: PskIndex,
178
  cs: CiphersuiteImpl,
179
  callback: IncomingMessageCallback = acceptAll,
180
): Promise<NewStateWithActionTaken> {
181
  if (pm.content.epoch < state.groupContext.epoch) throw new ValidationError("Cannot process message, epoch too old")
2,274✔
182

183
  const content = await unprotectPublicMessage(
2,274✔
184
    state.keySchedule.membershipKey,
185
    state.groupContext,
186
    state.ratchetTree,
187
    pm,
188
    cs,
189
  )
190

191
  if (content.content.contentType === contentTypes.proposal) {
2,274✔
192
    const action = callback({
1,759✔
193
      kind: "proposal",
194
      proposal: { proposal: content.content.proposal, senderLeafIndex: getSenderLeafNodeIndex(content.content.sender) },
195
    })
196
    if (action === "reject")
1,759✔
197
      return {
19✔
198
        newState: state,
199
        actionTaken: action,
200
        consumed: [],
201
      }
202
    else
203
      return {
1,740✔
204
        newState: await processProposal(state, content, content.content.proposal, cs.hash),
205
        actionTaken: action,
206
        consumed: [],
207
      }
208
  } else {
209
    return processCommit(state, content as AuthenticatedContentCommit, "mls_public_message", pskSearch, callback, cs) //todo solve with types
515✔
210
  }
211
}
212

213
async function processCommit(
214
  state: ClientState,
215
  content: AuthenticatedContentCommit,
216
  wireformat: WireformatName,
217
  pskSearch: PskIndex,
218
  callback: IncomingMessageCallback,
219
  cs: CiphersuiteImpl,
220
): Promise<NewStateWithActionTaken> {
221
  if (content.content.epoch !== state.groupContext.epoch) throw new ValidationError("Could not validate epoch")
3,368✔
222

223
  const senderLeafIndex =
224
    content.content.sender.senderType === senderTypes.member ? toLeafIndex(content.content.sender.leafIndex) : undefined
3,368✔
225

226
  const result = await applyProposals(state, content.content.commit.proposals, senderLeafIndex, pskSearch, false, cs)
3,368✔
227

228
  const action = callback({ kind: "commit", senderLeafIndex, proposals: result.allProposals })
3,368✔
229

230
  if (action === "reject") {
3,368✔
231
    return { newState: state, actionTaken: action, consumed: [] }
38✔
232
  }
233

234
  if (content.content.commit.path !== undefined) {
3,330✔
235
    const committerLeafIndex =
236
      senderLeafIndex ??
2,007✔
237
      (result.additionalResult.kind === "externalCommit" ? result.additionalResult.newMemberLeafIndex : undefined)
238

239
    if (committerLeafIndex === undefined)
2,007✔
240
      throw new ValidationError("Cannot verify commit leaf node because no commiter leaf index found")
×
241

242
    throwIfDefined(
2,007✔
243
      await validateLeafNodeUpdateOrCommit(
244
        content.content.commit.path.leafNode,
245
        committerLeafIndex,
246
        state.groupContext,
247
        state.clientConfig.authService,
248
        cs.signature,
249
      ),
250
    )
251
    throwIfDefined(
2,007✔
252
      await validateLeafNodeCredentialAndKeyUniqueness(
253
        result.tree,
254
        content.content.commit.path.leafNode,
255
        committerLeafIndex,
256
      ),
257
    )
258
  }
259

260
  if (result.needsUpdatePath && content.content.commit.path === undefined)
3,330✔
261
    throw new ValidationError("Update path is required")
×
262

263
  const groupContextWithExtensions =
264
    result.additionalResult.kind === "memberCommit" && result.additionalResult.extensions.length > 0
3,330✔
265
      ? { ...state.groupContext, extensions: result.additionalResult.extensions }
266
      : state.groupContext
267

268
  const [pkp, commitSecret, tree] = await applyTreeUpdate(
3,368✔
269
    content.content.commit.path,
270
    content.content.sender,
271
    result.tree,
272
    cs,
273
    state,
274
    groupContextWithExtensions,
275
    result.additionalResult.kind === "memberCommit"
276
      ? result.additionalResult.addedLeafNodes.map((l) => leafToNodeIndex(toLeafIndex(l[0])))
2,010✔
277
      : [findBlankLeafNodeIndex(result.tree) ?? toNodeIndex(result.tree.length + 1)],
278
    cs.kdf,
279
  )
280

281
  const newTreeHash = await treeHashRoot(tree, cs.hash)
3,330✔
282

283
  if (content.auth.contentType !== contentTypes.commit)
3,330✔
NEW
284
    throw new ValidationError("Received content as commit, but not auth") //todo solve this with types?
×
285
  const updatedGroupContext = await nextEpochContext(
3,330✔
286
    groupContextWithExtensions,
287
    wireformat,
288
    content.content,
289
    content.auth.signature,
290
    newTreeHash,
291
    state.confirmationTag,
292
    cs.hash,
293
  )
294

295
  const initSecret =
296
    result.additionalResult.kind === "externalCommit"
3,330✔
297
      ? result.additionalResult.externalInitSecret
298
      : state.keySchedule.initSecret
299

300
  const epochSecrets = await initializeEpoch(initSecret, commitSecret, updatedGroupContext, result.pskSecret, cs.kdf)
3,368✔
301

302
  const confirmationTagValid = await verifyConfirmationTag(
3,330✔
303
    epochSecrets.keySchedule.confirmationKey,
304
    content.auth.confirmationTag,
305
    updatedGroupContext.confirmedTranscriptHash,
306
    cs.hash,
307
  )
308

309
  if (!confirmationTagValid) throw new CryptoVerificationError("Could not verify confirmation tag")
3,330✔
310

311
  const secretTree = await createSecretTree(leafWidth(tree.length), epochSecrets.encryptionSecret, cs.kdf)
3,330✔
312

313
  const suspendedPendingReinit = result.additionalResult.kind === "reinit" ? result.additionalResult.reinit : undefined
3,330✔
314

315
  const groupActiveState: GroupActiveState = result.selfRemoved
3,368✔
316
    ? { kind: "removedFromGroup" }
317
    : suspendedPendingReinit !== undefined
318
      ? { kind: "suspendedPendingReinit", reinit: suspendedPendingReinit }
319
      : { kind: "active" }
320

321
  const [historicalReceiverData, consumedEpochData] = addHistoricalReceiverData(state)
3,368✔
322

323
  zeroOutUint8Array(commitSecret)
3,368✔
324
  zeroOutUint8Array(epochSecrets.joinerSecret)
3,368✔
325
  zeroOutUint8Array(epochSecrets.encryptionSecret)
3,368✔
326

327
  const consumed = [...consumedEpochData, initSecret]
3,368✔
328

329
  return {
3,368✔
330
    newState: {
331
      ...state,
332
      secretTree,
333
      ratchetTree: tree,
334
      privatePath: pkp,
335
      groupContext: updatedGroupContext,
336
      keySchedule: epochSecrets.keySchedule,
337
      confirmationTag: content.auth.confirmationTag,
338
      historicalReceiverData,
339
      unappliedProposals: {},
340
      groupActiveState,
341
    },
342
    actionTaken: action,
343
    consumed,
344
  }
345
}
346

347
async function applyTreeUpdate(
348
  path: UpdatePath | undefined,
349
  sender: Sender,
350
  tree: RatchetTree,
351
  cs: CiphersuiteImpl,
352
  state: ClientState,
353
  groupContext: GroupContext,
354
  excludeNodes: NodeIndex[],
355
  kdf: Kdf,
356
): Promise<[PrivateKeyPath, Uint8Array, RatchetTree]> {
357
  if (path === undefined) return [state.privatePath, new Uint8Array(kdf.size), tree] as const
3,330✔
358
  if (sender.senderType === senderTypes.member) {
2,007✔
359
    const updatedTree = await applyUpdatePath(tree, toLeafIndex(sender.leafIndex), path, cs.hash)
1,931✔
360

361
    const [pkp, commitSecret] = await updatePrivateKeyPath(
1,931✔
362
      updatedTree,
363
      state,
364
      toLeafIndex(sender.leafIndex),
365
      { ...groupContext, treeHash: await treeHashRoot(updatedTree, cs.hash), epoch: groupContext.epoch + 1n },
366
      path,
367
      excludeNodes,
368
      cs,
369
    )
370
    return [pkp, commitSecret, updatedTree] as const
1,931✔
371
  } else {
372
    const [treeWithLeafNode, leafNodeIndex] = addLeafNode(tree, path.leafNode)
76✔
373

374
    const senderLeafIndex = nodeToLeafIndex(leafNodeIndex)
76✔
375
    const updatedTree = await applyUpdatePath(treeWithLeafNode, senderLeafIndex, path, cs.hash, true)
76✔
376

377
    const [pkp, commitSecret] = await updatePrivateKeyPath(
76✔
378
      updatedTree,
379
      state,
380
      senderLeafIndex,
381
      { ...groupContext, treeHash: await treeHashRoot(updatedTree, cs.hash), epoch: groupContext.epoch + 1n },
382
      path,
383
      excludeNodes,
384
      cs,
385
    )
386
    return [pkp, commitSecret, updatedTree] as const
76✔
387
  }
388
}
389

390
async function updatePrivateKeyPath(
391
  tree: RatchetTree,
392
  state: ClientState,
393
  leafNodeIndex: LeafIndex,
394
  groupContext: GroupContext,
395
  path: UpdatePath,
396
  excludeNodes: NodeIndex[],
397
  cs: CiphersuiteImpl,
398
): Promise<[PrivateKeyPath, Uint8Array]> {
399
  const secret = await applyUpdatePathSecret(
2,007✔
400
    tree,
401
    state.privatePath,
402
    leafNodeIndex,
403
    groupContext,
404
    path,
405
    excludeNodes,
406
    cs,
407
  )
408
  const pathSecrets = await pathToRoot(tree, toNodeIndex(secret.nodeIndex), secret.pathSecret, cs.kdf)
2,007✔
409
  const newPkp = mergePrivateKeyPaths(
2,007✔
410
    state.privatePath,
411
    await toPrivateKeyPath(pathSecrets, state.privatePath.leafIndex, cs),
412
  )
413

414
  const rootIndex = root(leafWidth(tree.length))
2,007✔
415
  const rootSecret = pathSecrets[rootIndex]
2,007✔
416
  if (rootSecret === undefined) throw new InternalError("Could not find secret for root")
2,007✔
417

418
  const commitSecret = await deriveSecret(rootSecret, "path", cs.kdf)
2,007✔
419
  return [newPkp, commitSecret] as const
2,007✔
420
}
421

422
/** @public */
423
export async function processMessage(
424
  message: MlsPrivateMessage | MlsPublicMessage,
425
  state: ClientState,
426
  pskIndex: PskIndex,
427
  action: IncomingMessageCallback,
428
  cs: CiphersuiteImpl,
429
): Promise<ProcessMessageResult> {
430
  if (message.wireformat === wireformats.mls_public_message) {
266✔
431
    const result = await processPublicMessage(state, message.publicMessage, pskIndex, cs, action)
114✔
432

433
    return { ...result, kind: "newState" }
114✔
434
  } else return processPrivateMessage(state, message.privateMessage, emptyPskIndex, cs, action)
152✔
435
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc