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

LukaJCB / ts-mls / 20944655171

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

Pull #200

github

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

412 of 421 branches covered (97.86%)

Branch coverage included in aggregate %.

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

6 existing lines in 3 files now uncovered.

2364 of 2495 relevant lines covered (94.75%)

72670.37 hits per line

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

95.84
/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, pskTypes, resumptionPSKUsages } 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, wireformats } from "./wireformat.js"
46
import { ProposalOrRef, proposalOrRefTypes } from "./proposalOrRefType.js"
47
import {
48
  isDefaultProposal,
49
  Proposal,
50
  ProposalAdd,
51
  ProposalExternalInit,
52
  ProposalGroupContextExtensions,
53
  ProposalPSK,
54
  ProposalReinit,
55
  ProposalRemove,
56
  ProposalUpdate,
57
  Reinit,
58
  Remove,
59
} from "./proposal.js"
60
import { defaultProposalTypes } from "./defaultProposalType.js"
61
import { defaultExtensionTypes } from "./defaultExtensionType.js"
62
import { pathToRoot } from "./pathSecrets.js"
63
import {
64
  PrivateKeyPath,
65
  decodePrivateKeyPath,
66
  mergePrivateKeyPaths,
67
  privateKeyPathEncoder,
68
  toPrivateKeyPath,
69
} from "./privateKeyPath.js"
70
import {
71
  UnappliedProposals,
72
  addUnappliedProposal,
73
  ProposalWithSender,
74
  unappliedProposalsEncoder,
75
  decodeUnappliedProposals,
76
} from "./unappliedProposals.js"
77
import { accumulatePskSecret, PskIndex } from "./pskIndex.js"
78
import { getSenderLeafNodeIndex } from "./sender.js"
79
import { addToMap } from "./util/addToMap.js"
80
import {
81
  CryptoVerificationError,
82
  CodecError,
83
  InternalError,
84
  UsageError,
85
  ValidationError,
86
  MlsError,
87
} from "./mlsError.js"
88
import { Signature } from "./crypto/signature.js"
89
import {
90
  LeafNode,
91
  LeafNodeCommit,
92
  LeafNodeKeyPackage,
93
  LeafNodeUpdate,
94
  verifyLeafNodeSignature,
95
  verifyLeafNodeSignatureKeyPackage,
96
} from "./leafNode.js"
97
import { leafNodeSources } from "./leafNodeSource.js"
98
import { nodeTypes } from "./nodeType.js"
99
import { protocolVersions } from "./protocolVersion.js"
100
import { decodeRequiredCapabilities, RequiredCapabilities } from "./requiredCapabilities.js"
101
import { Capabilities } from "./capabilities.js"
102
import { verifyParentHashes } from "./parentHash.js"
103
import { AuthenticationService } from "./authenticationService.js"
104
import { LifetimeConfig } from "./lifetimeConfig.js"
105
import { KeyPackageEqualityConfig } from "./keyPackageEqualityConfig.js"
106
import { ClientConfig, defaultClientConfig } from "./clientConfig.js"
107
import { decodeExternalSender } from "./externalSender.js"
108
import { arraysEqual } from "./util/array.js"
109
import { BufferEncoder, contramapBufferEncoders, encode, Encoder } from "./codec/tlsEncoder.js"
110

111
import { bigintMapEncoder, decodeBigintMap, decodeVarLenData, varLenDataEncoder } from "./codec/variableLength.js"
112
import { decodeGroupActiveState, GroupActiveState, groupActiveStateEncoder } from "./groupActiveState.js"
113
import { decodeEpochReceiverData, EpochReceiverData, epochReceiverDataEncoder } from "./epochReceiverData.js"
114
import { Decoder, mapDecoders } from "./codec/tlsDecoder.js"
115
import { deriveSecret } from "./crypto/kdf.js"
116

117
/** @public */
118
export type ClientState = GroupState & { clientConfig: ClientConfig }
119

120
/** @public */
121
export interface GroupState {
122
  groupContext: GroupContext
123
  keySchedule: KeySchedule
124
  secretTree: SecretTree
125
  ratchetTree: RatchetTree
126
  privatePath: PrivateKeyPath
127
  signaturePrivateKey: Uint8Array
128
  unappliedProposals: UnappliedProposals
129
  confirmationTag: Uint8Array
130
  historicalReceiverData: Map<bigint, EpochReceiverData>
131
  groupActiveState: GroupActiveState
132
}
133

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

162
/** @public */
163
export const encodeGroupState: Encoder<GroupState> = encode(groupStateEncoder)
3✔
164

165
/** @public */
166
export const decodeGroupState: Decoder<GroupState> = mapDecoders(
3✔
167
  [
168
    decodeGroupContext,
169
    decodeKeySchedule,
170
    decodeSecretTree,
171
    decodeRatchetTree,
172
    decodePrivateKeyPath,
173
    decodeVarLenData,
174
    decodeUnappliedProposals,
175
    decodeVarLenData,
176
    decodeBigintMap(decodeEpochReceiverData),
177
    decodeGroupActiveState,
178
  ],
179
  (
180
    groupContext,
181
    keySchedule,
182
    secretTree,
183
    ratchetTree,
184
    privatePath,
185
    signaturePrivateKey,
186
    unappliedProposals,
187
    confirmationTag,
188
    historicalReceiverData,
189
    groupActiveState,
190
  ) => ({
1✔
191
    groupContext,
192
    keySchedule,
193
    secretTree,
194
    ratchetTree,
195
    privatePath,
196
    signaturePrivateKey,
197
    unappliedProposals,
198
    confirmationTag,
199
    historicalReceiverData,
200
    groupActiveState,
201
  }),
202
)
203

204
export const groupStateEncoderWithoutTree: BufferEncoder<GroupState> = contramapBufferEncoders(
3✔
205
  [
206
    groupContextEncoder,
207
    keyScheduleEncoder,
208
    secretTreeEncoder,
209
    privateKeyPathEncoder,
210
    varLenDataEncoder,
211
    unappliedProposalsEncoder,
212
    varLenDataEncoder,
213
    bigintMapEncoder(epochReceiverDataEncoder),
214
    groupActiveStateEncoder,
215
  ],
216
  (state) =>
217
    [
×
218
      state.groupContext,
219
      state.keySchedule,
220
      state.secretTree,
221
      state.privatePath,
222
      state.signaturePrivateKey,
223
      state.unappliedProposals,
224
      state.confirmationTag,
225
      state.historicalReceiverData,
226
      state.groupActiveState,
227
    ] as const,
228
)
229

230
export const encodeGroupStateWithoutTree: Encoder<GroupState> = encode(groupStateEncoderWithoutTree)
3✔
231

232
export function decodeGroupStateWithoutTree(ratchetTree: RatchetTree): Decoder<GroupState> {
233
  return mapDecoders(
×
234
    [
235
      decodeGroupContext,
236
      decodeKeySchedule,
237
      decodeSecretTree,
238
      decodePrivateKeyPath,
239
      decodeVarLenData,
240
      decodeUnappliedProposals,
241
      decodeVarLenData,
242
      decodeBigintMap(decodeEpochReceiverData),
243
      decodeGroupActiveState,
244
    ],
245
    (
246
      groupContext,
247
      keySchedule,
248
      secretTree,
249
      privatePath,
250
      signaturePrivateKey,
251
      unappliedProposals,
252
      confirmationTag,
253
      historicalReceiverData,
254
      groupActiveState,
255
    ) => ({
×
256
      groupContext,
257
      keySchedule,
258
      secretTree,
259
      ratchetTree,
260
      privatePath,
261
      signaturePrivateKey,
262
      unappliedProposals,
263
      confirmationTag,
264
      historicalReceiverData,
265
      groupActiveState,
266
    }),
267
  )
268
}
269

270
export function getOwnLeafNode(state: ClientState): LeafNode {
271
  const idx = leafToNodeIndex(toLeafIndex(state.privatePath.leafIndex))
3✔
272
  const leaf = state.ratchetTree[idx]
3✔
273
  if (leaf?.nodeType !== nodeTypes.leaf) throw new InternalError("Expected leaf node")
3✔
274
  return leaf.leaf
3✔
275
}
276

277
export function getGroupMembers(state: ClientState): LeafNode[] {
278
  return extractFromGroupMembers(
1✔
279
    state,
280
    () => false,
3✔
281
    (l) => l,
3✔
282
  )
283
}
284

285
export function extractFromGroupMembers<T>(
286
  state: ClientState,
287
  exclude: (l: LeafNode) => boolean,
288
  map: (l: LeafNode) => T,
289
): T[] {
290
  const recipients = []
3✔
291
  for (const node of state.ratchetTree) {
3✔
292
    if (node?.nodeType === nodeTypes.leaf && !exclude(node.leaf)) {
21✔
293
      recipients.push(map(node.leaf))
8✔
294
    }
295
  }
296
  return recipients
3✔
297
}
298

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

303
  checkCanSendHandshakeMessages(state)
2,508✔
304
}
305

306
export function checkCanSendHandshakeMessages(state: ClientState): void {
307
  if (state.groupActiveState.kind === "suspendedPendingReinit")
4,505✔
308
    throw new UsageError("Cannot send messages while Group is suspended pending reinit")
4,486✔
309
  else if (state.groupActiveState.kind === "removedFromGroup")
310
    throw new UsageError("Cannot send messages after being removed from group")
76✔
311
}
312

313
export interface Proposals {
314
  [defaultProposalTypes.add]: { senderLeafIndex: number | undefined; proposal: ProposalAdd }[]
315
  [defaultProposalTypes.update]: { senderLeafIndex: number | undefined; proposal: ProposalUpdate }[]
316
  [defaultProposalTypes.remove]: { senderLeafIndex: number | undefined; proposal: ProposalRemove }[]
317
  [defaultProposalTypes.psk]: { senderLeafIndex: number | undefined; proposal: ProposalPSK }[]
318
  [defaultProposalTypes.reinit]: { senderLeafIndex: number | undefined; proposal: ProposalReinit }[]
319
  [defaultProposalTypes.external_init]: { senderLeafIndex: number | undefined; proposal: ProposalExternalInit }[]
320
  [defaultProposalTypes.group_context_extensions]: {
321
    senderLeafIndex: number | undefined
322
    proposal: ProposalGroupContextExtensions
323
  }[]
324
}
325

326
const emptyProposals: Proposals = {
3✔
327
  [defaultProposalTypes.add]: [],
328
  [defaultProposalTypes.update]: [],
329
  [defaultProposalTypes.remove]: [],
330
  [defaultProposalTypes.psk]: [],
331
  [defaultProposalTypes.reinit]: [],
332
  [defaultProposalTypes.external_init]: [],
333
  [defaultProposalTypes.group_context_extensions]: [],
334
}
335

336
function flattenExtensions(groupContextExtensions: { proposal: ProposalGroupContextExtensions }[]): Extension[] {
337
  return groupContextExtensions.reduce((acc, { proposal }) => {
2,445✔
338
    return [...acc, ...proposal.groupContextExtensions.extensions]
61✔
339
  }, [] as Extension[])
340
}
341

342
async function validateProposals(
343
  p: Proposals,
344
  committerLeafIndex: number | undefined,
345
  groupContext: GroupContext,
346
  config: KeyPackageEqualityConfig,
347
  authService: AuthenticationService,
348
  tree: RatchetTree,
349
): Promise<MlsError | undefined> {
350
  const containsUpdateByCommitter = p[defaultProposalTypes.update].some(
5,174✔
351
    (o) => o.senderLeafIndex !== undefined && o.senderLeafIndex === committerLeafIndex,
15✔
352
  )
353

354
  if (containsUpdateByCommitter)
5,174✔
355
    return new ValidationError("Commit cannot contain an update proposal sent by committer")
1✔
356

357
  const containsRemoveOfCommitter = p[defaultProposalTypes.remove].some(
5,173✔
358
    (o) => o.proposal.remove.removed === committerLeafIndex,
2,428✔
359
  )
360

361
  if (containsRemoveOfCommitter)
5,173✔
362
    return new ValidationError("Commit cannot contain a remove proposal removing committer")
1✔
363

364
  const multipleUpdateRemoveForSameLeaf =
365
    p[defaultProposalTypes.update].some(
5,172✔
366
      ({ senderLeafIndex: a }, indexA) =>
367
        p[defaultProposalTypes.update].some(({ senderLeafIndex: b }, indexB) => a === b && indexA !== indexB) ||
14✔
368
        p[defaultProposalTypes.remove].some((r) => r.proposal.remove.removed === a),
7✔
369
    ) ||
370
    p[defaultProposalTypes.remove].some(
371
      (a, indexA) =>
372
        p[defaultProposalTypes.remove].some(
2,426✔
373
          (b, indexB) => b.proposal.remove.removed === a.proposal.remove.removed && indexA !== indexB,
40,379✔
374
        ) ||
375
        p[defaultProposalTypes.update].some(({ senderLeafIndex }) => a.proposal.remove.removed === senderLeafIndex),
7✔
376
    )
377

378
  if (multipleUpdateRemoveForSameLeaf)
5,174✔
379
    return new ValidationError(
1✔
380
      "Commit cannot contain multiple update and/or remove proposals that apply to the same leaf",
381
    )
382

383
  const multipleAddsContainSameKeypackage = p[defaultProposalTypes.add].some(({ proposal: a }, indexA) =>
5,171✔
384
    p[defaultProposalTypes.add].some(
3,178✔
385
      ({ proposal: b }, indexB) => config.compareKeyPackages(a.add.keyPackage, b.add.keyPackage) && indexA !== indexB,
32,921✔
386
    ),
387
  )
388

389
  if (multipleAddsContainSameKeypackage)
5,171✔
390
    return new ValidationError(
1✔
391
      "Commit cannot contain multiple Add proposals that contain KeyPackages that represent the same client",
392
    )
393

394
  // checks if there is an Add proposal with a KeyPackage that matches a client already in the group
395
  // unless there is a Remove proposal in the list removing the matching client from the group.
396
  const addsContainExistingKeypackage = p[defaultProposalTypes.add].some(({ proposal }) =>
5,170✔
397
    tree.some(
3,177✔
398
      (node, nodeIndex) =>
399
        node !== undefined &&
186,515✔
400
        node.nodeType === nodeTypes.leaf &&
401
        config.compareKeyPackageToLeafNode(proposal.add.keyPackage, node.leaf) &&
402
        p[defaultProposalTypes.remove].every(
NEW
403
          (r) => r.proposal.remove.removed !== nodeToLeafIndex(toNodeIndex(nodeIndex)),
×
404
        ),
405
    ),
406
  )
407

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

411
  const everyLeafSupportsGroupExtensions = p[defaultProposalTypes.add].every(({ proposal }) =>
5,169✔
412
    extensionsSupportedByCapabilities(groupContext.extensions, proposal.add.keyPackage.leafNode.capabilities),
413
  )
414

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

418
  const multiplePskWithSamePskId = p[defaultProposalTypes.psk].some((a, indexA) =>
5,150✔
419
    p[defaultProposalTypes.psk].some(
209✔
420
      (b, indexB) =>
314✔
421
        constantTimeEqual(
422
          encode(pskIdEncoder)(a.proposal.psk.preSharedKeyId),
423
          encode(pskIdEncoder)(b.proposal.psk.preSharedKeyId),
424
        ) && indexA !== indexB,
425
    ),
426
  )
427

428
  if (multiplePskWithSamePskId)
5,150✔
429
    return new ValidationError("Commit cannot contain PreSharedKey proposals that reference the same PreSharedKeyID")
1✔
430

431
  const multipleGroupContextExtensions = p[defaultProposalTypes.group_context_extensions].length > 1
5,149✔
432

433
  if (multipleGroupContextExtensions)
5,149✔
434
    return new ValidationError("Commit cannot contain multiple GroupContextExtensions proposals")
1✔
435

436
  const allExtensions = flattenExtensions(p[defaultProposalTypes.group_context_extensions])
5,148✔
437

438
  const requiredCapabilities = allExtensions.find(
5,148✔
439
    (e) => e.extensionType === defaultExtensionTypes.required_capabilities,
5✔
440
  )
441

442
  if (requiredCapabilities !== undefined) {
5,148✔
443
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
3✔
444
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
3✔
445

446
    const everyLeafSupportsCapabilities = tree
2✔
447
      .filter((n) => n !== undefined && n.nodeType === nodeTypes.leaf)
14✔
448
      .every((l) => capabiltiesAreSupported(caps[0], l.leaf.capabilities))
4✔
449

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

452
    const allAdditionsSupportCapabilities = p[defaultProposalTypes.add].every((a) =>
1✔
453
      capabiltiesAreSupported(caps[0], a.proposal.add.keyPackage.leafNode.capabilities),
1✔
454
    )
455

456
    if (!allAdditionsSupportCapabilities)
1✔
457
      return new ValidationError("Commit contains add proposals of member without required capabilities")
1✔
458
  }
459

460
  return await validateExternalSenders(allExtensions, authService)
5,145✔
461
}
462

463
async function validateExternalSenders(
464
  extensions: Extension[],
465
  authService: AuthenticationService,
466
): Promise<MlsError | undefined> {
467
  const externalSenders = extensions.filter((e) => e.extensionType === defaultExtensionTypes.external_senders)
6,061✔
468
  for (const externalSender of externalSenders) {
6,061✔
469
    const decoded = decodeExternalSender(externalSender.extensionData, 0)
21✔
470
    if (decoded === undefined) return new CodecError("Could not decode external_senders")
21✔
471

472
    const validCredential = await authService.validateCredential(decoded[0].credential, decoded[0].signaturePublicKey)
20✔
473
    if (!validCredential) return new ValidationError("Could not validate external credential")
20✔
474
  }
475
}
476

477
function capabiltiesAreSupported(caps: RequiredCapabilities, cs: Capabilities): boolean {
478
  return (
81✔
479
    caps.credentialTypes.every((c) => cs.credentials.includes(c)) &&
480
    caps.extensionTypes.every((e) => cs.extensions.includes(e)) &&
481
    caps.proposalTypes.every((p) => cs.proposals.includes(p))
1✔
482
  )
483
}
484

485
export async function validateRatchetTree(
486
  tree: RatchetTree,
487
  groupContext: GroupContext,
488
  config: LifetimeConfig,
489
  authService: AuthenticationService,
490
  treeHash: Uint8Array,
491
  cs: CiphersuiteImpl,
492
): Promise<MlsError | undefined> {
493
  const hpkeKeys = new Set<string>()
1,290✔
494
  const signatureKeys = new Set<string>()
1,290✔
495
  const credentialTypes = new Set<number>()
1,290✔
496
  for (const [i, n] of tree.entries()) {
1,290✔
497
    const nodeIndex = toNodeIndex(i)
8,758✔
498
    if (n?.nodeType === nodeTypes.leaf) {
8,758✔
499
      if (!isLeaf(nodeIndex)) return new ValidationError("Received Ratchet Tree is not structurally sound")
4,546✔
500

501
      const hpkeKey = bytesToBase64(n.leaf.hpkePublicKey)
4,546✔
502
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
4,546✔
503
      else hpkeKeys.add(hpkeKey)
4,527✔
504

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

509
      {
510
        credentialTypes.add(n.leaf.credential.credentialType)
4,508✔
511
      }
512

513
      const err =
514
        n.leaf.leafNodeSource === leafNodeSources.key_package
4,508✔
515
          ? await validateLeafNodeKeyPackage(n.leaf, groupContext, false, config, authService, cs.signature)
516
          : await validateLeafNodeUpdateOrCommit(
517
              n.leaf,
518
              nodeToLeafIndex(nodeIndex),
519
              groupContext,
520
              authService,
521
              cs.signature,
522
            )
523

524
      if (err !== undefined) return err
436✔
525
    } else if (n?.nodeType === nodeTypes.parent) {
4,212✔
526
      if (isLeaf(nodeIndex)) return new ValidationError("Received Ratchet Tree is not structurally sound")
528✔
527

528
      const hpkeKey = bytesToBase64(n.parent.hpkePublicKey)
509✔
529
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
509✔
530
      else hpkeKeys.add(hpkeKey)
509✔
531

532
      for (const unmergedLeaf of n.parent.unmergedLeaves) {
509✔
533
        const leafIndex = toLeafIndex(unmergedLeaf)
114✔
534
        const dp = directPath(leafToNodeIndex(leafIndex), leafWidth(tree.length))
114✔
535
        const nodeIndex = leafToNodeIndex(leafIndex)
114✔
536
        if (tree[nodeIndex]?.nodeType !== nodeTypes.leaf && !dp.includes(toNodeIndex(i)))
114✔
537
          return new ValidationError("Unmerged leaf did not represent a non-blank descendant leaf node")
×
538

539
        for (const parentIdx of dp) {
114✔
540
          const dpNode = tree[parentIdx]
342✔
541

542
          if (dpNode !== undefined) {
342✔
543
            if (dpNode.nodeType !== nodeTypes.parent) return new InternalError("Expected parent node")
114✔
544

545
            if (!arraysEqual(dpNode.parent.unmergedLeaves, n.parent.unmergedLeaves))
114✔
546
              return new ValidationError("non-blank intermediate node must list leaf node in its unmerged_leaves")
×
547
          }
548
        }
549
      }
550
    }
551
  }
552

553
  for (const n of tree) {
1,176✔
554
    if (n?.nodeType === nodeTypes.leaf) {
8,568✔
555
      for (const credentialType of credentialTypes) {
4,413✔
556
        if (!n.leaf.capabilities.credentials.includes(credentialType))
4,413✔
557
          return new ValidationError("LeafNode has credential that is not supported by member of the group")
×
558
      }
559
    }
560
  }
561

562
  const parentHashesVerified = await verifyParentHashes(tree, cs.hash)
1,176✔
563

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

566
  if (!constantTimeEqual(treeHash, await treeHashRoot(tree, cs.hash)))
1,157✔
567
    return new ValidationError("Unable to verify tree hash")
19✔
568
}
569

570
export async function validateLeafNodeUpdateOrCommit(
571
  leafNode: LeafNodeCommit | LeafNodeUpdate,
572
  leafIndex: number,
573
  groupContext: GroupContext,
574
  authService: AuthenticationService,
575
  s: Signature,
576
): Promise<MlsError | undefined> {
577
  const signatureValid = await verifyLeafNodeSignature(leafNode, groupContext.groupId, leafIndex, s)
2,457✔
578

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

581
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, authService)
2,438✔
582

583
  if (commonError !== undefined) return commonError
2,438✔
584
}
585

586
export function throwIfDefined(err: MlsError | undefined): void {
587
  if (err !== undefined) throw err
17,118✔
588
}
589

590
async function validateLeafNodeCommon(
591
  leafNode: LeafNode,
592
  groupContext: GroupContext,
593
  authService: AuthenticationService,
594
) {
595
  const credentialValid = await authService.validateCredential(leafNode.credential, leafNode.signaturePublicKey)
9,608✔
596

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

599
  const requiredCapabilities = groupContext.extensions.find(
9,588✔
600
    (e) => e.extensionType === defaultExtensionTypes.required_capabilities,
190✔
601
  )
602

603
  if (requiredCapabilities !== undefined) {
9,588✔
604
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
76✔
605
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
76✔
606

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

609
    if (!leafSupportsCapabilities) return new ValidationError("LeafNode does not support required capabilities")
76✔
610
  }
611

612
  const extensionsSupported = extensionsSupportedByCapabilities(leafNode.extensions, leafNode.capabilities)
9,569✔
613

614
  if (!extensionsSupported) return new ValidationError("LeafNode contains extension not listed in capabilities")
9,569✔
615
}
616

617
async function validateLeafNodeKeyPackage(
618
  leafNode: LeafNodeKeyPackage,
619
  groupContext: GroupContext,
620
  sentByClient: boolean,
621
  config: LifetimeConfig,
622
  authService: AuthenticationService,
623
  s: Signature,
624
): Promise<MlsError | undefined> {
625
  const signatureValid = await verifyLeafNodeSignatureKeyPackage(leafNode, s)
7,189✔
626
  if (!signatureValid) return new CryptoVerificationError("Could not verify leaf node signature")
7,189✔
627

628
  //verify lifetime
629
  if (sentByClient || config.validateLifetimeOnReceive) {
7,170✔
630
    if (leafNode.leafNodeSource === leafNodeSources.key_package) {
1,107✔
631
      const currentTime = BigInt(Math.floor(Date.now() / 1000))
1,107✔
632
      if (leafNode.lifetime.notBefore > currentTime || leafNode.lifetime.notAfter < currentTime)
1,107✔
633
        return new ValidationError("Current time not within Lifetime")
×
634
    }
635
  }
636

637
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, authService)
7,170✔
638

639
  if (commonError !== undefined) return commonError
7,170✔
640
}
641

642
export async function validateLeafNodeCredentialAndKeyUniqueness(
643
  tree: RatchetTree,
644
  leafNode: LeafNode,
645
  existingLeafIndex?: number,
646
): Promise<ValidationError | undefined> {
647
  const hpkeKeys = new Set<string>()
5,139✔
648
  const signatureKeys = new Set<string>()
5,139✔
649
  for (const [nodeIndex, node] of tree.entries()) {
5,139✔
650
    if (node?.nodeType === nodeTypes.leaf) {
222,571✔
651
      const credentialType = leafNode.credential.credentialType
46,544✔
652
      if (!node.leaf.capabilities.credentials.includes(credentialType)) {
46,544✔
653
        return new ValidationError("LeafNode has credential that is not supported by member of the group")
1✔
654
      }
655

656
      const hpkeKey = bytesToBase64(node.leaf.hpkePublicKey)
46,543✔
657
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
46,543✔
658
      else hpkeKeys.add(hpkeKey)
46,543✔
659

660
      const signatureKey = bytesToBase64(node.leaf.signaturePublicKey)
46,543✔
661
      if (signatureKeys.has(signatureKey) && existingLeafIndex !== nodeToLeafIndex(toNodeIndex(nodeIndex)))
46,543✔
662
        return new ValidationError("signature keys not unique")
×
663
      else signatureKeys.add(signatureKey)
46,543✔
664
    } else if (node?.nodeType === nodeTypes.parent) {
176,027✔
665
      const hpkeKey = bytesToBase64(node.parent.hpkePublicKey)
16,903✔
666
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
16,903✔
667
      else hpkeKeys.add(hpkeKey)
16,903✔
668
    }
669
  }
670
}
671

672
async function validateKeyPackage(
673
  kp: KeyPackage,
674
  groupContext: GroupContext,
675
  tree: RatchetTree,
676
  sentByClient: boolean,
677
  config: LifetimeConfig,
678
  authService: AuthenticationService,
679
  s: Signature,
680
): Promise<MlsError | undefined> {
681
  if (kp.cipherSuite !== groupContext.cipherSuite) return new ValidationError("Invalid CipherSuite")
3,156✔
682

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

685
  const leafNodeConsistentWithTree = await validateLeafNodeCredentialAndKeyUniqueness(tree, kp.leafNode)
3,118✔
686

687
  if (leafNodeConsistentWithTree !== undefined) return leafNodeConsistentWithTree
3,118✔
688

689
  const leafNodeError = await validateLeafNodeKeyPackage(
3,117✔
690
    kp.leafNode,
691
    groupContext,
692
    sentByClient,
693
    config,
694
    authService,
695
    s,
696
  )
697
  if (leafNodeError !== undefined) return leafNodeError
3,117✔
698

699
  const signatureValid = await verifyKeyPackage(kp, s)
3,096✔
700
  if (!signatureValid) return new CryptoVerificationError("Invalid keypackage signature")
3,096✔
701

702
  if (constantTimeEqual(kp.initKey, kp.leafNode.hpkePublicKey))
3,077✔
703
    return new ValidationError("Cannot have identicial init and encryption keys")
3,077✔
704
}
705

706
function validateReinit(
707
  allProposals: ProposalWithSender[],
708
  reinit: Reinit,
709
  gc: GroupContext,
710
): ValidationError | undefined {
711
  if (allProposals.length !== 1) return new ValidationError("Reinit proposal needs to be commited by itself")
77✔
712

713
  if (reinit.version < gc.version)
77✔
714
    return new ValidationError("A ReInit proposal cannot use a version less than the version for the current group")
×
715
}
716

717
function validateExternalInit(grouped: Proposals): ValidationError | undefined {
718
  if (grouped[defaultProposalTypes.external_init].length > 1)
76✔
719
    return new ValidationError("Cannot contain more than one external_init proposal")
×
720

721
  if (grouped[defaultProposalTypes.remove].length > 1)
76✔
NEW
722
    return new ValidationError("Cannot contain more than one remove proposal")
×
723

724
  if (
76✔
725
    grouped[defaultProposalTypes.add].length > 0 ||
726
    grouped[defaultProposalTypes.group_context_extensions].length > 0 ||
727
    grouped[defaultProposalTypes.reinit].length > 0 ||
728
    grouped[defaultProposalTypes.update].length > 0
729
  )
730
    return new ValidationError("Invalid proposals")
×
731
}
732

733
function validateRemove(remove: Remove, tree: RatchetTree): MlsError | undefined {
734
  if (tree[leafToNodeIndex(toLeafIndex(remove.removed))] === undefined)
2,425✔
735
    return new ValidationError("Tried to remove empty leaf node")
×
736
}
737

738
export interface ApplyProposalsResult {
739
  tree: RatchetTree
740
  pskSecret: Uint8Array
741
  pskIds: PreSharedKeyID[]
742
  needsUpdatePath: boolean
743
  additionalResult: ApplyProposalsData
744
  selfRemoved: boolean
745
  allProposals: ProposalWithSender[]
746
}
747

748
export type ApplyProposalsData =
749
  | { kind: "memberCommit"; addedLeafNodes: [LeafIndex, KeyPackage][]; extensions: Extension[] }
750
  | { kind: "externalCommit"; externalInitSecret: Uint8Array; newMemberLeafIndex: LeafIndex }
751
  | { kind: "reinit"; reinit: Reinit }
752

753
export async function applyProposals(
754
  state: ClientState,
755
  proposals: ProposalOrRef[],
756
  committerLeafIndex: LeafIndex | undefined,
757
  pskSearch: PskIndex,
758
  sentByClient: boolean,
759
  cs: CiphersuiteImpl,
760
): Promise<ApplyProposalsResult> {
761
  const allProposals = proposals.reduce((acc, cur) => {
5,327✔
762
    if (cur.proposalOrRefType === proposalOrRefTypes.proposal)
6,096✔
763
      return [...acc, { proposal: cur.proposal, senderLeafIndex: committerLeafIndex }]
4,242✔
764

765
    const p = state.unappliedProposals[bytesToBase64(cur.reference)]
1,854✔
766
    if (p === undefined) throw new ValidationError("Could not find proposal with supplied reference")
1,854✔
767
    return [...acc, p]
1,854✔
768
  }, [] as ProposalWithSender[])
769

770
  const grouped = allProposals.reduce((acc, cur) => {
5,327✔
771
    //this skips any custom proposals
772
    if (isDefaultProposal(cur.proposal)) {
6,096✔
773
      const proposalType = cur.proposal.proposalType
6,058✔
774
      const proposals = acc[proposalType] ?? []
6,058✔
775
      return { ...acc, [cur.proposal.proposalType]: [...proposals, cur] }
6,058✔
776
    } else {
777
      return acc
38✔
778
    }
779
  }, emptyProposals)
780

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

783
  const isExternalInit = grouped[defaultProposalTypes.external_init].length > 0
5,327✔
784

785
  if (!isExternalInit) {
5,327✔
786
    if (grouped[defaultProposalTypes.reinit].length > 0) {
5,251✔
787
      const reinit = grouped[defaultProposalTypes.reinit].at(0)!.proposal.reinit
77✔
788

789
      throwIfDefined(validateReinit(allProposals, reinit, state.groupContext))
77✔
790

791
      return {
77✔
792
        tree: state.ratchetTree,
793
        pskSecret: zeroes,
794
        pskIds: [],
795
        needsUpdatePath: false,
796
        additionalResult: {
797
          kind: "reinit",
798
          reinit,
799
        },
800
        selfRemoved: false,
801
        allProposals,
802
      }
803
    }
804

805
    throwIfDefined(
5,174✔
806
      await validateProposals(
807
        grouped,
808
        committerLeafIndex,
809
        state.groupContext,
810
        state.clientConfig.keyPackageEqualityConfig,
811
        state.clientConfig.authService,
812
        state.ratchetTree,
813
      ),
814
    )
815

816
    const newExtensions = flattenExtensions(grouped[defaultProposalTypes.group_context_extensions])
5,174✔
817

818
    const [mutatedTree, addedLeafNodes] = await applyTreeMutations(
5,174✔
819
      state.ratchetTree,
820
      grouped,
821
      state.groupContext,
822
      sentByClient,
823
      state.clientConfig.authService,
824
      state.clientConfig.lifetimeConfig,
825
      cs.signature,
826
    )
827

828
    const [updatedPskSecret, pskIds] = await accumulatePskSecret(
5,064✔
829
      grouped[defaultProposalTypes.psk].map((p) => p.proposal.psk.preSharedKeyId),
208✔
830
      pskSearch,
831
      cs,
832
      zeroes,
833
    )
834

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

837
    const needsUpdatePath =
838
      allProposals.length === 0 ||
5,064✔
839
      Object.values(grouped[defaultProposalTypes.update]).length > 1 ||
840
      Object.values(grouped[defaultProposalTypes.remove]).length > 1
841

842
    return {
5,251✔
843
      tree: mutatedTree,
844
      pskSecret: updatedPskSecret,
845
      additionalResult: {
846
        kind: "memberCommit" as const,
847
        addedLeafNodes,
848
        extensions: newExtensions,
849
      },
850
      pskIds,
851
      needsUpdatePath,
852
      selfRemoved,
853
      allProposals,
854
    }
855
  } else {
856
    throwIfDefined(validateExternalInit(grouped))
76✔
857

858
    const treeAfterRemove = grouped[defaultProposalTypes.remove].reduce((acc, { proposal }) => {
76✔
859
      return removeLeafNode(acc, toLeafIndex(proposal.remove.removed))
38✔
860
    }, state.ratchetTree)
861

862
    const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
76✔
863

864
    const [updatedPskSecret, pskIds] = await accumulatePskSecret(
76✔
NEW
865
      grouped[defaultProposalTypes.psk].map((p) => p.proposal.psk.preSharedKeyId),
×
866
      pskSearch,
867
      cs,
868
      zeroes,
869
    )
870

871
    const initProposal = grouped[defaultProposalTypes.external_init].at(0)!
76✔
872

873
    const externalKeyPair = await cs.hpke.deriveKeyPair(state.keySchedule.externalSecret)
76✔
874

875
    const externalInitSecret = await importSecret(
76✔
876
      await cs.hpke.exportPrivateKey(externalKeyPair.privateKey),
877
      initProposal.proposal.externalInit.kemOutput,
878
      cs,
879
    )
880

881
    return {
76✔
882
      needsUpdatePath: true,
883
      tree: treeAfterRemove,
884
      pskSecret: updatedPskSecret,
885
      pskIds,
886
      additionalResult: {
887
        kind: "externalCommit",
888
        externalInitSecret,
889
        newMemberLeafIndex: nodeToLeafIndex(findBlankLeafNodeIndexOrExtend(treeAfterRemove)),
890
      },
891
      selfRemoved: false,
892
      allProposals,
893
    }
894
  }
895
}
896

897
/** @public */
898
export function makePskIndex(state: ClientState | undefined, externalPsks: Record<string, Uint8Array>): PskIndex {
899
  return {
1,477✔
900
    findPsk(preSharedKeyId) {
901
      if (preSharedKeyId.psktype === pskTypes.external) {
441✔
902
        return externalPsks[bytesToBase64(preSharedKeyId.pskId)]
261✔
903
      }
904

905
      if (state !== undefined && constantTimeEqual(preSharedKeyId.pskGroupId, state.groupContext.groupId)) {
180✔
906
        if (preSharedKeyId.pskEpoch === state.groupContext.epoch) return state.keySchedule.resumptionPsk
180✔
907
        else return state.historicalReceiverData.get(preSharedKeyId.pskEpoch)?.resumptionPsk
28✔
908
      }
909
    },
910
  }
911
}
912

913
export async function nextEpochContext(
914
  groupContext: GroupContext,
915
  wireformat: WireformatName,
916
  content: FramedContentCommit,
917
  signature: Uint8Array,
918
  updatedTreeHash: Uint8Array,
919
  confirmationTag: Uint8Array,
920
  h: Hash,
921
): Promise<GroupContext> {
922
  const interimTranscriptHash = await createInterimHash(groupContext.confirmedTranscriptHash, confirmationTag, h)
4,836✔
923
  const newConfirmedHash = await createConfirmedHash(
4,836✔
924
    interimTranscriptHash,
925
    { wireformat: wireformats[wireformat], content, signature },
926
    h,
927
  )
928

929
  return {
4,836✔
930
    ...groupContext,
931
    epoch: groupContext.epoch + 1n,
932
    treeHash: updatedTreeHash,
933
    confirmedTranscriptHash: newConfirmedHash,
934
  }
935
}
936

937
/** @public */
938
export async function joinGroup(
939
  welcome: Welcome,
940
  keyPackage: KeyPackage,
941
  privateKeys: PrivateKeyPackage,
942
  pskSearch: PskIndex,
943
  cs: CiphersuiteImpl,
944
  ratchetTree?: RatchetTree,
945
  resumingFromState?: ClientState,
946
  clientConfig: ClientConfig = defaultClientConfig,
947
): Promise<ClientState> {
948
  const res = await joinGroupWithExtensions(
1,138✔
949
    welcome,
950
    keyPackage,
951
    privateKeys,
952
    pskSearch,
953
    cs,
954
    ratchetTree,
955
    resumingFromState,
956
    clientConfig,
957
  )
958

959
  return res[0]
1,081✔
960
}
961

962
/** @public */
963
export async function joinGroupWithExtensions(
964
  welcome: Welcome,
965
  keyPackage: KeyPackage,
966
  privateKeys: PrivateKeyPackage,
967
  pskSearch: PskIndex,
968
  cs: CiphersuiteImpl,
969
  ratchetTree?: RatchetTree,
970
  resumingFromState?: ClientState,
971
  clientConfig: ClientConfig = defaultClientConfig,
972
): Promise<[ClientState, Extension[]]> {
973
  const keyPackageRef = await makeKeyPackageRef(keyPackage, cs.hash)
1,157✔
974
  const privKey = await cs.hpke.importPrivateKey(privateKeys.initPrivateKey)
1,157✔
975
  const groupSecrets = await decryptGroupSecrets(privKey, keyPackageRef, welcome, cs.hpke)
1,157✔
976

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

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

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

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

986
  const resumptionPsk = pskIds.find((id) => id.psktype === pskTypes.resumption)
1,157✔
987
  if (resumptionPsk !== undefined) {
1,157✔
988
    if (resumingFromState === undefined) throw new ValidationError("No prior state passed for resumption")
95✔
989

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

992
    if (!constantTimeEqual(resumptionPsk.pskGroupId, resumingFromState.groupContext.groupId))
95✔
993
      throw new ValidationError("old groupId mismatch")
×
994

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

997
    if (resumptionPsk.usage === resumptionPSKUsages.reinit) {
95✔
998
      if (resumingFromState.groupActiveState.kind !== "suspendedPendingReinit")
76✔
999
        throw new ValidationError("Found reinit psk but no old suspended clientState")
×
1000

1001
      if (!constantTimeEqual(resumingFromState.groupActiveState.reinit.groupId, gi.groupContext.groupId))
76✔
1002
        throw new ValidationError("new groupId mismatch")
19✔
1003

1004
      if (resumingFromState.groupActiveState.reinit.version !== gi.groupContext.version)
57✔
1005
        throw new ValidationError("Version mismatch")
19✔
1006

1007
      if (resumingFromState.groupActiveState.reinit.cipherSuite !== gi.groupContext.cipherSuite)
38✔
1008
        throw new ValidationError("Ciphersuite mismatch")
×
1009

1010
      if (!extensionsEqual(resumingFromState.groupActiveState.reinit.extensions, gi.groupContext.extensions))
38✔
1011
        throw new ValidationError("Extensions mismatch")
19✔
1012
    }
1013
  }
1014

1015
  const allExtensionsSupported = extensionsSupportedByCapabilities(
1,100✔
1016
    gi.groupContext.extensions,
1017
    keyPackage.leafNode.capabilities,
1018
  )
1019
  if (!allExtensionsSupported) throw new UsageError("client does not support every extension in the GroupContext")
1,100✔
1020

1021
  const tree = ratchetTreeFromExtension(gi) ?? ratchetTree
1,100✔
1022

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

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

1027
  if (signerNode === undefined) {
1,100✔
1028
    throw new ValidationError("Could not find signer leafNode")
×
1029
  }
1030
  if (signerNode.nodeType === nodeTypes.parent) throw new ValidationError("Expected non blank leaf node")
1,100✔
1031

1032
  const credentialVerified = await clientConfig.authService.validateCredential(
1,100✔
1033
    signerNode.leaf.credential,
1034
    signerNode.leaf.signaturePublicKey,
1035
  )
1036

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

1039
  const groupInfoSignatureVerified = await verifyGroupInfoSignature(
1,100✔
1040
    gi,
1041
    signerNode.leaf.signaturePublicKey,
1042
    cs.signature,
1043
  )
1044

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

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

1050
  throwIfDefined(
1,100✔
1051
    await validateRatchetTree(
1052
      tree,
1053
      gi.groupContext,
1054
      clientConfig.lifetimeConfig,
1055
      clientConfig.authService,
1056
      gi.groupContext.treeHash,
1057
      cs,
1058
    ),
1059
  )
1060

1061
  const newLeaf = findLeafIndex(tree, keyPackage.leafNode)
1,100✔
1062

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

1065
  const privateKeyPath: PrivateKeyPath = {
1,100✔
1066
    leafIndex: newLeaf,
1067
    privateKeys: { [leafToNodeIndex(newLeaf)]: privateKeys.hpkePrivateKey },
1068
  }
1069

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

1072
  const updatedPkp =
1073
    groupSecrets.pathSecret === undefined
1,100✔
1074
      ? privateKeyPath
1075
      : mergePrivateKeyPaths(
1076
          await toPrivateKeyPath(
1077
            await pathToRoot(tree, ancestorNodeIndex, groupSecrets.pathSecret, cs.kdf),
1078
            newLeaf,
1079
            cs,
1080
          ),
1081
          privateKeyPath,
1082
        )
1083

1084
  const [keySchedule, encryptionSecret] = await deriveKeySchedule(
1,157✔
1085
    groupSecrets.joinerSecret,
1086
    pskSecret,
1087
    gi.groupContext,
1088
    cs.kdf,
1089
  )
1090

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

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

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

1097
  zeroOutUint8Array(encryptionSecret)
1,100✔
1098
  zeroOutUint8Array(groupSecrets.joinerSecret)
1,100✔
1099

1100
  return [
1,100✔
1101
    {
1102
      groupContext: gi.groupContext,
1103
      ratchetTree: tree,
1104
      privatePath: updatedPkp,
1105
      signaturePrivateKey: privateKeys.signaturePrivateKey,
1106
      confirmationTag: gi.confirmationTag,
1107
      unappliedProposals: {},
1108
      keySchedule,
1109
      secretTree,
1110
      historicalReceiverData: new Map(),
1111
      groupActiveState: { kind: "active" },
1112
      clientConfig,
1113
    },
1114
    gi.extensions,
1115
  ]
1116
}
1117

1118
/** @public */
1119
export async function createGroup(
1120
  groupId: Uint8Array,
1121
  keyPackage: KeyPackage,
1122
  privateKeyPackage: PrivateKeyPackage,
1123
  extensions: Extension[],
1124
  cs: CiphersuiteImpl,
1125
  clientConfig: ClientConfig = defaultClientConfig,
1126
): Promise<ClientState> {
1127
  const ratchetTree: RatchetTree = [{ nodeType: nodeTypes.leaf, leaf: keyPackage.leafNode }]
837✔
1128

1129
  const privatePath: PrivateKeyPath = {
837✔
1130
    leafIndex: 0,
1131
    privateKeys: { [0]: privateKeyPackage.hpkePrivateKey },
1132
  }
1133

1134
  const confirmedTranscriptHash = new Uint8Array()
837✔
1135

1136
  const groupContext: GroupContext = {
837✔
1137
    version: protocolVersions.mls10,
1138
    cipherSuite: cs.name,
1139
    epoch: 0n,
1140
    treeHash: await treeHashRoot(ratchetTree, cs.hash),
1141
    groupId,
1142
    extensions,
1143
    confirmedTranscriptHash,
1144
  }
1145

1146
  throwIfDefined(await validateExternalSenders(extensions, clientConfig.authService))
837✔
1147

1148
  const epochSecret = cs.rng.randomBytes(cs.kdf.size)
837✔
1149

1150
  const keySchedule = await initializeKeySchedule(epochSecret, cs.kdf)
837✔
1151

1152
  const confirmationTag = await createConfirmationTag(keySchedule.confirmationKey, confirmedTranscriptHash, cs.hash)
837✔
1153

1154
  const encryptionSecret = await deriveSecret(epochSecret, "encryption", cs.kdf)
837✔
1155

1156
  const secretTree = await createSecretTree(1, encryptionSecret, cs.kdf)
837✔
1157

1158
  zeroOutUint8Array(epochSecret)
837✔
1159

1160
  return {
837✔
1161
    ratchetTree,
1162
    keySchedule,
1163
    secretTree,
1164
    privatePath,
1165
    signaturePrivateKey: privateKeyPackage.signaturePrivateKey,
1166
    unappliedProposals: {},
1167
    historicalReceiverData: new Map(),
1168
    groupContext,
1169
    confirmationTag,
1170
    groupActiveState: { kind: "active" },
1171
    clientConfig,
1172
  }
1173
}
1174

1175
export async function exportSecret(
1176
  publicKey: Uint8Array,
1177
  cs: CiphersuiteImpl,
1178
): Promise<{ enc: Uint8Array; secret: Uint8Array }> {
1179
  return cs.hpke.exportSecret(
152✔
1180
    await cs.hpke.importPublicKey(publicKey),
1181
    new TextEncoder().encode("MLS 1.0 external init secret"),
1182
    cs.kdf.size,
1183
    new Uint8Array(),
1184
  )
1185
}
1186

1187
async function importSecret(privateKey: Uint8Array, kemOutput: Uint8Array, cs: CiphersuiteImpl): Promise<Uint8Array> {
1188
  return cs.hpke.importSecret(
76✔
1189
    await cs.hpke.importPrivateKey(privateKey),
1190
    new TextEncoder().encode("MLS 1.0 external init secret"),
1191
    kemOutput,
1192
    cs.kdf.size,
1193
    new Uint8Array(),
1194
  )
1195
}
1196

1197
async function applyTreeMutations(
1198
  ratchetTree: RatchetTree,
1199
  grouped: Proposals,
1200
  gc: GroupContext,
1201
  sentByClient: boolean,
1202
  authService: AuthenticationService,
1203
  lifetimeConfig: LifetimeConfig,
1204
  s: Signature,
1205
): Promise<[RatchetTree, [LeafIndex, KeyPackage][]]> {
1206
  const treeAfterUpdate = await grouped[defaultProposalTypes.update].reduce(
4,896✔
1207
    async (acc, { senderLeafIndex, proposal }) => {
1208
      if (senderLeafIndex === undefined) throw new InternalError("No sender index found for update proposal")
14✔
1209

1210
      throwIfDefined(
14✔
1211
        await validateLeafNodeUpdateOrCommit(proposal.update.leafNode, senderLeafIndex, gc, authService, s),
1212
      )
1213
      throwIfDefined(
14✔
1214
        await validateLeafNodeCredentialAndKeyUniqueness(ratchetTree, proposal.update.leafNode, senderLeafIndex),
1215
      )
1216

1217
      return updateLeafNode(await acc, proposal.update.leafNode, toLeafIndex(senderLeafIndex))
14✔
1218
    },
1219
    Promise.resolve(ratchetTree),
1220
  )
1221

1222
  const treeAfterRemove = grouped[defaultProposalTypes.remove].reduce((acc, { proposal }) => {
4,896✔
1223
    throwIfDefined(validateRemove(proposal.remove, ratchetTree))
2,425✔
1224

1225
    return removeLeafNode(acc, toLeafIndex(proposal.remove.removed))
2,425✔
1226
  }, treeAfterUpdate)
1227

1228
  const [treeAfterAdd, addedLeafNodes] = await grouped[defaultProposalTypes.add].reduce(
4,896✔
1229
    async (acc, { proposal }) => {
1230
      throwIfDefined(
3,156✔
1231
        await validateKeyPackage(
1232
          proposal.add.keyPackage,
1233
          gc,
1234
          ratchetTree,
1235
          sentByClient,
1236
          lifetimeConfig,
1237
          authService,
1238
          s,
1239
        ),
1240
      )
1241

1242
      const [tree, ws] = await acc
3,156✔
1243
      const [updatedTree, leafNodeIndex] = addLeafNode(tree, proposal.add.keyPackage.leafNode)
3,077✔
1244
      return [
3,077✔
1245
        updatedTree,
1246
        [...ws, [nodeToLeafIndex(leafNodeIndex), proposal.add.keyPackage] as [LeafIndex, KeyPackage]],
1247
      ]
1248
    },
1249
    Promise.resolve([treeAfterRemove, []] as [RatchetTree, [LeafIndex, KeyPackage][]]),
1250
  )
1251

1252
  return [treeAfterAdd, addedLeafNodes]
4,817✔
1253
}
1254

1255
export async function processProposal(
1256
  state: ClientState,
1257
  content: AuthenticatedContent,
1258
  proposal: Proposal,
1259
  h: Hash,
1260
): Promise<ClientState> {
1261
  const ref = await makeProposalRef(content, h)
1,835✔
1262
  return {
1,835✔
1263
    ...state,
1264
    unappliedProposals: addUnappliedProposal(
1265
      ref,
1266
      state.unappliedProposals,
1267
      proposal,
1268
      getSenderLeafNodeIndex(content.content.sender),
1269
    ),
1270
  }
1271
}
1272

1273
export function addHistoricalReceiverData(state: ClientState): [Map<bigint, EpochReceiverData>, Uint8Array[]] {
1274
  const withNew = addToMap(state.historicalReceiverData, state.groupContext.epoch, {
5,180✔
1275
    secretTree: state.secretTree,
1276
    ratchetTree: state.ratchetTree,
1277
    senderDataSecret: state.keySchedule.senderDataSecret,
1278
    groupContext: state.groupContext,
1279
    resumptionPsk: state.keySchedule.resumptionPsk,
1280
  })
1281

1282
  const epochs = [...withNew.keys()]
5,180✔
1283

1284
  const result: [Map<bigint, EpochReceiverData>, Uint8Array[]] =
1285
    epochs.length >= state.clientConfig.keyRetentionConfig.retainKeysForEpochs
5,180✔
1286
      ? removeOldHistoricalReceiverData(withNew, state.clientConfig.keyRetentionConfig.retainKeysForEpochs)
1287
      : [withNew, []]
1288

1289
  return result
5,180✔
1290
}
1291

1292
function removeOldHistoricalReceiverData(
1293
  historicalReceiverData: Map<bigint, EpochReceiverData>,
1294
  max: number,
1295
): [Map<bigint, EpochReceiverData>, Uint8Array[]] {
1296
  const sortedEpochs = [...historicalReceiverData.keys()].sort((a, b) => (a < b ? -1 : 1))
10,724✔
1297

1298
  const cutoff = sortedEpochs.length - max
2,762✔
1299

1300
  const toBeDeleted = new Array<Uint8Array>()
2,762✔
1301

1302
  const map = new Map<bigint, EpochReceiverData>()
2,762✔
1303
  for (const [n, epoch] of sortedEpochs.entries()) {
2,762✔
1304
    const data = historicalReceiverData.get(epoch)!
13,486✔
1305
    if (n < cutoff) {
13,486✔
1306
      toBeDeleted.push(...allSecretTreeValues(data.secretTree))
2,514✔
1307
    } else {
1308
      map.set(epoch, data)
10,972✔
1309
    }
1310
  }
1311

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