Repository navigation
fix(sdk-lib-mpc): free DKLS signing and keygen sessions (WCN-4341) - #10143
Conversation
|
|
|
@claude review always |
c8d1060 to
368a7c1
Compare
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Besides the inline finding, I checked two other things and neither is a problem. In init(), _deserializeState() only reads this.dsgSession and never reassigns it, so the finally frees the session created at dsg.ts:140. Reading firstMessage.payload and then freeing the message follows the same pattern the existing round handler already uses (nextRoundMessages.forEach((m) => m.free()) after m.payload). The binding is not installed in this checkout, so I did not confirm from its source that the payload getter returns a copy.
Extended reasoning...
The change adds explicit free() calls for DKLS WASM signing sessions and first-round messages in modules/sdk-lib-mpc/src/tss/ecdsa-dkls/dsg.ts, plus a unit test that counts the frees. It touches MPC/ECDSA signing code, which is crypto-sensitive. Because an inline finding was reported, this note only records the two concerns that were checked and ruled out.
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
There was a problem hiding this comment.
I reviewed the latest commits and found no bugs. This changes native session ownership in DKLs ECDSA keygen and signing, and the code is owned by the wallet-core and HSM CODEOWNERS, so a human should still look at it.
What was reviewed:
- Commit footers: all three commits now carry
Ticket: WCN-4341.WCN-is a configured issue prefix, so commitlint's references-empty rule should pass. - dsg.ts: on failure,
dsgSessionBytesis no longer re-serialized, so a failed round leaves the previous round's bytes in place.free()errors are swallowed when an earlier error should be kept. In the Complete state the reference is only dropped, becausecombineconsumes the session. - dkg.ts: the retrofit key share is now freed right after
initKeyRotationinstead of after round 4. That is safe only if the binding borrows the share rather than keeping it, which the new code comment says but this review could not confirm from the binding itself.
Extended reasoning...
The change touches WASM memory ownership in sdk-lib-mpc's DKLs Dkg and Dsg classes (dkg.ts, dsg.ts) and adds unit tests. This is threshold-signature crypto code, and freeing a session at the wrong time could cause a double free or use-after-free in the native binding. CODEOWNERS assigns /modules/sdk-lib-mpc/ to the wallet-core, wallet-core-india and HSM teams. Both earlier human reviews were dismissed after later commits, so no current human approval exists for the latest code. The earlier commit-footer finding appears fixed by the Ticket footers on the new commits.
Problem and fix
Signing and distributed key generation left native WebAssembly sessions allocated after saving their state as bytes. The JavaScript reference was discarded without calling
free(). In the installed library, newly constructed sessions have no automatic cleanup callback, so those allocations leak.This PR frees sessions and first-round messages after copying their data. It also clears session references before cleanup and preserves the original signing error if cleanup fails. Final signature combination and key-share extraction already consume their sessions; those paths are protected from double-freeing.
Ticket: WCN-4341 · Customer report
Validation
The table shows memory capacity after 1,500 signing cycles per configuration. MiB means 1,048,576 bytes. WebAssembly capacity is a high-water mark, not live allocated bytes; the ownership probes below separately confirm the leak.
The chart shows the configuration without periodic forced collection. Red: original. Blue: fixed.
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#c62828,#1565c0"}}}}%% xychart-beta title "Signing: WebAssembly capacity without forced collection" x-axis "Verified signing cycles" [0,100,200,300,400,500,600,700,800,900,1000,1100,1200,1300,1400,1500] y-axis "Capacity (MiB)" 0 --> 1800 line [1.75,109.56,217.50,325.44,433.81,541.25,649.31,757.19,865.25,973.06,1081.06,1189.50,1297.06,1405.00,1512.88,1621.44] line [1.75,2.31,2.31,2.31,2.31,2.31,2.31,2.31,2.31,2.31,2.31,2.31,2.31,2.31,2.31,2.31]Additional finding
The audit found the same permanent leak in key generation, which this PR also fixes. Ten complete three-party workflows produced all 30 key shares. After the fix, the probe recorded 120 explicit session frees and 30 consuming calls, with no session cleanup deferred to automatic finalizers. Raw output and the probe are below.
Other audited algorithm bindings register automatic cleanup callbacks. Their probes showed delayed reclamation, not this missing-callback defect; they are outside this change.
Root cause and corrected ownership flow
A JavaScript wrapper holds a pointer to native protocol state. Serializing the state copies it into bytes but leaves the original allocation owned by the wrapper. Dropping that wrapper is safe only if its native allocation is explicitly released or a registered finalizer eventually releases it.
The installed binding registers restored wrappers in
__wrap()but not directly constructed sessions (node_modules/@silencelaboratories/dkls-wasm-ll-node/dkls-wasm-ll-node.js:802,:827). That creates two distinct problems: constructor allocations are leaked, while restored-session allocations wait for garbage collection.flowchart TD A[Create or restore native session] --> B[Process signing round] B --> C[Copy session state into bytes] C --> D[Free native session explicitly] D --> E[Next request restores from saved bytes] E --> B B --> F[Final signature combination] F --> G[Native combination consumes session]The binding documents final combination as: “This method consumes the session and deallocates all internal data.” (
dkls-wasm-ll-node.js:945). Key-share extraction also consumes its session. Callingfree()again after either operation would be incorrect.Implementation:
modules/sdk-lib-mpc/src/tss/ecdsa-dkls/dsg.tsandmodules/sdk-lib-mpc/src/tss/ecdsa-dkls/dkg.ts. Regression coverage:modules/sdk-lib-mpc/test/unit/tss/ecdsa/dklsDsg.tsandmodules/sdk-lib-mpc/test/unit/tss/ecdsa/dklsDkg.ts.Signing proof: raw output and reproducible harness
The original source is
368a7c1^. The tested fixed signing source has SHA-256dcf003c0f70adab9762f230118c1e17310377590052dea9ceafefa1392d58ed5and is included in follow-up commit7cd4130.Each configuration ran in a fresh process. One party was restored from serialized bytes before each subsequent round. The workload excludes HTTP transport and encryption. Final memory records follow forced garbage collection; already-grown WebAssembly capacity does not shrink afterward.
Raw proof
Actual final workload records (verbatim)
{"variant":"baseline","gcMode":"gc","cycle":1500,"phase":"after-final-gc","created":3000,"restored":12000,"freed":0,"consumed":3000,"wasmBytes":507641856,"rss":641056768,"heapTotal":62504960,"heapUsed":25697584,"external":514933899,"arrayBuffers":1017553} {"variant":"baseline","gcMode":"nogc","cycle":1500,"phase":"after-final-gc","created":3000,"restored":12000,"freed":0,"consumed":3000,"wasmBytes":1700200448,"rss":1834844160,"heapTotal":63291392,"heapUsed":25701856,"external":1707492507,"arrayBuffers":1017569} {"variant":"fixed","gcMode":"gc","cycle":1500,"phase":"after-final-gc","created":3000,"restored":12000,"freed":12000,"consumed":3000,"wasmBytes":2424832,"rss":138215424,"heapTotal":64077824,"heapUsed":25833952,"external":9716857,"arrayBuffers":1017535} {"variant":"fixed","gcMode":"nogc","cycle":1500,"phase":"after-final-gc","created":3000,"restored":12000,"freed":12000,"consumed":3000,"wasmBytes":2424832,"rss":136200192,"heapTotal":63553536,"heapUsed":25809472,"external":9716870,"arrayBuffers":1017548}Finalizer ownership probes (actual output)
A finalizer is an automatic cleanup callback registered for a JavaScript wrapper. These probes instrument registration, explicit disposal, consuming operations, and callback execution. Directly constructed sessions have no registration in this binding; restored sessions do.
Reproducible workload
Run from the repository root after building
modules/sdk-lib-mpc. Save the following harness as/tmp/wcn4341-run.cjs. The harness reads baseline source through Git and transpiles each variant in memory; it does not replace working source. It records source hashes, runtime, dependency version, memory counters and session-ownership counts. The fixed mode always reads the current working signing source, so compare its logged hash to the tested hash above.node_modules/.bin/tsc --build modules/sdk-lib-mpc # Each command runs in a separate Node 22.23.4 process. node --expose-gc /tmp/wcn4341-run.cjs baseline gc 1500 node --expose-gc /tmp/wcn4341-run.cjs baseline nogc 1500 node --expose-gc /tmp/wcn4341-run.cjs fixed gc 1500 node --expose-gc /tmp/wcn4341-run.cjs fixed nogc 1500Complete workload harness
Key-generation proof: raw output and probe
Follow-up key-generation probe, actual output
Invocation from repository root, Node 22.23.4:
node --expose-gc full-keygen.cjs. This probe and the original ownership probes are investigation artifacts, not checked-in regression tests. The PR's accompanying tests cover the corresponding cleanup assertions.Key-generation probe source
Save as
full-keygen.cjsand run from the repository root after building the tested source.Remaining questions, alternatives, and glossary
modules/sdk-api/src/encrypt.ts:136,:167).Appendix: alternatives considered
Appendix: domain primer
Native allocation versus wrapper
The JavaScript wrapper holds a pointer to native protocol state. Dropping the reference does not call its destructor. The installed binding registers restored wrappers in
__wrap()but not its direct constructor (node_modules/@silencelaboratories/dkls-wasm-ll-node/dkls-wasm-ll-node.js:802,:827). References: wasm-bindgen exported types, MDN finalization.Serialized protocol state
Serialization copies enough state into bytes for another request to continue signing. It leaves ownership of the original allocation with the caller. Final signature combination and key-share extraction instead consume their sessions, so they must not be followed by another session destructor. References:
modules/sdk-lib-mpc/src/tss/ecdsa-dkls/dsg.ts,modules/sdk-lib-mpc/src/tss/ecdsa-dkls/dkg.ts, WebAssembly memory.Appendix: glossary