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

LukaJCB / ts-mls / 20887934276

11 Jan 2026 02:12AM UTC coverage: 95.727% (+0.06%) from 95.665%
20887934276

push

github

LukaJCB
Update to vitest v4

409 of 417 branches covered (98.08%)

Branch coverage included in aggregate %.

2369 of 2485 relevant lines covered (95.33%)

80736.06 hits per line

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

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

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

35
export const updatePathNodeEncoder: BufferEncoder<UpdatePathNode> = contramapBufferEncoders(
3✔
36
  [varLenDataEncoder, varLenTypeEncoder(hpkeCiphertextEncoder)],
37
  (node) => [node.hpkePublicKey, node.encryptedPathSecret] as const,
14,349✔
38
)
39

40
export const encodeUpdatePathNode: Encoder<UpdatePathNode> = encode(updatePathNodeEncoder)
3✔
41

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

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

52
export const updatePathEncoder: BufferEncoder<UpdatePath> = contramapBufferEncoders(
3✔
53
  [leafNodeEncoder, varLenTypeEncoder(updatePathNodeEncoder)],
54
  (path) => [path.leafNode, path.nodes] as const,
5,679✔
55
)
56

57
export const encodeUpdatePath: Encoder<UpdatePath> = encode(updatePathEncoder)
3✔
58

59
export const decodeUpdatePath: Decoder<UpdatePath> = mapDecoders(
3✔
60
  [decodeLeafNodeCommit, decodeVarLenType(decodeUpdatePathNode)],
61
  (leafNode, nodes) => ({ leafNode, nodes }),
1,505✔
62
)
63

64
export interface PathSecret {
65
  nodeIndex: number
66
  secret: Uint8Array
67
  sendTo: number[]
68
}
69

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

81
  const pathSecret = cs.rng.randomBytes(cs.kdf.size)
1,101✔
82

83
  const leafNodeSecret = await deriveSecret(pathSecret, "node", cs.kdf)
1,101✔
84
  const leafKeypair = await cs.hpke.deriveKeyPair(leafNodeSecret)
1,101✔
85

86
  const fdp = filteredDirectPathAndCopathResolution(senderLeafIndex, originalTree)
1,101✔
87

88
  const copy = originalTree.slice()
1,101✔
89

90
  const [ps, updatedTree]: [PathSecret[], RatchetTree] = await applyInitialTreeUpdate(
1,101✔
91
    fdp,
92
    pathSecret,
93
    senderLeafIndex,
94
    copy,
95
    cs,
96
  )
97

98
  const treeWithHashes = await insertParentHashes(fdp, updatedTree, cs)
1,101✔
99

100
  const leafParentHash = await calculateParentHash(treeWithHashes, leafToNodeIndex(senderLeafIndex), cs.hash)
1,101✔
101

102
  const updatedLeafNodeTbs: LeafNodeTBSCommit = {
1,101✔
103
    leafNodeSource: "commit",
104
    hpkePublicKey: await cs.hpke.exportPublicKey(leafKeypair.publicKey),
105
    extensions: originalLeafNode.leaf.extensions,
106
    capabilities: originalLeafNode.leaf.capabilities,
107
    credential: originalLeafNode.leaf.credential,
108
    signaturePublicKey: originalLeafNode.leaf.signaturePublicKey,
109
    parentHash: leafParentHash[0],
110
    groupId: groupContext.groupId,
111
    leafIndex: senderLeafIndex,
112
  }
113

114
  const updatedLeafNode = await signLeafNodeCommit(updatedLeafNodeTbs, signaturePrivateKey, cs.signature)
1,101✔
115

116
  treeWithHashes[leafToNodeIndex(senderLeafIndex)] = {
1,101✔
117
    nodeType: "leaf",
118
    leaf: updatedLeafNode,
119
  }
120

121
  const updatedTreeHash = await treeHashRoot(treeWithHashes, cs.hash)
1,101✔
122

123
  const updatedGroupContext: GroupContext = {
1,101✔
124
    ...groupContext,
125
    treeHash: updatedTreeHash,
126
    epoch: groupContext.epoch + 1n,
127
  }
128

129
  // we have to remove the leaf secret since we don't send it to anyone
130
  const pathSecrets = ps.slice(0, ps.length - 1).reverse()
1,101✔
131

132
  // we have to pass the old tree here since the receiver won't have the updated public keys yet
133
  const updatePathNodes: UpdatePathNode[] = await Promise.all(
1,101✔
134
    pathSecrets.map(encryptSecretsForPath(originalTree, treeWithHashes, updatedGroupContext, cs)),
135
  )
136

137
  const updatePath: UpdatePath = { leafNode: updatedLeafNode, nodes: updatePathNodes }
1,101✔
138

139
  return [treeWithHashes, updatePath, pathSecrets, leafKeypair.privateKey] as const
1,101✔
140
}
141

142
function encryptSecretsForPath(
143
  originalTree: RatchetTree,
144
  updatedTree: RatchetTree,
145
  updatedGroupContext: GroupContext,
146
  cs: CiphersuiteImpl,
147
): (pathSecret: PathSecret) => Promise<UpdatePathNode> {
148
  return async (pathSecret) => {
852✔
149
    const key = getHpkePublicKey(updatedTree[pathSecret.nodeIndex]!)
2,276✔
150

151
    const res: UpdatePathNode = {
2,276✔
152
      hpkePublicKey: key,
153
      encryptedPathSecret: await Promise.all(
154
        pathSecret.sendTo.map(async (nodeIndex) => {
155
          const { ct, enc } = await encryptWithLabel(
2,514✔
156
            await cs.hpke.importPublicKey(getHpkePublicKey(originalTree[nodeIndex]!)),
157
            "UpdatePathNode",
158
            encode(groupContextEncoder)(updatedGroupContext),
159
            pathSecret.secret,
160
            cs.hpke,
161
          )
162
          return { ciphertext: ct, kemOutput: enc }
2,514✔
163
        }),
164
      ),
165
    }
166
    return res
2,276✔
167
  }
168
}
169

170
async function insertParentHashes(
171
  fdp: { resolution: NodeIndex[]; nodeIndex: NodeIndex }[],
172
  tree: RatchetTree,
173
  cs: CiphersuiteImpl,
174
) {
175
  for (let x = fdp.length - 1; x >= 0; x--) {
1,101✔
176
    const { nodeIndex } = fdp[x]!
2,279✔
177
    const parentHash = await calculateParentHash(tree, nodeIndex, cs.hash)
2,279✔
178
    const currentNode = tree[nodeIndex]
2,279✔
179
    if (currentNode === undefined || currentNode.nodeType === "leaf")
2,279✔
180
      throw new InternalError("Expected non-blank parent node")
×
181
    const updatedNode: Node = { nodeType: "parent", parent: { ...currentNode.parent, parentHash: parentHash[0] } }
2,279✔
182
    tree[nodeIndex] = updatedNode
2,279✔
183
  }
184
  return tree
1,101✔
185
}
186

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

206
      tree[nodeIndex] = {
2,279✔
207
        nodeType: "parent",
208
        parent: {
209
          hpkePublicKey: await cs.hpke.exportPublicKey(publicKey),
210
          parentHash: new Uint8Array(),
211
          unmergedLeaves: [],
212
        },
213
      }
214

215
      return [[{ nodeIndex, secret: nextPathSecret, sendTo: resolution }, ...pathSecrets], tree]
2,279✔
216
    },
217
    Promise.resolve([[{ secret: pathSecret, nodeIndex: leafToNodeIndex(senderLeafIndex), sendTo: [] }], tree] as [
218
      PathSecret[],
219
      RatchetTree,
220
    ]),
221
  )
222
}
223

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

235
    if (leafToUpdate === undefined || leafToUpdate.nodeType === "parent")
2,365✔
236
      throw new InternalError("Leaf node not defined or is parent")
×
237

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

240
    if (leafNodePublicKeyNotNew)
2,365✔
241
      throw new ValidationError("Public key in the LeafNode is the same as the committer's current leaf node")
×
242
  }
243

244
  const pathNodePublicKeysExistInTree = path.nodes.some((node) =>
2,441✔
245
    tree.some((treeNode) => {
6,508✔
246
      return treeNode?.nodeType === "parent"
138,560✔
247
        ? constantTimeEqual(treeNode.parent.hpkePublicKey, node.hpkePublicKey)
248
        : false
249
    }),
250
  )
251

252
  if (pathNodePublicKeysExistInTree)
2,441✔
253
    throw new ValidationError("Public keys in the UpdatePath may not appear in a node of the new ratchet tree")
×
254

255
  const copy = tree.slice()
2,441✔
256

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

259
  const reverseFilteredDirectPath = filteredDirectPath(senderLeafIndex, tree).reverse()
2,441✔
260

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

264
  if (reverseUpdatePath.length !== reverseFilteredDirectPath.length) {
2,441✔
265
    throw new ValidationError("Invalid length of UpdatePath")
×
266
  }
267

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

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

277
  const leafParentHash = await calculateParentHash(copy, leafToNodeIndex(senderLeafIndex), h)
2,441✔
278

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

282
  return copy
2,441✔
283
}
284

285
export function firstCommonAncestor(tree: RatchetTree, leafIndex: LeafIndex, senderLeafIndex: LeafIndex): NodeIndex {
286
  const fdp = filteredDirectPathAndCopathResolution(senderLeafIndex, tree)
2,167✔
287

288
  for (const { nodeIndex } of fdp) {
2,167✔
289
    if (isAncestor(leafToNodeIndex(leafIndex), nodeIndex, tree.length)) {
3,135✔
290
      return nodeIndex
2,167✔
291
    }
292
  }
293

294
  throw new ValidationError("Could not find common ancestor")
×
295
}
296

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

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

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