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

LukaJCB / ts-mls / 20887934276

11 Jan 2026 02:12AM UTC coverage: 95.727% (+0.06%) from 95.665%
20887934276

push

github

LukaJCB
Update to vitest v4

409 of 417 branches covered (98.08%)

Branch coverage included in aggregate %.

2369 of 2485 relevant lines covered (95.33%)

80736.06 hits per line

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

98.18
/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 } 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 } from "./wireformat.js"
46
import { zeroOutUint8Array } from "./util/byteArray.js"
47

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

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

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

81
      const newHistoricalReceiverData = addToMap(state.historicalReceiverData, pm.epoch, {
152✔
82
        ...receiverData,
83
        secretTree: result.tree,
84
      })
85

86
      const newState = { ...state, historicalReceiverData: newHistoricalReceiverData }
152✔
87

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

103
  const result = await unprotectPrivateMessage(
8,819✔
104
    state.keySchedule.senderDataSecret,
105
    pm,
106
    state.secretTree,
107
    state.ratchetTree,
108
    state.groupContext,
109
    state.clientConfig.keyRetentionConfig,
110
    cs,
111
  )
112

113
  const updatedState = { ...state, secretTree: result.tree }
8,762✔
114

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

162
export interface NewStateWithActionTaken {
163
  newState: ClientState
164
  actionTaken: IncomingMessageAction
165
  consumed: Uint8Array[]
166
}
167

168
export async function processPublicMessage(
169
  state: ClientState,
170
  pm: PublicMessage,
171
  pskSearch: PskIndex,
172
  cs: CiphersuiteImpl,
173
  callback: IncomingMessageCallback = acceptAll,
174
): Promise<NewStateWithActionTaken> {
175
  if (pm.content.epoch < state.groupContext.epoch) throw new ValidationError("Cannot process message, epoch too old")
2,274✔
176

177
  const content = await unprotectPublicMessage(
2,274✔
178
    state.keySchedule.membershipKey,
179
    state.groupContext,
180
    state.ratchetTree,
181
    pm,
182
    cs,
183
  )
184

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

207
async function processCommit(
208
  state: ClientState,
209
  content: AuthenticatedContentCommit,
210
  wireformat: WireformatName,
211
  pskSearch: PskIndex,
212
  callback: IncomingMessageCallback,
213
  cs: CiphersuiteImpl,
214
): Promise<NewStateWithActionTaken> {
215
  if (content.content.epoch !== state.groupContext.epoch) throw new ValidationError("Could not validate epoch")
3,368✔
216

217
  const senderLeafIndex =
218
    content.content.sender.senderType === "member" ? toLeafIndex(content.content.sender.leafIndex) : undefined
3,368✔
219

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

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

224
  if (action === "reject") {
3,368✔
225
    return { newState: state, actionTaken: action, consumed: [] }
38✔
226
  }
227

228
  if (content.content.commit.path !== undefined) {
3,330✔
229
    const committerLeafIndex =
230
      senderLeafIndex ??
2,007✔
231
      (result.additionalResult.kind === "externalCommit" ? result.additionalResult.newMemberLeafIndex : undefined)
232

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

236
    throwIfDefined(
2,007✔
237
      await validateLeafNodeUpdateOrCommit(
238
        content.content.commit.path.leafNode,
239
        committerLeafIndex,
240
        state.groupContext,
241
        state.clientConfig.authService,
242
        cs.signature,
243
      ),
244
    )
245
    throwIfDefined(
2,007✔
246
      await validateLeafNodeCredentialAndKeyUniqueness(
247
        result.tree,
248
        content.content.commit.path.leafNode,
249
        committerLeafIndex,
250
      ),
251
    )
252
  }
253

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

257
  const groupContextWithExtensions =
258
    result.additionalResult.kind === "memberCommit" && result.additionalResult.extensions.length > 0
3,330✔
259
      ? { ...state.groupContext, extensions: result.additionalResult.extensions }
260
      : state.groupContext
261

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

275
  const newTreeHash = await treeHashRoot(tree, cs.hash)
3,330✔
276

277
  if (content.auth.contentType !== "commit") throw new ValidationError("Received content as commit, but not auth") //todo solve this with types?
3,330✔
278
  const updatedGroupContext = await nextEpochContext(
3,330✔
279
    groupContextWithExtensions,
280
    wireformat,
281
    content.content,
282
    content.auth.signature,
283
    newTreeHash,
284
    state.confirmationTag,
285
    cs.hash,
286
  )
287

288
  const initSecret =
289
    result.additionalResult.kind === "externalCommit"
3,330✔
290
      ? result.additionalResult.externalInitSecret
291
      : state.keySchedule.initSecret
292

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

295
  const confirmationTagValid = await verifyConfirmationTag(
3,330✔
296
    epochSecrets.keySchedule.confirmationKey,
297
    content.auth.confirmationTag,
298
    updatedGroupContext.confirmedTranscriptHash,
299
    cs.hash,
300
  )
301

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

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

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

308
  const groupActiveState: GroupActiveState = result.selfRemoved
3,368✔
309
    ? { kind: "removedFromGroup" }
310
    : suspendedPendingReinit !== undefined
311
      ? { kind: "suspendedPendingReinit", reinit: suspendedPendingReinit }
312
      : { kind: "active" }
313

314
  const [historicalReceiverData, consumedEpochData] = addHistoricalReceiverData(state)
3,368✔
315

316
  zeroOutUint8Array(commitSecret)
3,368✔
317
  zeroOutUint8Array(epochSecrets.joinerSecret)
3,368✔
318
  zeroOutUint8Array(epochSecrets.encryptionSecret)
3,368✔
319

320
  const consumed = [...consumedEpochData, initSecret]
3,368✔
321

322
  return {
3,368✔
323
    newState: {
324
      ...state,
325
      secretTree,
326
      ratchetTree: tree,
327
      privatePath: pkp,
328
      groupContext: updatedGroupContext,
329
      keySchedule: epochSecrets.keySchedule,
330
      confirmationTag: content.auth.confirmationTag,
331
      historicalReceiverData,
332
      unappliedProposals: {},
333
      groupActiveState,
334
    },
335
    actionTaken: action,
336
    consumed,
337
  }
338
}
339

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

354
    const [pkp, commitSecret] = await updatePrivateKeyPath(
1,931✔
355
      updatedTree,
356
      state,
357
      toLeafIndex(sender.leafIndex),
358
      { ...groupContext, treeHash: await treeHashRoot(updatedTree, cs.hash), epoch: groupContext.epoch + 1n },
359
      path,
360
      excludeNodes,
361
      cs,
362
    )
363
    return [pkp, commitSecret, updatedTree] as const
1,931✔
364
  } else {
365
    const [treeWithLeafNode, leafNodeIndex] = addLeafNode(tree, path.leafNode)
76✔
366

367
    const senderLeafIndex = nodeToLeafIndex(leafNodeIndex)
76✔
368
    const updatedTree = await applyUpdatePath(treeWithLeafNode, senderLeafIndex, path, cs.hash, true)
76✔
369

370
    const [pkp, commitSecret] = await updatePrivateKeyPath(
76✔
371
      updatedTree,
372
      state,
373
      senderLeafIndex,
374
      { ...groupContext, treeHash: await treeHashRoot(updatedTree, cs.hash), epoch: groupContext.epoch + 1n },
375
      path,
376
      excludeNodes,
377
      cs,
378
    )
379
    return [pkp, commitSecret, updatedTree] as const
76✔
380
  }
381
}
382

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

407
  const rootIndex = root(leafWidth(tree.length))
2,007✔
408
  const rootSecret = pathSecrets[rootIndex]
2,007✔
409
  if (rootSecret === undefined) throw new InternalError("Could not find secret for root")
2,007✔
410

411
  const commitSecret = await deriveSecret(rootSecret, "path", cs.kdf)
2,007✔
412
  return [newPkp, commitSecret] as const
2,007✔
413
}
414

415
export async function processMessage(
416
  message: MlsPrivateMessage | MlsPublicMessage,
417
  state: ClientState,
418
  pskIndex: PskIndex,
419
  action: IncomingMessageCallback,
420
  cs: CiphersuiteImpl,
421
): Promise<ProcessMessageResult> {
422
  if (message.wireformat === "mls_public_message") {
266✔
423
    const result = await processPublicMessage(state, message.publicMessage, pskIndex, cs, action)
114✔
424

425
    return { ...result, kind: "newState" }
114✔
426
  } else return processPrivateMessage(state, message.privateMessage, emptyPskIndex, cs, action)
152✔
427
}
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