• 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

95.98
/src/clientState.ts
1
import { AuthenticatedContent, makeProposalRef } from "./authenticatedContent.js"
2
import { CiphersuiteImpl } from "./crypto/ciphersuite.js"
3
import { Hash } from "./crypto/hash.js"
4
import { Extension, extensionsEqual, extensionsSupportedByCapabilities } from "./extension.js"
5
import { createConfirmationTag, FramedContentCommit } from "./framedContent.js"
6
import { decodeGroupContext, GroupContext, groupContextEncoder } from "./groupContext.js"
7
import { ratchetTreeFromExtension, verifyGroupInfoConfirmationTag, verifyGroupInfoSignature } from "./groupInfo.js"
8
import { KeyPackage, makeKeyPackageRef, PrivateKeyPackage, verifyKeyPackage } from "./keyPackage.js"
9
import {
10
  decodeKeySchedule,
11
  deriveKeySchedule,
12
  initializeKeySchedule,
13
  KeySchedule,
14
  keyScheduleEncoder,
15
} from "./keySchedule.js"
16
import { pskIdEncoder, PreSharedKeyID } from "./presharedkey.js"
17

18
import {
19
  addLeafNode,
20
  decodeRatchetTree,
21
  findBlankLeafNodeIndexOrExtend,
22
  findLeafIndex,
23
  ratchetTreeEncoder,
24
  removeLeafNode,
25
  updateLeafNode,
26
} from "./ratchetTree.js"
27
import { RatchetTree } from "./ratchetTree.js"
28
import { allSecretTreeValues, createSecretTree, decodeSecretTree, SecretTree, secretTreeEncoder } from "./secretTree.js"
29
import { createConfirmedHash, createInterimHash } from "./transcriptHash.js"
30
import { treeHashRoot } from "./treeHash.js"
31
import {
32
  directPath,
33
  isLeaf,
34
  LeafIndex,
35
  leafToNodeIndex,
36
  leafWidth,
37
  nodeToLeafIndex,
38
  toLeafIndex,
39
  toNodeIndex,
40
} from "./treemath.js"
41
import { firstCommonAncestor } from "./updatePath.js"
42
import { bytesToBase64, zeroOutUint8Array } from "./util/byteArray.js"
43
import { constantTimeEqual } from "./util/constantTimeCompare.js"
44
import { decryptGroupInfo, decryptGroupSecrets, Welcome } from "./welcome.js"
45
import { WireformatName } from "./wireformat.js"
46
import { ProposalOrRef } from "./proposalOrRefType.js"
47
import {
48
  Proposal,
49
  ProposalAdd,
50
  ProposalExternalInit,
51
  ProposalGroupContextExtensions,
52
  ProposalPSK,
53
  ProposalReinit,
54
  ProposalRemove,
55
  ProposalUpdate,
56
  Reinit,
57
  Remove,
58
} from "./proposal.js"
59
import { pathToRoot } from "./pathSecrets.js"
60
import {
61
  PrivateKeyPath,
62
  decodePrivateKeyPath,
63
  mergePrivateKeyPaths,
64
  privateKeyPathEncoder,
65
  toPrivateKeyPath,
66
} from "./privateKeyPath.js"
67
import {
68
  UnappliedProposals,
69
  addUnappliedProposal,
70
  ProposalWithSender,
71
  unappliedProposalsEncoder,
72
  decodeUnappliedProposals,
73
} from "./unappliedProposals.js"
74
import { accumulatePskSecret, PskIndex } from "./pskIndex.js"
75
import { getSenderLeafNodeIndex } from "./sender.js"
76
import { addToMap } from "./util/addToMap.js"
77
import {
78
  CryptoVerificationError,
79
  CodecError,
80
  InternalError,
81
  UsageError,
82
  ValidationError,
83
  MlsError,
84
} from "./mlsError.js"
85
import { Signature } from "./crypto/signature.js"
86
import {
87
  LeafNode,
88
  LeafNodeCommit,
89
  LeafNodeKeyPackage,
90
  LeafNodeUpdate,
91
  verifyLeafNodeSignature,
92
  verifyLeafNodeSignatureKeyPackage,
93
} from "./leafNode.js"
94
import { protocolVersions } from "./protocolVersion.js"
95
import { decodeRequiredCapabilities, RequiredCapabilities } from "./requiredCapabilities.js"
96
import { Capabilities } from "./capabilities.js"
97
import { verifyParentHashes } from "./parentHash.js"
98
import { AuthenticationService } from "./authenticationService.js"
99
import { LifetimeConfig } from "./lifetimeConfig.js"
100
import { KeyPackageEqualityConfig } from "./keyPackageEqualityConfig.js"
101
import { ClientConfig, defaultClientConfig } from "./clientConfig.js"
102
import { decodeExternalSender } from "./externalSender.js"
103
import { arraysEqual } from "./util/array.js"
104
import { BufferEncoder, contramapBufferEncoders, encode, Encoder } from "./codec/tlsEncoder.js"
105
import { CredentialTypeName } from "./credentialType.js"
106
import { bigintMapEncoder, decodeBigintMap, decodeVarLenData, varLenDataEncoder } from "./codec/variableLength.js"
107
import { decodeGroupActiveState, GroupActiveState, groupActiveStateEncoder } from "./groupActiveState.js"
108
import { decodeEpochReceiverData, EpochReceiverData, epochReceiverDataEncoder } from "./epochReceiverData.js"
109
import { Decoder, mapDecoders } from "./codec/tlsDecoder.js"
110
import { deriveSecret } from "./crypto/kdf.js"
111

112
export type ClientState = GroupState & { clientConfig: ClientConfig }
113

114
export interface GroupState {
115
  groupContext: GroupContext
116
  keySchedule: KeySchedule
117
  secretTree: SecretTree
118
  ratchetTree: RatchetTree
119
  privatePath: PrivateKeyPath
120
  signaturePrivateKey: Uint8Array
121
  unappliedProposals: UnappliedProposals
122
  confirmationTag: Uint8Array
123
  historicalReceiverData: Map<bigint, EpochReceiverData>
124
  groupActiveState: GroupActiveState
125
}
126

127
export const groupStateEncoder: BufferEncoder<GroupState> = contramapBufferEncoders(
3✔
128
  [
129
    groupContextEncoder,
130
    keyScheduleEncoder,
131
    secretTreeEncoder,
132
    ratchetTreeEncoder,
133
    privateKeyPathEncoder,
134
    varLenDataEncoder,
135
    unappliedProposalsEncoder,
136
    varLenDataEncoder,
137
    bigintMapEncoder(epochReceiverDataEncoder),
138
    groupActiveStateEncoder,
139
  ],
140
  (state) =>
141
    [
1✔
142
      state.groupContext,
143
      state.keySchedule,
144
      state.secretTree,
145
      state.ratchetTree,
146
      state.privatePath,
147
      state.signaturePrivateKey,
148
      state.unappliedProposals,
149
      state.confirmationTag,
150
      state.historicalReceiverData,
151
      state.groupActiveState,
152
    ] as const,
153
)
154

155
export const encodeGroupState: Encoder<GroupState> = encode(groupStateEncoder)
3✔
156

157
export const decodeGroupState: Decoder<GroupState> = mapDecoders(
3✔
158
  [
159
    decodeGroupContext,
160
    decodeKeySchedule,
161
    decodeSecretTree,
162
    decodeRatchetTree,
163
    decodePrivateKeyPath,
164
    decodeVarLenData,
165
    decodeUnappliedProposals,
166
    decodeVarLenData,
167
    decodeBigintMap(decodeEpochReceiverData),
168
    decodeGroupActiveState,
169
  ],
170
  (
171
    groupContext,
172
    keySchedule,
173
    secretTree,
174
    ratchetTree,
175
    privatePath,
176
    signaturePrivateKey,
177
    unappliedProposals,
178
    confirmationTag,
179
    historicalReceiverData,
180
    groupActiveState,
181
  ) => ({
1✔
182
    groupContext,
183
    keySchedule,
184
    secretTree,
185
    ratchetTree,
186
    privatePath,
187
    signaturePrivateKey,
188
    unappliedProposals,
189
    confirmationTag,
190
    historicalReceiverData,
191
    groupActiveState,
192
  }),
193
)
194

195
export const groupStateEncoderWithoutTree: BufferEncoder<GroupState> = contramapBufferEncoders(
3✔
196
  [
197
    groupContextEncoder,
198
    keyScheduleEncoder,
199
    secretTreeEncoder,
200
    privateKeyPathEncoder,
201
    varLenDataEncoder,
202
    unappliedProposalsEncoder,
203
    varLenDataEncoder,
204
    bigintMapEncoder(epochReceiverDataEncoder),
205
    groupActiveStateEncoder,
206
  ],
207
  (state) =>
208
    [
×
209
      state.groupContext,
210
      state.keySchedule,
211
      state.secretTree,
212
      state.privatePath,
213
      state.signaturePrivateKey,
214
      state.unappliedProposals,
215
      state.confirmationTag,
216
      state.historicalReceiverData,
217
      state.groupActiveState,
218
    ] as const,
219
)
220

221
export const encodeGroupStateWithoutTree: Encoder<GroupState> = encode(groupStateEncoderWithoutTree)
3✔
222

223
export function decodeGroupStateWithoutTree(ratchetTree: RatchetTree): Decoder<GroupState> {
224
  return mapDecoders(
×
225
    [
226
      decodeGroupContext,
227
      decodeKeySchedule,
228
      decodeSecretTree,
229
      decodePrivateKeyPath,
230
      decodeVarLenData,
231
      decodeUnappliedProposals,
232
      decodeVarLenData,
233
      decodeBigintMap(decodeEpochReceiverData),
234
      decodeGroupActiveState,
235
    ],
236
    (
237
      groupContext,
238
      keySchedule,
239
      secretTree,
240
      privatePath,
241
      signaturePrivateKey,
242
      unappliedProposals,
243
      confirmationTag,
244
      historicalReceiverData,
245
      groupActiveState,
246
    ) => ({
×
247
      groupContext,
248
      keySchedule,
249
      secretTree,
250
      ratchetTree,
251
      privatePath,
252
      signaturePrivateKey,
253
      unappliedProposals,
254
      confirmationTag,
255
      historicalReceiverData,
256
      groupActiveState,
257
    }),
258
  )
259
}
260

261
export function getOwnLeafNode(state: ClientState): LeafNode {
262
  const idx = leafToNodeIndex(toLeafIndex(state.privatePath.leafIndex))
3✔
263
  const leaf = state.ratchetTree[idx]
3✔
264
  if (leaf?.nodeType !== "leaf") throw new InternalError("Expected leaf node")
3✔
265
  return leaf.leaf
3✔
266
}
267

268
export function getGroupMembers(state: ClientState): LeafNode[] {
269
  return extractFromGroupMembers(
1✔
270
    state,
271
    () => false,
3✔
272
    (l) => l,
3✔
273
  )
274
}
275

276
export function extractFromGroupMembers<T>(
277
  state: ClientState,
278
  exclude: (l: LeafNode) => boolean,
279
  map: (l: LeafNode) => T,
280
): T[] {
281
  const recipients = []
3✔
282
  for (const node of state.ratchetTree) {
3✔
283
    if (node?.nodeType === "leaf" && !exclude(node.leaf)) {
21✔
284
      recipients.push(map(node.leaf))
8✔
285
    }
286
  }
287
  return recipients
3✔
288
}
289

290
export function checkCanSendApplicationMessages(state: ClientState): void {
291
  if (Object.keys(state.unappliedProposals).length !== 0)
2,527✔
292
    throw new UsageError("Cannot send application message with unapplied proposals")
19✔
293

294
  checkCanSendHandshakeMessages(state)
2,508✔
295
}
296

297
export function checkCanSendHandshakeMessages(state: ClientState): void {
298
  if (state.groupActiveState.kind === "suspendedPendingReinit")
4,505✔
299
    throw new UsageError("Cannot send messages while Group is suspended pending reinit")
4,486✔
300
  else if (state.groupActiveState.kind === "removedFromGroup")
301
    throw new UsageError("Cannot send messages after being removed from group")
76✔
302
}
303

304
export interface Proposals {
305
  add: { senderLeafIndex: number | undefined; proposal: ProposalAdd }[]
306
  update: { senderLeafIndex: number | undefined; proposal: ProposalUpdate }[]
307
  remove: { senderLeafIndex: number | undefined; proposal: ProposalRemove }[]
308
  psk: { senderLeafIndex: number | undefined; proposal: ProposalPSK }[]
309
  reinit: { senderLeafIndex: number | undefined; proposal: ProposalReinit }[]
310
  external_init: { senderLeafIndex: number | undefined; proposal: ProposalExternalInit }[]
311
  group_context_extensions: { senderLeafIndex: number | undefined; proposal: ProposalGroupContextExtensions }[]
312
}
313

314
const emptyProposals: Proposals = {
3✔
315
  add: [],
316
  update: [],
317
  remove: [],
318
  psk: [],
319
  reinit: [],
320
  external_init: [],
321
  group_context_extensions: [],
322
}
323

324
function flattenExtensions(groupContextExtensions: { proposal: ProposalGroupContextExtensions }[]): Extension[] {
325
  return groupContextExtensions.reduce((acc, { proposal }) => {
8,525✔
326
    return [...acc, ...proposal.groupContextExtensions.extensions]
61✔
327
  }, [] as Extension[])
328
}
329

330
async function validateProposals(
331
  p: Proposals,
332
  committerLeafIndex: number | undefined,
333
  groupContext: GroupContext,
334
  config: KeyPackageEqualityConfig,
335
  authService: AuthenticationService,
336
  tree: RatchetTree,
337
): Promise<MlsError | undefined> {
338
  const containsUpdateByCommitter = p.update.some(
5,174✔
339
    (o) => o.senderLeafIndex !== undefined && o.senderLeafIndex === committerLeafIndex,
15✔
340
  )
341

342
  if (containsUpdateByCommitter)
5,174✔
343
    return new ValidationError("Commit cannot contain an update proposal sent by committer")
1✔
344

345
  const containsRemoveOfCommitter = p.remove.some((o) => o.proposal.remove.removed === committerLeafIndex)
5,173✔
346

347
  if (containsRemoveOfCommitter)
5,173✔
348
    return new ValidationError("Commit cannot contain a remove proposal removing committer")
1✔
349

350
  const multipleUpdateRemoveForSameLeaf =
351
    p.update.some(
5,172✔
352
      ({ senderLeafIndex: a }, indexA) =>
353
        p.update.some(({ senderLeafIndex: b }, indexB) => a === b && indexA !== indexB) ||
14✔
354
        p.remove.some((r) => r.proposal.remove.removed === a),
7✔
355
    ) ||
356
    p.remove.some(
357
      (a, indexA) =>
358
        p.remove.some((b, indexB) => b.proposal.remove.removed === a.proposal.remove.removed && indexA !== indexB) ||
40,379✔
359
        p.update.some(({ senderLeafIndex }) => a.proposal.remove.removed === senderLeafIndex),
7✔
360
    )
361

362
  if (multipleUpdateRemoveForSameLeaf)
5,174✔
363
    return new ValidationError(
1✔
364
      "Commit cannot contain multiple update and/or remove proposals that apply to the same leaf",
365
    )
366

367
  const multipleAddsContainSameKeypackage = p.add.some(({ proposal: a }, indexA) =>
5,171✔
368
    p.add.some(
3,178✔
369
      ({ proposal: b }, indexB) => config.compareKeyPackages(a.add.keyPackage, b.add.keyPackage) && indexA !== indexB,
32,921✔
370
    ),
371
  )
372

373
  if (multipleAddsContainSameKeypackage)
5,171✔
374
    return new ValidationError(
1✔
375
      "Commit cannot contain multiple Add proposals that contain KeyPackages that represent the same client",
376
    )
377

378
  // checks if there is an Add proposal with a KeyPackage that matches a client already in the group
379
  // unless there is a Remove proposal in the list removing the matching client from the group.
380
  const addsContainExistingKeypackage = p.add.some(({ proposal }) =>
5,170✔
381
    tree.some(
3,177✔
382
      (node, nodeIndex) =>
383
        node !== undefined &&
186,515✔
384
        node.nodeType === "leaf" &&
385
        config.compareKeyPackageToLeafNode(proposal.add.keyPackage, node.leaf) &&
386
        p.remove.every((r) => r.proposal.remove.removed !== nodeToLeafIndex(toNodeIndex(nodeIndex))),
×
387
    ),
388
  )
389

390
  if (addsContainExistingKeypackage)
5,170✔
391
    return new ValidationError("Commit cannot contain an Add proposal for someone already in the group")
1✔
392

393
  const everyLeafSupportsGroupExtensions = p.add.every(({ proposal }) =>
5,169✔
394
    extensionsSupportedByCapabilities(groupContext.extensions, proposal.add.keyPackage.leafNode.capabilities),
395
  )
396

397
  if (!everyLeafSupportsGroupExtensions)
5,169✔
398
    return new ValidationError("Added leaf node that doesn't support extension in GroupContext")
19✔
399

400
  const multiplePskWithSamePskId = p.psk.some((a, indexA) =>
5,150✔
401
    p.psk.some(
209✔
402
      (b, indexB) =>
314✔
403
        constantTimeEqual(
404
          encode(pskIdEncoder)(a.proposal.psk.preSharedKeyId),
405
          encode(pskIdEncoder)(b.proposal.psk.preSharedKeyId),
406
        ) && indexA !== indexB,
407
    ),
408
  )
409

410
  if (multiplePskWithSamePskId)
5,150✔
411
    return new ValidationError("Commit cannot contain PreSharedKey proposals that reference the same PreSharedKeyID")
1✔
412

413
  const multipleGroupContextExtensions = p.group_context_extensions.length > 1
5,149✔
414

415
  if (multipleGroupContextExtensions)
5,149✔
416
    return new ValidationError("Commit cannot contain multiple GroupContextExtensions proposals")
1✔
417

418
  const allExtensions = flattenExtensions(p.group_context_extensions)
5,148✔
419

420
  const requiredCapabilities = allExtensions.find((e) => e.extensionType === "required_capabilities")
5,148✔
421

422
  if (requiredCapabilities !== undefined) {
5,148✔
423
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
3✔
424
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
3✔
425

426
    const everyLeafSupportsCapabilities = tree
2✔
427
      .filter((n) => n !== undefined && n.nodeType === "leaf")
14✔
428
      .every((l) => capabiltiesAreSupported(caps[0], l.leaf.capabilities))
4✔
429

430
    if (!everyLeafSupportsCapabilities) return new ValidationError("Not all members support required capabilities")
2✔
431

432
    const allAdditionsSupportCapabilities = p.add.every((a) =>
1✔
433
      capabiltiesAreSupported(caps[0], a.proposal.add.keyPackage.leafNode.capabilities),
1✔
434
    )
435

436
    if (!allAdditionsSupportCapabilities)
1✔
437
      return new ValidationError("Commit contains add proposals of member without required capabilities")
1✔
438
  }
439

440
  return await validateExternalSenders(allExtensions, authService)
5,145✔
441
}
442

443
async function validateExternalSenders(
444
  extensions: Extension[],
445
  authService: AuthenticationService,
446
): Promise<MlsError | undefined> {
447
  const externalSenders = extensions.filter((e) => e.extensionType === "external_senders")
6,061✔
448
  for (const externalSender of externalSenders) {
6,061✔
449
    const decoded = decodeExternalSender(externalSender.extensionData, 0)
21✔
450
    if (decoded === undefined) return new CodecError("Could not decode external_senders")
21✔
451

452
    const validCredential = await authService.validateCredential(decoded[0].credential, decoded[0].signaturePublicKey)
20✔
453
    if (!validCredential) return new ValidationError("Could not validate external credential")
20✔
454
  }
455
}
456

457
function capabiltiesAreSupported(caps: RequiredCapabilities, cs: Capabilities): boolean {
458
  return (
81✔
459
    caps.credentialTypes.every((c) => cs.credentials.includes(c)) &&
460
    caps.extensionTypes.every((e) => cs.extensions.includes(e)) &&
461
    caps.proposalTypes.every((p) => cs.proposals.includes(p))
1✔
462
  )
463
}
464

465
export async function validateRatchetTree(
466
  tree: RatchetTree,
467
  groupContext: GroupContext,
468
  config: LifetimeConfig,
469
  authService: AuthenticationService,
470
  treeHash: Uint8Array,
471
  cs: CiphersuiteImpl,
472
): Promise<MlsError | undefined> {
473
  const hpkeKeys = new Set<string>()
1,290✔
474
  const signatureKeys = new Set<string>()
1,290✔
475
  const credentialTypes = new Set<CredentialTypeName>()
1,290✔
476
  for (const [i, n] of tree.entries()) {
1,290✔
477
    const nodeIndex = toNodeIndex(i)
8,758✔
478
    if (n?.nodeType === "leaf") {
8,758✔
479
      if (!isLeaf(nodeIndex)) return new ValidationError("Received Ratchet Tree is not structurally sound")
4,546✔
480

481
      const hpkeKey = bytesToBase64(n.leaf.hpkePublicKey)
4,546✔
482
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
4,546✔
483
      else hpkeKeys.add(hpkeKey)
4,527✔
484

485
      const signatureKey = bytesToBase64(n.leaf.signaturePublicKey)
4,527✔
486
      if (signatureKeys.has(signatureKey)) return new ValidationError("signature keys not unique")
4,527✔
487
      else signatureKeys.add(signatureKey)
4,508✔
488

489
      credentialTypes.add(n.leaf.credential.credentialType)
4,508✔
490

491
      const err =
492
        n.leaf.leafNodeSource === "key_package"
4,508✔
493
          ? await validateLeafNodeKeyPackage(n.leaf, groupContext, false, config, authService, cs.signature)
494
          : await validateLeafNodeUpdateOrCommit(
495
              n.leaf,
496
              nodeToLeafIndex(nodeIndex),
497
              groupContext,
498
              authService,
499
              cs.signature,
500
            )
501

502
      if (err !== undefined) return err
436✔
503
    } else if (n?.nodeType === "parent") {
4,212✔
504
      if (isLeaf(nodeIndex)) return new ValidationError("Received Ratchet Tree is not structurally sound")
528✔
505

506
      const hpkeKey = bytesToBase64(n.parent.hpkePublicKey)
509✔
507
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
509✔
508
      else hpkeKeys.add(hpkeKey)
509✔
509

510
      for (const unmergedLeaf of n.parent.unmergedLeaves) {
509✔
511
        const leafIndex = toLeafIndex(unmergedLeaf)
114✔
512
        const dp = directPath(leafToNodeIndex(leafIndex), leafWidth(tree.length))
114✔
513
        const nodeIndex = leafToNodeIndex(leafIndex)
114✔
514
        if (tree[nodeIndex]?.nodeType !== "leaf" && !dp.includes(toNodeIndex(i)))
114✔
515
          return new ValidationError("Unmerged leaf did not represent a non-blank descendant leaf node")
×
516

517
        for (const parentIdx of dp) {
114✔
518
          const dpNode = tree[parentIdx]
342✔
519

520
          if (dpNode !== undefined) {
342✔
521
            if (dpNode.nodeType !== "parent") return new InternalError("Expected parent node")
114✔
522

523
            if (!arraysEqual(dpNode.parent.unmergedLeaves, n.parent.unmergedLeaves))
114✔
524
              return new ValidationError("non-blank intermediate node must list leaf node in its unmerged_leaves")
×
525
          }
526
        }
527
      }
528
    }
529
  }
530

531
  for (const n of tree) {
1,176✔
532
    if (n?.nodeType === "leaf") {
8,568✔
533
      for (const credentialType of credentialTypes) {
4,413✔
534
        if (!n.leaf.capabilities.credentials.includes(credentialType))
4,413✔
535
          return new ValidationError("LeafNode has credential that is not supported by member of the group")
×
536
      }
537
    }
538
  }
539

540
  const parentHashesVerified = await verifyParentHashes(tree, cs.hash)
1,176✔
541

542
  if (!parentHashesVerified) return new CryptoVerificationError("Unable to verify parent hash")
1,176✔
543

544
  if (!constantTimeEqual(treeHash, await treeHashRoot(tree, cs.hash)))
1,157✔
545
    return new ValidationError("Unable to verify tree hash")
19✔
546
}
547

548
export async function validateLeafNodeUpdateOrCommit(
549
  leafNode: LeafNodeCommit | LeafNodeUpdate,
550
  leafIndex: number,
551
  groupContext: GroupContext,
552
  authService: AuthenticationService,
553
  s: Signature,
554
): Promise<MlsError | undefined> {
555
  const signatureValid = await verifyLeafNodeSignature(leafNode, groupContext.groupId, leafIndex, s)
2,457✔
556

557
  if (!signatureValid) return new CryptoVerificationError("Could not verify leaf node signature")
2,457✔
558

559
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, authService)
2,438✔
560

561
  if (commonError !== undefined) return commonError
2,438✔
562
}
563

564
export function throwIfDefined(err: MlsError | undefined): void {
565
  if (err !== undefined) throw err
17,118✔
566
}
567

568
async function validateLeafNodeCommon(
569
  leafNode: LeafNode,
570
  groupContext: GroupContext,
571
  authService: AuthenticationService,
572
) {
573
  const credentialValid = await authService.validateCredential(leafNode.credential, leafNode.signaturePublicKey)
9,608✔
574

575
  if (!credentialValid) return new ValidationError("Could not validate credential")
9,608✔
576

577
  const requiredCapabilities = groupContext.extensions.find((e) => e.extensionType === "required_capabilities")
9,588✔
578

579
  if (requiredCapabilities !== undefined) {
9,588✔
580
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
76✔
581
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
76✔
582

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

585
    if (!leafSupportsCapabilities) return new ValidationError("LeafNode does not support required capabilities")
76✔
586
  }
587

588
  const extensionsSupported = extensionsSupportedByCapabilities(leafNode.extensions, leafNode.capabilities)
9,569✔
589

590
  if (!extensionsSupported) return new ValidationError("LeafNode contains extension not listed in capabilities")
9,569✔
591
}
592

593
async function validateLeafNodeKeyPackage(
594
  leafNode: LeafNodeKeyPackage,
595
  groupContext: GroupContext,
596
  sentByClient: boolean,
597
  config: LifetimeConfig,
598
  authService: AuthenticationService,
599
  s: Signature,
600
): Promise<MlsError | undefined> {
601
  const signatureValid = await verifyLeafNodeSignatureKeyPackage(leafNode, s)
7,189✔
602
  if (!signatureValid) return new CryptoVerificationError("Could not verify leaf node signature")
7,189✔
603

604
  //verify lifetime
605
  if (sentByClient || config.validateLifetimeOnReceive) {
7,170✔
606
    if (leafNode.leafNodeSource === "key_package") {
1,107✔
607
      const currentTime = BigInt(Math.floor(Date.now() / 1000))
1,107✔
608
      if (leafNode.lifetime.notBefore > currentTime || leafNode.lifetime.notAfter < currentTime)
1,107✔
609
        return new ValidationError("Current time not within Lifetime")
×
610
    }
611
  }
612

613
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, authService)
7,170✔
614

615
  if (commonError !== undefined) return commonError
7,170✔
616
}
617

618
export async function validateLeafNodeCredentialAndKeyUniqueness(
619
  tree: RatchetTree,
620
  leafNode: LeafNode,
621
  existingLeafIndex?: number,
622
): Promise<ValidationError | undefined> {
623
  const hpkeKeys = new Set<string>()
5,139✔
624
  const signatureKeys = new Set<string>()
5,139✔
625
  for (const [nodeIndex, node] of tree.entries()) {
5,139✔
626
    if (node?.nodeType === "leaf") {
222,571✔
627
      if (!node.leaf.capabilities.credentials.includes(leafNode.credential.credentialType)) {
46,544✔
628
        return new ValidationError("LeafNode has credential that is not supported by member of the group")
1✔
629
      }
630

631
      const hpkeKey = bytesToBase64(node.leaf.hpkePublicKey)
46,543✔
632
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
46,543✔
633
      else hpkeKeys.add(hpkeKey)
46,543✔
634

635
      const signatureKey = bytesToBase64(node.leaf.signaturePublicKey)
46,543✔
636
      if (signatureKeys.has(signatureKey) && existingLeafIndex !== nodeToLeafIndex(toNodeIndex(nodeIndex)))
46,543✔
637
        return new ValidationError("signature keys not unique")
×
638
      else signatureKeys.add(signatureKey)
46,543✔
639
    } else if (node?.nodeType === "parent") {
176,027✔
640
      const hpkeKey = bytesToBase64(node.parent.hpkePublicKey)
16,851✔
641
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
16,851✔
642
      else hpkeKeys.add(hpkeKey)
16,851✔
643
    }
644
  }
645
}
646

647
async function validateKeyPackage(
648
  kp: KeyPackage,
649
  groupContext: GroupContext,
650
  tree: RatchetTree,
651
  sentByClient: boolean,
652
  config: LifetimeConfig,
653
  authService: AuthenticationService,
654
  s: Signature,
655
): Promise<MlsError | undefined> {
656
  if (kp.cipherSuite !== groupContext.cipherSuite) return new ValidationError("Invalid CipherSuite")
3,156✔
657

658
  if (kp.version !== groupContext.version) return new ValidationError("Invalid mls version")
3,137✔
659

660
  const leafNodeConsistentWithTree = await validateLeafNodeCredentialAndKeyUniqueness(tree, kp.leafNode)
3,118✔
661

662
  if (leafNodeConsistentWithTree !== undefined) return leafNodeConsistentWithTree
3,118✔
663

664
  const leafNodeError = await validateLeafNodeKeyPackage(
3,117✔
665
    kp.leafNode,
666
    groupContext,
667
    sentByClient,
668
    config,
669
    authService,
670
    s,
671
  )
672
  if (leafNodeError !== undefined) return leafNodeError
3,117✔
673

674
  const signatureValid = await verifyKeyPackage(kp, s)
3,096✔
675
  if (!signatureValid) return new CryptoVerificationError("Invalid keypackage signature")
3,096✔
676

677
  if (constantTimeEqual(kp.initKey, kp.leafNode.hpkePublicKey))
3,077✔
678
    return new ValidationError("Cannot have identicial init and encryption keys")
3,077✔
679
}
680

681
function validateReinit(
682
  allProposals: ProposalWithSender[],
683
  reinit: Reinit,
684
  gc: GroupContext,
685
): ValidationError | undefined {
686
  if (allProposals.length !== 1) return new ValidationError("Reinit proposal needs to be commited by itself")
77✔
687

688
  if (protocolVersions[reinit.version] < protocolVersions[gc.version])
77✔
689
    return new ValidationError("A ReInit proposal cannot use a version less than the version for the current group")
×
690
}
691

692
function validateExternalInit(grouped: Proposals): ValidationError | undefined {
693
  if (grouped.external_init.length > 1)
76✔
694
    return new ValidationError("Cannot contain more than one external_init proposal")
×
695

696
  if (grouped.remove.length > 1) return new ValidationError("Cannot contain more than one remove proposal")
76✔
697

698
  if (
76✔
699
    grouped.add.length > 0 ||
700
    grouped.group_context_extensions.length > 0 ||
701
    grouped.reinit.length > 0 ||
702
    grouped.update.length > 0
703
  )
704
    return new ValidationError("Invalid proposals")
×
705
}
706

707
function validateRemove(remove: Remove, tree: RatchetTree): MlsError | undefined {
708
  if (tree[leafToNodeIndex(toLeafIndex(remove.removed))] === undefined)
2,425✔
709
    return new ValidationError("Tried to remove empty leaf node")
×
710
}
711

712
export interface ApplyProposalsResult {
713
  tree: RatchetTree
714
  pskSecret: Uint8Array
715
  pskIds: PreSharedKeyID[]
716
  needsUpdatePath: boolean
717
  additionalResult: ApplyProposalsData
718
  selfRemoved: boolean
719
  allProposals: ProposalWithSender[]
720
}
721

722
export type ApplyProposalsData =
723
  | { kind: "memberCommit"; addedLeafNodes: [LeafIndex, KeyPackage][]; extensions: Extension[] }
724
  | { kind: "externalCommit"; externalInitSecret: Uint8Array; newMemberLeafIndex: LeafIndex }
725
  | { kind: "reinit"; reinit: Reinit }
726

727
export async function applyProposals(
728
  state: ClientState,
729
  proposals: ProposalOrRef[],
730
  committerLeafIndex: LeafIndex | undefined,
731
  pskSearch: PskIndex,
732
  sentByClient: boolean,
733
  cs: CiphersuiteImpl,
734
): Promise<ApplyProposalsResult> {
735
  const allProposals = proposals.reduce((acc, cur) => {
5,327✔
736
    if (cur.proposalOrRefType === "proposal")
6,096✔
737
      return [...acc, { proposal: cur.proposal, senderLeafIndex: committerLeafIndex }]
4,242✔
738

739
    const p = state.unappliedProposals[bytesToBase64(cur.reference)]
1,854✔
740
    if (p === undefined) throw new ValidationError("Could not find proposal with supplied reference")
1,854✔
741
    return [...acc, p]
1,854✔
742
  }, [] as ProposalWithSender[])
743

744
  const grouped = allProposals.reduce((acc, cur) => {
5,327✔
745
    //this skips any custom proposals
746
    if (typeof cur.proposal.proposalType === "number") return acc
6,096✔
747
    const proposal = acc[cur.proposal.proposalType] ?? []
6,058✔
748
    return { ...acc, [cur.proposal.proposalType]: [...proposal, cur] }
6,096✔
749
  }, emptyProposals)
750

751
  const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
5,327✔
752

753
  const isExternalInit = grouped.external_init.length > 0
5,327✔
754

755
  if (!isExternalInit) {
5,327✔
756
    if (grouped.reinit.length > 0) {
5,251✔
757
      const reinit = grouped.reinit.at(0)!.proposal.reinit
77✔
758

759
      throwIfDefined(validateReinit(allProposals, reinit, state.groupContext))
77✔
760

761
      return {
77✔
762
        tree: state.ratchetTree,
763
        pskSecret: zeroes,
764
        pskIds: [],
765
        needsUpdatePath: false,
766
        additionalResult: {
767
          kind: "reinit",
768
          reinit,
769
        },
770
        selfRemoved: false,
771
        allProposals,
772
      }
773
    }
774

775
    throwIfDefined(
5,174✔
776
      await validateProposals(
777
        grouped,
778
        committerLeafIndex,
779
        state.groupContext,
780
        state.clientConfig.keyPackageEqualityConfig,
781
        state.clientConfig.authService,
782
        state.ratchetTree,
783
      ),
784
    )
785

786
    const newExtensions = flattenExtensions(grouped.group_context_extensions)
5,174✔
787

788
    const [mutatedTree, addedLeafNodes] = await applyTreeMutations(
5,174✔
789
      state.ratchetTree,
790
      grouped,
791
      state.groupContext,
792
      sentByClient,
793
      state.clientConfig.authService,
794
      state.clientConfig.lifetimeConfig,
795
      cs.signature,
796
    )
797

798
    const [updatedPskSecret, pskIds] = await accumulatePskSecret(
5,064✔
799
      grouped.psk.map((p) => p.proposal.psk.preSharedKeyId),
208✔
800
      pskSearch,
801
      cs,
802
      zeroes,
803
    )
804

805
    const selfRemoved = mutatedTree[leafToNodeIndex(toLeafIndex(state.privatePath.leafIndex))] === undefined
5,064✔
806

807
    const needsUpdatePath =
808
      allProposals.length === 0 || Object.values(grouped.update).length > 1 || Object.values(grouped.remove).length > 1
5,064✔
809

810
    return {
5,251✔
811
      tree: mutatedTree,
812
      pskSecret: updatedPskSecret,
813
      additionalResult: {
814
        kind: "memberCommit" as const,
815
        addedLeafNodes,
816
        extensions: newExtensions,
817
      },
818
      pskIds,
819
      needsUpdatePath,
820
      selfRemoved,
821
      allProposals,
822
    }
823
  } else {
824
    throwIfDefined(validateExternalInit(grouped))
76✔
825

826
    const treeAfterRemove = grouped.remove.reduce((acc, { proposal }) => {
76✔
827
      return removeLeafNode(acc, toLeafIndex(proposal.remove.removed))
38✔
828
    }, state.ratchetTree)
829

830
    const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
76✔
831

832
    const [updatedPskSecret, pskIds] = await accumulatePskSecret(
76✔
833
      grouped.psk.map((p) => p.proposal.psk.preSharedKeyId),
×
834
      pskSearch,
835
      cs,
836
      zeroes,
837
    )
838

839
    const initProposal = grouped.external_init.at(0)!
76✔
840

841
    const externalKeyPair = await cs.hpke.deriveKeyPair(state.keySchedule.externalSecret)
76✔
842

843
    const externalInitSecret = await importSecret(
76✔
844
      await cs.hpke.exportPrivateKey(externalKeyPair.privateKey),
845
      initProposal.proposal.externalInit.kemOutput,
846
      cs,
847
    )
848

849
    return {
76✔
850
      needsUpdatePath: true,
851
      tree: treeAfterRemove,
852
      pskSecret: updatedPskSecret,
853
      pskIds,
854
      additionalResult: {
855
        kind: "externalCommit",
856
        externalInitSecret,
857
        newMemberLeafIndex: nodeToLeafIndex(findBlankLeafNodeIndexOrExtend(treeAfterRemove)),
858
      },
859
      selfRemoved: false,
860
      allProposals,
861
    }
862
  }
863
}
864

865
export function makePskIndex(state: ClientState | undefined, externalPsks: Record<string, Uint8Array>): PskIndex {
866
  return {
8,469✔
867
    findPsk(preSharedKeyId) {
868
      if (preSharedKeyId.psktype === "external") {
441✔
869
        return externalPsks[bytesToBase64(preSharedKeyId.pskId)]
261✔
870
      }
871

872
      if (state !== undefined && constantTimeEqual(preSharedKeyId.pskGroupId, state.groupContext.groupId)) {
180✔
873
        if (preSharedKeyId.pskEpoch === state.groupContext.epoch) return state.keySchedule.resumptionPsk
180✔
874
        else return state.historicalReceiverData.get(preSharedKeyId.pskEpoch)?.resumptionPsk
28✔
875
      }
876
    },
877
  }
878
}
879

880
export async function nextEpochContext(
881
  groupContext: GroupContext,
882
  wireformat: WireformatName,
883
  content: FramedContentCommit,
884
  signature: Uint8Array,
885
  updatedTreeHash: Uint8Array,
886
  confirmationTag: Uint8Array,
887
  h: Hash,
888
): Promise<GroupContext> {
889
  const interimTranscriptHash = await createInterimHash(groupContext.confirmedTranscriptHash, confirmationTag, h)
4,333✔
890
  const newConfirmedHash = await createConfirmedHash(interimTranscriptHash, { wireformat, content, signature }, h)
4,333✔
891

892
  return {
4,333✔
893
    ...groupContext,
894
    epoch: groupContext.epoch + 1n,
895
    treeHash: updatedTreeHash,
896
    confirmedTranscriptHash: newConfirmedHash,
897
  }
898
}
899

900
export async function joinGroup(
901
  welcome: Welcome,
902
  keyPackage: KeyPackage,
903
  privateKeys: PrivateKeyPackage,
904
  pskSearch: PskIndex,
905
  cs: CiphersuiteImpl,
906
  ratchetTree?: RatchetTree,
907
  resumingFromState?: ClientState,
908
  clientConfig: ClientConfig = defaultClientConfig,
909
): Promise<ClientState> {
910
  const res = await joinGroupWithExtensions(
1,100✔
911
    welcome,
912
    keyPackage,
913
    privateKeys,
914
    pskSearch,
915
    cs,
916
    ratchetTree,
917
    resumingFromState,
918
    clientConfig,
919
  )
920

921
  return res[0]
1,043✔
922
}
923

924
export async function joinGroupWithExtensions(
925
  welcome: Welcome,
926
  keyPackage: KeyPackage,
927
  privateKeys: PrivateKeyPackage,
928
  pskSearch: PskIndex,
929
  cs: CiphersuiteImpl,
930
  ratchetTree?: RatchetTree,
931
  resumingFromState?: ClientState,
932
  clientConfig: ClientConfig = defaultClientConfig,
933
): Promise<[ClientState, Extension[]]> {
934
  const keyPackageRef = await makeKeyPackageRef(keyPackage, cs.hash)
1,157✔
935
  const privKey = await cs.hpke.importPrivateKey(privateKeys.initPrivateKey)
1,157✔
936
  const groupSecrets = await decryptGroupSecrets(privKey, keyPackageRef, welcome, cs.hpke)
1,157✔
937

938
  if (groupSecrets === undefined) throw new CodecError("Could not decode group secrets")
1,157✔
939

940
  const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
1,157✔
941

942
  const [pskSecret, pskIds] = await accumulatePskSecret(groupSecrets.psks, pskSearch, cs, zeroes)
1,157✔
943

944
  const gi = await decryptGroupInfo(welcome, groupSecrets.joinerSecret, pskSecret, cs)
1,157✔
945
  if (gi === undefined) throw new CodecError("Could not decode group info")
1,157✔
946

947
  const resumptionPsk = pskIds.find((id) => id.psktype === "resumption")
1,157✔
948
  if (resumptionPsk !== undefined) {
1,157✔
949
    if (resumingFromState === undefined) throw new ValidationError("No prior state passed for resumption")
95✔
950

951
    if (resumptionPsk.pskEpoch !== resumingFromState.groupContext.epoch) throw new ValidationError("Epoch mismatch")
95✔
952

953
    if (!constantTimeEqual(resumptionPsk.pskGroupId, resumingFromState.groupContext.groupId))
95✔
954
      throw new ValidationError("old groupId mismatch")
×
955

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

958
    if (resumptionPsk.usage === "reinit") {
95✔
959
      if (resumingFromState.groupActiveState.kind !== "suspendedPendingReinit")
76✔
960
        throw new ValidationError("Found reinit psk but no old suspended clientState")
×
961

962
      if (!constantTimeEqual(resumingFromState.groupActiveState.reinit.groupId, gi.groupContext.groupId))
76✔
963
        throw new ValidationError("new groupId mismatch")
19✔
964

965
      if (resumingFromState.groupActiveState.reinit.version !== gi.groupContext.version)
57✔
966
        throw new ValidationError("Version mismatch")
19✔
967

968
      if (resumingFromState.groupActiveState.reinit.cipherSuite !== gi.groupContext.cipherSuite)
38✔
969
        throw new ValidationError("Ciphersuite mismatch")
×
970

971
      if (!extensionsEqual(resumingFromState.groupActiveState.reinit.extensions, gi.groupContext.extensions))
38✔
972
        throw new ValidationError("Extensions mismatch")
19✔
973
    }
974
  }
975

976
  const allExtensionsSupported = extensionsSupportedByCapabilities(
1,100✔
977
    gi.groupContext.extensions,
978
    keyPackage.leafNode.capabilities,
979
  )
980
  if (!allExtensionsSupported) throw new UsageError("client does not support every extension in the GroupContext")
1,100✔
981

982
  const tree = ratchetTreeFromExtension(gi) ?? ratchetTree
1,100✔
983

984
  if (tree === undefined) throw new UsageError("No RatchetTree passed and no ratchet_tree extension")
1,157✔
985

986
  const signerNode = tree[leafToNodeIndex(toLeafIndex(gi.signer))]
1,100✔
987

988
  if (signerNode === undefined) {
1,100✔
989
    throw new ValidationError("Could not find signer leafNode")
×
990
  }
991
  if (signerNode.nodeType === "parent") throw new ValidationError("Expected non blank leaf node")
1,100✔
992

993
  const credentialVerified = await clientConfig.authService.validateCredential(
1,100✔
994
    signerNode.leaf.credential,
995
    signerNode.leaf.signaturePublicKey,
996
  )
997

998
  if (!credentialVerified) throw new ValidationError("Could not validate credential")
1,100✔
999

1000
  const groupInfoSignatureVerified = await verifyGroupInfoSignature(
1,100✔
1001
    gi,
1002
    signerNode.leaf.signaturePublicKey,
1003
    cs.signature,
1004
  )
1005

1006
  if (!groupInfoSignatureVerified) throw new CryptoVerificationError("Could not verify groupInfo signature")
1,100✔
1007

1008
  if (gi.groupContext.cipherSuite !== keyPackage.cipherSuite)
1,100✔
1009
    throw new ValidationError("cipher suite in the GroupInfo does not match the cipher_suite in the KeyPackage")
1,100✔
1010

1011
  throwIfDefined(
1,100✔
1012
    await validateRatchetTree(
1013
      tree,
1014
      gi.groupContext,
1015
      clientConfig.lifetimeConfig,
1016
      clientConfig.authService,
1017
      gi.groupContext.treeHash,
1018
      cs,
1019
    ),
1020
  )
1021

1022
  const newLeaf = findLeafIndex(tree, keyPackage.leafNode)
1,100✔
1023

1024
  if (newLeaf === undefined) throw new ValidationError("Could not find own leaf when processing welcome")
1,100✔
1025

1026
  const privateKeyPath: PrivateKeyPath = {
1,100✔
1027
    leafIndex: newLeaf,
1028
    privateKeys: { [leafToNodeIndex(newLeaf)]: privateKeys.hpkePrivateKey },
1029
  }
1030

1031
  const ancestorNodeIndex = firstCommonAncestor(tree, newLeaf, toLeafIndex(gi.signer))
1,100✔
1032

1033
  const updatedPkp =
1034
    groupSecrets.pathSecret === undefined
1,100✔
1035
      ? privateKeyPath
1036
      : mergePrivateKeyPaths(
1037
          await toPrivateKeyPath(
1038
            await pathToRoot(tree, ancestorNodeIndex, groupSecrets.pathSecret, cs.kdf),
1039
            newLeaf,
1040
            cs,
1041
          ),
1042
          privateKeyPath,
1043
        )
1044

1045
  const [keySchedule, encryptionSecret] = await deriveKeySchedule(
1,157✔
1046
    groupSecrets.joinerSecret,
1047
    pskSecret,
1048
    gi.groupContext,
1049
    cs.kdf,
1050
  )
1051

1052
  const confirmationTagVerified = await verifyGroupInfoConfirmationTag(gi, groupSecrets.joinerSecret, pskSecret, cs)
1,100✔
1053

1054
  if (!confirmationTagVerified) throw new CryptoVerificationError("Could not verify confirmation tag")
1,100✔
1055

1056
  const secretTree = await createSecretTree(leafWidth(tree.length), encryptionSecret, cs.kdf)
1,100✔
1057

1058
  zeroOutUint8Array(encryptionSecret)
1,100✔
1059
  zeroOutUint8Array(groupSecrets.joinerSecret)
1,100✔
1060

1061
  return [
1,100✔
1062
    {
1063
      groupContext: gi.groupContext,
1064
      ratchetTree: tree,
1065
      privatePath: updatedPkp,
1066
      signaturePrivateKey: privateKeys.signaturePrivateKey,
1067
      confirmationTag: gi.confirmationTag,
1068
      unappliedProposals: {},
1069
      keySchedule,
1070
      secretTree,
1071
      historicalReceiverData: new Map(),
1072
      groupActiveState: { kind: "active" },
1073
      clientConfig,
1074
    },
1075
    gi.extensions,
1076
  ]
1077
}
1078

1079
export async function createGroup(
1080
  groupId: Uint8Array,
1081
  keyPackage: KeyPackage,
1082
  privateKeyPackage: PrivateKeyPackage,
1083
  extensions: Extension[],
1084
  cs: CiphersuiteImpl,
1085
  clientConfig: ClientConfig = defaultClientConfig,
1086
): Promise<ClientState> {
1087
  const ratchetTree: RatchetTree = [{ nodeType: "leaf", leaf: keyPackage.leafNode }]
856✔
1088

1089
  const privatePath: PrivateKeyPath = {
856✔
1090
    leafIndex: 0,
1091
    privateKeys: { [0]: privateKeyPackage.hpkePrivateKey },
1092
  }
1093

1094
  const confirmedTranscriptHash = new Uint8Array()
856✔
1095

1096
  const groupContext: GroupContext = {
856✔
1097
    version: "mls10",
1098
    cipherSuite: cs.name,
1099
    epoch: 0n,
1100
    treeHash: await treeHashRoot(ratchetTree, cs.hash),
1101
    groupId,
1102
    extensions,
1103
    confirmedTranscriptHash,
1104
  }
1105

1106
  throwIfDefined(await validateExternalSenders(extensions, clientConfig.authService))
856✔
1107

1108
  const epochSecret = cs.rng.randomBytes(cs.kdf.size)
856✔
1109

1110
  const keySchedule = await initializeKeySchedule(epochSecret, cs.kdf)
856✔
1111

1112
  const confirmationTag = await createConfirmationTag(keySchedule.confirmationKey, confirmedTranscriptHash, cs.hash)
856✔
1113

1114
  const encryptionSecret = await deriveSecret(epochSecret, "encryption", cs.kdf)
856✔
1115

1116
  const secretTree = await createSecretTree(1, encryptionSecret, cs.kdf)
856✔
1117

1118
  zeroOutUint8Array(epochSecret)
856✔
1119

1120
  return {
856✔
1121
    ratchetTree,
1122
    keySchedule,
1123
    secretTree,
1124
    privatePath,
1125
    signaturePrivateKey: privateKeyPackage.signaturePrivateKey,
1126
    unappliedProposals: {},
1127
    historicalReceiverData: new Map(),
1128
    groupContext,
1129
    confirmationTag,
1130
    groupActiveState: { kind: "active" },
1131
    clientConfig,
1132
  }
1133
}
1134

1135
export async function exportSecret(
1136
  publicKey: Uint8Array,
1137
  cs: CiphersuiteImpl,
1138
): Promise<{ enc: Uint8Array; secret: Uint8Array }> {
1139
  return cs.hpke.exportSecret(
152✔
1140
    await cs.hpke.importPublicKey(publicKey),
1141
    new TextEncoder().encode("MLS 1.0 external init secret"),
1142
    cs.kdf.size,
1143
    new Uint8Array(),
1144
  )
1145
}
1146

1147
async function importSecret(privateKey: Uint8Array, kemOutput: Uint8Array, cs: CiphersuiteImpl): Promise<Uint8Array> {
1148
  return cs.hpke.importSecret(
76✔
1149
    await cs.hpke.importPrivateKey(privateKey),
1150
    new TextEncoder().encode("MLS 1.0 external init secret"),
1151
    kemOutput,
1152
    cs.kdf.size,
1153
    new Uint8Array(),
1154
  )
1155
}
1156

1157
async function applyTreeMutations(
1158
  ratchetTree: RatchetTree,
1159
  grouped: Proposals,
1160
  gc: GroupContext,
1161
  sentByClient: boolean,
1162
  authService: AuthenticationService,
1163
  lifetimeConfig: LifetimeConfig,
1164
  s: Signature,
1165
): Promise<[RatchetTree, [LeafIndex, KeyPackage][]]> {
1166
  const treeAfterUpdate = await grouped.update.reduce(async (acc, { senderLeafIndex, proposal }) => {
4,877✔
1167
    if (senderLeafIndex === undefined) throw new InternalError("No sender index found for update proposal")
14✔
1168

1169
    throwIfDefined(await validateLeafNodeUpdateOrCommit(proposal.update.leafNode, senderLeafIndex, gc, authService, s))
14✔
1170
    throwIfDefined(
14✔
1171
      await validateLeafNodeCredentialAndKeyUniqueness(ratchetTree, proposal.update.leafNode, senderLeafIndex),
1172
    )
1173

1174
    return updateLeafNode(await acc, proposal.update.leafNode, toLeafIndex(senderLeafIndex))
14✔
1175
  }, Promise.resolve(ratchetTree))
1176

1177
  const treeAfterRemove = grouped.remove.reduce((acc, { proposal }) => {
4,877✔
1178
    throwIfDefined(validateRemove(proposal.remove, ratchetTree))
2,425✔
1179

1180
    return removeLeafNode(acc, toLeafIndex(proposal.remove.removed))
2,425✔
1181
  }, treeAfterUpdate)
1182

1183
  const [treeAfterAdd, addedLeafNodes] = await grouped.add.reduce(
4,877✔
1184
    async (acc, { proposal }) => {
1185
      throwIfDefined(
3,118✔
1186
        await validateKeyPackage(
1187
          proposal.add.keyPackage,
1188
          gc,
1189
          ratchetTree,
1190
          sentByClient,
1191
          lifetimeConfig,
1192
          authService,
1193
          s,
1194
        ),
1195
      )
1196

1197
      const [tree, ws] = await acc
3,118✔
1198
      const [updatedTree, leafNodeIndex] = addLeafNode(tree, proposal.add.keyPackage.leafNode)
3,039✔
1199
      return [
3,039✔
1200
        updatedTree,
1201
        [...ws, [nodeToLeafIndex(leafNodeIndex), proposal.add.keyPackage] as [LeafIndex, KeyPackage]],
1202
      ]
1203
    },
1204
    Promise.resolve([treeAfterRemove, []] as [RatchetTree, [LeafIndex, KeyPackage][]]),
1205
  )
1206

1207
  return [treeAfterAdd, addedLeafNodes]
4,798✔
1208
}
1209

1210
export async function processProposal(
1211
  state: ClientState,
1212
  content: AuthenticatedContent,
1213
  proposal: Proposal,
1214
  h: Hash,
1215
): Promise<ClientState> {
1216
  const ref = await makeProposalRef(content, h)
1,835✔
1217
  return {
1,835✔
1218
    ...state,
1219
    unappliedProposals: addUnappliedProposal(
1220
      ref,
1221
      state.unappliedProposals,
1222
      proposal,
1223
      getSenderLeafNodeIndex(content.content.sender),
1224
    ),
1225
  }
1226
}
1227

1228
export function addHistoricalReceiverData(state: ClientState): [Map<bigint, EpochReceiverData>, Uint8Array[]] {
1229
  const withNew = addToMap(state.historicalReceiverData, state.groupContext.epoch, {
5,180✔
1230
    secretTree: state.secretTree,
1231
    ratchetTree: state.ratchetTree,
1232
    senderDataSecret: state.keySchedule.senderDataSecret,
1233
    groupContext: state.groupContext,
1234
    resumptionPsk: state.keySchedule.resumptionPsk,
1235
  })
1236

1237
  const epochs = [...withNew.keys()]
5,180✔
1238

1239
  const result: [Map<bigint, EpochReceiverData>, Uint8Array[]] =
1240
    epochs.length >= state.clientConfig.keyRetentionConfig.retainKeysForEpochs
5,180✔
1241
      ? removeOldHistoricalReceiverData(withNew, state.clientConfig.keyRetentionConfig.retainKeysForEpochs)
1242
      : [withNew, []]
1243

1244
  return result
5,180✔
1245
}
1246

1247
function removeOldHistoricalReceiverData(
1248
  historicalReceiverData: Map<bigint, EpochReceiverData>,
1249
  max: number,
1250
): [Map<bigint, EpochReceiverData>, Uint8Array[]] {
1251
  const sortedEpochs = [...historicalReceiverData.keys()].sort((a, b) => (a < b ? -1 : 1))
10,724✔
1252

1253
  const cutoff = sortedEpochs.length - max
2,762✔
1254

1255
  const toBeDeleted = new Array<Uint8Array>()
2,762✔
1256

1257
  const map = new Map<bigint, EpochReceiverData>()
2,762✔
1258
  for (const [n, epoch] of sortedEpochs.entries()) {
2,762✔
1259
    const data = historicalReceiverData.get(epoch)!
13,486✔
1260
    if (n < cutoff) {
13,486✔
1261
      toBeDeleted.push(...allSecretTreeValues(data.secretTree))
2,514✔
1262
    } else {
1263
      map.set(epoch, data)
10,972✔
1264
    }
1265
  }
1266

1267
  return [new Map(sortedEpochs.slice(-max).map((epoch) => [epoch, historicalReceiverData.get(epoch)!])), []]
10,972✔
1268
}
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