Skip to content

fix: stop signing a package from allocating several times its size - #2170

Merged
netomi merged 3 commits into
mainfrom
fix/signing-heap-usage
Sep 7, 2026
Merged

netomi merged 3 commits into
mainfrom
fix/signing-heap-usage

Conversation

@netomi

@netomi netomi commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Towards #1450.

The bug

createSignatureFile streamed the package into BouncyCastle's Ed25519Signer in 1 KiB chunks. That reads as the memory-safe way to do it and is the opposite of one:

final class org.bouncycastle.crypto.signers.Ed25519Signer$Buffer extends java.io.ByteArrayOutputStream

Pure Ed25519 (RFC 8032) hashes the message twice — once for the nonce H(prefix ‖ M), once for the challenge H(R ‖ A ‖ M) — so a signer cannot discard what it is fed. BouncyCastle keeps it in a ByteArrayOutputStream, which doubles its capacity as it fills and allocates every intermediate array on the way.

The ~300 MB package in #1450 therefore ended up in a 512 MB array with the 256 MB one it had been copied from still live — three quarters of a gigabyte to sign 300 MB. That is why 1 GB of heap failed and 2 GB worked, and the reported stack trace comes straight out of that growth: ByteArrayOutputStream.write → ensureCapacity → Arrays.copyOf.

Worth noting #1458 fixed a different OOM on this path (the MAX_CONTENT_SIZE check). At 304.6 MB the reporter's file is under the 512 MB limit, so it passes that check and dies later, in signing — as they predicted in the thread.

The change

Read the file into one exactly sized array and sign that. Measured on a 9 MiB package by the test below:

allocated ratio
streaming into Ed25519Signer 33,560,752 bytes 3.6×
read in one go 9,445,000 bytes 1.0×

The signature is unchanged. Ed25519Signer does no more than accumulate the message and hand it to Ed25519PrivateKeyParameters.sign, which is what this now calls directly with the same algorithm and a null context. signsExactlyAsTheStreamingSignerDid asserts the two produce identical bytes, so signatures already published keep matching what a re-sign produces.

Verification had the same defect the other way round — on the mirroring path, via MirrorExtensionService — and is fixed the same way. It now also reports a PEM holding something other than an Ed25519 key, instead of letting a ClassCastException out of Ed25519Signer.init.

What this does not fix

A smaller heap, not a bounded one. It still scales with the package, so an instance with the integrity service enabled wants a heap above ovsx.publishing.max-content-size (512 MB by default). Constant memory isn't reachable through BouncyCastle: every sign overload it exposes for pure Ed25519 takes a byte[], and its only streaming signer implements Ed25519ph — a different scheme, whose signatures nothing verifying these packages today would accept. Switching to it, or shelling out to an external signer, is the discussion in #1450 and belongs in its own issue.

The silent failure is untouched. #1450's second complaint is that the version is left active = false while the CLI reports success. That is still true, and there are two reasons for it worth recording:

  • publishAsync catches Exception to call markScanAsErrored, but OutOfMemoryError is an Error, so even an instance with scanning enabled records nothing.
  • doPublish carries @Retryable, and the comment above it assumes retries happen — but it is a private method invoked from publishAsync in the same class, so the proxy is bypassed and the annotation never applies. (@EnableResilientMethods is on RegistryApplication, and the @Retryable on the public createExtensionVersion does work.)

Both are behavioural changes rather than a memory fix, so they are deliberately not in here.

Tests

  • signsExactlyAsTheStreamingSignerDid — byte-identical signatures, the guard against a format change.
  • allocatesThePackageOnceWhereStreamingAllocatedItSeveralTimesOver — measures both ways round with per-thread allocation counters, prints the numbers, and asserts the shipped path stays under 1.5× the package while the streaming one exceeds 2×. Thresholds sit well clear of the measured 1.0× and 3.6× so it fails on a regression rather than on JVM noise; it skips where the JVM does not report per-thread allocation.
  • The pre-existing testGenerateSignature, which checks a real .vsix against a recorded sigzip fixture, still passes.

Full server suite green (1167 tests).

🤖 Generated with Claude Code

Signing streamed the package into BouncyCastle's Ed25519Signer in 1 KiB
chunks, which reads as the memory-safe way to do it and is the opposite.
Pure Ed25519 hashes the message twice - once for the nonce, once for the
challenge - so a signer cannot discard what it is fed, and BouncyCastle's
holds it in a ByteArrayOutputStream that doubles its capacity as it fills.
The ~300 MB package in #1450 therefore ended up in a 512 MB array with the
256 MB one it was copied from still live: three quarters of a gigabyte to
sign 300 MB, which is why that publish needed the heap raised to 2 GB. The
reported OutOfMemoryError comes straight out of that growth, at
ByteArrayOutputStream.write -> ensureCapacity -> Arrays.copyOf.

Read the file into one exactly sized array and sign that instead. Measured
on a 9 MiB package: 33,560,752 bytes allocated streaming, 9,445,000 read in
one go - 3.6x down to 1.0x.

The signature is unchanged. Ed25519Signer does no more than accumulate and
hand the message to Ed25519PrivateKeyParameters.sign, which is what this
now calls directly, and a test asserts the two produce identical bytes so
that signatures already published keep matching.

Verification had the same defect the other way round, on the mirroring path,
and is fixed the same way. It also now says so when handed a PEM holding
something other than an Ed25519 key, rather than letting a ClassCastException
out of Ed25519Signer.init.

This is a smaller heap, not a bounded one: it still scales with the package,
so an instance with the integrity service enabled wants a heap above
ovsx.publishing.max-content-size. Constant memory is not reachable through
BouncyCastle - every pure-Ed25519 sign overload takes a byte[], and the one
streaming signer implements Ed25519ph, a different scheme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

It introduces user-facing and robustness issues (misleading key-read error message, missing signature-length validation, and a potentially flaky allocation-measurement helper in tests) that should be addressed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR changes the extension signature generation and verification flow to avoid excessive heap allocation when signing/verifying large VSIX packages by bypassing BouncyCastle’s streaming Ed25519Signer buffering behavior and using direct Ed25519 byte[] APIs instead.

Changes:

  • Switch signing to Ed25519PrivateKeyParameters.sign(...) over a single byte[] message buffer.
  • Switch verification to Ed25519PublicKeyParameters.verify(...) over a single byte[] message buffer, with improved public-key type diagnostics.
  • Add regression tests asserting signature equivalence vs the prior streaming approach and measuring allocation behavior.
File summaries
File Description
server/src/main/java/org/eclipse/openvsx/publish/ExtensionVersionIntegrityService.java Updates signing/verification to avoid multi-growth buffering and improves key-type error reporting.
server/src/test/java/org/eclipse/openvsx/publish/ExtensionVersionIntegrityServiceTest.java Adds equivalence + allocation-regression tests for the updated signing approach.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Reading the whole signature and handing it to the low-level verify lost a
check the streaming signer was making. Ed25519Signer.verifySignature
compares the length against SIGNATURE_SIZE and answers false; the low-level
Ed25519PublicKeyParameters.verify reads a fixed 64 bytes from the offset it
is given, so a truncated signature came back out of BouncyCastle as
"ArrayIndexOutOfBoundsException: last source index 64 out of bounds for
byte[63]" and a padded one would have verified on its first 64 bytes with
the rest ignored.

Check the length and answer false, which is both the honest answer and what
the one caller already refuses the package on. A test covers it, and fails
with that same exception when the check is taken out.

Also from the review: the failure of reading the public key PEM reported
"Failed to read private key file", and the allocation test assumed a
supported per-thread counter is a running one. It reports -1 when it is not,
which would have satisfied the "allocated little" assertion for entirely the
wrong reason; the counter is now switched on if it is off, and a negative
reading skips rather than passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The length check sat after the package had already been pulled into memory,
which in a change about not allocating needlessly is the wrong way round.
Read the signature first, and check its size rather than its contents: both
files arrive from a remote registry, so there is no point reading a few
hundred megabytes to check something that has already failed - nor reading a
"signature" that is a few hundred megabytes to find out it is not 64 bytes.

The new test points verification at a package that does not exist, so
reading it throws rather than merely wasting the memory. That turns "did not
read it" into something a test can see: with the package read first it fails
with NoSuchFileException.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netomi
netomi merged commit e964852 into main Sep 7, 2026
5 checks passed
@netomi
netomi deleted the fix/signing-heap-usage branch September 7, 2026 12:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants