Skip to content

fix: Keep Data pointers alive across C crypto calls - #396

Merged
DarthMike merged 3 commits into
developfrom
fix/aes-linux-pointer-lifetime
Aug 13, 2026
Merged

fix: Keep Data pointers alive across C crypto calls#396
DarthMike merged 3 commits into
developfrom
fix/aes-linux-pointer-lifetime

Conversation

@DarthMike

@DarthMike DarthMike commented Aug 11, 2026

Copy link
Copy Markdown
Member

Fixes #395.

Root cause

Not the aes dependency — the wrapper. Aes128Util and KeyUtil took raw pointers like this:

let keyPtr = (key as NSData).bytes.assumingMemoryBound(to: UInt8.self)

The bridged NSData is a temporary that nothing owns past the end of the statement. The pointer is therefore only valid by accident: on Darwin the autorelease pool keeps the object alive until the pool drains, so it works; swift-corelibs-foundation has no autorelease pool, so the buffer is released immediately and the pointer dangles.

In Aes128Util the key pointer was captured and then read after three further allocations (the IV bridge, AES_ctx, the output buffer) had a chance to reuse the freed memory. That is why AES-128-CTR returns wrong bytes against the NIST SP 800-38A vector on Linux, and why KeystoreUtil — which encrypts and decrypts through it — writes keystores that cannot be read back.

Two other call sites corroborate the diagnosis: KeccakExtensions stores the bridge in a local let before using .bytes, and KeyUtil.recoverPublicKey already uses withUnsafeBytes. Both pass on Linux. Only the temporary-bridge form fails.

Commits

1. fix: Keep Data pointers alive across C crypto calls

  • Aes128Util.xcrypt takes key, IV and input pointers inside withUnsafeBytes scopes. AES_ctx is now a zero-initialised value instead of uninitialised heap memory, and the input is encrypted in place on a copy rather than hand-copied through a raw buffer.
  • KeyUtil.generatePublicKey and KeyUtil.sign get the same treatment. Neither had been observed to misbehave — their pointers are consumed immediately with nothing allocated in between — but it is the same undefined behaviour applied to private key material.

KeccakExtensions is left alone: it retains the bridge in a local, so it is not UB. Worth tidying separately.

2. test: Cover the AES and secp256k1 buffer handling

Aes128Util only had the NIST vector, which passes whole Data values with an IV — none of the paths the fix touches. Added: sliced inputs (a non-zero start index has to be honoured), a nil IV (previously read whatever was in the freshly allocated context, now a zero IV), and a round trip that also checks the caller's buffer is left alone. Expected values come from openssl enc -aes-128-ctr, which reproduces the existing NIST expectation exactly, so it is an independent oracle rather than our own output.

KeyUtil had no coverage of its failure paths, and writing it turned up a pre-existing out-of-bounds read. secp256k1 reads a fixed 32 bytes through the pointer, so a shorter private key or unhashed message was read past the end of the buffer instead of being rejected, and a longer one was silently truncated. recoverPublicKey already guarded its message length; generatePublicKey and sign never did. Both now guard.

⚠️ Behaviour change worth a look. encryptAndStorePrivateKey with a key that is not 32 bytes now throws privateKeyInvalid instead of silently deriving an address from the first 32 bytes. testEncryptAndStorePrivateKey and its multiple-key twin were passing 256 random bytes as a private key and only passed because of that truncation, so their fixtures are fixed here too. This is stricter than #395 strictly requires — happy to split it into its own PR if you would rather keep this one to the Linux fix.

3. tech: Block on the Linux job now that it passes

Reverts continue-on-error: true on test_linux, per the Status section of #395. Note this makes Linux blocking for every PR, and it takes ~9 minutes against macOS's ~30 seconds.

Testing

macOS Linux
before 427 tests, 0 failures crashed at EthereumAccountTests, 18 suites reached
fix commit, 3 consecutive runs 427, 0 failures 326 tests, 0 failures ×3
head, Linux blocking 434, 0 failures 333 tests, 0 failures

The fix commit was re-run three times before the Linux job was made blocking, to check it was not flaky. Results read from the job logs rather than the check status, since continue-on-error reports success either way.

KeystoreUtilTests is the coverage that matters most here and it already existed — it encodes and decodes the canonical Web3 Secret Storage vector, so it checks interoperability rather than just self-consistency. It had simply never run on Linux (see below).

🤖 Generated with Claude Code

Aes128Util and KeyUtil took raw pointers via `(data as NSData).bytes`.
The bridged NSData is a temporary owned by nothing past the statement,
so the pointer is only valid by accident: on Darwin the autorelease pool
holds the object until the pool drains, and on Linux there is no pool,
so the buffer is freed immediately.

In Aes128Util the key pointer was captured and then read after further
allocations (the IV bridge, the context, the output buffer) had a chance
to reuse the freed memory, which is why AES-128-CTR returned wrong bytes
against the NIST SP 800-38A vector on Linux and KeystoreUtil produced
unreadable keystores on top of it.

KeyUtil had the same construct for the private key and message pointers.
It has not been observed to misbehave because those pointers are consumed
immediately, but it is the same undefined behaviour over private key
material, so fix it in the same pass.

Take the pointers with withUnsafeBytes so their lifetime is bounded by
the scope that uses them, matching what recoverPublicKey already did.
Aes128Util now also holds AES_ctx as a zero-initialised value rather than
uninitialised heap memory, and encrypts a copy of the input in place
instead of hand-copying through a raw buffer.

Fixes #395
Aes128Util only had the NIST SP 800-38A vector, which passes whole Data
values with an IV. Add the paths the fix touches: slices, whose non-zero
start index has to be honoured; a nil IV, which used to read whatever was
in the freshly allocated context and is now a zero IV; and a round trip
that also checks the input buffer is left alone.

Expected values come from `openssl enc -aes-128-ctr`, which reproduces the
existing NIST expectation exactly, so it is a real oracle rather than our
own output.

KeyUtil had no coverage of its failure paths, and adding it turned up an
out-of-bounds read: secp256k1 reads a fixed 32 bytes through the pointer,
so a shorter private key or unhashed message was read past the end of the
buffer instead of being rejected, and a longer one was silently truncated.
recoverPublicKey already guarded its message length; generatePublicKey and
sign never did. Guard both.

testEncryptAndStorePrivateKey and its multiple-key twin passed 256 random
bytes as a private key and passed only because the first 32 were taken
silently. Give them a real key.
@DarthMike
DarthMike marked this pull request as ready for review August 11, 2026 08:50
@DarthMike
DarthMike requested review from a team and dmcrodrigues as code owners August 11, 2026 08:51
@DarthMike
DarthMike merged commit ee80eca into develop Aug 13, 2026
3 checks passed
@DarthMike
DarthMike deleted the fix/aes-linux-pointer-lifetime branch August 13, 2026 13:52
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.

Aes128Util returns wrong bytes on Linux, breaking keystore encryption

2 participants