Skip to content

feat: add zeroizing read helper for sensitive deserialization#1057

Open
Jr-kenny wants to merge 4 commits into
0xMiden:nextfrom
Jr-kenny:zeroizing-read-helper
Open

feat: add zeroizing read helper for sensitive deserialization#1057
Jr-kenny wants to merge 4 commits into
0xMiden:nextfrom
Jr-kenny:zeroizing-read-helper

Conversation

@Jr-kenny

Copy link
Copy Markdown
Contributor

Describe your changes

Closes #593. Follows up on my comment there, opening this so the actual diff is easier to judge than a description.

Deserializing a secret key reads its bytes into a stack buffer, and each read_from had to remember to call zeroize() on that buffer before every return path. Going through the sensitive read_from impls, some forgot:

  • ecdsa_k256_keccak::SecretKey::read_from zeroized on success but leaked the buffer on the early return when from_slice rejects the bytes
  • falcon512_poseidon2::SecretKey::read_from never wiped its SK_LEN buffer on any path, success included
  • the Poseidon2 AEAD SecretKey::read_from never wiped its buffer either
  • the eddsa path was correct, but only because from_bytes is infallible

This adds read_sensitive_array to utils, which returns the array wrapped in Zeroizing so the buffer is wiped on drop no matter how the calling function exits, and switches the four impls over to it. The manual zeroize() calls go away, and a forgotten cleanup in a future read_from stops being possible by construction.

One limit I want to be straight about (also documented on the helper): read_array returns by value, so the single move into the Zeroizing wrapper is not wiped. That residual is the same one the manual calls had. Closing it fully would need a ByteReader method that reads into a caller-provided &mut [u8], which felt like a bigger conversation than this fix.

While in there I noticed falcon512 read_from calls decode_i8(...).unwrap() for the g and big_F chunks (only f gets a proper error). If those can fail on malformed input that's a panic on attacker-supplied bytes, but I didn't want to widen this PR before asking. Happy to file it separately if you think it's reachable.

Testing

  • Added a test covering the ECDSA error path (out-of-range and zero scalars must return an error), which is the path that previously skipped the cleanup
  • Full miden-crypto lib suite passes (648 tests), clippy clean
  • CHANGELOG entry will follow in a commit once this PR has its number

Checklist before requesting a review

  • Repo forked and branch created from next according to naming convention.
  • Commit messages and codestyle follow conventions.
  • Relevant issues are linked in the PR description.
  • Tests added for new functionality.
  • Documentation/comments updated according to changes.

@github-actions

Copy link
Copy Markdown

Automated check (CONTRIBUTING.md)

Findings:

Recommendations:

  • Consider adding a Test plan or clear review steps.

Next steps:

@huitseeker huitseeker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks for tackling this!

impl Deserializable for SecretKey {
fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
let byte_vector: [u8; SK_LEN] = source.read_array()?;
let byte_vector = read_sensitive_array::<SK_LEN, _>(source)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this fixes the serialized SK_LEN buffer, but this Falcon path still decodes secret coefficients into the f, g, and big_f temporaries below. Those are ordinary Vec/Polynomial values, and I do not see a Drop impl for Polynomial, only Zeroize. Should those decoded temporaries also be wrapped or wiped?

@Jr-kenny Jr-kenny Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. The Vec<i8> temporaries coming out of decode_i8 were easy to cover, I've wrapped them in Zeroizing in 53587db so the raw coefficient bytes get wiped on every exit path now.

The Polynomial side turned out to be a deeper hole than it looks. FalconFelt doesn't implement Zeroize at all, so the generic impl on Polynomial can't touch Polynomial<FalconFelt>. And it's actually a bit worse than no Drop impl: polynomial.rs has a manual impl<F: Zeroize> ZeroizeOnDrop for Polynomial<F> {} with nothing backing it, and since ZeroizeOnDrop is just a marker trait, that's a wipe-on-drop promise that never actually runs. Even if both of those were fixed, computing big_g goes through fft().hadamard_div().hadamard_mul().ifft() and building the basis negates polynomials, all of which allocate intermediates carrying secret coefficients that read_from has no handle on.

So doing this properly means giving FalconFelt a Zeroize impl, backing the marker with a real Drop, and going over the falcon math ops for those intermediate allocations. That felt too big to bolt onto this PR, so I'd file it as a follow-up issue and take it on, shout if you'd rather see it handled here. Thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Filed the follow-up as #1059 with the full picture. While writing it up I found the gap is wider than the read path: write_into drops the encoded secret key unwiped behind a stale comment, and generate_seed leaks an unwiped copy of it on every signature. I have the fix building and passing the falcon suite locally, ready to open once assigned.

@huitseeker huitseeker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks!

@Jr-kenny Jr-kenny force-pushed the zeroizing-read-helper branch from ba7b5ae to 4a5d507 Compare June 17, 2026 01:42

@Al-Kindi-0 Al-Kindi-0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, ty!

Jr-kenny added 4 commits June 23, 2026 16:01
Deserializing a secret key reads its bytes into a stack buffer, and each
read_from was responsible for remembering to zeroize that buffer before
every return path. Some forgot: the ecdsa path leaked the buffer on the
early return when from_slice rejects the bytes, and the falcon and
poseidon2 AEAD secret keys never wiped their read buffers at all.

Add read_sensitive_array to utils, which returns the array wrapped in
Zeroizing so the buffer is wiped on drop no matter how the function
exits. Switch the four sensitive read_from impls (ecdsa, eddsa, falcon
secret key, poseidon2 AEAD secret key) over to it and drop the manual
zeroize calls.

One honest limit, also noted on the helper doc: read_array returns by
value, so the single move into the wrapper is not wiped. That residual
is the same one the manual calls had. Closing it fully would need a
ByteReader method that reads into a caller-provided buffer.

Closes 0xMiden#593
Wrap the f, g, and big_F byte vectors produced by decode_i8 in
Zeroizing so the raw secret coefficients are wiped when read_from
returns, on both the success and error paths.
@Jr-kenny Jr-kenny force-pushed the zeroizing-read-helper branch from 4a5d507 to 20fbb02 Compare June 23, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider adding a zeroizing deserialization reader that would be used for sensitive material

3 participants