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

LukaJCB / ts-mls / 19520921507

20 Nov 2025 12:25AM UTC coverage: 96.901% (+0.2%) from 96.711%
19520921507

push

github

web-flow
Break down validation into smaller tests (#150)

1169 of 1288 branches covered (90.76%)

Branch coverage included in aggregate %.

6680 of 6812 relevant lines covered (98.06%)

47808.62 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

174
  if (containsRemoveOfCommitter)
5,145✔
175
    return new ValidationError("Commit cannot contain a remove proposal removing committer")
5,146✔
176

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

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

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

200
  if (multipleAddsContainSameKeypackage)
5,143✔
201
    return new ValidationError(
5,146✔
202
      "Commit cannot contain multiple Add proposals that contain KeyPackages that represent the same client",
1✔
203
    )
1✔
204

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

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

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

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

227
  const multiplePskWithSamePskId = p.psk.some((a, indexA) =>
5,122✔
228
    p.psk.some(
209✔
229
      (b, indexB) =>
209✔
230
        constantTimeEqual(
314✔
231
          encode(pskIdEncoder)(a.proposal.psk.preSharedKeyId),
314✔
232
          encode(pskIdEncoder)(b.proposal.psk.preSharedKeyId),
314✔
233
        ) && indexA !== indexB,
314✔
234
    ),
209✔
235
  )
5,122✔
236

237
  if (multiplePskWithSamePskId)
5,122✔
238
    return new ValidationError("Commit cannot contain PreSharedKey proposals that reference the same PreSharedKeyID")
5,146✔
239

240
  const multipleGroupContextExtensions = p.group_context_extensions.length > 1
5,121✔
241

242
  if (multipleGroupContextExtensions)
5,121✔
243
    return new ValidationError("Commit cannot contain multiple GroupContextExtensions proposals")
5,146✔
244

245
  const allExtensions = flattenExtensions(p.group_context_extensions)
5,120✔
246

247
  const requiredCapabilities = allExtensions.find((e) => e.extensionType === "required_capabilities")
5,120✔
248

249
  if (requiredCapabilities !== undefined) {
5,146✔
250
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
3✔
251
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
3✔
252

253
    const everyLeafSupportsCapabilities = tree
2✔
254
      .filter((n) => n !== undefined && n.nodeType === "leaf")
2✔
255
      .every((l) => capabiltiesAreSupported(caps[0], l.leaf.capabilities))
2✔
256

257
    if (!everyLeafSupportsCapabilities) return new ValidationError("Not all members support required capabilities")
3✔
258

259
    const allAdditionsSupportCapabilities = p.add.every((a) =>
1✔
260
      capabiltiesAreSupported(caps[0], a.proposal.add.keyPackage.leafNode.capabilities),
1✔
261
    )
1✔
262

263
    if (!allAdditionsSupportCapabilities)
1✔
264
      return new ValidationError("Commit contains add proposals of member without required capabilities")
1✔
265
  }
3✔
266

267
  return await validateExternalSenders(allExtensions, authService)
5,117✔
268
}
5,117✔
269

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

279
    const validCredential = await authService.validateCredential(decoded[0].credential, decoded[0].signaturePublicKey)
20✔
280
    if (!validCredential) return new ValidationError("Could not validate external credential")
20✔
281
  }
21✔
282
}
5,987✔
283

284
function capabiltiesAreSupported(caps: RequiredCapabilities, cs: Capabilities): boolean {
81✔
285
  return (
81✔
286
    caps.credentialTypes.every((c) => cs.credentials.includes(c)) &&
81✔
287
    caps.extensionTypes.every((e) => cs.extensions.includes(e)) &&
61✔
288
    caps.proposalTypes.every((p) => cs.proposals.includes(p))
61✔
289
  )
290
}
81✔
291

292
export async function validateRatchetTree(
1,265✔
293
  tree: RatchetTree,
1,265✔
294
  groupContext: GroupContext,
1,265✔
295
  config: LifetimeConfig,
1,265✔
296
  authService: AuthenticationService,
1,265✔
297
  treeHash: Uint8Array,
1,265✔
298
  cs: CiphersuiteImpl,
1,265✔
299
): Promise<MlsError | undefined> {
1,265✔
300
  const hpkeKeys = new Set<string>()
1,265✔
301
  const signatureKeys = new Set<string>()
1,265✔
302
  const credentialTypes = new Set<CredentialTypeName>()
1,265✔
303
  for (const [i, n] of tree.entries()) {
1,265✔
304
    const nodeIndex = toNodeIndex(i)
8,671✔
305
    if (n?.nodeType === "leaf") {
8,671✔
306
      if (!isLeaf(nodeIndex)) return new ValidationError("Received Ratchet Tree is not structurally sound")
4,493!
307

308
      const hpkeKey = bytesToBase64(n.leaf.hpkePublicKey)
4,493✔
309
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
4,493✔
310
      else hpkeKeys.add(hpkeKey)
4,474✔
311

312
      const signatureKey = bytesToBase64(n.leaf.signaturePublicKey)
4,474✔
313
      if (signatureKeys.has(signatureKey)) return new ValidationError("signature keys not unique")
4,493✔
314
      else signatureKeys.add(signatureKey)
4,455✔
315

316
      credentialTypes.add(n.leaf.credential.credentialType)
4,455✔
317

318
      const err =
4,455✔
319
        n.leaf.leafNodeSource === "key_package"
4,455✔
320
          ? await validateLeafNodeKeyPackage(n.leaf, groupContext, false, config, authService, cs.signature)
4,019✔
321
          : await validateLeafNodeUpdateOrCommit(
436✔
322
              n.leaf,
436✔
323
              nodeToLeafIndex(nodeIndex),
436✔
324
              groupContext,
436✔
325
              authService,
436✔
326
              cs.signature,
436✔
327
            )
436✔
328

329
      if (err !== undefined) return err
2,510✔
330
    } else if (n?.nodeType === "parent") {
8,671✔
331
      if (isLeaf(nodeIndex)) return new ValidationError("Received Ratchet Tree is not structurally sound")
528✔
332

333
      const hpkeKey = bytesToBase64(n.parent.hpkePublicKey)
509✔
334
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
528✔
335
      else hpkeKeys.add(hpkeKey)
509✔
336

337
      for (const unmergedLeaf of n.parent.unmergedLeaves) {
528✔
338
        const leafIndex = toLeafIndex(unmergedLeaf)
114✔
339
        const dp = directPath(leafToNodeIndex(leafIndex), leafWidth(tree.length))
114✔
340
        const nodeIndex = leafToNodeIndex(leafIndex)
114✔
341
        if (tree[nodeIndex]?.nodeType !== "leaf" && !dp.includes(toNodeIndex(i)))
114!
342
          return new ValidationError("Unmerged leaf did not represent a non-blank descendant leaf node")
114!
343

344
        for (const parentIdx of dp) {
114✔
345
          const dpNode = tree[parentIdx]
342✔
346

347
          if (dpNode !== undefined) {
342✔
348
            if (dpNode.nodeType !== "parent") return new InternalError("Expected parent node")
114!
349

350
            if (!arraysEqual(dpNode.parent.unmergedLeaves, n.parent.unmergedLeaves))
114✔
351
              return new ValidationError("non-blank intermediate node must list leaf node in its unmerged_leaves")
114!
352
          }
114✔
353
        }
342✔
354
      }
114✔
355
    }
509✔
356
  }
8,671✔
357

358
  for (const n of tree) {
1,265✔
359
    if (n?.nodeType === "leaf") {
8,481✔
360
      for (const credentialType of credentialTypes) {
4,360✔
361
        if (!n.leaf.capabilities.credentials.includes(credentialType))
4,360✔
362
          return new ValidationError("LeafNode has credential that is not supported by member of the group")
4,360!
363
      }
4,360✔
364
    }
4,360✔
365
  }
8,481✔
366

367
  const parentHashesVerified = await verifyParentHashes(tree, cs.hash)
1,151✔
368

369
  if (!parentHashesVerified) return new CryptoVerificationError("Unable to verify parent hash")
1,265✔
370

371
  if (!constantTimeEqual(treeHash, await treeHashRoot(tree, cs.hash)))
1,132✔
372
    return new ValidationError("Unable to verify tree hash")
1,132✔
373
}
1,265✔
374

375
export async function validateLeafNodeUpdateOrCommit(
2,457✔
376
  leafNode: LeafNodeCommit | LeafNodeUpdate,
2,457✔
377
  leafIndex: number,
2,457✔
378
  groupContext: GroupContext,
2,457✔
379
  authService: AuthenticationService,
2,457✔
380
  s: Signature,
2,457✔
381
): Promise<MlsError | undefined> {
2,457✔
382
  const signatureValid = await verifyLeafNodeSignature(leafNode, groupContext.groupId, leafIndex, s)
2,457✔
383

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

386
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, authService)
2,438✔
387

388
  if (commonError !== undefined) return commonError
2,457✔
389
}
2,457✔
390

391
export function throwIfDefined(err: MlsError | undefined): void {
1✔
392
  if (err !== undefined) throw err
16,992✔
393
}
16,992✔
394

395
async function validateLeafNodeCommon(
9,527✔
396
  leafNode: LeafNode,
9,527✔
397
  groupContext: GroupContext,
9,527✔
398
  authService: AuthenticationService,
9,527✔
399
) {
9,527✔
400
  const credentialValid = await authService.validateCredential(leafNode.credential, leafNode.signaturePublicKey)
9,527✔
401

402
  if (!credentialValid) return new ValidationError("Could not validate credential")
9,527✔
403

404
  const requiredCapabilities = groupContext.extensions.find((e) => e.extensionType === "required_capabilities")
9,507✔
405

406
  if (requiredCapabilities !== undefined) {
9,527✔
407
    const caps = decodeRequiredCapabilities(requiredCapabilities.extensionData, 0)
76✔
408
    if (caps === undefined) return new CodecError("Could not decode required_capabilities")
76!
409

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

412
    if (!leafSupportsCapabilities) return new ValidationError("LeafNode does not support required capabilities")
76✔
413
  }
76✔
414

415
  const extensionsSupported = extensionsSupportedByCapabilities(leafNode.extensions, leafNode.capabilities)
9,488✔
416

417
  if (!extensionsSupported) return new ValidationError("LeafNode contains extension not listed in capabilities")
9,527✔
418
}
9,527✔
419

420
async function validateLeafNodeKeyPackage(
7,108✔
421
  leafNode: LeafNodeKeyPackage,
7,108✔
422
  groupContext: GroupContext,
7,108✔
423
  sentByClient: boolean,
7,108✔
424
  config: LifetimeConfig,
7,108✔
425
  authService: AuthenticationService,
7,108✔
426
  s: Signature,
7,108✔
427
): Promise<MlsError | undefined> {
7,108✔
428
  const signatureValid = await verifyLeafNodeSignatureKeyPackage(leafNode, s)
7,108✔
429
  if (!signatureValid) return new CryptoVerificationError("Could not verify leaf node signature")
7,108✔
430

431
  //verify lifetime
432
  if (sentByClient || config.validateLifetimeOnReceive) {
7,108✔
433
    if (leafNode.leafNodeSource === "key_package") {
1,082✔
434
      const currentTime = BigInt(Math.floor(Date.now() / 1000))
1,082✔
435
      if (leafNode.lifetime.notBefore > currentTime || leafNode.lifetime.notAfter < currentTime)
1,082✔
436
        return new ValidationError("Current time not within Lifetime")
1,082!
437
    }
1,082✔
438
  }
1,082✔
439

440
  const commonError = await validateLeafNodeCommon(leafNode, groupContext, authService)
7,089✔
441

442
  if (commonError !== undefined) return commonError
7,108✔
443
}
7,108✔
444

445
export async function validateLeafNodeCredentialAndKeyUniqueness(
5,111✔
446
  tree: RatchetTree,
5,111✔
447
  leafNode: LeafNode,
5,111✔
448
  existingLeafIndex?: number,
5,111✔
449
): Promise<ValidationError | undefined> {
5,111✔
450
  const hpkeKeys = new Set<string>()
5,111✔
451
  const signatureKeys = new Set<string>()
5,111✔
452
  for (const [nodeIndex, node] of tree.entries()) {
5,111✔
453
    if (node?.nodeType === "leaf") {
222,531✔
454
      if (!node.leaf.capabilities.credentials.includes(leafNode.credential.credentialType)) {
46,510✔
455
        return new ValidationError("LeafNode has credential that is not supported by member of the group")
1✔
456
      }
1✔
457

458
      const hpkeKey = bytesToBase64(node.leaf.hpkePublicKey)
46,509✔
459
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
46,510✔
460
      else hpkeKeys.add(hpkeKey)
46,509✔
461

462
      const signatureKey = bytesToBase64(node.leaf.signaturePublicKey)
46,509✔
463
      if (signatureKeys.has(signatureKey) && existingLeafIndex !== nodeToLeafIndex(toNodeIndex(nodeIndex)))
46,510!
464
        return new ValidationError("signature keys not unique")
46,510✔
465
      else signatureKeys.add(signatureKey)
46,509✔
466
    } else if (node?.nodeType === "parent") {
222,531✔
467
      const hpkeKey = bytesToBase64(node.parent.hpkePublicKey)
16,768✔
468
      if (hpkeKeys.has(hpkeKey)) return new ValidationError("hpke keys not unique")
16,768!
469
      else hpkeKeys.add(hpkeKey)
16,768✔
470
    }
16,768✔
471
  }
222,531✔
472
}
5,110✔
473

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

485
  if (kp.version !== groupContext.version) return new ValidationError("Invalid mls version")
3,128✔
486

487
  const leafNodeConsistentWithTree = await validateLeafNodeCredentialAndKeyUniqueness(tree, kp.leafNode)
3,090✔
488

489
  if (leafNodeConsistentWithTree !== undefined) return leafNodeConsistentWithTree
3,128✔
490

491
  const leafNodeError = await validateLeafNodeKeyPackage(
3,089✔
492
    kp.leafNode,
3,089✔
493
    groupContext,
3,089✔
494
    sentByClient,
3,089✔
495
    config,
3,089✔
496
    authService,
3,089✔
497
    s,
3,089✔
498
  )
3,089✔
499
  if (leafNodeError !== undefined) return leafNodeError
3,128✔
500

501
  const signatureValid = await verifyKeyPackage(kp, s)
3,068✔
502
  if (!signatureValid) return new CryptoVerificationError("Invalid keypackage signature")
3,128✔
503

504
  if (constantTimeEqual(kp.initKey, kp.leafNode.hpkePublicKey))
3,049✔
505
    return new ValidationError("Cannot have identicial init and encryption keys")
3,128!
506
}
3,128✔
507

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

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

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

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

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

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

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

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

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

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

571
  const grouped = allProposals.reduce((acc, cur) => {
5,298✔
572
    //this skips any custom proposals
573
    if (typeof cur.proposal.proposalType === "number") return acc
6,067✔
574
    const proposal = acc[cur.proposal.proposalType] ?? []
6,067!
575
    return { ...acc, [cur.proposal.proposalType]: [...proposal, cur] }
6,067✔
576
  }, emptyProposals)
5,298✔
577

578
  const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
5,298✔
579

580
  const isExternalInit = grouped.external_init.length > 0
5,298✔
581

582
  if (!isExternalInit) {
5,298✔
583
    if (grouped.reinit.length > 0) {
5,222✔
584
      const reinit = grouped.reinit.at(0)!.proposal.reinit
76✔
585

586
      throwIfDefined(validateReinit(allProposals, reinit, state.groupContext))
76✔
587

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

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

613
    const newExtensions = flattenExtensions(grouped.group_context_extensions)
5,146✔
614

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

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

632
    const selfRemoved = mutatedTree[leafToNodeIndex(toLeafIndex(state.privatePath.leafIndex))] === undefined
5,036✔
633

634
    const needsUpdatePath =
5,036✔
635
      allProposals.length === 0 || Object.values(grouped.update).length > 1 || Object.values(grouped.remove).length > 1
5,222✔
636

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

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

657
    const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
76✔
658

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

666
    const initProposal = grouped.external_init.at(0)!
76✔
667

668
    const externalKeyPair = await cs.hpke.deriveKeyPair(state.keySchedule.externalSecret)
76✔
669

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

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

692
export function makePskIndex(state: ClientState | undefined, externalPsks: Record<string, Uint8Array>): PskIndex {
1✔
693
  return {
1,477✔
694
    findPsk(preSharedKeyId) {
1,477✔
695
      if (preSharedKeyId.psktype === "external") {
441✔
696
        return externalPsks[bytesToBase64(preSharedKeyId.pskId)]
261✔
697
      }
261✔
698

699
      if (state !== undefined && constantTimeEqual(preSharedKeyId.pskGroupId, state.groupContext.groupId)) {
441✔
700
        if (preSharedKeyId.pskEpoch === state.groupContext.epoch) return state.keySchedule.resumptionPsk
180✔
701
        else return state.historicalReceiverData.get(preSharedKeyId.pskEpoch)?.resumptionPsk
28✔
702
      }
180✔
703
    },
441✔
704
  }
1,477✔
705
}
1,477✔
706

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

719
  return {
4,713✔
720
    ...groupContext,
4,713✔
721
    epoch: groupContext.epoch + 1n,
4,713✔
722
    treeHash: updatedTreeHash,
4,713✔
723
    confirmedTranscriptHash: newConfirmedHash,
4,713✔
724
  }
4,713✔
725
}
4,713✔
726

727
export async function joinGroup(
1,113✔
728
  welcome: Welcome,
1,113✔
729
  keyPackage: KeyPackage,
1,113✔
730
  privateKeys: PrivateKeyPackage,
1,113✔
731
  pskSearch: PskIndex,
1,113✔
732
  cs: CiphersuiteImpl,
1,113✔
733
  ratchetTree?: RatchetTree,
1,113✔
734
  resumingFromState?: ClientState,
1,113✔
735
  clientConfig: ClientConfig = defaultClientConfig,
1,113✔
736
): Promise<ClientState> {
1,113✔
737
  const res = await joinGroupWithExtensions(
1,113✔
738
    welcome,
1,113✔
739
    keyPackage,
1,113✔
740
    privateKeys,
1,113✔
741
    pskSearch,
1,113✔
742
    cs,
1,113✔
743
    ratchetTree,
1,113✔
744
    resumingFromState,
1,113✔
745
    clientConfig,
1,113✔
746
  )
1,113✔
747

748
  return res[0]
1,056✔
749
}
1,056✔
750

751
export async function joinGroupWithExtensions(
1,132✔
752
  welcome: Welcome,
1,132✔
753
  keyPackage: KeyPackage,
1,132✔
754
  privateKeys: PrivateKeyPackage,
1,132✔
755
  pskSearch: PskIndex,
1,132✔
756
  cs: CiphersuiteImpl,
1,132✔
757
  ratchetTree?: RatchetTree,
1,132✔
758
  resumingFromState?: ClientState,
1,132✔
759
  clientConfig: ClientConfig = defaultClientConfig,
1,132✔
760
): Promise<[ClientState, Extension[]]> {
1,132✔
761
  const keyPackageRef = await makeKeyPackageRef(keyPackage, cs.hash)
1,132✔
762
  const privKey = await cs.hpke.importPrivateKey(privateKeys.initPrivateKey)
1,132✔
763
  const groupSecrets = await decryptGroupSecrets(privKey, keyPackageRef, welcome, cs.hpke)
1,132✔
764

765
  if (groupSecrets === undefined) throw new CodecError("Could not decode group secrets")
1,132!
766

767
  const zeroes: Uint8Array = new Uint8Array(cs.kdf.size)
1,132✔
768

769
  const [pskSecret, pskIds] = await accumulatePskSecret(groupSecrets.psks, pskSearch, cs, zeroes)
1,132✔
770

771
  const gi = await decryptGroupInfo(welcome, groupSecrets.joinerSecret, pskSecret, cs)
1,132✔
772
  if (gi === undefined) throw new CodecError("Could not decode group info")
1,132!
773

774
  const resumptionPsk = pskIds.find((id) => id.psktype === "resumption")
1,132✔
775
  if (resumptionPsk !== undefined) {
1,132✔
776
    if (resumingFromState === undefined) throw new ValidationError("No prior state passed for resumption")
95!
777

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

780
    if (!constantTimeEqual(resumptionPsk.pskGroupId, resumingFromState.groupContext.groupId))
95✔
781
      throw new ValidationError("old groupId mismatch")
95!
782

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

785
    if (resumptionPsk.usage === "reinit") {
95✔
786
      if (resumingFromState.groupActiveState.kind !== "suspendedPendingReinit")
76✔
787
        throw new ValidationError("Found reinit psk but no old suspended clientState")
76!
788

789
      if (!constantTimeEqual(resumingFromState.groupActiveState.reinit.groupId, gi.groupContext.groupId))
76✔
790
        throw new ValidationError("new groupId mismatch")
76✔
791

792
      if (resumingFromState.groupActiveState.reinit.version !== gi.groupContext.version)
57✔
793
        throw new ValidationError("Version mismatch")
76✔
794

795
      if (resumingFromState.groupActiveState.reinit.cipherSuite !== gi.groupContext.cipherSuite)
38✔
796
        throw new ValidationError("Ciphersuite mismatch")
76✔
797

798
      if (!extensionsEqual(resumingFromState.groupActiveState.reinit.extensions, gi.groupContext.extensions))
38✔
799
        throw new ValidationError("Extensions mismatch")
38✔
800
    }
76✔
801
  }
95✔
802

803
  const allExtensionsSupported = extensionsSupportedByCapabilities(
1,075✔
804
    gi.groupContext.extensions,
1,075✔
805
    keyPackage.leafNode.capabilities,
1,075✔
806
  )
1,075✔
807
  if (!allExtensionsSupported) throw new UsageError("client does not support every extension in the GroupContext")
1,132✔
808

809
  const tree = ratchetTreeFromExtension(gi) ?? ratchetTree
1,075✔
810

811
  if (tree === undefined) throw new UsageError("No RatchetTree passed and no ratchet_tree extension")
1,132✔
812

813
  const signerNode = tree[leafToNodeIndex(toLeafIndex(gi.signer))]
1,075✔
814

815
  if (signerNode === undefined) {
1,132!
816
    throw new ValidationError("Could not find signer leafNode")
×
817
  }
✔
818
  if (signerNode.nodeType === "parent") throw new ValidationError("Expected non blank leaf node")
1,132✔
819

820
  const credentialVerified = await clientConfig.authService.validateCredential(
1,075✔
821
    signerNode.leaf.credential,
1,075✔
822
    signerNode.leaf.signaturePublicKey,
1,075✔
823
  )
1,075✔
824

825
  if (!credentialVerified) throw new ValidationError("Could not validate credential")
1,132✔
826

827
  const groupInfoSignatureVerified = await verifyGroupInfoSignature(
1,075✔
828
    gi,
1,075✔
829
    signerNode.leaf.signaturePublicKey,
1,075✔
830
    cs.signature,
1,075✔
831
  )
1,075✔
832

833
  if (!groupInfoSignatureVerified) throw new CryptoVerificationError("Could not verify groupInfo signature")
1,132✔
834

835
  if (gi.groupContext.cipherSuite !== keyPackage.cipherSuite)
1,075✔
836
    throw new ValidationError("cipher suite in the GroupInfo does not match the cipher_suite in the KeyPackage")
1,132✔
837

838
  throwIfDefined(
1,075✔
839
    await validateRatchetTree(
1,075✔
840
      tree,
1,075✔
841
      gi.groupContext,
1,075✔
842
      clientConfig.lifetimeConfig,
1,075✔
843
      clientConfig.authService,
1,075✔
844
      gi.groupContext.treeHash,
1,075✔
845
      cs,
1,075✔
846
    ),
1,075✔
847
  )
1,075✔
848

849
  const newLeaf = findLeafIndex(tree, keyPackage.leafNode)
1,075✔
850

851
  if (newLeaf === undefined) throw new ValidationError("Could not find own leaf when processing welcome")
1,132✔
852

853
  const privateKeyPath: PrivateKeyPath = {
1,075✔
854
    leafIndex: newLeaf,
1,075✔
855
    privateKeys: { [leafToNodeIndex(newLeaf)]: privateKeys.hpkePrivateKey },
1,075✔
856
  }
1,075✔
857

858
  const ancestorNodeIndex = firstCommonAncestor(tree, newLeaf, toLeafIndex(gi.signer))
1,075✔
859

860
  const updatedPkp =
1,075✔
861
    groupSecrets.pathSecret === undefined
1,075✔
862
      ? privateKeyPath
1,019✔
863
      : mergePrivateKeyPaths(
56✔
864
          await toPrivateKeyPath(
56✔
865
            await pathToRoot(tree, ancestorNodeIndex, groupSecrets.pathSecret, cs.kdf),
56✔
866
            newLeaf,
56✔
867
            cs,
56✔
868
          ),
56✔
869
          privateKeyPath,
56✔
870
        )
56✔
871

872
  const keySchedule = await deriveKeySchedule(groupSecrets.joinerSecret, pskSecret, gi.groupContext, cs.kdf)
1,132✔
873

874
  const confirmationTagVerified = await verifyGroupInfoConfirmationTag(gi, groupSecrets.joinerSecret, pskSecret, cs)
1,075✔
875

876
  if (!confirmationTagVerified) throw new CryptoVerificationError("Could not verify confirmation tag")
1,132✔
877

878
  const secretTree = await createSecretTree(leafWidth(tree.length), keySchedule.encryptionSecret, cs.kdf)
1,075✔
879

880
  return [
1,075✔
881
    {
1,075✔
882
      groupContext: gi.groupContext,
1,075✔
883
      ratchetTree: tree,
1,075✔
884
      privatePath: updatedPkp,
1,075✔
885
      signaturePrivateKey: privateKeys.signaturePrivateKey,
1,075✔
886
      confirmationTag: gi.confirmationTag,
1,075✔
887
      unappliedProposals: {},
1,075✔
888
      keySchedule,
1,075✔
889
      secretTree,
1,075✔
890
      historicalReceiverData: new Map(),
1,075✔
891
      groupActiveState: { kind: "active" },
1,075✔
892
      clientConfig,
1,075✔
893
    },
1,075✔
894
    gi.extensions,
1,075✔
895
  ]
1,075✔
896
}
1,075✔
897

898
export async function createGroup(
834✔
899
  groupId: Uint8Array,
834✔
900
  keyPackage: KeyPackage,
834✔
901
  privateKeyPackage: PrivateKeyPackage,
834✔
902
  extensions: Extension[],
834✔
903
  cs: CiphersuiteImpl,
834✔
904
  clientConfig: ClientConfig = defaultClientConfig,
834✔
905
): Promise<ClientState> {
834✔
906
  const ratchetTree: RatchetTree = [{ nodeType: "leaf", leaf: keyPackage.leafNode }]
834✔
907

908
  const privatePath: PrivateKeyPath = {
834✔
909
    leafIndex: 0,
834✔
910
    privateKeys: { [0]: privateKeyPackage.hpkePrivateKey },
834✔
911
  }
834✔
912

913
  const confirmedTranscriptHash = new Uint8Array()
834✔
914

915
  const groupContext: GroupContext = {
834✔
916
    version: "mls10",
834✔
917
    cipherSuite: cs.name,
834✔
918
    epoch: 0n,
834✔
919
    treeHash: await treeHashRoot(ratchetTree, cs.hash),
834✔
920
    groupId,
834✔
921
    extensions,
834✔
922
    confirmedTranscriptHash,
834✔
923
  }
834✔
924

925
  throwIfDefined(await validateExternalSenders(extensions, clientConfig.authService))
834✔
926

927
  const epochSecret = cs.rng.randomBytes(cs.kdf.size)
834✔
928

929
  const keySchedule = await initializeKeySchedule(epochSecret, cs.kdf)
834✔
930

931
  const confirmationTag = await createConfirmationTag(keySchedule.confirmationKey, confirmedTranscriptHash, cs.hash)
834✔
932

933
  const secretTree = await createSecretTree(1, keySchedule.encryptionSecret, cs.kdf)
834✔
934

935
  return {
834✔
936
    ratchetTree,
834✔
937
    keySchedule,
834✔
938
    secretTree,
834✔
939
    privatePath,
834✔
940
    signaturePrivateKey: privateKeyPackage.signaturePrivateKey,
834✔
941
    unappliedProposals: {},
834✔
942
    historicalReceiverData: new Map(),
834✔
943
    groupContext,
834✔
944
    confirmationTag,
834✔
945
    groupActiveState: { kind: "active" },
834✔
946
    clientConfig,
834✔
947
  }
834✔
948
}
834✔
949

950
export async function exportSecret(
152✔
951
  publicKey: Uint8Array,
152✔
952
  cs: CiphersuiteImpl,
152✔
953
): Promise<{ enc: Uint8Array; secret: Uint8Array }> {
152✔
954
  return cs.hpke.exportSecret(
152✔
955
    await cs.hpke.importPublicKey(publicKey),
152✔
956
    new TextEncoder().encode("MLS 1.0 external init secret"),
152✔
957
    cs.kdf.size,
152✔
958
    new Uint8Array(),
152✔
959
  )
152✔
960
}
152✔
961

962
async function importSecret(privateKey: Uint8Array, kemOutput: Uint8Array, cs: CiphersuiteImpl): Promise<Uint8Array> {
76✔
963
  return cs.hpke.importSecret(
76✔
964
    await cs.hpke.importPrivateKey(privateKey),
76✔
965
    new TextEncoder().encode("MLS 1.0 external init secret"),
76✔
966
    kemOutput,
76✔
967
    cs.kdf.size,
76✔
968
    new Uint8Array(),
76✔
969
  )
76✔
970
}
76✔
971

972
async function applyTreeMutations(
4,811✔
973
  ratchetTree: RatchetTree,
4,811✔
974
  grouped: Proposals,
4,811✔
975
  gc: GroupContext,
4,811✔
976
  sentByClient: boolean,
4,811✔
977
  authService: AuthenticationService,
4,811✔
978
  lifetimeConfig: LifetimeConfig,
4,811✔
979
  s: Signature,
4,811✔
980
): Promise<[RatchetTree, [LeafIndex, KeyPackage][]]> {
4,811✔
981
  const treeAfterUpdate = await grouped.update.reduce(async (acc, { senderLeafIndex, proposal }) => {
4,811✔
982
    if (senderLeafIndex === undefined) throw new InternalError("No sender index found for update proposal")
14!
983

984
    throwIfDefined(await validateLeafNodeUpdateOrCommit(proposal.update.leafNode, senderLeafIndex, gc, authService, s))
14✔
985
    throwIfDefined(
14✔
986
      await validateLeafNodeCredentialAndKeyUniqueness(ratchetTree, proposal.update.leafNode, senderLeafIndex),
14✔
987
    )
14✔
988

989
    return updateLeafNode(await acc, proposal.update.leafNode, toLeafIndex(senderLeafIndex))
14✔
990
  }, Promise.resolve(ratchetTree))
4,811✔
991

992
  const treeAfterRemove = grouped.remove.reduce((acc, { proposal }) => {
4,811✔
993
    throwIfDefined(validateRemove(proposal.remove, ratchetTree))
2,425✔
994

995
    return removeLeafNode(acc, toLeafIndex(proposal.remove.removed))
2,425✔
996
  }, treeAfterUpdate)
4,811✔
997

998
  const [treeAfterAdd, addedLeafNodes] = await grouped.add.reduce(
4,811✔
999
    async (acc, { proposal }) => {
4,811✔
1000
      throwIfDefined(
3,128✔
1001
        await validateKeyPackage(
3,128✔
1002
          proposal.add.keyPackage,
3,128✔
1003
          gc,
3,128✔
1004
          ratchetTree,
3,128✔
1005
          sentByClient,
3,128✔
1006
          lifetimeConfig,
3,128✔
1007
          authService,
3,128✔
1008
          s,
3,128✔
1009
        ),
3,128✔
1010
      )
3,128✔
1011

1012
      const [tree, ws] = await acc
3,128✔
1013
      const [updatedTree, leafNodeIndex] = addLeafNode(tree, proposal.add.keyPackage.leafNode)
3,049✔
1014
      return [
3,049✔
1015
        updatedTree,
3,049✔
1016
        [...ws, [nodeToLeafIndex(leafNodeIndex), proposal.add.keyPackage] as [LeafIndex, KeyPackage]],
3,049✔
1017
      ]
3,049✔
1018
    },
3,128✔
1019
    Promise.resolve([treeAfterRemove, []] as [RatchetTree, [LeafIndex, KeyPackage][]]),
4,811✔
1020
  )
4,811✔
1021

1022
  return [treeAfterAdd, addedLeafNodes]
4,732✔
1023
}
4,732✔
1024

1025
export async function processProposal(
1,835✔
1026
  state: ClientState,
1,835✔
1027
  content: AuthenticatedContent,
1,835✔
1028
  proposal: Proposal,
1,835✔
1029
  h: Hash,
1,835✔
1030
): Promise<ClientState> {
1,835✔
1031
  const ref = await makeProposalRef(content, h)
1,835✔
1032
  return {
1,835✔
1033
    ...state,
1,835✔
1034
    unappliedProposals: addUnappliedProposal(
1,835✔
1035
      ref,
1,835✔
1036
      state.unappliedProposals,
1,835✔
1037
      proposal,
1,835✔
1038
      getSenderLeafNodeIndex(content.content.sender),
1,835✔
1039
    ),
1,835✔
1040
  }
1,835✔
1041
}
1,835✔
1042

1043
export function addHistoricalReceiverData(state: ClientState): Map<bigint, EpochReceiverData> {
1✔
1044
  const withNew = addToMap(state.historicalReceiverData, state.groupContext.epoch, {
5,150✔
1045
    secretTree: state.secretTree,
5,150✔
1046
    ratchetTree: state.ratchetTree,
5,150✔
1047
    senderDataSecret: state.keySchedule.senderDataSecret,
5,150✔
1048
    groupContext: state.groupContext,
5,150✔
1049
    resumptionPsk: state.keySchedule.resumptionPsk,
5,150✔
1050
  })
5,150✔
1051

1052
  const epochs = [...withNew.keys()]
5,150✔
1053

1054
  const result =
5,150✔
1055
    epochs.length >= state.clientConfig.keyRetentionConfig.retainKeysForEpochs
5,150✔
1056
      ? removeOldHistoricalReceiverData(withNew, state.clientConfig.keyRetentionConfig.retainKeysForEpochs)
2,762✔
1057
      : withNew
2,388✔
1058

1059
  return result
5,150✔
1060
}
5,150✔
1061

1062
function removeOldHistoricalReceiverData(
2,762✔
1063
  historicalReceiverData: Map<bigint, EpochReceiverData>,
2,762✔
1064
  max: number,
2,762✔
1065
): Map<bigint, EpochReceiverData> {
2,762✔
1066
  const sortedEpochs = [...historicalReceiverData.keys()].sort((a, b) => (a < b ? -1 : 1))
2,762✔
1067

1068
  return new Map(sortedEpochs.slice(-max).map((epoch) => [epoch, historicalReceiverData.get(epoch)!]))
2,762✔
1069
}
2,762✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc