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

LukaJCB / ts-mls / 17283659198

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

push

github

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

1121 of 1250 branches covered (89.68%)

Branch coverage included in aggregate %.

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

57 existing lines in 13 files now uncovered.

6364 of 6734 relevant lines covered (94.51%)

51774.21 hits per line

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

94.09
/src/updatePath.ts
1
import { Decoder, mapDecoders } from "./codec/tlsDecoder.js"
1✔
2
import { contramapEncoders, Encoder } from "./codec/tlsEncoder.js"
1✔
3
import { decodeVarLenData, decodeVarLenType, encodeVarLenData, encodeVarLenType } from "./codec/variableLength.js"
1✔
4
import { CiphersuiteImpl } from "./crypto/ciphersuite.js"
5
import { Hash } from "./crypto/hash.js"
6
import { encryptWithLabel, PrivateKey } from "./crypto/hpke.js"
1✔
7
import { deriveSecret } from "./crypto/kdf.js"
1✔
8
import { encodeGroupContext, GroupContext } from "./groupContext.js"
1✔
9
import {
1✔
10
  decodeLeafNodeCommit,
11
  encodeLeafNode,
12
  LeafNodeCommit,
13
  LeafNodeTBSCommit,
14
  signLeafNodeCommit,
15
} from "./leafNode.js"
16
import { calculateParentHash } from "./parentHash.js"
1✔
17
import {
1✔
18
  filteredDirectPath,
19
  filteredDirectPathAndCopathResolution,
20
  getHpkePublicKey,
21
  Node,
22
  RatchetTree,
23
} from "./ratchetTree.js"
24
import { treeHashRoot } from "./treeHash.js"
1✔
25
import { isAncestor, LeafIndex, leafToNodeIndex, NodeIndex } from "./treemath.js"
1✔
26
import { updateArray } from "./util/array.js"
1✔
27
import { constantTimeEqual } from "./util/constantTimeCompare.js"
1✔
28
import { decodeHpkeCiphertext, encodeHpkeCiphertext, HPKECiphertext } from "./hpkeCiphertext.js"
1✔
29
import { InternalError, ValidationError } from "./mlsError.js"
1✔
30

31
export interface UpdatePathNode {
32
  hpkePublicKey: Uint8Array
33
  encryptedPathSecret: HPKECiphertext[]
34
}
35

36
export const encodeUpdatePathNode: Encoder<UpdatePathNode> = contramapEncoders(
1✔
37
  [encodeVarLenData, encodeVarLenType(encodeHpkeCiphertext)],
1✔
38
  (node) => [node.hpkePublicKey, node.encryptedPathSecret] as const,
1✔
39
)
1✔
40

41
export const decodeUpdatePathNode: Decoder<UpdatePathNode> = mapDecoders(
1✔
42
  [decodeVarLenData, decodeVarLenType(decodeHpkeCiphertext)],
1✔
43
  (hpkePublicKey, encryptedPathSecret) => ({ hpkePublicKey, encryptedPathSecret }),
1✔
44
)
1✔
45

46
export interface UpdatePath {
47
  leafNode: LeafNodeCommit
48
  nodes: UpdatePathNode[]
49
}
50

51
export const encodeUpdatePath: Encoder<UpdatePath> = contramapEncoders(
1✔
52
  [encodeLeafNode, encodeVarLenType(encodeUpdatePathNode)],
1✔
53
  (path) => [path.leafNode, path.nodes] as const,
1✔
54
)
1✔
55

56
export const decodeUpdatePath: Decoder<UpdatePath> = mapDecoders(
1✔
57
  [decodeLeafNodeCommit, decodeVarLenType(decodeUpdatePathNode)],
1✔
58
  (leafNode, nodes) => ({ leafNode, nodes }),
1✔
59
)
1✔
60

61
export interface PathSecret {
62
  nodeIndex: number
63
  secret: Uint8Array
64
  sendTo: number[]
65
}
66

67
export async function createUpdatePath(
1,004✔
68
  originalTree: RatchetTree,
1,004✔
69
  senderLeafIndex: LeafIndex,
1,004✔
70
  groupContext: GroupContext,
1,004✔
71
  signaturePrivateKey: Uint8Array,
1,004✔
72
  cs: CiphersuiteImpl,
1,004✔
73
): Promise<[RatchetTree, UpdatePath, PathSecret[], PrivateKey]> {
1,004✔
74
  const originalLeafNode = originalTree[leafToNodeIndex(senderLeafIndex)]
1,004✔
75
  if (originalLeafNode === undefined || originalLeafNode.nodeType === "parent")
1,004✔
76
    throw new InternalError("Expected non-blank leaf node")
1,004!
77

78
  const pathSecret = cs.rng.randomBytes(cs.kdf.size)
1,004✔
79

80
  const leafNodeSecret = await deriveSecret(pathSecret, "node", cs.kdf)
1,004✔
81
  const leafKeypair = await cs.hpke.deriveKeyPair(leafNodeSecret)
1,004✔
82

83
  const fdp = filteredDirectPathAndCopathResolution(senderLeafIndex, originalTree)
1,004✔
84

85
  const [ps, updatedTree]: [PathSecret[], RatchetTree] = await applyInitialTreeUpdate(
1,004✔
86
    fdp,
1,004✔
87
    pathSecret,
1,004✔
88
    senderLeafIndex,
1,004✔
89
    originalTree,
1,004✔
90
    cs,
1,004✔
91
  )
1,004✔
92

93
  const treeWithHashes = await insertParentHashes(fdp, updatedTree, cs)
1,004✔
94

95
  const leafParentHash = await calculateParentHash(treeWithHashes, leafToNodeIndex(senderLeafIndex), cs.hash)
1,004✔
96

97
  const updatedLeafNodeTbs: LeafNodeTBSCommit = {
1,004✔
98
    leafNodeSource: "commit",
1,004✔
99
    hpkePublicKey: await cs.hpke.exportPublicKey(leafKeypair.publicKey),
1,004✔
100
    extensions: originalLeafNode.leaf.extensions,
1,004✔
101
    capabilities: originalLeafNode.leaf.capabilities,
1,004✔
102
    credential: originalLeafNode.leaf.credential,
1,004✔
103
    signaturePublicKey: originalLeafNode.leaf.signaturePublicKey,
1,004✔
104
    parentHash: leafParentHash[0],
1,004✔
105
    info: { leafNodeSource: "commit", groupId: groupContext.groupId, leafIndex: senderLeafIndex },
1,004✔
106
  }
1,004✔
107

108
  const updatedLeafNode = await signLeafNodeCommit(updatedLeafNodeTbs, signaturePrivateKey, cs.signature)
1,004✔
109

110
  const finalTree = updateArray(treeWithHashes, leafToNodeIndex(senderLeafIndex), {
1,004✔
111
    nodeType: "leaf",
1,004✔
112
    leaf: updatedLeafNode,
1,004✔
113
  })
1,004✔
114

115
  const updatedTreeHash = await treeHashRoot(finalTree, cs.hash)
1,004✔
116

117
  const updatedGroupContext: GroupContext = {
1,004✔
118
    ...groupContext,
1,004✔
119
    treeHash: updatedTreeHash,
1,004✔
120
    epoch: groupContext.epoch + 1n,
1,004✔
121
  }
1,004✔
122

123
  // we have to remove the leaf secret since we don't send it to anyone
124
  const pathSecrets = ps.slice(0, ps.length - 1).reverse()
1,004✔
125

126
  // we have to pass the old tree here since the receiver won't have the updated public keys yet
127
  const updatePathNodes: UpdatePathNode[] = await Promise.all(
1,004✔
128
    pathSecrets.map(encryptSecretsForPath(originalTree, finalTree, updatedGroupContext, cs)),
1,004✔
129
  )
1,004✔
130

131
  const updatePath: UpdatePath = { leafNode: updatedLeafNode, nodes: updatePathNodes }
1,004✔
132

133
  return [finalTree, updatePath, pathSecrets, leafKeypair.privateKey] as const
1,004✔
134
}
1,004✔
135

136
function encryptSecretsForPath(
1,004✔
137
  originalTree: RatchetTree,
1,004✔
138
  updatedTree: RatchetTree,
1,004✔
139
  updatedGroupContext: GroupContext,
1,004✔
140
  cs: CiphersuiteImpl,
1,004✔
141
): (pathSecret: PathSecret) => Promise<UpdatePathNode> {
1,004✔
142
  return async (pathSecret) => {
1,004✔
143
    const key = getHpkePublicKey(updatedTree[pathSecret.nodeIndex]!)
2,181✔
144

145
    const res: UpdatePathNode = {
2,181✔
146
      hpkePublicKey: key,
2,181✔
147
      encryptedPathSecret: await Promise.all(
2,181✔
148
        pathSecret.sendTo.map(async (nodeIndex) => {
2,181✔
149
          const { ct, enc } = await encryptWithLabel(
2,429✔
150
            await cs.hpke.importPublicKey(getHpkePublicKey(originalTree[nodeIndex]!)),
2,429✔
151
            "UpdatePathNode",
2,429✔
152
            encodeGroupContext(updatedGroupContext),
2,429✔
153
            pathSecret.secret,
2,429✔
154
            cs.hpke,
2,429✔
155
          )
2,429✔
156
          return { ciphertext: ct, kemOutput: enc }
2,429✔
157
        }),
2,181✔
158
      ),
2,181✔
159
    }
2,181✔
160
    return res
2,181✔
161
  }
2,181✔
162
}
1,004✔
163

164
async function insertParentHashes(
1,004✔
165
  fdp: { resolution: NodeIndex[]; nodeIndex: NodeIndex }[],
1,004✔
166
  updatedTree: RatchetTree,
1,004✔
167
  cs: CiphersuiteImpl,
1,004✔
168
) {
1,004✔
169
  return await fdp
1,004✔
170
    .slice()
1,004✔
171
    .reverse()
1,004✔
172
    .reduce(async (treePromise, { nodeIndex }) => {
1,004✔
173
      const tree = await treePromise
2,181✔
174
      const parentHash = await calculateParentHash(tree, nodeIndex, cs.hash)
2,181✔
175
      const currentNode = tree[nodeIndex]
2,181✔
176
      if (currentNode === undefined || currentNode.nodeType === "leaf")
2,181✔
177
        throw new InternalError("Expected non-blank parent node")
2,181!
178
      const updatedNode: Node = { nodeType: "parent", parent: { ...currentNode.parent, parentHash: parentHash[0] } }
2,181✔
179

180
      return updateArray(tree, nodeIndex, updatedNode)
2,181✔
181
    }, Promise.resolve(updatedTree))
1,004✔
182
}
1,004✔
183

184
/**
185
 * Inserts new public keys from a single secret in the update path and returns the resulting tree along with the secrets along the path
186
 * Note that the path secrets are returned root to leaf
187
 */
188
async function applyInitialTreeUpdate(
1,004✔
189
  fdp: { resolution: number[]; nodeIndex: number }[],
1,004✔
190
  pathSecret: Uint8Array,
1,004✔
191
  senderLeafIndex: LeafIndex,
1,004✔
192
  tree: RatchetTree,
1,004✔
193
  cs: CiphersuiteImpl,
1,004✔
194
): Promise<[PathSecret[], RatchetTree]> {
1,004✔
195
  return await fdp.reduce(
1,004✔
196
    async (acc, { nodeIndex, resolution }) => {
1,004✔
197
      const [pathSecrets, tree] = await acc
2,181✔
198
      const lastPathSecret = pathSecrets[0]!
2,181✔
199
      const nextPathSecret = await deriveSecret(lastPathSecret.secret, "path", cs.kdf)
2,181✔
200
      const nextNodeSecret = await deriveSecret(nextPathSecret, "node", cs.kdf)
2,181✔
201
      const { publicKey } = await cs.hpke.deriveKeyPair(nextNodeSecret)
2,181✔
202

203
      const updatedTree = updateArray(tree, nodeIndex, {
2,181✔
204
        nodeType: "parent",
2,181✔
205
        parent: {
2,181✔
206
          hpkePublicKey: await cs.hpke.exportPublicKey(publicKey),
2,181✔
207
          parentHash: new Uint8Array(),
2,181✔
208
          unmergedLeaves: [],
2,181✔
209
        },
2,181✔
210
      })
2,181✔
211

212
      return [[{ nodeIndex, secret: nextPathSecret, sendTo: resolution }, ...pathSecrets], updatedTree]
2,181✔
213
    },
2,181✔
214
    Promise.resolve([[{ secret: pathSecret, nodeIndex: leafToNodeIndex(senderLeafIndex), sendTo: [] }], tree] as [
1,004✔
215
      PathSecret[],
216
      RatchetTree,
217
    ]),
1,004✔
218
  )
1,004✔
219
}
1,004✔
220

221
export async function applyUpdatePath(
2,441✔
222
  tree: RatchetTree,
2,441✔
223
  senderLeafIndex: LeafIndex,
2,441✔
224
  path: UpdatePath,
2,441✔
225
  h: Hash,
2,441✔
226
  isExternal: boolean = false,
2,441✔
227
): Promise<RatchetTree> {
2,441✔
228
  // if this is an external commit, the leaf node did not exist prior
229
  if (!isExternal) {
2,441✔
230
    const leafToUpdate = tree[leafToNodeIndex(senderLeafIndex)]
2,365✔
231

232
    if (leafToUpdate === undefined || leafToUpdate.nodeType === "parent")
2,365✔
233
      throw new InternalError("Leaf node not defined or is parent")
2,365!
234

235
    const leafNodePublicKeyNotNew = constantTimeEqual(leafToUpdate.leaf.hpkePublicKey, path.leafNode.hpkePublicKey)
2,365✔
236

237
    if (leafNodePublicKeyNotNew)
2,365✔
238
      throw new ValidationError("Public key in the LeafNode is the same as the committer's current leaf node")
2,365!
239
  }
2,365✔
240

241
  const pathNodePublicKeysExistInTree = path.nodes.some((node) =>
2,441✔
242
    tree.some((treeNode) => {
6,508✔
243
      return treeNode?.nodeType === "parent"
138,560✔
244
        ? constantTimeEqual(treeNode.parent.hpkePublicKey, node.hpkePublicKey)
28,724✔
245
        : false
109,836✔
246
    }),
6,508✔
247
  )
2,441✔
248

249
  if (pathNodePublicKeysExistInTree)
2,441✔
250
    throw new ValidationError("Public keys in the UpdatePath may not appear in a node of the new ratchet tree")
2,441!
251

252
  const copy = tree.slice()
2,441✔
253

254
  copy[leafToNodeIndex(senderLeafIndex)] = { nodeType: "leaf", leaf: path.leafNode }
2,441✔
255

256
  const reverseFilteredDirectPath = filteredDirectPath(senderLeafIndex, tree).reverse()
2,441✔
257

258
  // need to call .slice here so as not to mutate the original
259
  const reverseUpdatePath = path.nodes.slice().reverse()
2,441✔
260

261
  if (reverseUpdatePath.length !== reverseFilteredDirectPath.length) {
2,441!
262
    throw new ValidationError("Invalid length of UpdatePath")
×
UNCOV
263
  }
×
264

265
  for (const [level, nodeIndex] of reverseFilteredDirectPath.entries()) {
2,441✔
266
    const parentHash = await calculateParentHash(copy, nodeIndex, h)
6,508✔
267

268
    copy[nodeIndex] = {
6,508✔
269
      nodeType: "parent",
6,508✔
270
      parent: { hpkePublicKey: reverseUpdatePath[level]!.hpkePublicKey, unmergedLeaves: [], parentHash: parentHash[0] },
6,508✔
271
    }
6,508✔
272
  }
6,508✔
273

274
  const leafParentHash = await calculateParentHash(copy, leafToNodeIndex(senderLeafIndex), h)
2,441✔
275

276
  if (!constantTimeEqual(leafParentHash[0], path.leafNode.parentHash))
2,441✔
277
    throw new ValidationError("Parent hash did not match the UpdatePath")
2,441!
278

279
  return copy
2,441✔
280
}
2,441✔
281

282
export function firstCommonAncestor(tree: RatchetTree, leafIndex: LeafIndex, senderLeafIndex: LeafIndex): NodeIndex {
1✔
283
  const fdp = filteredDirectPathAndCopathResolution(senderLeafIndex, tree)
1,991✔
284

285
  for (const { nodeIndex } of fdp) {
1,991✔
286
    if (isAncestor(leafToNodeIndex(leafIndex), nodeIndex, tree.length)) {
2,970✔
287
      return nodeIndex
1,991✔
288
    }
1,991✔
289
  }
2,970!
290

291
  throw new ValidationError("Could not find common ancestor")
×
UNCOV
292
}
×
293

294
export function firstMatchAncestor(
1✔
295
  tree: RatchetTree,
6,599✔
296
  leafIndex: LeafIndex,
6,599✔
297
  senderLeafIndex: LeafIndex,
6,599✔
298
  path: UpdatePath,
6,599✔
299
): { nodeIndex: NodeIndex; resolution: NodeIndex[]; updateNode: UpdatePathNode | undefined } {
6,599✔
300
  const fdp = filteredDirectPathAndCopathResolution(senderLeafIndex, tree)
6,599✔
301

302
  for (const [n, { nodeIndex, resolution }] of fdp.entries()) {
6,599✔
303
    if (isAncestor(leafToNodeIndex(leafIndex), nodeIndex, tree.length)) {
14,437✔
304
      return { nodeIndex, resolution, updateNode: path.nodes[n] }
6,599✔
305
    }
6,599✔
306
  }
14,437!
307

308
  throw new ValidationError("Could not find common ancestor")
×
UNCOV
309
}
×
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