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

LukaJCB / ts-mls / 16971029851

14 Aug 2025 04:32PM UTC coverage: 91.866% (-0.01%) from 91.876%
16971029851

push

github

web-flow
Use branded types for NodeIndex/LeafIndex (#58)

740 of 972 branches covered (76.13%)

Branch coverage included in aggregate %.

98 of 100 new or added lines in 13 files covered. (98.0%)

1 existing line in 1 file now uncovered.

2637 of 2704 relevant lines covered (97.52%)

140686.48 hits per line

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

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

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

79
export interface ClientState {
80
  groupContext: GroupContext
81
  keySchedule: KeySchedule
82
  secretTree: SecretTree
83
  ratchetTree: RatchetTree
84
  privatePath: PrivateKeyPath
85
  signaturePrivateKey: Uint8Array
86
  unappliedProposals: UnappliedProposals
87
  confirmationTag: Uint8Array
88
  historicalReceiverData: Map<bigint, EpochReceiverData>
89
  groupActiveState: GroupActiveState
90
  clientConfig: ClientConfig
91
}
92

93
export type GroupActiveState =
94
  | { kind: "active" }
95
  | { kind: "suspendedPendingReinit"; reinit: Reinit }
96
  | { kind: "removedFromGroup" }
97

98
/**
99
 * This type contains everything necessary to receieve application messages for an earlier epoch
100
 */
101
export interface EpochReceiverData {
102
  resumptionPsk: Uint8Array
103
  secretTree: SecretTree
104
  ratchetTree: RatchetTree
105
  senderDataSecret: Uint8Array
106
  groupContext: GroupContext
107
}
108

109
export function checkCanSendApplicationMessages(state: ClientState): void {
56✔
110
  if (Object.keys(state.unappliedProposals).length !== 0)
4,940✔
111
    throw new UsageError("Cannot send application message with unapplied proposals")
38✔
112

113
  checkCanSendHandshakeMessages(state)
4,902✔
114
}
115

116
export function checkCanSendHandshakeMessages(state: ClientState): void {
56✔
117
  if (state.groupActiveState.kind === "suspendedPendingReinit")
8,854✔
118
    throw new UsageError("Cannot send messages while Group is suspended pending reinit")
38✔
119
  else if (state.groupActiveState.kind === "removedFromGroup")
8,816✔
120
    throw new UsageError("Cannot send messages after being removed from group")
152✔
121
}
122

123
export interface Proposals {
124
  add: { senderLeafIndex: number | undefined; proposal: ProposalAdd }[]
125
  update: { senderLeafIndex: number | undefined; proposal: ProposalUpdate }[]
126
  remove: { senderLeafIndex: number | undefined; proposal: ProposalRemove }[]
127
  psk: { senderLeafIndex: number | undefined; proposal: ProposalPSK }[]
128
  reinit: { senderLeafIndex: number | undefined; proposal: ProposalReinit }[]
129
  external_init: { senderLeafIndex: number | undefined; proposal: ProposalExternalInit }[]
130
  group_context_extensions: { senderLeafIndex: number | undefined; proposal: ProposalGroupContextExtensions }[]
131
}
132

133
const emptyProposals: Proposals = {
56✔
134
  add: [],
135
  update: [],
136
  remove: [],
137
  psk: [],
138
  reinit: [],
139
  external_init: [],
140
  group_context_extensions: [],
141
}
142

143
function flattenExtensions(groupContextExtensions: { proposal: ProposalGroupContextExtensions }[]): Extension[] {
144
  return groupContextExtensions.reduce((acc, { proposal }) => {
19,806✔
145
    return [...acc, ...proposal.groupContextExtensions.extensions]
302✔
146
  }, [] as Extension[])
147
}
148

149
async function validateProposals(
150
  p: Proposals,
151
  committerLeafIndex: number | undefined,
152
  groupContext: GroupContext,
153
  config: KeyPackageEqualityConfig,
154
  authService: AuthenticationService,
155
  tree: RatchetTree,
156
): Promise<MlsError | undefined> {
157
  const containsUpdateByCommitter = p.update.some(
10,302✔
158
    (o) => o.senderLeafIndex !== undefined && o.senderLeafIndex === committerLeafIndex,
66✔
159
  )
160

161
  if (containsUpdateByCommitter)
10,302✔
162
    return new ValidationError("Commit cannot contain an update proposal sent by committer")
38✔
163

164
  const containsRemoveOfCommitter = p.remove.some((o) => o.proposal.remove.removed === committerLeafIndex)
10,264✔
165

166
  if (containsRemoveOfCommitter)
10,264✔
167
    return new ValidationError("Commit cannot contain a remove proposal removing committer")
38✔
168

169
  const multipleUpdateRemoveForSameLeaf =
170
    p.update.some(
10,226✔
171
      ({ senderLeafIndex: a }, indexA) =>
172
        p.update.some(({ senderLeafIndex: b }, indexB) => a === b && indexA !== indexB) ||
28✔
173
        p.remove.some((r) => r.proposal.remove.removed === a),
14✔
174
    ) ||
175
    p.remove.some(
176
      (a, indexA) =>
177
        p.remove.some((b, indexB) => b.proposal.remove.removed === a.proposal.remove.removed && indexA !== indexB) ||
80,830✔
178
        p.update.some(({ senderLeafIndex }) => a.proposal.remove.removed === senderLeafIndex),
14✔
179
    )
180

181
  if (multipleUpdateRemoveForSameLeaf)
10,226✔
182
    return new ValidationError(
38✔
183
      "Commit cannot contain multiple update and/or remove proposals that apply to the same leaf",
184
    )
185

186
  const multipleAddsContainSameKeypackage = p.add.some(({ proposal: a }, indexA) =>
10,188✔
187
    p.add.some(
6,180✔
188
      ({ proposal: b }, indexB) => config.compareKeyPackages(a.add.keyPackage, b.add.keyPackage) && indexA !== indexB,
65,710✔
189
    ),
190
  )
191

192
  if (multipleAddsContainSameKeypackage)
10,188✔
193
    return new ValidationError(
38✔
194
      "Commit cannot contain multiple Add proposals that contain KeyPackages that represent the same client",
195
    )
196

197
  // checks if there is an Add proposal with a KeyPackage that matches a client already in the group
198
  // unless there is a Remove proposal in the list removing the matching client from the group.
199
  const addsContainExistingKeypackage = p.add.some(({ proposal }) =>
10,150✔
200
    tree.some(
6,142✔
201
      (node, nodeIndex) =>
202
        node !== undefined &&
373,718✔
203
        node.nodeType === "leaf" &&
204
        config.compareKeyPackageToLeafNode(proposal.add.keyPackage, node.leaf) &&
NEW
205
        p.remove.every((r) => r.proposal.remove.removed !== nodeToLeafIndex(toNodeIndex(nodeIndex))),
×
206
    ),
207
  )
208

209
  if (addsContainExistingKeypackage)
10,150✔
210
    return new ValidationError("Commit cannot contain an Add proposal for someone already in the group")
38✔
211

212
  const everyLeafSupportsGroupExtensions = p.add.every(({ proposal }) =>
10,112✔
213
    extensionsSupportedByCapabilities(groupContext.extensions, proposal.add.keyPackage.leafNode.capabilities),
6,104✔
214
  )
215

216
  if (!everyLeafSupportsGroupExtensions)
10,112✔
217
    return new ValidationError("Added leaf node that doesn't support extension in GroupContext")
38✔
218

219
  const multiplePskWithSamePskId = p.psk.some((a, indexA) =>
10,074✔
220
    p.psk.some(
454✔
221
      (b, indexB) =>
222
        constantTimeEqual(encodePskId(a.proposal.psk.preSharedKeyId), encodePskId(b.proposal.psk.preSharedKeyId)) &&
700✔
223
        indexA !== indexB,
224
    ),
225
  )
226

227
  if (multiplePskWithSamePskId)
10,074✔
228
    return new ValidationError("Commit cannot contain PreSharedKey proposals that reference the same PreSharedKeyID")
38✔
229

230
  const multipleGroupContextExtensions = p.group_context_extensions.length > 1
10,036✔
231

232
  if (multipleGroupContextExtensions)
10,036✔
233
    return new ValidationError("Commit cannot contain multiple GroupContextExtensions proposals")
38✔
234

235
  const allExtensions = flattenExtensions(p.group_context_extensions)
9,998✔
236

237
  const requiredCapabilities = allExtensions.find((e) => e.extensionType === "required_capabilities")
9,998✔
238

239
  if (requiredCapabilities !== undefined) {
9,998✔
240
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
114✔
241
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
114✔
242

243
    const everyLeafSupportsCapabilities = tree
76✔
244
      .filter((n) => n !== undefined && n.nodeType === "leaf")
532✔
245
      .every((l) => capabiltiesAreSupported(caps[0], l.leaf.capabilities))
152✔
246

247
    if (!everyLeafSupportsCapabilities) return new ValidationError("Not all members support required capabilities")
76✔
248

249
    const allAdditionsSupportCapabilities = p.add.every((a) =>
38✔
250
      capabiltiesAreSupported(caps[0], a.proposal.add.keyPackage.leafNode.capabilities),
38✔
251
    )
252

253
    if (!allAdditionsSupportCapabilities)
38✔
254
      return new ValidationError("Commit contains add proposals of member without required capabilities")
38✔
255
  }
256

257
  return await validateExternalSenders(allExtensions, authService)
9,884✔
258
}
259

260
async function validateExternalSenders(
261
  extensions: Extension[],
262
  authService: AuthenticationService,
263
): Promise<MlsError | undefined> {
264
  const externalSenders = extensions.filter((e) => e.extensionType === "external_senders")
11,176✔
265
  for (const externalSender of externalSenders) {
11,176✔
266
    const decoded = decodeExternalSender(externalSender.extensionData, 0)
152✔
267
    if (decoded === undefined) return new CodecError("Could not decode external_senders")
152✔
268

269
    const validCredential = await authService.validateCredential(decoded[0].credential, decoded[0].signaturePublicKey)
114✔
270
    if (!validCredential) return new ValidationError("Could not validate external credential")
114✔
271
  }
272
}
273

274
function capabiltiesAreSupported(caps: RequiredCapabilities, cs: Capabilities): boolean {
275
  return (
342✔
276
    caps.credentialTypes.every((c) => cs.credentials.includes(c)) &&
418✔
277
    caps.extensionTypes.every((e) => cs.extensions.includes(e)) &&
228✔
278
    caps.proposalTypes.every((p) => cs.proposals.includes(p))
38✔
279
  )
280
}
281

282
export async function validateRatchetTree(
56✔
283
  tree: RatchetTree,
284
  groupContext: GroupContext,
285
  config: LifetimeConfig,
286
  authService: AuthenticationService,
287
  treeHash: Uint8Array,
288
  cs: CiphersuiteImpl,
289
): Promise<MlsError | undefined> {
290
  const treeIsStructurallySound = tree.every((n, index) =>
2,234✔
291
    isLeaf(toNodeIndex(index)) ? n === undefined || n.nodeType === "leaf" : n === undefined || n.nodeType === "parent",
16,714✔
292
  )
293

294
  if (!treeIsStructurallySound) return new ValidationError("Received Ratchet Tree is not structurally sound")
2,234✔
295

296
  const parentHashesVerified = await verifyParentHashes(tree, cs.hash)
2,196✔
297

298
  if (!parentHashesVerified) return new CryptoVerificationError("Unable to verify parent hash")
2,196!
299

300
  if (!constantTimeEqual(treeHash, await treeHashRoot(tree, cs.hash)))
2,196!
301
    return new ValidationError("Unable to verify tree hash")
×
302

303
  //validate all parent nodes
304
  for (const [parentIndex, n] of tree.entries()) {
2,196✔
305
    if (n?.nodeType === "parent") {
16,676✔
306
      // verify unmerged leaves
307
      for (const unmergedLeaf of n.parent.unmergedLeaves) {
904✔
308
        const leafIndex = toLeafIndex(unmergedLeaf)
228✔
309
        const dp = directPath(leafToNodeIndex(leafIndex), leafWidth(tree.length))
228✔
310
        const nodeIndex = leafToNodeIndex(leafIndex)
228✔
311
        if (tree[nodeIndex]?.nodeType !== "leaf" && !dp.includes(toNodeIndex(parentIndex)))
228!
UNCOV
312
          return new ValidationError("Unmerged leaf did not represent a non-blank descendant leaf node")
×
313

314
        for (const parentIdx of dp) {
228✔
315
          const dpNode = tree[parentIdx]
684✔
316

317
          if (dpNode !== undefined) {
684✔
318
            if (dpNode.nodeType !== "parent") return new InternalError("Expected parent node")
228!
319

320
            if (!arraysEqual(dpNode.parent.unmergedLeaves, n.parent.unmergedLeaves))
228!
321
              return new ValidationError("non-blank intermediate node must list leaf node in its unmerged_leaves")
×
322
          }
323
        }
324
      }
325
    }
326
  }
327

328
  const duplicateHpkeKeys = hasDuplicateUint8Arrays(
2,196✔
329
    tree.map((n) => (n !== undefined ? getHpkePublicKey(n) : undefined)),
16,676✔
330
  )
331

332
  if (duplicateHpkeKeys) return new ValidationError("Multiple public keys with the same value")
2,196!
333

334
  // validate all leaf nodes
335
  for (const [index, n] of tree.entries()) {
2,196✔
336
    if (n?.nodeType === "leaf") {
16,676✔
337
      const err =
338
        n.leaf.leafNodeSource === "key_package"
8,516✔
339
          ? await validateLeafNodeKeyPackage(
340
              n.leaf,
341
              groupContext,
342
              tree,
343
              false,
344
              config,
345
              authService,
346
              nodeToLeafIndex(toNodeIndex(index)),
347
              cs.signature,
348
            )
349
          : await validateLeafNodeUpdateOrCommit(
350
              n.leaf,
351
              nodeToLeafIndex(toNodeIndex(index)),
352
              groupContext,
353
              tree,
354
              authService,
355
              cs.signature,
356
            )
357

358
      if (err !== undefined) return err
8,516!
359
    }
360
  }
361
}
362

363
function hasDuplicateUint8Arrays(byteArrays: (Uint8Array | undefined)[]): boolean {
364
  const seen = new Set<string>()
2,196✔
365

366
  for (const data of byteArrays) {
2,196✔
367
    if (data === undefined) continue
16,676✔
368

369
    const key = bytesToBase64(data)
9,420✔
370
    if (seen.has(key)) {
9,420!
371
      return true
×
372
    }
373
    seen.add(key)
9,420✔
374
  }
375

376
  return false
2,196✔
377
}
378

379
export async function validateLeafNodeUpdateOrCommit(
56✔
380
  leafNode: LeafNodeCommit | LeafNodeUpdate,
381
  leafIndex: number,
382
  groupContext: GroupContext,
383
  tree: RatchetTree,
384
  authService: AuthenticationService,
385
  s: Signature,
386
): Promise<MlsError | undefined> {
387
  const signatureValid = await verifyLeafNodeSignature(leafNode, groupContext.groupId, leafIndex, s)
4,724✔
388

389
  if (!signatureValid) return new CryptoVerificationError("Could not verify leaf node signature")
4,724!
390

391
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, tree, authService, leafIndex)
4,724✔
392

393
  if (commonError !== undefined) return commonError
4,724!
394
}
395

396
export function throwIfDefined(err: MlsError | undefined): void {
56✔
397
  if (err !== undefined) throw err
29,014✔
398
}
399

400
async function validateLeafNodeCommon(
401
  leafNode: LeafNode,
402
  groupContext: GroupContext,
403
  tree: RatchetTree,
404
  authService: AuthenticationService,
405
  leafIndex?: number,
406
) {
407
  const credentialValid = await authService.validateCredential(leafNode.credential, leafNode.signaturePublicKey)
18,586✔
408

409
  if (!credentialValid) return new ValidationError("Could not validate credential")
18,586✔
410

411
  const requiredCapabilities = groupContext.extensions.find((e) => e.extensionType === "required_capabilities")
18,548✔
412

413
  if (requiredCapabilities !== undefined) {
18,548✔
414
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
152✔
415
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
152!
416

417
    const leafSupportsCapabilities = capabiltiesAreSupported(caps[0], leafNode.capabilities)
152✔
418

419
    if (!leafSupportsCapabilities) return new ValidationError("LeafNode does not support required capabilities")
152✔
420
  }
421

422
  const credentialUnsupported = tree.some(
18,510✔
423
    (node) =>
424
      node !== undefined &&
559,490✔
425
      node.nodeType === "leaf" &&
426
      !node.leaf.capabilities.credentials.includes(leafNode.credential.credentialType),
427
  )
428

429
  if (credentialUnsupported)
18,510✔
430
    return new ValidationError("LeafNode has credential that is not supported by member of the group")
38✔
431

432
  const extensionsSupported = extensionsSupportedByCapabilities(leafNode.extensions, leafNode.capabilities)
18,472✔
433

434
  if (!extensionsSupported) return new ValidationError("LeafNode contains extension not listed in capabilities")
18,472✔
435

436
  const keysAreNotUnique = tree.some(
18,434✔
437
    (node, nodeIndex) =>
438
      node !== undefined &&
559,186✔
439
      node.nodeType === "leaf" &&
440
      (constantTimeEqual(node.leaf.hpkePublicKey, leafNode.hpkePublicKey) ||
441
        constantTimeEqual(node.leaf.signaturePublicKey, leafNode.signaturePublicKey)) &&
442
      leafIndex !== nodeToLeafIndex(toNodeIndex(nodeIndex)),
443
  )
444

445
  if (keysAreNotUnique) return new ValidationError("hpke and signature keys not unique")
18,434!
446
}
447

448
async function validateLeafNodeKeyPackage(
449
  leafNode: LeafNodeKeyPackage,
450
  groupContext: GroupContext,
451
  tree: RatchetTree,
452
  sentByClient: boolean,
453
  config: LifetimeConfig,
454
  authService: AuthenticationService,
455
  leafIndex: number | undefined,
456
  s: Signature,
457
): Promise<MlsError | undefined> {
458
  const signatureValid = await verifyLeafNodeSignatureKeyPackage(leafNode, s)
13,862✔
459
  if (!signatureValid) return new CryptoVerificationError("Could not verify leaf node signature")
13,862!
460

461
  //verify lifetime
462
  if (sentByClient || config.validateLifetimeOnReceive) {
13,862✔
463
    if (leafNode.leafNodeSource === "key_package") {
2,014✔
464
      const currentTime = BigInt(Math.floor(Date.now() / 1000))
2,014✔
465
      if (leafNode.lifetime.notBefore > currentTime || leafNode.lifetime.notAfter < currentTime)
2,014!
466
        return new ValidationError("Current time not within Lifetime")
×
467
    }
468
  }
469

470
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, tree, authService, leafIndex)
13,862✔
471

472
  if (commonError !== undefined) return commonError
13,862✔
473
}
474

475
async function validateKeyPackage(
476
  kp: KeyPackage,
477
  groupContext: GroupContext,
478
  tree: RatchetTree,
479
  sentByClient: boolean,
480
  config: LifetimeConfig,
481
  authService: AuthenticationService,
482
  s: Signature,
483
): Promise<MlsError | undefined> {
484
  if (kp.cipherSuite !== groupContext.cipherSuite) return new ValidationError("Invalid CipherSuite")
6,028!
485

486
  if (kp.version !== groupContext.version) return new ValidationError("Invalid mls version")
6,028!
487

488
  const leafNodeError = await validateLeafNodeKeyPackage(
6,028✔
489
    kp.leafNode,
490
    groupContext,
491
    tree,
492
    sentByClient,
493
    config,
494
    authService,
495
    undefined,
496
    s,
497
  )
498
  if (leafNodeError !== undefined) return leafNodeError
6,028✔
499

500
  const signatureValid = await verifyKeyPackage(kp, s)
5,876✔
501
  if (!signatureValid) return new CryptoVerificationError("Invalid keypackage signature")
5,876!
502

503
  if (constantTimeEqual(kp.initKey, kp.leafNode.hpkePublicKey))
5,876!
504
    return new ValidationError("Cannot have identicial init and encryption keys")
×
505
}
506

507
function validateReinit(
508
  allProposals: ProposalWithSender[],
509
  reinit: Reinit,
510
  gc: GroupContext,
511
): ValidationError | undefined {
512
  if (allProposals.length !== 1) return new ValidationError("Reinit proposal needs to be commited by itself")
152!
513

514
  if (protocolVersions[reinit.version] < protocolVersions[gc.version])
152!
515
    return new ValidationError("A ReInit proposal cannot use a version less than the version for the current group")
×
516
}
517

518
function validateExternalInit(grouped: Proposals): ValidationError | undefined {
519
  if (grouped.external_init.length > 1)
152!
520
    return new ValidationError("Cannot contain more than one external_init proposal")
×
521

522
  if (grouped.remove.length > 1) return new ValidationError("Cannot contain more than one remove proposal")
152!
523

524
  if (
152!
525
    grouped.add.length > 0 ||
304✔
526
    grouped.group_context_extensions.length > 0 ||
527
    grouped.reinit.length > 0 ||
528
    grouped.update.length > 0
529
  )
530
    return new ValidationError("Invalid proposals")
×
531
}
532

533
function validateRemove(remove: Remove, tree: RatchetTree): MlsError | undefined {
534
  if (tree[leafToNodeIndex(toLeafIndex(remove.removed))] === undefined)
4,850!
NEW
535
    return new ValidationError("Tried to remove empty leaf node")
×
536
}
537

538
export interface ApplyProposalsResult {
539
  tree: RatchetTree
540
  pskSecret: Uint8Array
541
  pskIds: PreSharedKeyID[]
542
  needsUpdatePath: boolean
543
  additionalResult: ApplyProposalsData
544
  selfRemoved: boolean
545
  allProposals: ProposalWithSender[]
546
}
547

548
export type ApplyProposalsData =
549
  | { kind: "memberCommit"; addedLeafNodes: [LeafIndex, KeyPackage][]; extensions: Extension[] }
550
  | { kind: "externalCommit"; externalInitSecret: Uint8Array; newMemberLeafIndex: LeafIndex }
551
  | { kind: "reinit"; reinit: Reinit }
552

553
export async function applyProposals(
56✔
554
  state: ClientState,
555
  proposals: ProposalOrRef[],
556
  committerLeafIndex: LeafIndex | undefined,
557
  pskSearch: PskIndex,
558
  sentByClient: boolean,
559
  cs: CiphersuiteImpl,
560
): Promise<ApplyProposalsResult> {
561
  const allProposals = proposals.reduce((acc, cur) => {
10,606✔
562
    if (cur.proposalOrRefType === "proposal")
12,518✔
563
      return [...acc, { proposal: cur.proposal, senderLeafIndex: committerLeafIndex }]
8,810✔
564

565
    const p = state.unappliedProposals[bytesToBase64(cur.reference)]
3,708✔
566
    if (p === undefined) throw new ValidationError("Could not find proposal with supplied reference")
3,708!
567
    return [...acc, p]
3,708✔
568
  }, [] as ProposalWithSender[])
569

570
  const grouped = allProposals.reduce((acc, cur) => {
10,606✔
571
    //this skips any custom proposals
572
    if (typeof cur.proposal.proposalType === "number") return acc
12,518✔
573
    const proposal = acc[cur.proposal.proposalType] ?? []
12,442!
574
    return { ...acc, [cur.proposal.proposalType]: [...proposal, cur] }
12,442✔
575
  }, emptyProposals)
576

577
  const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
10,606✔
578

579
  const isExternalInit = grouped.external_init.length > 0
10,606✔
580

581
  if (!isExternalInit) {
10,606✔
582
    if (grouped.reinit.length > 0) {
10,454✔
583
      const reinit = grouped.reinit.at(0)!.proposal.reinit
152✔
584

585
      throwIfDefined(validateReinit(allProposals, reinit, state.groupContext))
152✔
586

587
      return {
152✔
588
        tree: state.ratchetTree,
589
        pskSecret: zeroes,
590
        pskIds: [],
591
        needsUpdatePath: false,
592
        additionalResult: {
593
          kind: "reinit",
594
          reinit,
595
        },
596
        selfRemoved: false,
597
        allProposals,
598
      }
599
    }
600

601
    throwIfDefined(
10,302✔
602
      await validateProposals(
603
        grouped,
604
        committerLeafIndex,
605
        state.groupContext,
606
        state.clientConfig.keyPackageEqualityConfig,
607
        state.clientConfig.authService,
608
        state.ratchetTree,
609
      ),
610
    )
611

612
    const newExtensions = flattenExtensions(grouped.group_context_extensions)
9,808✔
613

614
    const [mutatedTree, addedLeafNodes] = await applyTreeMutations(
9,808✔
615
      state.ratchetTree,
616
      grouped,
617
      state.groupContext,
618
      sentByClient,
619
      state.clientConfig.authService,
620
      state.clientConfig.lifetimeConfig,
621
      cs.signature,
622
    )
623

624
    const [updatedPskSecret, pskIds] = await accumulatePskSecret(
9,656✔
625
      grouped.psk.map((p) => p.proposal.psk.preSharedKeyId),
416✔
626
      pskSearch,
627
      cs,
628
      zeroes,
629
    )
630

631
    const selfRemoved = mutatedTree[leafToNodeIndex(toLeafIndex(state.privatePath.leafIndex))] === undefined
9,656✔
632

633
    const needsUpdatePath =
634
      allProposals.length === 0 || Object.values(grouped.update).length > 1 || Object.values(grouped.remove).length > 1
9,656✔
635

636
    return {
9,656✔
637
      tree: mutatedTree,
638
      pskSecret: updatedPskSecret,
639
      additionalResult: {
640
        kind: "memberCommit" as const,
641
        addedLeafNodes,
642
        extensions: newExtensions,
643
      },
644
      pskIds,
645
      needsUpdatePath,
646
      selfRemoved,
647
      allProposals,
648
    }
649
  } else {
650
    throwIfDefined(validateExternalInit(grouped))
152✔
651

652
    const treeAfterRemove = grouped.remove.reduce((acc, { proposal }) => {
152✔
653
      return removeLeafNode(acc, toLeafIndex(proposal.remove.removed))
76✔
654
    }, state.ratchetTree)
655

656
    const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
152✔
657

658
    const [updatedPskSecret, pskIds] = await accumulatePskSecret(
152✔
659
      grouped.psk.map((p) => p.proposal.psk.preSharedKeyId),
×
660
      pskSearch,
661
      cs,
662
      zeroes,
663
    )
664

665
    const initProposal = grouped.external_init.at(0)!
152✔
666

667
    const externalKeyPair = await cs.hpke.deriveKeyPair(state.keySchedule.externalSecret)
152✔
668

669
    const externalInitSecret = await importSecret(
152✔
670
      await cs.hpke.exportPrivateKey(externalKeyPair.privateKey),
671
      initProposal.proposal.externalInit.kemOutput,
672
      cs,
673
    )
674

675
    return {
152✔
676
      needsUpdatePath: true,
677
      tree: treeAfterRemove,
678
      pskSecret: updatedPskSecret,
679
      pskIds,
680
      additionalResult: {
681
        kind: "externalCommit",
682
        externalInitSecret,
683
        newMemberLeafIndex: nodeToLeafIndex(findBlankLeafNodeIndexOrExtend(treeAfterRemove)),
684
      },
685
      selfRemoved: false,
686
      allProposals,
687
    }
688
  }
689
}
690

691
export function makePskIndex(state: ClientState | undefined, externalPsks: Record<string, Uint8Array>): PskIndex {
56✔
692
  return {
21,792✔
693
    findPsk(preSharedKeyId) {
694
      if (preSharedKeyId.psktype === "external") {
882✔
695
        return externalPsks[bytesToBase64(preSharedKeyId.pskId)]
522✔
696
      }
697

698
      if (state !== undefined && constantTimeEqual(preSharedKeyId.pskGroupId, state.groupContext.groupId)) {
360✔
699
        if (preSharedKeyId.pskEpoch === state.groupContext.epoch) return state.keySchedule.resumptionPsk
360✔
700
        else return state.historicalReceiverData.get(preSharedKeyId.pskEpoch)?.resumptionPsk
56✔
701
      }
702
    },
703
  }
704
}
705

706
export async function nextEpochContext(
56✔
707
  groupContext: GroupContext,
708
  wireformat: WireformatName,
709
  content: FramedContentCommit,
710
  signature: Uint8Array,
711
  updatedTreeHash: Uint8Array,
712
  confirmationTag: Uint8Array,
713
  h: Hash,
714
): Promise<GroupContext> {
715
  const interimTranscriptHash = await createInterimHash(groupContext.confirmedTranscriptHash, confirmationTag, h)
9,960✔
716
  const newConfirmedHash = await createConfirmedHash(interimTranscriptHash, { wireformat, content, signature }, h)
9,960✔
717

718
  return {
9,960✔
719
    ...groupContext,
720
    epoch: groupContext.epoch + 1n,
721
    treeHash: updatedTreeHash,
722
    confirmedTranscriptHash: newConfirmedHash,
723
  }
724
}
725

726
export async function joinGroup(
56✔
727
  welcome: Welcome,
728
  keyPackage: KeyPackage,
729
  privateKeys: PrivateKeyPackage,
730
  pskSearch: PskIndex,
731
  cs: CiphersuiteImpl,
732
  ratchetTree?: RatchetTree,
733
  resumingFromState?: ClientState,
734
  clientConfig: ClientConfig = defaultClientConfig,
1,003✔
735
): Promise<ClientState> {
736
  const keyPackageRef = await makeKeyPackageRef(keyPackage, cs.hash)
2,234✔
737
  const privKey = await cs.hpke.importPrivateKey(privateKeys.initPrivateKey)
2,234✔
738
  const groupSecrets = await decryptGroupSecrets(privKey, keyPackageRef, welcome, cs.hpke)
2,234✔
739

740
  if (groupSecrets === undefined) throw new CodecError("Could not decode group secrets")
2,234!
741

742
  const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
2,234✔
743

744
  const [pskSecret, pskIds] = await accumulatePskSecret(groupSecrets.psks, pskSearch, cs, zeroes)
2,234✔
745

746
  const gi = await decryptGroupInfo(welcome, groupSecrets.joinerSecret, pskSecret, cs)
2,234✔
747
  if (gi === undefined) throw new CodecError("Could not decode group info")
2,234!
748

749
  const resumptionPsk = pskIds.find((id) => id.psktype === "resumption")
2,234✔
750
  if (resumptionPsk !== undefined) {
2,234✔
751
    if (resumingFromState === undefined) throw new ValidationError("No prior state passed for resumption")
190!
752

753
    if (resumptionPsk.pskEpoch !== resumingFromState.groupContext.epoch) throw new ValidationError("Epoch mismatch")
190!
754

755
    if (!constantTimeEqual(resumptionPsk.pskGroupId, resumingFromState.groupContext.groupId))
190!
756
      throw new ValidationError("old groupId mismatch")
×
757

758
    if (gi.groupContext.epoch !== 1n) throw new ValidationError("Resumption must be started at epoch 1")
190!
759

760
    if (resumptionPsk.usage === "reinit") {
190✔
761
      if (resumingFromState.groupActiveState.kind !== "suspendedPendingReinit")
152!
762
        throw new ValidationError("Found reinit psk but no old suspended clientState")
×
763

764
      if (!constantTimeEqual(resumingFromState.groupActiveState.reinit.groupId, gi.groupContext.groupId))
152✔
765
        throw new ValidationError("new groupId mismatch")
38✔
766

767
      if (resumingFromState.groupActiveState.reinit.version !== gi.groupContext.version)
114✔
768
        throw new ValidationError("Version mismatch")
38✔
769

770
      if (resumingFromState.groupActiveState.reinit.cipherSuite !== gi.groupContext.cipherSuite)
76!
771
        throw new ValidationError("Ciphersuite mismatch")
×
772

773
      if (!extensionsEqual(resumingFromState.groupActiveState.reinit.extensions, gi.groupContext.extensions))
76✔
774
        throw new ValidationError("Extensions mismatch")
38✔
775
    }
776
  }
777

778
  const allExtensionsSupported = extensionsSupportedByCapabilities(
2,120✔
779
    gi.groupContext.extensions,
780
    keyPackage.leafNode.capabilities,
781
  )
782
  if (!allExtensionsSupported) throw new UsageError("client does not support every extension in the GroupContext")
2,120!
783

784
  const tree = ratchetTreeFromExtension(gi) ?? ratchetTree
2,120✔
785

786
  if (tree === undefined) throw new UsageError("No RatchetTree passed and no ratchet_tree extension")
2,120!
787

788
  const signerNode = tree[leafToNodeIndex(toLeafIndex(gi.signer))]
2,120✔
789

790
  if (signerNode === undefined) {
2,120!
791
    throw new ValidationError("Could not find signer leafNode")
×
792
  }
793
  if (signerNode.nodeType === "parent") throw new ValidationError("Expected non blank leaf node")
2,120!
794

795
  const credentialVerified = await clientConfig.authService.validateCredential(
2,120✔
796
    signerNode.leaf.credential,
797
    signerNode.leaf.signaturePublicKey,
798
  )
799

800
  if (!credentialVerified) throw new ValidationError("Could not validate credential")
2,120!
801

802
  const groupInfoSignatureVerified = verifyGroupInfoSignature(gi, signerNode.leaf.signaturePublicKey, cs.signature)
2,120✔
803

804
  if (!groupInfoSignatureVerified) throw new CryptoVerificationError("Could not verify groupInfo signature")
2,120!
805

806
  if (gi.groupContext.cipherSuite !== keyPackage.cipherSuite)
2,120!
807
    throw new ValidationError("cipher suite in the GroupInfo does not match the cipher_suite in the KeyPackage")
×
808

809
  throwIfDefined(
2,120✔
810
    await validateRatchetTree(
811
      tree,
812
      gi.groupContext,
813
      clientConfig.lifetimeConfig,
814
      clientConfig.authService,
815
      gi.groupContext.treeHash,
816
      cs,
817
    ),
818
  )
819

820
  const newLeaf = findLeafIndex(tree, keyPackage.leafNode)
2,120✔
821

822
  if (newLeaf === undefined) throw new ValidationError("Could not find own leaf when processing welcome")
2,120!
823

824
  const privateKeyPath: PrivateKeyPath = {
2,120✔
825
    leafIndex: newLeaf,
826
    privateKeys: { [leafToNodeIndex(newLeaf)]: privateKeys.hpkePrivateKey },
827
  }
828

829
  const ancestorNodeIndex = firstCommonAncestor(tree, newLeaf, toLeafIndex(gi.signer))
2,120✔
830

831
  const updatedPkp =
832
    groupSecrets.pathSecret === undefined
2,120✔
833
      ? privateKeyPath
834
      : mergePrivateKeyPaths(
835
          await toPrivateKeyPath(
836
            await pathToRoot(tree, ancestorNodeIndex, groupSecrets.pathSecret, cs.kdf),
837
            newLeaf,
838
            cs,
839
          ),
840
          privateKeyPath,
841
        )
842

843
  const keySchedule = await deriveKeySchedule(groupSecrets.joinerSecret, pskSecret, gi.groupContext, cs.kdf)
2,120✔
844

845
  const confirmationTagVerified = await verifyGroupInfoConfirmationTag(gi, groupSecrets.joinerSecret, pskSecret, cs)
2,120✔
846

847
  if (!confirmationTagVerified) throw new CryptoVerificationError("Could not verify confirmation tag")
2,120!
848

849
  const secretTree = await createSecretTree(leafWidth(tree.length), keySchedule.encryptionSecret, cs.kdf)
2,120✔
850

851
  return {
2,120✔
852
    groupContext: gi.groupContext,
853
    ratchetTree: tree,
854
    privatePath: updatedPkp,
855
    signaturePrivateKey: privateKeys.signaturePrivateKey,
856
    confirmationTag: gi.confirmationTag,
857
    unappliedProposals: {},
858
    keySchedule,
859
    secretTree,
860
    historicalReceiverData: new Map(),
861
    groupActiveState: { kind: "active" },
862
    clientConfig,
863
  }
864
}
865

866
export async function createGroup(
56✔
867
  groupId: Uint8Array,
868
  keyPackage: KeyPackage,
869
  privateKeyPackage: PrivateKeyPackage,
870
  extensions: Extension[],
871
  cs: CiphersuiteImpl,
872
  clientConfig: ClientConfig = defaultClientConfig,
646✔
873
): Promise<ClientState> {
874
  const ratchetTree: RatchetTree = [{ nodeType: "leaf", leaf: keyPackage.leafNode }]
1,292✔
875

876
  const privatePath: PrivateKeyPath = {
1,292✔
877
    leafIndex: 0,
878
    privateKeys: { [0]: privateKeyPackage.hpkePrivateKey },
879
  }
880

881
  const confirmedTranscriptHash = new Uint8Array()
1,292✔
882

883
  const groupContext: GroupContext = {
1,292✔
884
    version: "mls10",
885
    cipherSuite: cs.name,
886
    epoch: 0n,
887
    treeHash: await treeHashRoot(ratchetTree, cs.hash),
888
    groupId,
889
    extensions,
890
    confirmedTranscriptHash,
891
  }
892

893
  throwIfDefined(await validateExternalSenders(extensions, clientConfig.authService))
1,292✔
894

895
  const epochSecret = cs.rng.randomBytes(cs.kdf.size)
1,292✔
896

897
  const keySchedule = await initializeKeySchedule(epochSecret, cs.kdf)
1,292✔
898

899
  const confirmationTag = await createConfirmationTag(keySchedule.confirmationKey, confirmedTranscriptHash, cs.hash)
1,292✔
900

901
  const secretTree = await createSecretTree(1, keySchedule.encryptionSecret, cs.kdf)
1,292✔
902

903
  return {
1,292✔
904
    ratchetTree,
905
    keySchedule,
906
    secretTree,
907
    privatePath,
908
    signaturePrivateKey: privateKeyPackage.signaturePrivateKey,
909
    unappliedProposals: {},
910
    historicalReceiverData: new Map(),
911
    groupContext,
912
    confirmationTag,
913
    groupActiveState: { kind: "active" },
914
    clientConfig,
915
  }
916
}
917

918
export async function exportSecret(
56✔
919
  publicKey: Uint8Array,
920
  cs: CiphersuiteImpl,
921
): Promise<{ enc: Uint8Array; secret: Uint8Array }> {
922
  return cs.hpke.exportSecret(
76✔
923
    await cs.hpke.importPublicKey(publicKey),
924
    new TextEncoder().encode("MLS 1.0 external init secret"),
925
    cs.kdf.size,
926
    new Uint8Array(),
927
  )
928
}
929

930
async function importSecret(privateKey: Uint8Array, kemOutput: Uint8Array, cs: CiphersuiteImpl): Promise<Uint8Array> {
931
  return cs.hpke.importSecret(
152✔
932
    await cs.hpke.importPrivateKey(privateKey),
933
    new TextEncoder().encode("MLS 1.0 external init secret"),
934
    kemOutput,
935
    cs.kdf.size,
936
    new Uint8Array(),
937
  )
938
}
939

940
async function applyTreeMutations(
941
  ratchetTree: RatchetTree,
942
  grouped: Proposals,
943
  gc: GroupContext,
944
  sentByClient: boolean,
945
  authService: AuthenticationService,
946
  lifetimeConfig: LifetimeConfig,
947
  s: Signature,
948
): Promise<[RatchetTree, [LeafIndex, KeyPackage][]]> {
949
  const treeAfterUpdate = await grouped.update.reduce(async (acc, { senderLeafIndex, proposal }) => {
9,808✔
950
    if (senderLeafIndex === undefined) throw new InternalError("No sender index found for update proposal")
28!
951

952
    throwIfDefined(
28✔
953
      await validateLeafNodeUpdateOrCommit(proposal.update.leafNode, senderLeafIndex, gc, ratchetTree, authService, s),
954
    )
955
    return updateLeafNode(await acc, proposal.update.leafNode, toLeafIndex(senderLeafIndex))
28✔
956
  }, Promise.resolve(ratchetTree))
957

958
  const treeAfterRemove = grouped.remove.reduce((acc, { proposal }) => {
9,808✔
959
    throwIfDefined(validateRemove(proposal.remove, ratchetTree))
4,850✔
960

961
    return removeLeafNode(acc, toLeafIndex(proposal.remove.removed))
4,850✔
962
  }, treeAfterUpdate)
963

964
  const [treeAfterAdd, addedLeafNodes] = await grouped.add.reduce(
9,808✔
965
    async (acc, { proposal }) => {
966
      throwIfDefined(
6,028✔
967
        await validateKeyPackage(
968
          proposal.add.keyPackage,
969
          gc,
970
          ratchetTree,
971
          sentByClient,
972
          lifetimeConfig,
973
          authService,
974
          s,
975
        ),
976
      )
977

978
      const [tree, ws] = await acc
5,876✔
979
      const [updatedTree, leafNodeIndex] = addLeafNode(tree, proposal.add.keyPackage.leafNode)
5,876✔
980
      return [
5,876✔
981
        updatedTree,
982
        [...ws, [nodeToLeafIndex(leafNodeIndex), proposal.add.keyPackage] as [LeafIndex, KeyPackage]],
983
      ]
984
    },
985
    Promise.resolve([treeAfterRemove, []] as [RatchetTree, [LeafIndex, KeyPackage][]]),
986
  )
987

988
  return [treeAfterAdd, addedLeafNodes]
9,656✔
989
}
990

991
export async function processProposal(
56✔
992
  state: ClientState,
993
  content: AuthenticatedContent,
994
  proposal: Proposal,
995
  h: Hash,
996
): Promise<ClientState> {
997
  const ref = await makeProposalRef(content, h)
3,670✔
998
  return {
3,670✔
999
    ...state,
1000
    unappliedProposals: addUnappliedProposal(
1001
      ref,
1002
      state.unappliedProposals,
1003
      proposal,
1004
      getSenderLeafNodeIndex(content.content.sender),
1005
    ),
1006
  }
1007
}
1008

1009
export function addHistoricalReceiverData(state: ClientState): Map<bigint, EpochReceiverData> {
56✔
1010
  const withNew = addToMap(state.historicalReceiverData, state.groupContext.epoch, {
9,884✔
1011
    secretTree: state.secretTree,
1012
    ratchetTree: state.ratchetTree,
1013
    senderDataSecret: state.keySchedule.senderDataSecret,
1014
    groupContext: state.groupContext,
1015
    resumptionPsk: state.keySchedule.resumptionPsk,
1016
  })
1017

1018
  const epochs = [...withNew.keys()]
9,884✔
1019

1020
  const result =
1021
    epochs.length >= state.clientConfig.keyRetentionConfig.retainKeysForEpochs
9,884✔
1022
      ? removeOldHistoricalReceiverData(withNew, state.clientConfig.keyRetentionConfig.retainKeysForEpochs)
1023
      : withNew
1024

1025
  return result
9,884✔
1026
}
1027

1028
function removeOldHistoricalReceiverData(
1029
  historicalReceiverData: Map<bigint, EpochReceiverData>,
1030
  max: number,
1031
): Map<bigint, EpochReceiverData> {
1032
  const sortedEpochs = [...historicalReceiverData.keys()].sort((a, b) => (a < b ? -1 : 1))
21,448!
1033

1034
  return new Map(sortedEpochs.slice(-max).map((epoch) => [epoch, historicalReceiverData.get(epoch)!]))
21,944✔
1035
}
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