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

LukaJCB / ts-mls / 17283659198

28 Aug 2025 02:05AM UTC coverage: 93.75% (+1.7%) from 92.046%
17283659198

push

github

web-flow
Migrate from jest to vitest and parallelize tests (#90)

1121 of 1250 branches covered (89.68%)

Branch coverage included in aggregate %.

388 of 393 new or added lines in 63 files covered. (98.73%)

57 existing lines in 13 files now uncovered.

6364 of 6734 relevant lines covered (94.51%)

51774.21 hits per line

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

96.02
/src/clientState.ts
1
import { AuthenticatedContent, makeProposalRef } from "./authenticatedContent.js"
1✔
2
import { CiphersuiteImpl } from "./crypto/ciphersuite.js"
3
import { Hash } from "./crypto/hash.js"
4
import { Extension, extensionsEqual, extensionsSupportedByCapabilities } from "./extension.js"
1✔
5
import { createConfirmationTag, FramedContentCommit } from "./framedContent.js"
1✔
6
import { GroupContext } from "./groupContext.js"
7
import { ratchetTreeFromExtension, verifyGroupInfoConfirmationTag, verifyGroupInfoSignature } from "./groupInfo.js"
1✔
8
import { KeyPackage, makeKeyPackageRef, PrivateKeyPackage, verifyKeyPackage } from "./keyPackage.js"
1✔
9
import { deriveKeySchedule, initializeKeySchedule, KeySchedule } from "./keySchedule.js"
1✔
10
import { encodePskId, PreSharedKeyID } from "./presharedkey.js"
1✔
11

12
import {
1✔
13
  addLeafNode,
14
  findBlankLeafNodeIndexOrExtend,
15
  findLeafIndex,
16
  getHpkePublicKey,
17
  removeLeafNode,
18
  updateLeafNode,
19
} from "./ratchetTree.js"
20
import { RatchetTree } from "./ratchetTree.js"
21
import { createSecretTree, SecretTree } from "./secretTree.js"
1✔
22
import { createConfirmedHash, createInterimHash } from "./transcriptHash.js"
1✔
23
import { treeHashRoot } from "./treeHash.js"
1✔
24
import {
1✔
25
  directPath,
26
  isLeaf,
27
  LeafIndex,
28
  leafToNodeIndex,
29
  leafWidth,
30
  nodeToLeafIndex,
31
  toLeafIndex,
32
  toNodeIndex,
33
} from "./treemath.js"
34
import { firstCommonAncestor } from "./updatePath.js"
1✔
35
import { bytesToBase64 } from "./util/byteArray.js"
1✔
36
import { constantTimeEqual } from "./util/constantTimeCompare.js"
1✔
37
import { decryptGroupInfo, decryptGroupSecrets, Welcome } from "./welcome.js"
1✔
38
import { WireformatName } from "./wireformat.js"
39
import { ProposalOrRef } from "./proposalOrRefType.js"
40
import {
41
  Proposal,
42
  ProposalAdd,
43
  ProposalExternalInit,
44
  ProposalGroupContextExtensions,
45
  ProposalPSK,
46
  ProposalReinit,
47
  ProposalRemove,
48
  ProposalUpdate,
49
  Reinit,
50
  Remove,
51
} from "./proposal.js"
52
import { pathToRoot } from "./pathSecrets.js"
1✔
53
import { PrivateKeyPath, mergePrivateKeyPaths, toPrivateKeyPath } from "./privateKeyPath.js"
1✔
54
import { UnappliedProposals, addUnappliedProposal, ProposalWithSender } from "./unappliedProposals.js"
1✔
55
import { accumulatePskSecret, PskIndex } from "./pskIndex.js"
1✔
56
import { getSenderLeafNodeIndex } from "./sender.js"
1✔
57
import { addToMap } from "./util/addToMap.js"
1✔
58
import {
1✔
59
  CryptoVerificationError,
60
  CodecError,
61
  InternalError,
62
  UsageError,
63
  ValidationError,
64
  MlsError,
65
} from "./mlsError.js"
66
import { Signature } from "./crypto/signature.js"
67
import {
1✔
68
  LeafNode,
69
  LeafNodeCommit,
70
  LeafNodeKeyPackage,
71
  LeafNodeUpdate,
72
  verifyLeafNodeSignature,
73
  verifyLeafNodeSignatureKeyPackage,
74
} from "./leafNode.js"
75
import { protocolVersions } from "./protocolVersion.js"
1✔
76
import { decodeRequiredCapabilities, RequiredCapabilities } from "./requiredCapabilities.js"
1✔
77
import { Capabilities } from "./capabilities.js"
78
import { verifyParentHashes } from "./parentHash.js"
1✔
79
import { AuthenticationService } from "./authenticationService.js"
80
import { LifetimeConfig } from "./lifetimeConfig.js"
81
import { KeyPackageEqualityConfig } from "./keyPackageEqualityConfig.js"
82
import { ClientConfig, defaultClientConfig } from "./clientConfig.js"
1✔
83
import { decodeExternalSender } from "./externalSender.js"
1✔
84
import { arraysEqual } from "./util/array.js"
1✔
85

86
export interface ClientState {
87
  groupContext: GroupContext
88
  keySchedule: KeySchedule
89
  secretTree: SecretTree
90
  ratchetTree: RatchetTree
91
  privatePath: PrivateKeyPath
92
  signaturePrivateKey: Uint8Array
93
  unappliedProposals: UnappliedProposals
94
  confirmationTag: Uint8Array
95
  historicalReceiverData: Map<bigint, EpochReceiverData>
96
  groupActiveState: GroupActiveState
97
  clientConfig: ClientConfig
98
}
99

100
export type GroupActiveState =
101
  | { kind: "active" }
102
  | { kind: "suspendedPendingReinit"; reinit: Reinit }
103
  | { kind: "removedFromGroup" }
104

105
/**
106
 * This type contains everything necessary to receieve application messages for an earlier epoch
107
 */
108
export interface EpochReceiverData {
109
  resumptionPsk: Uint8Array
110
  secretTree: SecretTree
111
  ratchetTree: RatchetTree
112
  senderDataSecret: Uint8Array
113
  groupContext: GroupContext
114
}
115

116
export function checkCanSendApplicationMessages(state: ClientState): void {
1✔
117
  if (Object.keys(state.unappliedProposals).length !== 0)
2,470✔
118
    throw new UsageError("Cannot send application message with unapplied proposals")
2,470✔
119

120
  checkCanSendHandshakeMessages(state)
2,451✔
121
}
2,451✔
122

123
export function checkCanSendHandshakeMessages(state: ClientState): void {
1✔
124
  if (state.groupActiveState.kind === "suspendedPendingReinit")
4,427✔
125
    throw new UsageError("Cannot send messages while Group is suspended pending reinit")
4,427✔
126
  else if (state.groupActiveState.kind === "removedFromGroup")
4,408✔
127
    throw new UsageError("Cannot send messages after being removed from group")
4,408✔
128
}
4,427✔
129

130
export interface Proposals {
131
  add: { senderLeafIndex: number | undefined; proposal: ProposalAdd }[]
132
  update: { senderLeafIndex: number | undefined; proposal: ProposalUpdate }[]
133
  remove: { senderLeafIndex: number | undefined; proposal: ProposalRemove }[]
134
  psk: { senderLeafIndex: number | undefined; proposal: ProposalPSK }[]
135
  reinit: { senderLeafIndex: number | undefined; proposal: ProposalReinit }[]
136
  external_init: { senderLeafIndex: number | undefined; proposal: ProposalExternalInit }[]
137
  group_context_extensions: { senderLeafIndex: number | undefined; proposal: ProposalGroupContextExtensions }[]
138
}
139

140
const emptyProposals: Proposals = {
1✔
141
  add: [],
1✔
142
  update: [],
1✔
143
  remove: [],
1✔
144
  psk: [],
1✔
145
  reinit: [],
1✔
146
  external_init: [],
1✔
147
  group_context_extensions: [],
1✔
148
}
1✔
149

150
function flattenExtensions(groupContextExtensions: { proposal: ProposalGroupContextExtensions }[]): Extension[] {
9,903✔
151
  return groupContextExtensions.reduce((acc, { proposal }) => {
9,903✔
152
    return [...acc, ...proposal.groupContextExtensions.extensions]
151✔
153
  }, [] as Extension[])
9,903✔
154
}
9,903✔
155

156
async function validateProposals(
5,151✔
157
  p: Proposals,
5,151✔
158
  committerLeafIndex: number | undefined,
5,151✔
159
  groupContext: GroupContext,
5,151✔
160
  config: KeyPackageEqualityConfig,
5,151✔
161
  authService: AuthenticationService,
5,151✔
162
  tree: RatchetTree,
5,151✔
163
): Promise<MlsError | undefined> {
5,151✔
164
  const containsUpdateByCommitter = p.update.some(
5,151✔
165
    (o) => o.senderLeafIndex !== undefined && o.senderLeafIndex === committerLeafIndex,
5,151✔
166
  )
5,151✔
167

168
  if (containsUpdateByCommitter)
5,151✔
169
    return new ValidationError("Commit cannot contain an update proposal sent by committer")
5,151✔
170

171
  const containsRemoveOfCommitter = p.remove.some((o) => o.proposal.remove.removed === committerLeafIndex)
5,132✔
172

173
  if (containsRemoveOfCommitter)
5,132✔
174
    return new ValidationError("Commit cannot contain a remove proposal removing committer")
5,151✔
175

176
  const multipleUpdateRemoveForSameLeaf =
5,113✔
177
    p.update.some(
5,113✔
178
      ({ senderLeafIndex: a }, indexA) =>
5,113✔
179
        p.update.some(({ senderLeafIndex: b }, indexB) => a === b && indexA !== indexB) ||
14✔
180
        p.remove.some((r) => r.proposal.remove.removed === a),
14✔
181
    ) ||
5,113✔
182
    p.remove.some(
5,113✔
183
      (a, indexA) =>
5,113✔
184
        p.remove.some((b, indexB) => b.proposal.remove.removed === a.proposal.remove.removed && indexA !== indexB) ||
2,444✔
185
        p.update.some(({ senderLeafIndex }) => a.proposal.remove.removed === senderLeafIndex),
2,425✔
186
    )
5,113✔
187

188
  if (multipleUpdateRemoveForSameLeaf)
5,151✔
189
    return new ValidationError(
5,151✔
190
      "Commit cannot contain multiple update and/or remove proposals that apply to the same leaf",
19✔
191
    )
19✔
192

193
  const multipleAddsContainSameKeypackage = p.add.some(({ proposal: a }, indexA) =>
5,094✔
194
    p.add.some(
3,090✔
195
      ({ proposal: b }, indexB) => config.compareKeyPackages(a.add.keyPackage, b.add.keyPackage) && indexA !== indexB,
3,090✔
196
    ),
3,090✔
197
  )
5,094✔
198

199
  if (multipleAddsContainSameKeypackage)
5,094✔
200
    return new ValidationError(
5,151✔
201
      "Commit cannot contain multiple Add proposals that contain KeyPackages that represent the same client",
19✔
202
    )
19✔
203

204
  // checks if there is an Add proposal with a KeyPackage that matches a client already in the group
205
  // unless there is a Remove proposal in the list removing the matching client from the group.
206
  const addsContainExistingKeypackage = p.add.some(({ proposal }) =>
5,075✔
207
    tree.some(
3,071✔
208
      (node, nodeIndex) =>
3,071✔
209
        node !== undefined &&
186,859✔
210
        node.nodeType === "leaf" &&
41,591✔
211
        config.compareKeyPackageToLeafNode(proposal.add.keyPackage, node.leaf) &&
32,517✔
212
        p.remove.every((r) => r.proposal.remove.removed !== nodeToLeafIndex(toNodeIndex(nodeIndex))),
19✔
213
    ),
3,071✔
214
  )
5,075✔
215

216
  if (addsContainExistingKeypackage)
5,075✔
217
    return new ValidationError("Commit cannot contain an Add proposal for someone already in the group")
5,151✔
218

219
  const everyLeafSupportsGroupExtensions = p.add.every(({ proposal }) =>
5,056✔
220
    extensionsSupportedByCapabilities(groupContext.extensions, proposal.add.keyPackage.leafNode.capabilities),
3,052✔
221
  )
5,056✔
222

223
  if (!everyLeafSupportsGroupExtensions)
5,056✔
224
    return new ValidationError("Added leaf node that doesn't support extension in GroupContext")
5,151✔
225

226
  const multiplePskWithSamePskId = p.psk.some((a, indexA) =>
5,037✔
227
    p.psk.some(
227✔
228
      (b, indexB) =>
227✔
229
        constantTimeEqual(encodePskId(a.proposal.psk.preSharedKeyId), encodePskId(b.proposal.psk.preSharedKeyId)) &&
350✔
230
        indexA !== indexB,
246✔
231
    ),
227✔
232
  )
5,037✔
233

234
  if (multiplePskWithSamePskId)
5,037✔
235
    return new ValidationError("Commit cannot contain PreSharedKey proposals that reference the same PreSharedKeyID")
5,151✔
236

237
  const multipleGroupContextExtensions = p.group_context_extensions.length > 1
5,018✔
238

239
  if (multipleGroupContextExtensions)
5,018✔
240
    return new ValidationError("Commit cannot contain multiple GroupContextExtensions proposals")
5,151✔
241

242
  const allExtensions = flattenExtensions(p.group_context_extensions)
4,999✔
243

244
  const requiredCapabilities = allExtensions.find((e) => e.extensionType === "required_capabilities")
4,999✔
245

246
  if (requiredCapabilities !== undefined) {
5,151✔
247
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
57✔
248
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
57✔
249

250
    const everyLeafSupportsCapabilities = tree
38✔
251
      .filter((n) => n !== undefined && n.nodeType === "leaf")
38✔
252
      .every((l) => capabiltiesAreSupported(caps[0], l.leaf.capabilities))
38✔
253

254
    if (!everyLeafSupportsCapabilities) return new ValidationError("Not all members support required capabilities")
57✔
255

256
    const allAdditionsSupportCapabilities = p.add.every((a) =>
19✔
257
      capabiltiesAreSupported(caps[0], a.proposal.add.keyPackage.leafNode.capabilities),
19✔
258
    )
19✔
259

260
    if (!allAdditionsSupportCapabilities)
19✔
261
      return new ValidationError("Commit contains add proposals of member without required capabilities")
19✔
262
  }
57✔
263

264
  return await validateExternalSenders(allExtensions, authService)
4,942✔
265
}
4,942✔
266

267
async function validateExternalSenders(
5,607✔
268
  extensions: Extension[],
5,607✔
269
  authService: AuthenticationService,
5,607✔
270
): Promise<MlsError | undefined> {
5,607✔
271
  const externalSenders = extensions.filter((e) => e.extensionType === "external_senders")
5,607✔
272
  for (const externalSender of externalSenders) {
5,607✔
273
    const decoded = decodeExternalSender(externalSender.extensionData, 0)
76✔
274
    if (decoded === undefined) return new CodecError("Could not decode external_senders")
76✔
275

276
    const validCredential = await authService.validateCredential(decoded[0].credential, decoded[0].signaturePublicKey)
57✔
277
    if (!validCredential) return new ValidationError("Could not validate external credential")
57✔
278
  }
76✔
279
}
5,569✔
280

281
function capabiltiesAreSupported(caps: RequiredCapabilities, cs: Capabilities): boolean {
171✔
282
  return (
171✔
283
    caps.credentialTypes.every((c) => cs.credentials.includes(c)) &&
171✔
284
    caps.extensionTypes.every((e) => cs.extensions.includes(e)) &&
133✔
285
    caps.proposalTypes.every((p) => cs.proposals.includes(p))
133✔
286
  )
287
}
171✔
288

289
export async function validateRatchetTree(
1,117✔
290
  tree: RatchetTree,
1,117✔
291
  groupContext: GroupContext,
1,117✔
292
  config: LifetimeConfig,
1,117✔
293
  authService: AuthenticationService,
1,117✔
294
  treeHash: Uint8Array,
1,117✔
295
  cs: CiphersuiteImpl,
1,117✔
296
): Promise<MlsError | undefined> {
1,117✔
297
  const treeIsStructurallySound = tree.every((n, index) =>
1,117✔
298
    isLeaf(toNodeIndex(index)) ? n === undefined || n.nodeType === "leaf" : n === undefined || n.nodeType === "parent",
8,357✔
299
  )
1,117✔
300

301
  if (!treeIsStructurallySound) return new ValidationError("Received Ratchet Tree is not structurally sound")
1,117✔
302

303
  const parentHashesVerified = await verifyParentHashes(tree, cs.hash)
1,098✔
304

305
  if (!parentHashesVerified) return new CryptoVerificationError("Unable to verify parent hash")
1,098!
306

307
  if (!constantTimeEqual(treeHash, await treeHashRoot(tree, cs.hash)))
1,098✔
308
    return new ValidationError("Unable to verify tree hash")
1,098!
309

310
  //validate all parent nodes
311
  for (const [parentIndex, n] of tree.entries()) {
1,098✔
312
    if (n?.nodeType === "parent") {
8,338✔
313
      // verify unmerged leaves
314
      for (const unmergedLeaf of n.parent.unmergedLeaves) {
452✔
315
        const leafIndex = toLeafIndex(unmergedLeaf)
114✔
316
        const dp = directPath(leafToNodeIndex(leafIndex), leafWidth(tree.length))
114✔
317
        const nodeIndex = leafToNodeIndex(leafIndex)
114✔
318
        if (tree[nodeIndex]?.nodeType !== "leaf" && !dp.includes(toNodeIndex(parentIndex)))
114!
319
          return new ValidationError("Unmerged leaf did not represent a non-blank descendant leaf node")
114!
320

321
        for (const parentIdx of dp) {
114✔
322
          const dpNode = tree[parentIdx]
342✔
323

324
          if (dpNode !== undefined) {
342✔
325
            if (dpNode.nodeType !== "parent") return new InternalError("Expected parent node")
114!
326

327
            if (!arraysEqual(dpNode.parent.unmergedLeaves, n.parent.unmergedLeaves))
114✔
328
              return new ValidationError("non-blank intermediate node must list leaf node in its unmerged_leaves")
114!
329
          }
114✔
330
        }
342✔
331
      }
114✔
332
    }
452✔
333
  }
8,338✔
334

335
  const duplicateHpkeKeys = hasDuplicateUint8Arrays(
1,098✔
336
    tree.map((n) => (n !== undefined ? getHpkePublicKey(n) : undefined)),
1,098✔
337
  )
1,098✔
338

339
  if (duplicateHpkeKeys) return new ValidationError("Multiple public keys with the same value")
1,098!
340

341
  // validate all leaf nodes
342
  for (const [index, n] of tree.entries()) {
1,098✔
343
    if (n?.nodeType === "leaf") {
8,338✔
344
      const err =
4,258✔
345
        n.leaf.leafNodeSource === "key_package"
4,258✔
346
          ? await validateLeafNodeKeyPackage(
3,917✔
347
              n.leaf,
3,917✔
348
              groupContext,
3,917✔
349
              tree,
3,917✔
350
              false,
3,917✔
351
              config,
3,917✔
352
              authService,
3,917✔
353
              nodeToLeafIndex(toNodeIndex(index)),
3,917✔
354
              cs.signature,
3,917✔
355
            )
3,917✔
356
          : await validateLeafNodeUpdateOrCommit(
341✔
357
              n.leaf,
341✔
358
              nodeToLeafIndex(toNodeIndex(index)),
341✔
359
              groupContext,
341✔
360
              tree,
341✔
361
              authService,
341✔
362
              cs.signature,
341✔
363
            )
341✔
364

365
      if (err !== undefined) return err
2,301!
366
    }
4,258✔
367
  }
8,338✔
368
}
1,098✔
369

370
function hasDuplicateUint8Arrays(byteArrays: (Uint8Array | undefined)[]): boolean {
1,098✔
371
  const seen = new Set<string>()
1,098✔
372

373
  for (const data of byteArrays) {
1,098✔
374
    if (data === undefined) continue
8,338✔
375

376
    const key = bytesToBase64(data)
4,710✔
377
    if (seen.has(key)) {
8,338!
378
      return true
×
UNCOV
379
    }
✔
380
    seen.add(key)
4,710✔
381
  }
4,710✔
382

383
  return false
1,098✔
384
}
1,098✔
385

386
export async function validateLeafNodeUpdateOrCommit(
2,362✔
387
  leafNode: LeafNodeCommit | LeafNodeUpdate,
2,362✔
388
  leafIndex: number,
2,362✔
389
  groupContext: GroupContext,
2,362✔
390
  tree: RatchetTree,
2,362✔
391
  authService: AuthenticationService,
2,362✔
392
  s: Signature,
2,362✔
393
): Promise<MlsError | undefined> {
2,362✔
394
  const signatureValid = await verifyLeafNodeSignature(leafNode, groupContext.groupId, leafIndex, s)
2,362✔
395

396
  if (!signatureValid) return new CryptoVerificationError("Could not verify leaf node signature")
2,362!
397

398
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, tree, authService, leafIndex)
2,362✔
399

400
  if (commonError !== undefined) return commonError
2,362!
401
}
2,362✔
402

403
export function throwIfDefined(err: MlsError | undefined): void {
1✔
404
  if (err !== undefined) throw err
14,526✔
405
}
14,526✔
406

407
async function validateLeafNodeCommon(
9,293✔
408
  leafNode: LeafNode,
9,293✔
409
  groupContext: GroupContext,
9,293✔
410
  tree: RatchetTree,
9,293✔
411
  authService: AuthenticationService,
9,293✔
412
  leafIndex?: number,
9,293✔
413
) {
9,293✔
414
  const credentialValid = await authService.validateCredential(leafNode.credential, leafNode.signaturePublicKey)
9,293✔
415

416
  if (!credentialValid) return new ValidationError("Could not validate credential")
9,293✔
417

418
  const requiredCapabilities = groupContext.extensions.find((e) => e.extensionType === "required_capabilities")
9,274✔
419

420
  if (requiredCapabilities !== undefined) {
9,293✔
421
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
76✔
422
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
76!
423

424
    const leafSupportsCapabilities = capabiltiesAreSupported(caps[0], leafNode.capabilities)
76✔
425

426
    if (!leafSupportsCapabilities) return new ValidationError("LeafNode does not support required capabilities")
76✔
427
  }
76✔
428

429
  const credentialUnsupported = tree.some(
9,255✔
430
    (node) =>
9,255✔
431
      node !== undefined &&
279,745✔
432
      node.nodeType === "leaf" &&
97,508✔
433
      !node.leaf.capabilities.credentials.includes(leafNode.credential.credentialType),
75,453✔
434
  )
9,255✔
435

436
  if (credentialUnsupported)
9,255✔
437
    return new ValidationError("LeafNode has credential that is not supported by member of the group")
9,293✔
438

439
  const extensionsSupported = extensionsSupportedByCapabilities(leafNode.extensions, leafNode.capabilities)
9,236✔
440

441
  if (!extensionsSupported) return new ValidationError("LeafNode contains extension not listed in capabilities")
9,293✔
442

443
  const keysAreNotUnique = tree.some(
9,217✔
444
    (node, nodeIndex) =>
9,217✔
445
      node !== undefined &&
279,593✔
446
      node.nodeType === "leaf" &&
97,432✔
447
      (constantTimeEqual(node.leaf.hpkePublicKey, leafNode.hpkePublicKey) ||
75,377✔
448
        constantTimeEqual(node.leaf.signaturePublicKey, leafNode.signaturePublicKey)) &&
71,119✔
449
      leafIndex !== nodeToLeafIndex(toNodeIndex(nodeIndex)),
6,203✔
450
  )
9,217✔
451

452
  if (keysAreNotUnique) return new ValidationError("hpke and signature keys not unique")
9,293!
453
}
9,293✔
454

455
async function validateLeafNodeKeyPackage(
6,931✔
456
  leafNode: LeafNodeKeyPackage,
6,931✔
457
  groupContext: GroupContext,
6,931✔
458
  tree: RatchetTree,
6,931✔
459
  sentByClient: boolean,
6,931✔
460
  config: LifetimeConfig,
6,931✔
461
  authService: AuthenticationService,
6,931✔
462
  leafIndex: number | undefined,
6,931✔
463
  s: Signature,
6,931✔
464
): Promise<MlsError | undefined> {
6,931✔
465
  const signatureValid = await verifyLeafNodeSignatureKeyPackage(leafNode, s)
6,931✔
466
  if (!signatureValid) return new CryptoVerificationError("Could not verify leaf node signature")
6,931!
467

468
  //verify lifetime
469
  if (sentByClient || config.validateLifetimeOnReceive) {
6,931✔
470
    if (leafNode.leafNodeSource === "key_package") {
1,007✔
471
      const currentTime = BigInt(Math.floor(Date.now() / 1000))
1,007✔
472
      if (leafNode.lifetime.notBefore > currentTime || leafNode.lifetime.notAfter < currentTime)
1,007✔
473
        return new ValidationError("Current time not within Lifetime")
1,007!
474
    }
1,007✔
475
  }
1,007✔
476

477
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, tree, authService, leafIndex)
6,931✔
478

479
  if (commonError !== undefined) return commonError
6,931✔
480
}
6,931✔
481

482
async function validateKeyPackage(
3,014✔
483
  kp: KeyPackage,
3,014✔
484
  groupContext: GroupContext,
3,014✔
485
  tree: RatchetTree,
3,014✔
486
  sentByClient: boolean,
3,014✔
487
  config: LifetimeConfig,
3,014✔
488
  authService: AuthenticationService,
3,014✔
489
  s: Signature,
3,014✔
490
): Promise<MlsError | undefined> {
3,014✔
491
  if (kp.cipherSuite !== groupContext.cipherSuite) return new ValidationError("Invalid CipherSuite")
3,014!
492

493
  if (kp.version !== groupContext.version) return new ValidationError("Invalid mls version")
3,014!
494

495
  const leafNodeError = await validateLeafNodeKeyPackage(
3,014✔
496
    kp.leafNode,
3,014✔
497
    groupContext,
3,014✔
498
    tree,
3,014✔
499
    sentByClient,
3,014✔
500
    config,
3,014✔
501
    authService,
3,014✔
502
    undefined,
3,014✔
503
    s,
3,014✔
504
  )
3,014✔
505
  if (leafNodeError !== undefined) return leafNodeError
3,014✔
506

507
  const signatureValid = await verifyKeyPackage(kp, s)
2,938✔
508
  if (!signatureValid) return new CryptoVerificationError("Invalid keypackage signature")
3,014✔
509

510
  if (constantTimeEqual(kp.initKey, kp.leafNode.hpkePublicKey))
2,938✔
511
    return new ValidationError("Cannot have identicial init and encryption keys")
3,014!
512
}
3,014✔
513

514
function validateReinit(
76✔
515
  allProposals: ProposalWithSender[],
76✔
516
  reinit: Reinit,
76✔
517
  gc: GroupContext,
76✔
518
): ValidationError | undefined {
76✔
519
  if (allProposals.length !== 1) return new ValidationError("Reinit proposal needs to be commited by itself")
76!
520

521
  if (protocolVersions[reinit.version] < protocolVersions[gc.version])
76✔
522
    return new ValidationError("A ReInit proposal cannot use a version less than the version for the current group")
76!
523
}
76✔
524

525
function validateExternalInit(grouped: Proposals): ValidationError | undefined {
76✔
526
  if (grouped.external_init.length > 1)
76✔
527
    return new ValidationError("Cannot contain more than one external_init proposal")
76!
528

529
  if (grouped.remove.length > 1) return new ValidationError("Cannot contain more than one remove proposal")
76!
530

531
  if (
76✔
532
    grouped.add.length > 0 ||
76✔
533
    grouped.group_context_extensions.length > 0 ||
76✔
534
    grouped.reinit.length > 0 ||
76✔
535
    grouped.update.length > 0
76✔
536
  )
537
    return new ValidationError("Invalid proposals")
76!
538
}
76✔
539

540
function validateRemove(remove: Remove, tree: RatchetTree): MlsError | undefined {
2,425✔
541
  if (tree[leafToNodeIndex(toLeafIndex(remove.removed))] === undefined)
2,425✔
542
    return new ValidationError("Tried to remove empty leaf node")
2,425!
543
}
2,425✔
544

545
export interface ApplyProposalsResult {
546
  tree: RatchetTree
547
  pskSecret: Uint8Array
548
  pskIds: PreSharedKeyID[]
549
  needsUpdatePath: boolean
550
  additionalResult: ApplyProposalsData
551
  selfRemoved: boolean
552
  allProposals: ProposalWithSender[]
553
}
554

555
export type ApplyProposalsData =
556
  | { kind: "memberCommit"; addedLeafNodes: [LeafIndex, KeyPackage][]; extensions: Extension[] }
557
  | { kind: "externalCommit"; externalInitSecret: Uint8Array; newMemberLeafIndex: LeafIndex }
558
  | { kind: "reinit"; reinit: Reinit }
559

560
export async function applyProposals(
5,303✔
561
  state: ClientState,
5,303✔
562
  proposals: ProposalOrRef[],
5,303✔
563
  committerLeafIndex: LeafIndex | undefined,
5,303✔
564
  pskSearch: PskIndex,
5,303✔
565
  sentByClient: boolean,
5,303✔
566
  cs: CiphersuiteImpl,
5,303✔
567
): Promise<ApplyProposalsResult> {
5,303✔
568
  const allProposals = proposals.reduce((acc, cur) => {
5,303✔
569
    if (cur.proposalOrRefType === "proposal")
6,259✔
570
      return [...acc, { proposal: cur.proposal, senderLeafIndex: committerLeafIndex }]
6,259✔
571

572
    const p = state.unappliedProposals[bytesToBase64(cur.reference)]
1,854✔
573
    if (p === undefined) throw new ValidationError("Could not find proposal with supplied reference")
3,580✔
574
    return [...acc, p]
1,854✔
575
  }, [] as ProposalWithSender[])
5,303✔
576

577
  const grouped = allProposals.reduce((acc, cur) => {
5,303✔
578
    //this skips any custom proposals
579
    if (typeof cur.proposal.proposalType === "number") return acc
6,259✔
580
    const proposal = acc[cur.proposal.proposalType] ?? []
6,259!
581
    return { ...acc, [cur.proposal.proposalType]: [...proposal, cur] }
6,259✔
582
  }, emptyProposals)
5,303✔
583

584
  const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
5,303✔
585

586
  const isExternalInit = grouped.external_init.length > 0
5,303✔
587

588
  if (!isExternalInit) {
5,303✔
589
    if (grouped.reinit.length > 0) {
5,227✔
590
      const reinit = grouped.reinit.at(0)!.proposal.reinit
76✔
591

592
      throwIfDefined(validateReinit(allProposals, reinit, state.groupContext))
76✔
593

594
      return {
76✔
595
        tree: state.ratchetTree,
76✔
596
        pskSecret: zeroes,
76✔
597
        pskIds: [],
76✔
598
        needsUpdatePath: false,
76✔
599
        additionalResult: {
76✔
600
          kind: "reinit",
76✔
601
          reinit,
76✔
602
        },
76✔
603
        selfRemoved: false,
76✔
604
        allProposals,
76✔
605
      }
76✔
606
    }
76✔
607

608
    throwIfDefined(
5,151✔
609
      await validateProposals(
5,151✔
610
        grouped,
5,151✔
611
        committerLeafIndex,
5,151✔
612
        state.groupContext,
5,151✔
613
        state.clientConfig.keyPackageEqualityConfig,
5,151✔
614
        state.clientConfig.authService,
5,151✔
615
        state.ratchetTree,
5,151✔
616
      ),
5,151✔
617
    )
5,151✔
618

619
    const newExtensions = flattenExtensions(grouped.group_context_extensions)
5,151✔
620

621
    const [mutatedTree, addedLeafNodes] = await applyTreeMutations(
5,151✔
622
      state.ratchetTree,
5,151✔
623
      grouped,
5,151✔
624
      state.groupContext,
5,151✔
625
      sentByClient,
5,151✔
626
      state.clientConfig.authService,
5,151✔
627
      state.clientConfig.lifetimeConfig,
5,151✔
628
      cs.signature,
5,151✔
629
    )
5,151✔
630

631
    const [updatedPskSecret, pskIds] = await accumulatePskSecret(
4,828✔
632
      grouped.psk.map((p) => p.proposal.psk.preSharedKeyId),
4,828✔
633
      pskSearch,
4,828✔
634
      cs,
4,828✔
635
      zeroes,
4,828✔
636
    )
4,828✔
637

638
    const selfRemoved = mutatedTree[leafToNodeIndex(toLeafIndex(state.privatePath.leafIndex))] === undefined
4,828✔
639

640
    const needsUpdatePath =
4,828✔
641
      allProposals.length === 0 || Object.values(grouped.update).length > 1 || Object.values(grouped.remove).length > 1
5,227✔
642

643
    return {
5,227✔
644
      tree: mutatedTree,
5,227✔
645
      pskSecret: updatedPskSecret,
5,227✔
646
      additionalResult: {
5,227✔
647
        kind: "memberCommit" as const,
5,227✔
648
        addedLeafNodes,
5,227✔
649
        extensions: newExtensions,
5,227✔
650
      },
5,227✔
651
      pskIds,
5,227✔
652
      needsUpdatePath,
5,227✔
653
      selfRemoved,
5,227✔
654
      allProposals,
5,227✔
655
    }
5,227✔
656
  } else {
5,303✔
657
    throwIfDefined(validateExternalInit(grouped))
76✔
658

659
    const treeAfterRemove = grouped.remove.reduce((acc, { proposal }) => {
76✔
660
      return removeLeafNode(acc, toLeafIndex(proposal.remove.removed))
38✔
661
    }, state.ratchetTree)
76✔
662

663
    const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
76✔
664

665
    const [updatedPskSecret, pskIds] = await accumulatePskSecret(
76✔
666
      grouped.psk.map((p) => p.proposal.psk.preSharedKeyId),
76✔
667
      pskSearch,
76✔
668
      cs,
76✔
669
      zeroes,
76✔
670
    )
76✔
671

672
    const initProposal = grouped.external_init.at(0)!
76✔
673

674
    const externalKeyPair = await cs.hpke.deriveKeyPair(state.keySchedule.externalSecret)
76✔
675

676
    const externalInitSecret = await importSecret(
76✔
677
      await cs.hpke.exportPrivateKey(externalKeyPair.privateKey),
76✔
678
      initProposal.proposal.externalInit.kemOutput,
76✔
679
      cs,
76✔
680
    )
76✔
681

682
    return {
76✔
683
      needsUpdatePath: true,
76✔
684
      tree: treeAfterRemove,
76✔
685
      pskSecret: updatedPskSecret,
76✔
686
      pskIds,
76✔
687
      additionalResult: {
76✔
688
        kind: "externalCommit",
76✔
689
        externalInitSecret,
76✔
690
        newMemberLeafIndex: nodeToLeafIndex(findBlankLeafNodeIndexOrExtend(treeAfterRemove)),
76✔
691
      },
76✔
692
      selfRemoved: false,
76✔
693
      allProposals,
76✔
694
    }
76✔
695
  }
76✔
696
}
5,303✔
697

698
export function makePskIndex(state: ClientState | undefined, externalPsks: Record<string, Uint8Array>): PskIndex {
1✔
699
  return {
12,739✔
700
    findPsk(preSharedKeyId) {
12,739✔
701
      if (preSharedKeyId.psktype === "external") {
441✔
702
        return externalPsks[bytesToBase64(preSharedKeyId.pskId)]
261✔
703
      }
261✔
704

705
      if (state !== undefined && constantTimeEqual(preSharedKeyId.pskGroupId, state.groupContext.groupId)) {
441✔
706
        if (preSharedKeyId.pskEpoch === state.groupContext.epoch) return state.keySchedule.resumptionPsk
180✔
707
        else return state.historicalReceiverData.get(preSharedKeyId.pskEpoch)?.resumptionPsk
28✔
708
      }
180✔
709
    },
441✔
710
  }
12,739✔
711
}
12,739✔
712

713
export async function nextEpochContext(
4,980✔
714
  groupContext: GroupContext,
4,980✔
715
  wireformat: WireformatName,
4,980✔
716
  content: FramedContentCommit,
4,980✔
717
  signature: Uint8Array,
4,980✔
718
  updatedTreeHash: Uint8Array,
4,980✔
719
  confirmationTag: Uint8Array,
4,980✔
720
  h: Hash,
4,980✔
721
): Promise<GroupContext> {
4,980✔
722
  const interimTranscriptHash = await createInterimHash(groupContext.confirmedTranscriptHash, confirmationTag, h)
4,980✔
723
  const newConfirmedHash = await createConfirmedHash(interimTranscriptHash, { wireformat, content, signature }, h)
4,980✔
724

725
  return {
4,980✔
726
    ...groupContext,
4,980✔
727
    epoch: groupContext.epoch + 1n,
4,980✔
728
    treeHash: updatedTreeHash,
4,980✔
729
    confirmedTranscriptHash: newConfirmedHash,
4,980✔
730
  }
4,980✔
731
}
4,980✔
732

733
export async function joinGroup(
1,117✔
734
  welcome: Welcome,
1,117✔
735
  keyPackage: KeyPackage,
1,117✔
736
  privateKeys: PrivateKeyPackage,
1,117✔
737
  pskSearch: PskIndex,
1,117✔
738
  cs: CiphersuiteImpl,
1,117✔
739
  ratchetTree?: RatchetTree,
1,117✔
740
  resumingFromState?: ClientState,
1,117✔
741
  clientConfig: ClientConfig = defaultClientConfig,
1,117✔
742
): Promise<ClientState> {
1,117✔
743
  const keyPackageRef = await makeKeyPackageRef(keyPackage, cs.hash)
1,117✔
744
  const privKey = await cs.hpke.importPrivateKey(privateKeys.initPrivateKey)
1,117✔
745
  const groupSecrets = await decryptGroupSecrets(privKey, keyPackageRef, welcome, cs.hpke)
1,117✔
746

747
  if (groupSecrets === undefined) throw new CodecError("Could not decode group secrets")
1,117!
748

749
  const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
1,117✔
750

751
  const [pskSecret, pskIds] = await accumulatePskSecret(groupSecrets.psks, pskSearch, cs, zeroes)
1,117✔
752

753
  const gi = await decryptGroupInfo(welcome, groupSecrets.joinerSecret, pskSecret, cs)
1,117✔
754
  if (gi === undefined) throw new CodecError("Could not decode group info")
1,117!
755

756
  const resumptionPsk = pskIds.find((id) => id.psktype === "resumption")
1,117✔
757
  if (resumptionPsk !== undefined) {
1,117✔
758
    if (resumingFromState === undefined) throw new ValidationError("No prior state passed for resumption")
95!
759

760
    if (resumptionPsk.pskEpoch !== resumingFromState.groupContext.epoch) throw new ValidationError("Epoch mismatch")
95!
761

762
    if (!constantTimeEqual(resumptionPsk.pskGroupId, resumingFromState.groupContext.groupId))
95✔
763
      throw new ValidationError("old groupId mismatch")
95!
764

765
    if (gi.groupContext.epoch !== 1n) throw new ValidationError("Resumption must be started at epoch 1")
95!
766

767
    if (resumptionPsk.usage === "reinit") {
95✔
768
      if (resumingFromState.groupActiveState.kind !== "suspendedPendingReinit")
76✔
769
        throw new ValidationError("Found reinit psk but no old suspended clientState")
76!
770

771
      if (!constantTimeEqual(resumingFromState.groupActiveState.reinit.groupId, gi.groupContext.groupId))
76✔
772
        throw new ValidationError("new groupId mismatch")
76✔
773

774
      if (resumingFromState.groupActiveState.reinit.version !== gi.groupContext.version)
57✔
775
        throw new ValidationError("Version mismatch")
76✔
776

777
      if (resumingFromState.groupActiveState.reinit.cipherSuite !== gi.groupContext.cipherSuite)
38✔
778
        throw new ValidationError("Ciphersuite mismatch")
76✔
779

780
      if (!extensionsEqual(resumingFromState.groupActiveState.reinit.extensions, gi.groupContext.extensions))
38✔
781
        throw new ValidationError("Extensions mismatch")
38✔
782
    }
76✔
783
  }
95✔
784

785
  const allExtensionsSupported = extensionsSupportedByCapabilities(
1,060✔
786
    gi.groupContext.extensions,
1,060✔
787
    keyPackage.leafNode.capabilities,
1,060✔
788
  )
1,060✔
789
  if (!allExtensionsSupported) throw new UsageError("client does not support every extension in the GroupContext")
1,117✔
790

791
  const tree = ratchetTreeFromExtension(gi) ?? ratchetTree
1,060✔
792

793
  if (tree === undefined) throw new UsageError("No RatchetTree passed and no ratchet_tree extension")
1,117✔
794

795
  const signerNode = tree[leafToNodeIndex(toLeafIndex(gi.signer))]
1,060✔
796

797
  if (signerNode === undefined) {
1,117!
798
    throw new ValidationError("Could not find signer leafNode")
×
UNCOV
799
  }
✔
800
  if (signerNode.nodeType === "parent") throw new ValidationError("Expected non blank leaf node")
1,117✔
801

802
  const credentialVerified = await clientConfig.authService.validateCredential(
1,060✔
803
    signerNode.leaf.credential,
1,060✔
804
    signerNode.leaf.signaturePublicKey,
1,060✔
805
  )
1,060✔
806

807
  if (!credentialVerified) throw new ValidationError("Could not validate credential")
1,117✔
808

809
  const groupInfoSignatureVerified = await verifyGroupInfoSignature(
1,060✔
810
    gi,
1,060✔
811
    signerNode.leaf.signaturePublicKey,
1,060✔
812
    cs.signature,
1,060✔
813
  )
1,060✔
814

815
  if (!groupInfoSignatureVerified) throw new CryptoVerificationError("Could not verify groupInfo signature")
1,117✔
816

817
  if (gi.groupContext.cipherSuite !== keyPackage.cipherSuite)
1,060✔
818
    throw new ValidationError("cipher suite in the GroupInfo does not match the cipher_suite in the KeyPackage")
1,117✔
819

820
  throwIfDefined(
1,060✔
821
    await validateRatchetTree(
1,060✔
822
      tree,
1,060✔
823
      gi.groupContext,
1,060✔
824
      clientConfig.lifetimeConfig,
1,060✔
825
      clientConfig.authService,
1,060✔
826
      gi.groupContext.treeHash,
1,060✔
827
      cs,
1,060✔
828
    ),
1,060✔
829
  )
1,060✔
830

831
  const newLeaf = findLeafIndex(tree, keyPackage.leafNode)
1,060✔
832

833
  if (newLeaf === undefined) throw new ValidationError("Could not find own leaf when processing welcome")
1,117✔
834

835
  const privateKeyPath: PrivateKeyPath = {
1,060✔
836
    leafIndex: newLeaf,
1,060✔
837
    privateKeys: { [leafToNodeIndex(newLeaf)]: privateKeys.hpkePrivateKey },
1,060✔
838
  }
1,060✔
839

840
  const ancestorNodeIndex = firstCommonAncestor(tree, newLeaf, toLeafIndex(gi.signer))
1,060✔
841

842
  const updatedPkp =
1,060✔
843
    groupSecrets.pathSecret === undefined
1,060✔
844
      ? privateKeyPath
1,004✔
845
      : mergePrivateKeyPaths(
56✔
846
          await toPrivateKeyPath(
56✔
847
            await pathToRoot(tree, ancestorNodeIndex, groupSecrets.pathSecret, cs.kdf),
56✔
848
            newLeaf,
56✔
849
            cs,
56✔
850
          ),
56✔
851
          privateKeyPath,
56✔
852
        )
56✔
853

854
  const keySchedule = await deriveKeySchedule(groupSecrets.joinerSecret, pskSecret, gi.groupContext, cs.kdf)
1,117✔
855

856
  const confirmationTagVerified = await verifyGroupInfoConfirmationTag(gi, groupSecrets.joinerSecret, pskSecret, cs)
1,060✔
857

858
  if (!confirmationTagVerified) throw new CryptoVerificationError("Could not verify confirmation tag")
1,117✔
859

860
  const secretTree = await createSecretTree(leafWidth(tree.length), keySchedule.encryptionSecret, cs.kdf)
1,060✔
861

862
  return {
1,060✔
863
    groupContext: gi.groupContext,
1,060✔
864
    ratchetTree: tree,
1,060✔
865
    privatePath: updatedPkp,
1,060✔
866
    signaturePrivateKey: privateKeys.signaturePrivateKey,
1,060✔
867
    confirmationTag: gi.confirmationTag,
1,060✔
868
    unappliedProposals: {},
1,060✔
869
    keySchedule,
1,060✔
870
    secretTree,
1,060✔
871
    historicalReceiverData: new Map(),
1,060✔
872
    groupActiveState: { kind: "active" },
1,060✔
873
    clientConfig,
1,060✔
874
  }
1,060✔
875
}
1,060✔
876

877
export async function createGroup(
665✔
878
  groupId: Uint8Array,
665✔
879
  keyPackage: KeyPackage,
665✔
880
  privateKeyPackage: PrivateKeyPackage,
665✔
881
  extensions: Extension[],
665✔
882
  cs: CiphersuiteImpl,
665✔
883
  clientConfig: ClientConfig = defaultClientConfig,
665✔
884
): Promise<ClientState> {
665✔
885
  const ratchetTree: RatchetTree = [{ nodeType: "leaf", leaf: keyPackage.leafNode }]
665✔
886

887
  const privatePath: PrivateKeyPath = {
665✔
888
    leafIndex: 0,
665✔
889
    privateKeys: { [0]: privateKeyPackage.hpkePrivateKey },
665✔
890
  }
665✔
891

892
  const confirmedTranscriptHash = new Uint8Array()
665✔
893

894
  const groupContext: GroupContext = {
665✔
895
    version: "mls10",
665✔
896
    cipherSuite: cs.name,
665✔
897
    epoch: 0n,
665✔
898
    treeHash: await treeHashRoot(ratchetTree, cs.hash),
665✔
899
    groupId,
665✔
900
    extensions,
665✔
901
    confirmedTranscriptHash,
665✔
902
  }
665✔
903

904
  throwIfDefined(await validateExternalSenders(extensions, clientConfig.authService))
665✔
905

906
  const epochSecret = cs.rng.randomBytes(cs.kdf.size)
665✔
907

908
  const keySchedule = await initializeKeySchedule(epochSecret, cs.kdf)
665✔
909

910
  const confirmationTag = await createConfirmationTag(keySchedule.confirmationKey, confirmedTranscriptHash, cs.hash)
665✔
911

912
  const secretTree = await createSecretTree(1, keySchedule.encryptionSecret, cs.kdf)
665✔
913

914
  return {
665✔
915
    ratchetTree,
665✔
916
    keySchedule,
665✔
917
    secretTree,
665✔
918
    privatePath,
665✔
919
    signaturePrivateKey: privateKeyPackage.signaturePrivateKey,
665✔
920
    unappliedProposals: {},
665✔
921
    historicalReceiverData: new Map(),
665✔
922
    groupContext,
665✔
923
    confirmationTag,
665✔
924
    groupActiveState: { kind: "active" },
665✔
925
    clientConfig,
665✔
926
  }
665✔
927
}
665✔
928

929
export async function exportSecret(
38✔
930
  publicKey: Uint8Array,
38✔
931
  cs: CiphersuiteImpl,
38✔
932
): Promise<{ enc: Uint8Array; secret: Uint8Array }> {
38✔
933
  return cs.hpke.exportSecret(
38✔
934
    await cs.hpke.importPublicKey(publicKey),
38✔
935
    new TextEncoder().encode("MLS 1.0 external init secret"),
38✔
936
    cs.kdf.size,
38✔
937
    new Uint8Array(),
38✔
938
  )
38✔
939
}
38✔
940

941
async function importSecret(privateKey: Uint8Array, kemOutput: Uint8Array, cs: CiphersuiteImpl): Promise<Uint8Array> {
76✔
942
  return cs.hpke.importSecret(
76✔
943
    await cs.hpke.importPrivateKey(privateKey),
76✔
944
    new TextEncoder().encode("MLS 1.0 external init secret"),
76✔
945
    kemOutput,
76✔
946
    cs.kdf.size,
76✔
947
    new Uint8Array(),
76✔
948
  )
76✔
949
}
76✔
950

951
async function applyTreeMutations(
4,904✔
952
  ratchetTree: RatchetTree,
4,904✔
953
  grouped: Proposals,
4,904✔
954
  gc: GroupContext,
4,904✔
955
  sentByClient: boolean,
4,904✔
956
  authService: AuthenticationService,
4,904✔
957
  lifetimeConfig: LifetimeConfig,
4,904✔
958
  s: Signature,
4,904✔
959
): Promise<[RatchetTree, [LeafIndex, KeyPackage][]]> {
4,904✔
960
  const treeAfterUpdate = await grouped.update.reduce(async (acc, { senderLeafIndex, proposal }) => {
4,904✔
961
    if (senderLeafIndex === undefined) throw new InternalError("No sender index found for update proposal")
14!
962

963
    throwIfDefined(
14✔
964
      await validateLeafNodeUpdateOrCommit(proposal.update.leafNode, senderLeafIndex, gc, ratchetTree, authService, s),
14✔
965
    )
14✔
966
    return updateLeafNode(await acc, proposal.update.leafNode, toLeafIndex(senderLeafIndex))
14✔
967
  }, Promise.resolve(ratchetTree))
4,904✔
968

969
  const treeAfterRemove = grouped.remove.reduce((acc, { proposal }) => {
4,904✔
970
    throwIfDefined(validateRemove(proposal.remove, ratchetTree))
2,425✔
971

972
    return removeLeafNode(acc, toLeafIndex(proposal.remove.removed))
2,425✔
973
  }, treeAfterUpdate)
4,904✔
974

975
  const [treeAfterAdd, addedLeafNodes] = await grouped.add.reduce(
4,904✔
976
    async (acc, { proposal }) => {
4,904✔
977
      throwIfDefined(
3,014✔
978
        await validateKeyPackage(
3,014✔
979
          proposal.add.keyPackage,
3,014✔
980
          gc,
3,014✔
981
          ratchetTree,
3,014✔
982
          sentByClient,
3,014✔
983
          lifetimeConfig,
3,014✔
984
          authService,
3,014✔
985
          s,
3,014✔
986
        ),
3,014✔
987
      )
3,014✔
988

989
      const [tree, ws] = await acc
3,014✔
990
      const [updatedTree, leafNodeIndex] = addLeafNode(tree, proposal.add.keyPackage.leafNode)
2,938✔
991
      return [
2,938✔
992
        updatedTree,
2,938✔
993
        [...ws, [nodeToLeafIndex(leafNodeIndex), proposal.add.keyPackage] as [LeafIndex, KeyPackage]],
2,938✔
994
      ]
2,938✔
995
    },
3,014✔
996
    Promise.resolve([treeAfterRemove, []] as [RatchetTree, [LeafIndex, KeyPackage][]]),
4,904✔
997
  )
4,904✔
998

999
  return [treeAfterAdd, addedLeafNodes]
4,828✔
1000
}
4,828✔
1001

1002
export async function processProposal(
1,835✔
1003
  state: ClientState,
1,835✔
1004
  content: AuthenticatedContent,
1,835✔
1005
  proposal: Proposal,
1,835✔
1006
  h: Hash,
1,835✔
1007
): Promise<ClientState> {
1,835✔
1008
  const ref = await makeProposalRef(content, h)
1,835✔
1009
  return {
1,835✔
1010
    ...state,
1,835✔
1011
    unappliedProposals: addUnappliedProposal(
1,835✔
1012
      ref,
1,835✔
1013
      state.unappliedProposals,
1,835✔
1014
      proposal,
1,835✔
1015
      getSenderLeafNodeIndex(content.content.sender),
1,835✔
1016
    ),
1,835✔
1017
  }
1,835✔
1018
}
1,835✔
1019

1020
export function addHistoricalReceiverData(state: ClientState): Map<bigint, EpochReceiverData> {
1✔
1021
  const withNew = addToMap(state.historicalReceiverData, state.groupContext.epoch, {
4,942✔
1022
    secretTree: state.secretTree,
4,942✔
1023
    ratchetTree: state.ratchetTree,
4,942✔
1024
    senderDataSecret: state.keySchedule.senderDataSecret,
4,942✔
1025
    groupContext: state.groupContext,
4,942✔
1026
    resumptionPsk: state.keySchedule.resumptionPsk,
4,942✔
1027
  })
4,942✔
1028

1029
  const epochs = [...withNew.keys()]
4,942✔
1030

1031
  const result =
4,942✔
1032
    epochs.length >= state.clientConfig.keyRetentionConfig.retainKeysForEpochs
4,942✔
1033
      ? removeOldHistoricalReceiverData(withNew, state.clientConfig.keyRetentionConfig.retainKeysForEpochs)
2,762✔
1034
      : withNew
2,180✔
1035

1036
  return result
4,942✔
1037
}
4,942✔
1038

1039
function removeOldHistoricalReceiverData(
2,762✔
1040
  historicalReceiverData: Map<bigint, EpochReceiverData>,
2,762✔
1041
  max: number,
2,762✔
1042
): Map<bigint, EpochReceiverData> {
2,762✔
1043
  const sortedEpochs = [...historicalReceiverData.keys()].sort((a, b) => (a < b ? -1 : 1))
2,762✔
1044

1045
  return new Map(sortedEpochs.slice(-max).map((epoch) => [epoch, historicalReceiverData.get(epoch)!]))
2,762✔
1046
}
2,762✔
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