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

LukaJCB / ts-mls / 20770960568

07 Jan 2026 04:49AM UTC coverage: 95.703% (-0.5%) from 96.185%
20770960568

Pull #194

github

web-flow
Merge 34df8e333 into 529822824
Pull Request #194: Return consumed values to be deleted later

1260 of 1407 branches covered (89.55%)

Branch coverage included in aggregate %.

83 of 130 new or added lines in 7 files covered. (63.85%)

1 existing line in 1 file now uncovered.

7270 of 7506 relevant lines covered (96.86%)

41898.69 hits per line

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

97.92
/src/processMessages.ts
1
import { AuthenticatedContentCommit } from "./authenticatedContent.js"
2
import {
1✔
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"
1✔
14
import { CiphersuiteImpl } from "./crypto/ciphersuite.js"
15
import { Kdf, deriveSecret } from "./crypto/kdf.js"
1✔
16
import { verifyConfirmationTag } from "./framedContent.js"
1✔
17
import { GroupContext } from "./groupContext.js"
18
import { acceptAll, IncomingMessageAction, IncomingMessageCallback } from "./incomingMessageAction.js"
1✔
19
import { initializeEpoch } from "./keySchedule.js"
1✔
20
import { MlsPrivateMessage, MlsPublicMessage } from "./message.js"
21
import { unprotectPrivateMessage } from "./messageProtection.js"
1✔
22
import { unprotectPublicMessage } from "./messageProtectionPublic.js"
1✔
23
import { CryptoVerificationError, InternalError, ValidationError } from "./mlsError.js"
1✔
24
import { pathToRoot } from "./pathSecrets.js"
1✔
25
import { PrivateKeyPath, mergePrivateKeyPaths, toPrivateKeyPath } from "./privateKeyPath.js"
1✔
26
import { PrivateMessage } from "./privateMessage.js"
27
import { emptyPskIndex, PskIndex } from "./pskIndex.js"
1✔
28
import { PublicMessage } from "./publicMessage.js"
29
import { findBlankLeafNodeIndex, RatchetTree, addLeafNode } from "./ratchetTree.js"
1✔
30
import { createSecretTree } from "./secretTree.js"
1✔
31
import { getSenderLeafNodeIndex, Sender } from "./sender.js"
1✔
32
import { treeHashRoot } from "./treeHash.js"
1✔
33
import {
1✔
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"
1✔
44
import { addToMap } from "./util/addToMap.js"
1✔
45
import { WireformatName } from "./wireformat.js"
46

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

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

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

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

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

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

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

112
  const updatedState = { ...state, secretTree: result.tree }
8,705✔
113

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

313
  const consumed = [commitSecret, epochSecrets.encryptionSecret, epochSecrets.joinerSecret, initSecret]
3,368✔
314

315
  return {
3,368✔
316
    newState: {
3,368✔
317
      ...state,
3,368✔
318
      secretTree,
3,368✔
319
      ratchetTree: tree,
3,368✔
320
      privatePath: pkp,
3,368✔
321
      groupContext: updatedGroupContext,
3,368✔
322
      keySchedule: epochSecrets.keySchedule,
3,368✔
323
      confirmationTag: content.auth.confirmationTag,
3,368✔
324
      historicalReceiverData: addHistoricalReceiverData(state),
3,368✔
325
      unappliedProposals: {},
3,368✔
326
      groupActiveState,
3,368✔
327
    },
3,368✔
328
    actionTaken: action,
3,368✔
329
    consumed,
3,368✔
330
  }
3,368✔
331
}
3,368✔
332

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

347
    const [pkp, commitSecret] = await updatePrivateKeyPath(
1,931✔
348
      updatedTree,
1,931✔
349
      state,
1,931✔
350
      toLeafIndex(sender.leafIndex),
1,931✔
351
      { ...groupContext, treeHash: await treeHashRoot(updatedTree, cs.hash), epoch: groupContext.epoch + 1n },
1,931✔
352
      path,
1,931✔
353
      excludeNodes,
1,931✔
354
      cs,
1,931✔
355
    )
1,931✔
356
    return [pkp, commitSecret, updatedTree] as const
1,931✔
357
  } else {
3,330✔
358
    const [treeWithLeafNode, leafNodeIndex] = addLeafNode(tree, path.leafNode)
76✔
359

360
    const senderLeafIndex = nodeToLeafIndex(leafNodeIndex)
76✔
361
    const updatedTree = await applyUpdatePath(treeWithLeafNode, senderLeafIndex, path, cs.hash, true)
76✔
362

363
    const [pkp, commitSecret] = await updatePrivateKeyPath(
76✔
364
      updatedTree,
76✔
365
      state,
76✔
366
      senderLeafIndex,
76✔
367
      { ...groupContext, treeHash: await treeHashRoot(updatedTree, cs.hash), epoch: groupContext.epoch + 1n },
76✔
368
      path,
76✔
369
      excludeNodes,
76✔
370
      cs,
76✔
371
    )
76✔
372
    return [pkp, commitSecret, updatedTree] as const
76✔
373
  }
76✔
374
}
3,330✔
375

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

400
  const rootIndex = root(leafWidth(tree.length))
2,007✔
401
  const rootSecret = pathSecrets[rootIndex]
2,007✔
402
  if (rootSecret === undefined) throw new InternalError("Could not find secret for root")
2,007!
403

404
  const commitSecret = await deriveSecret(rootSecret, "path", cs.kdf)
2,007✔
405
  return [newPkp, commitSecret] as const
2,007✔
406
}
2,007✔
407

408
export async function processMessage(
266✔
409
  message: MlsPrivateMessage | MlsPublicMessage,
266✔
410
  state: ClientState,
266✔
411
  pskIndex: PskIndex,
266✔
412
  action: IncomingMessageCallback,
266✔
413
  cs: CiphersuiteImpl,
266✔
414
): Promise<ProcessMessageResult> {
266✔
415
  if (message.wireformat === "mls_public_message") {
266✔
416
    const result = await processPublicMessage(state, message.publicMessage, pskIndex, cs, action)
114✔
417

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