Skip to content

Zeroize PrivateKey - #112

Merged
leighmcculloch merged 16 commits into
mainfrom
fix-private-key-zeroization
May 8, 2026
Merged

Zeroize PrivateKey#112
leighmcculloch merged 16 commits into
mainfrom
fix-private-key-zeroization

Conversation

@leighmcculloch

Copy link
Copy Markdown
Member

What

  • Bump heapless from 0.8 to 0.9 with the zeroize feature; add zeroize 1 (no_std-compatible).
  • ed25519::PrivateKey: drop Copy, derive Zeroize and ZeroizeOnDrop so the 32-byte seed is overwritten on drop.
  • Add convert::encode_zeroizing and convert::decode_zeroizing. They wrap their intermediate scratch buffers in Zeroizing<…> and take a caller-provided output by &mut, eliminating return-value moves of secret bytes.
  • Add PrivateKey::write_string(&self, &mut Zeroizing<String<E>>) — the zeroize-aware encoding path that writes directly into a caller-provided buffer.
  • PrivateKey::from_slice now uses decode_zeroizing with a local Zeroizing<Vec<u8, P>>.
  • PrivateKey::to_string routes through write_string/encode_zeroizing so its intermediate scratch is zeroed; the returned String<E> itself remains plain by design (see docs).
  • Document # Zeroize semantics on PrivateKey (protections + remaining leak vectors) and on Strkey (its decode path does not zero — use PrivateKey::from_slice / from_string directly for a private-key strkey).
  • Tests: smoke tests in convert.rs confirming encode_zeroizing/decode_zeroizing produce identical output to encode/decode; unit test in ed25519.rs confirming write_string matches to_string.
  • New fuzz target fuzz_compare_v16 (and make fuzz-compare-v16) cross-checks parsing/encoding/decoded data of the in-tree code against the published stellar-strkey 0.0.16 crate to catch any behavioural drift introduced by this refactor.
  • Bump crate version to 0.0.17.

Why

PrivateKey previously held its raw 32-byte seed with Copy semantics and no Drop, so the bytes could remain in process memory after the value had nominally been dropped. The decode path compounded this by leaving stack-resident heapless::Vec scratch buffers (35-byte binary + 32-byte payload) uncleared on return.

This change overwrites the seed bytes on drop, removes the by-value Copy duplication, and clears the intermediate scratch buffers used by the encode/decode paths for private keys. Residue from the few remaining return-value moves is called out in the docs on the relevant items, with write_string provided as the no-residue encoding path.

Breaking changes (pre-1.0):

  • PrivateKey no longer derives Copy. Callers passing it by value need .clone() or to borrow.

@socket-security

socket-security Bot commented May 7, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedheapless@​0.9.38310095100100
Addedstellar-strkey@​0.0.1689100100100100
Addedzeroize@​1.8.210010093100100

View full report

@leighmcculloch
leighmcculloch marked this pull request as ready for review May 8, 2026 00:27
Copilot AI review requested due to automatic review settings May 8, 2026 00:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 624dba4432

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/convert.rs
Comment thread src/convert.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR strengthens handling of secret material by zeroizing ed25519::PrivateKey seeds on drop and adding zeroizing encode/decode helpers that avoid leaving intermediate scratch buffers in memory. It also adds fuzz targets to compare behavior against the v0.0.16 published crate and to assert equivalence between “plain” and “zeroizing” convert paths.

Changes:

  • Add Zeroize/ZeroizeOnDrop to ed25519::PrivateKey, introduce write_string, and route private-key decode/encode through zeroizing buffers.
  • Add convert::encode_zeroizing / convert::decode_zeroizing APIs plus unit tests ensuring they match existing encode/decode.
  • Add new fuzz targets (fuzz_compare_v16, fuzz_compare_zeroizing), corpus seeds, and bump crate/deps (heapless 0.9 + zeroize 1) and version to 0.0.17.

Reviewed changes

Copilot reviewed 167 out of 178 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/strkey.rs Document Strkey zeroization semantics and recommend private-key-specific decoding APIs when zeroization is required.
src/lib.rs Expose convert as a public (doc-hidden) module to access new zeroizing helpers.
src/ed25519.rs Make PrivateKey zeroize-on-drop; add write_string; update decode path to use decode_zeroizing; add unit test.
src/convert.rs Add encode_zeroizing/decode_zeroizing and tests comparing outputs to the existing encode/decode.
Makefile Add fuzz targets fuzz-compare-v16 and fuzz-compare-zeroizing.
fuzz/fuzz_targets/fuzz_compare_zeroizing.rs Fuzz target comparing plain vs zeroizing encode/decode behavior.
fuzz/fuzz_targets/fuzz_compare_v16.rs Fuzz target comparing current parsing/encoding against stellar-strkey v0.0.16.
fuzz/corpus/fuzz_compare_v16/seed-XBU2RRGLXH3E5CQHTD3ODLDF2BWDCYUSSBLLZ5GNW7JXHDIYKXZWGTOG Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-TBU2RRGLXH3E5CQHTD3ODLDF2BWDCYUSSBLLZ5GNW7JXHDIYKXZWHXL7 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-SBU2RRGLXH3E5CQHTD3ODLDF2BWDCYUSSBLLZ5GNW7JXHDIYKXZWHOKR Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAQACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6IBZGM Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAQACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6IAAAAAAAAPM Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAOQCAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4Z2PQ Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAOQCAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DXFH6 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAOQCAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUAAAAFGBU Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALGXP Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALGXO Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALGXN Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALGXM Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALGXL Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALGXK Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALGXJ Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALGXI Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAKB5 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAGAAAAAAAAAAAAAAAAAAACTPZ Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAGAAAAAAAAAAAAAAAAAAACTPY Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAGAAAAAAAAAAAAAAAAAAACTP3 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAGAAAAAAAAAAAAAAAAAAACTP2 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYZ Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYY Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYX Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYW Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYV Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYU Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYT Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYS Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYR Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKYQ Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKY7 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKY6 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKY5 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKY4 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKY3 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAEAAAAAAAAAAAAARKY2 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAACAAAAAABNWT Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-PA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAACAAAAAABNWS Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-MA7QYNF7SOWQ3GLR2DMLK Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJQ Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLKA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAAV75I Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUR Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQ Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUK=== Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-MA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAAAAAAAAAPCICBKU Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-M47QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQ Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-LAAAAAAAADLH2 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-LA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUPJNAAAAAA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-LA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUPJNAAA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-LA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUPJNA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-LA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUPJN Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-LA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAGPZA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-LA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGZ5J Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-L47QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUSV4 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-L=A7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUPJN Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-GAAAAAAAACGC6 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUACUSI Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-GA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQHES5 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-G47QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVP2I Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-G=3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQHES5 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAAT6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGXACA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAADMPVKHBTYIH522D2O3CGHPHSP4ZXFNISHBXEYYDWJYBZ5AXD3CA3GDEAAAAAAA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAADMPVKHBTYIH522D2O3CGHPHSP4ZXFNISHBXEYYDWJYBZ5AXD3CA3GDEAAAA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAADMPVKHBTYIH522D2O3CGHPHSP4ZXFNISHBXEYYDWJYBZ5AXD3CA3GDEA Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAADMPVKHBTYIH522D2O3CGHPHSP4ZXFNISHBXEYYDWJYBZ5AXD3CA3GDE Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAAD6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGR4TX Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAAD6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGR4TW Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAAD6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGR4TV Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAAD6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGR4TU Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BAAAAAAAAAAK3EY Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-BA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUADTYY Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-B47QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVA4D Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-AAD6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGR4TU Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/seed-3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQHES5 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/fe8092bb1b53a678d86898ac18d85c61f9e612d1 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/fe12e31b759edb1cafb8988d301242e5d57ab9c1 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/f5a63959843e299898a9cefcc9c3dd7f54c39054 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/f0487a34f7760f330ca2c020c1fd8df827f7b534 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/ee1cbaa0c703001aa9061f30e7911f54b479d7dd Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/e7fd8a5c56ee816241f6fa114319a1769814b4a4 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/e60fb62528dda11c7f47c25369b0205c7a2f304f Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/db3f49e07d16953ba6e0c04c17ca0a308353ef8f Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/da70a685b0c2d9d49bee34c00d36fe121113c792 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/d9d4f7b42a9949f41d7566a28ff67544ce91421d Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/d0a22300db064cb9fc471a5d9c780fe84e201421 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/d094b9d0cab14d274e5e7d651b37c07920d349f5 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/c5672c8bfc6e0bcc067cac30ea315256f29cf390 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/c521b85ce4ba3e296288ae3555a9ec43c93a2114 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/c427c553f0e2c4e269a0b0e98cfc86cef7e837f4 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/c1fe3a7b487f66a6ac8c7e4794bc55c31b0ef403 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/c1dfd96eea8cc2b62785275bca38ac261256e278 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/bfe2fce9ad197e3df58aef0ffe071c78a30bd6df Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/be14e7fcd9db06988506acfb8b52cdf3804712d3 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/bd100143b4df2f4d401a92eed9d1daa38dc4f14a Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/b3649a2987d9a438af63b49f7debf11c671077af Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/b0052b6d69e2c381e8a8e1d65fe79dd26e3dd236 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/af8229acb9c949874ec15d6d3c34d7964a0e9656 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/a94dfb23d720cda3283616d2d8d947101cdd15e9 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/a8e602f5f64af9048874285d07e2bab5aa88a031 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/a6f57425137e9aa54537f0b3f5364ce165aedb0a Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/a137d3c99e8e343e95f49b5cdff82ece4af7db7d Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/a122d7e19e22ea5704beb204e1dbb50f4438f440 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/971bb9b4947ac7f077e0475d1203b83a442e1b9b Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/935542905185520a379510bbe0aa9d594099e82a Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/8fc834929b471831f4fd1f43fa9e722715a921e1 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/8d31c2074ad0d76b5937eb5008ea7cc3deae3771 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/8b1f555d3e3e0ce0cb281bb48b9b423e0038a1ef Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/89b527c8f13870b4e4f1fe5c407b0565692c507f Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/897e4d6b4a082016c994c257d4a291123e521c56 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/867a3254ce1d333fae2f5e2e6a131a8375008c10 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/82596d711bb1802e2e4b9aa539e70d93b89a0cd2 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/8076066d40b97022d1044f7c89e3dd5ac05b6084 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/7e4806faa70fa87dd7ce286e0bfa21270f7850db Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/7cc65aa8a116bda64a43f69b6179abca8e245c05 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/7bb3c0eb51f17ab43967467b1e27623d0dd874f3 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/7b49a9e1a870ee9ee24295b145650390b169ea68 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/73407e379a8aca4fe8fe1ab09d660ec79d49cc2f Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/7162cc107f2c5f83bdd81617ec73ddac328ad500 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/6fb9efe5c95de9128bb4e99235db5d0bd5d7af10 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/6dac94b1351edd98973561fb69b2f53f7a68ebc0 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/6c8b58bb4f5e8e536e60dbaa035127ef5644fa22 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/6873e7825d3b55be073097bc327e934855270910 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/64ec4d036e34669ac6386a67288fe9a5f9999550 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/60d9c68282fbca9cdb07a26b0e33470010cbb2d7 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/572ccd879b3312ca5552c2ad1ed3ad20ecc9cf09 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/544beda29ec90e0b27ad24f2c813234c8fd65c1a Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/506c6591f7521b1e2943cf5095a7c866d02f2460 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/4e7c6dc43d91a015025f09371d9b891a3ae4d63e Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/4d1f399307709d26f418d72def11edb5ade4b343 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/4aa23ad905bea04f7057d19fca3ee97d01a875ca Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/491d867ae27270b088be7a912ae4c159948a40ab Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/4206d1a9b995faa079a3542c05e188058a59d222 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/40582d347d6d10c940d065bed10f19e1bc7b7e3f Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/3e5d02891cd711f2dee4f1ef708a15633940aaad Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/37a32288065fc19690f4da7047bc398f2155b620 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/368b0fafab29c6f722ed1b336bee2311af0afda7 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/3296027f2f6683080a4664c2ed358f8a286ef14e Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/31b61ed1c861e0ce9f89b081dbb7b78e026b31c0 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/2dba1d6c7456aafcc8136e62b4734126967e3dc1 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/1ff5bba3cab16637098dedcccb1222c200281cc4 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/1d0e706bf8162f355bd5e63e34c9f8da16412dcd Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/1c48a6d108d427bb31ca960352a868253d22388c Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/18c3155f5011b393f9570bd874298afd96ce9be0 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/18a722e72c42d975589a08dca53c3ee7d5149563 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/17c90bf25583dc87cacc1dd965f4f764f6f9fb69 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/16d36c98a0a4994a3a9eb09bd6660af396a85036 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/110722f71b3fc8b1e813b4ff9faa6ef8ff99a21c Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/0f4814337c5740c1377ebfa6bce72dcd634922d5 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/0c63b7c4feefb3d9fdb19070b520399099818598 Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/0766bf41b7a7ffa28561a61e8f887bc1367660fb Seed corpus input for fuzz_compare_v16.
fuzz/corpus/fuzz_compare_v16/0077f5d44970e20dfff5388ea003809ede53cf5e Seed corpus input for fuzz_compare_v16.
fuzz/Cargo.toml Add deps needed for fuzz targets (heapless+zeroize) and add new fuzz binaries + v0.0.16 comparison dep.
fuzz/Cargo.lock Lock updated to include heapless 0.9 and zeroize plus multiple stellar-strkey versions.
Cargo.toml Bump crate version/MSRV and add zeroize + heapless zeroize feature.
Cargo.lock Lock updated for heapless 0.9 and zeroize crates.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/convert.rs
Comment thread src/convert.rs
Comment thread fuzz/fuzz_targets/fuzz_compare_zeroizing.rs
@claude

claude Bot commented May 8, 2026

Copy link
Copy Markdown

Claude finished @leighmcculloch's task in 3m 59s —— View job


Code review — Zeroize PrivateKey seed and decode buffers

Overall direction is solid: derive Zeroize / ZeroizeOnDrop on PrivateKey, route the encode/decode scratch buffers through Zeroizing<…>, expose a no-residue write_string path, and keep behaviour-parity with the published v0.0.16 via a fuzz cross-check. The # Zeroize docs honestly call out the remaining leak vectors, which is the right move for a no-std crate that can't fully control register/return-value residue.

That said, two of the new pub APIs have a real correctness bug, and one design point on the Display path is worth tightening up.

Blocking — pub fn correctness bugs (already raised by Codex/Copilot, confirmed)

  • src/convert.rs:115 encode_zeroizing push_strs into out without clearing first. Reusing a Zeroizing<String<E>> either concatenates or panics on capacity (a 56-char private-key buffer fits exactly one encoding, so the second call panics on valid input).
  • src/convert.rs:227 decode_zeroizing has the mirror bug — extend_from_slice without clearing. A fallible pub decoder panicking on valid base32 due to prior buffer state is a particularly bad footgun.

In-tree these are dormant (the only callers, PrivateKey::write_string / PrivateKey::from_slice, always pass a fresh buffer), but both functions are pub and write_string's whole reason to exist is to let callers manage their own buffer — exactly the use-case the bug bites. Fix is one line in each:

out.clear(); // right after the const { … } assertions

Existing tests don't exercise reuse (everything starts from Zeroizing::new(String::new()) / Vec::new()), and fuzz_compare_zeroizing doesn't either — please add a regression test for both that calls into the same buffer twice. See the inline comments for details.

Design suggestion — Display for PrivateKey still leaks via to_string

Display::fmt (src/ed25519.rs line 113-115, unchanged in this PR) does write!(f, "{}", self.to_string()). That goes through the inherent to_string, whose returned String<56> is not Zeroizing — it lives on the Display::fmt stack frame, gets formatted into f, and is dropped un-zeroed. So format!("{key}") / println!("{key}") / the blanket ToString impl all still leave a private-key-shaped 56-byte residue, even though the encoder scratch is now clean.

A small change routes Display through write_string:

impl Display for PrivateKey {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        let mut buf: Zeroizing<String<{ Self::ENCODED_LEN }>> = Zeroizing::new(String::new());
        self.write_string(&mut buf);
        f.write_str(&buf)
    }
}

The formatter target is still out of the crate's control (your # Zeroize docs are accurate that Display doesn't fully zeroize), but this at least removes the un-managed String<56> from the encoder side. Up to you whether that's worth the churn vs. the existing doc note.

Smaller items

  • fuzz/fuzz_targets/fuzz_compare_zeroizing.rs:14 — comment typo zeroize_zeroizing (already raised by Copilot). Probably should reference encode_zeroizing / decode_zeroizing explicitly.
  • fuzz_compare_v16 is a nice safety net for this kind of behaviour-only refactor — good addition. Worth keeping past this PR.
  • Removing Copy from PrivateKey is the right call (the breaking-change note in the PR body covers it). I scanned tests/, src/cli/, and src/strkey.rs for usage and didn't see any spots that would have needed *key-style by-value moves; the existing call sites all go through &key / key.0, so the Clone-only impl is enough.
  • PrivateKey::to_string (lines 66-72) does the right thing: scratch goes through a Zeroizing<String>, then a final copy into a plain String<56> for the return value (which is the documented "remaining residue" case). Fine.
  • Hash / Ord derives on PrivateKey haven't changed semantics, but Hash will leak the seed into a hasher's internal state if anyone ever puts a PrivateKey in a HashSet / HashMap. Probably out of scope here, just worth being aware of.

Inline comments

I posted inline comments on the two convert.rs issues and the fuzz-target typo (which include out.clear() suggestions and a test-gap note).

Fix encode_zeroizing / decode_zeroizing reset bug →


• Branch: fix-private-key-zeroization

Comment thread src/convert.rs
Comment thread src/convert.rs
Comment thread fuzz/fuzz_targets/fuzz_compare_zeroizing.rs Outdated
@claude

claude Bot commented May 8, 2026

Copy link
Copy Markdown

Claude finished @leighmcculloch's task in 1m 48s —— View job


Code review — re-check after sync

Since the prior review (commit 624dba4), the only new changes on this branch are:

  • 5a1ba6f add corpus for comparison fuzz — adds 23 corpus files under fuzz/corpus/fuzz_compare_zeroizing/. No new test or source coverage.
  • 8e109de Merge branch 'main' into fix-private-key-zeroization — merge only.

git diff 624dba4..HEAD -- src/ fuzz/fuzz_targets/ is empty: no source-side changes. So all findings from the prior review still stand:

Still blocking

  • src/convert.rs:115-116 encode_zeroizing does out.push_str(...) without first calling out.clear(). A reused Zeroizing<String<56>> either concatenates (silently producing an invalid strkey) or panics on capacity (a 56-char buffer fits exactly one private-key encoding).
  • src/convert.rs:227 decode_zeroizing does out.extend_from_slice(...) without first calling out.clear() — mirror bug, same impact, more dangerous because decode_zeroizing is fallible and a Result-returning pub API panicking on valid base32 input is a particularly nasty footgun.
  • Neither path has a regression test for buffer reuse: test_encode_zeroizing_matches_encode / test_decode_zeroizing_matches_decode always start from a fresh Zeroizing::new(...), and fuzz_compare_zeroizing.rs likewise constructs new buffers each iteration (line 26, 36, 60) — which is why the new corpus didn't surface this either.

Fix is one line at the top of each function (after the const { ... } assertions):

out.clear();

Plus a regression test that calls each _zeroizing twice into the same buffer and asserts the second call produces the single-call result, and a second pass in fuzz_compare_zeroizing that exercises buffer reuse.

Fix the reset bug + add tests →

Still open (design / nit)

  • src/ed25519.rs:112-116 Display for PrivateKey routes through self.to_string(), whose returned String<56> is plain (not Zeroizing) and lives on the fmt stack frame after formatting — so format!("{key}") / println!("{key}") / the blanket ToString still leave a 56-byte private-key-shaped residue. Routing Display through write_string instead would remove the unmanaged copy. Optional — your # Zeroize doc honestly notes Display doesn't fully zeroize, so this is an explicit-tradeoff call.
  • fuzz/fuzz_targets/fuzz_compare_zeroizing.rs:14 — comment typo zeroize_zeroizing (already flagged by Codex/Copilot/me).

What looks good

  • The newly-added fuzz_compare_zeroizing corpus (23 files) is a useful seed. Once the buffer-reuse path is exercised in the target itself, the existing corpus + libfuzzer's mutator will pick up reuse-related drift cheaply.
  • fuzz_compare_v16 continues to be a great safety net for this kind of behaviour-only refactor.
  • All other findings from the prior review remain accurate — to_string (lines 66-72) routes through a Zeroizing<String> and only the deliberate final residue copy is plain; Clone-only PrivateKey is right; no by-value-Copy call sites broke.

Todos

  • Sync local branch and check what changed since prior review (no source changes — corpus + workflow only)
  • Re-check convert::encode_zeroizing / decode_zeroizing — buffer-reset bugs unaddressed
  • Re-check fuzz target typo — still present
  • Re-check Display for PrivateKey leak path — still goes through plain to_string
  • Look at any newly added corpus / tests for buffer-reuse coverage — none added; corpus seeds don't help because the target itself never reuses buffers
  • Run cargo build / cargo test — skipped; no source changed since prior review, so no new behaviour to verify
  • Post updated review
    • Branch: fix-private-key-zeroization

@claude

claude Bot commented May 8, 2026

Copy link
Copy Markdown

Claude finished @leighmcculloch's task in 5m 17s —— View job


Code review — re-check after f995894

The new commit f995894 refactor zeroization handling for key encoding decode addresses the P1 buffer-reset bugs from the prior review. Verified by reading src/convert.rs and fuzz/fuzz_targets/fuzz_compare_zeroizing.rs at HEAD.

Now resolved

  • encode_zeroizing clears out before writingsrc/convert.rs:101. Comment on lines 98–100 explains why. ✓
  • decode_zeroizing clears out before writingsrc/convert.rs:208. Same shape, plus a nice property: the out.clear() runs before the early Err returns from the data_len < 3 / decode-base32 paths, so a reused buffer is never left holding stale plaintext after a decode failure. The new test_decode_zeroizing_resets_output_on_error test exercises this. ✓
  • Regression tests for buffer reusesrc/convert.rs:336-369 (test_encode_zeroizing_resets_output_on_reuse, test_decode_zeroizing_resets_output_on_reuse) plus the error-path test. Each calls the function twice into the same buffer and asserts the second call equals a single-call result, exactly the gap I flagged. ✓
  • Fuzz comment typofuzz/fuzz_targets/fuzz_compare_zeroizing.rs:14 now reads `encode_zeroizing` / `decode_zeroizing`. ✓

Still open (design suggestion, not blocking)

  • src/ed25519.rs:112-116Display for PrivateKey still routes through self.to_string(), whose returned String<56> is plain (not Zeroizing) and lives on the fmt stack frame after formatting. So format!("{key}") / println!("{key}") / the blanket ToString still leave a 56-byte private-key-shaped residue, even though the encoder scratch is now clean.

    Routing Display through write_string removes the unmanaged copy:

    impl Display for PrivateKey {
        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
            let mut buf: Zeroizing<String<{ Self::ENCODED_LEN }>> = Zeroizing::new(String::new());
            self.write_string(&mut buf);
            f.write_str(&buf)
        }
    }

    Optional — your # Zeroize doc on PrivateKey (src/ed25519.rs:26-31) honestly notes Display doesn't fully zeroize, so this is an explicit-tradeoff call. If you take this route, the doc bullet for Display could be tightened to "the formatter target is outside our control" rather than implying the encoder leaves residue. Apply this →

Nit (low priority)

  • fuzz/fuzz_targets/fuzz_compare_zeroizing.rs:26,36,60 — the fuzz target still constructs fresh buffers each iteration, so it doesn't exercise the buffer-reuse path that the new unit tests cover. Now that out.clear() is in place this matters less, but a second pass that reuses the same Zeroizing<String<56>> / Zeroizing<Vec<u8, 32>> across two encode/decode calls in the same iteration would let libfuzzer's mutator hammer reuse-related drift cheaply. Optional.

Verification

  • I couldn't run cargo test / cargo build in this sandbox (cargo isn't on the action's allowlist), so the new regression tests are not executed here. By inspection they look correct: each does a single-call baseline via encode::<32, 35, 56> / decode::<32, 35>, then a second call into the same Zeroizing<…> buffer, and asserts the result equals the single-call output. CI should confirm.
  • All other findings from the prior review have been addressed.

Net

P1 buffer-reset bugs and the typo are resolved. Only the Display-leak design point remains, and that's a deliberate-tradeoff call documented in the type's own # Zeroize doc. Looks good to me.

Todos

  • Sync local branch and check what's changed since prior reviews
  • Re-check convert::encode_zeroizing / decode_zeroizing buffer-reset bugs — fixed in f995894
  • Re-check Display for PrivateKey leak path — still open (optional)
  • Re-check fuzz target typo — fixed
  • Look for any new test/source coverage that closes the prior gaps — three new tests added, including reset-on-error
  • Run cargo build / cargo test — cargo not in this action's allowlist; CI will verify
  • Post updated review
    • Branch: fix-private-key-zeroization

@claude

claude Bot commented May 8, 2026

Copy link
Copy Markdown

Claude finished @leighmcculloch's task in 4m 43s —— View job


Code review — re-check after b0cf80f

The new commit b0cf80f update display to use zeroizing addresses the only remaining open item from the prior review. I verified by reading src/ed25519.rs, src/convert.rs, and fuzz/fuzz_targets/fuzz_compare_zeroizing.rs at HEAD; full git diff f995894..HEAD is the 4-line change to Display and nothing else.

Now resolved

  • Display for PrivateKey no longer goes through plain to_string()src/ed25519.rs:112-118:

    impl Display for PrivateKey {
        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
            let mut buf: Zeroizing<String<{ Self::ENCODED_LEN }>> = Zeroizing::new(String::new());
            self.write_string(&mut buf);
            f.write_str(&buf)
        }
    }

    This is the form I suggested. The encoder-side residue is now contained: buf is Zeroizing<String<56>> so its 56 bytes are wiped on drop at the end of fmt, and the inner encode_zeroizing scratch is already Zeroizing<…>. Only the formatter target (f's underlying buffer — String for format!, the writer for write!, etc.) remains outside the crate's control, which is the inherent limit of Display and is what the type's # Zeroize doc covers. Drop-on-unwind is preserved if write_string or f.write_str ever panic. ✓

  • No regression elsewhere. out.clear() is still in place in encode_zeroizing (src/convert.rs:101) and decode_zeroizing (src/convert.rs:208); the three reuse / reset-on-error tests (src/convert.rs:336-386) are still there; the fuzz comment typo is still fixed.

Smaller items

  • src/ed25519.rs:26-31 — the # Zeroize bullet still lists Display alongside to_string and Debug under "do not zero the private key value." That's not wrong (the bytes still hit the un-managed formatter target), but Display and to_string now have meaningfully different residue stories: Display's only remaining leak is the formatter target itself, while to_string additionally returns a plain String<56>. Optional wording tighten: split them, e.g.

    Display and Debug write into a caller-provided Formatter whose backing buffer is outside this crate's control; the encoder-side scratch is zeroed.
    to_string additionally returns a plain String<{ENCODED_LEN}> (use write_string for the no-residue path).

    Skip if the current bullet's brevity is preferred. Apply this →

  • fuzz/fuzz_targets/fuzz_compare_zeroizing.rs:26,36,60 — the fuzz target still constructs fresh buffers each iteration. Now that out.clear() is in place this is much less critical, but a second pass exercising buffer reuse would let libfuzzer's mutator hammer reuse-related drift cheaply. Still optional, called out for completeness.

  • Hash / Ord derives on PrivateKey haven't changed semantics — out of scope here, but Hash will leak the seed into a hasher's internal state if anyone puts a PrivateKey in a HashMap / HashSet. Worth being aware of; not blocking.

Verification

  • Cargo isn't available in this action sandbox, so I couldn't run cargo build / cargo test — verification is by inspection only. The new 4-line Display impl is straightforward and the existing test_private_key_write_string_matches_to_string already covers the write_string path it now delegates to. CI should confirm.
  • Re-read all three regression tests in src/convert.rs and the Display/write_string/to_string paths in src/ed25519.rs. No issues spotted.

Net

LGTM. P1 buffer-reset bugs are fixed, the only design suggestion from the prior review is now applied, and the remaining items are minor wording / fuzz-coverage polish that don't need to block the merge.

Todos

  • Sync local branch and check what's changed since prior review (f995894 → b0cf80f)
  • Re-check Display for PrivateKey — applied as suggested, drop-on-unwind preserved
  • Verify the # Zeroize doc bullet is still consistent with the new Display impl — still accurate, optional wording tighten available
  • Look for any new tests / coverage and confirm previously-fixed items still hold — no regressions
  • Run cargo build / cargo test — cargo not in this action's allowlist; CI will verify
  • Post updated review
    • Branch: fix-private-key-zeroization

@leighmcculloch
leighmcculloch enabled auto-merge (squash) May 8, 2026 04:54
@claude

claude Bot commented May 8, 2026

Copy link
Copy Markdown

Claude finished @leighmcculloch's task in 1m 42s —— View job


Code review — re-check after a8d06e1 (merge from main)

git diff b0cf80f..HEAD -- src/ fuzz/ shows the only changes since the prior review are doc-only additions in src/lib.rs, src/strkey.rs, src/decoded_json_format.rs, and two missing top-level doc comments on PublicKey / MuxedAccount in src/ed25519.rs — all from the merged cf1337b "remove zero-allocation path (#110)". No changes to convert.rs, the PrivateKey/Display/write_string paths, or the fuzz target.

So all findings from the prior review still hold. Quick re-confirmation by reading the files at HEAD:

Verified at HEAD

Still open (optional polish)

  • src/ed25519.rs:26-31 — the # Zeroize bullet list still groups Debug, Display, and to_string together under "do not zero the private key value." After the b0cf80f change, Display's only remaining residue is the formatter target (outside the crate's control), while to_string additionally returns a plain String<56>. Splitting the bullet would be more accurate; the current wording isn't wrong, just a bit conservative. Not blocking.
  • fuzz/fuzz_targets/fuzz_compare_zeroizing.rs:26,36,60 — fuzz target still constructs fresh buffers each iteration, so the buffer-reuse path is only covered by the unit tests. Now that out.clear() is in place this matters a lot less, but it's a cheap one-line addition if you want libfuzzer to also explore reuse-related drift. Optional.

Out of scope but worth being aware of

  • Hash / Ord derives on PrivateKey are unchanged. Hash will leak the seed into a hasher's internal state if anyone puts a PrivateKey in a HashMap / HashSet — out of scope for this PR.

Verification

Cargo isn't on this action's allowlist, so cargo build / cargo test / fuzz runs are not executed here. The full CI matrix in this repo will cover that. Source review at HEAD: no regressions vs. the prior LGTM commit.

Net

LGTM. P1 buffer-reset bugs and the typo are resolved. Only the Display doc-bullet wording and an optional fuzz-coverage tweak remain, both opt-in polish.

Todos

  • Sync local branch and check what's new since prior reviews — a8d06e1 is doc-only (cf1337b merge from main); no source/fuzz changes
  • Re-verify convert::encode_zeroizing / decode_zeroizingout.clear() in place; reuse + reset-on-error tests present
  • Re-verify Display for PrivateKey zeroizing path — routes through write_string with a local Zeroizing<String<56>>
  • Re-check # Zeroize doc on PrivateKey — still groups Display with to_string/Debug; minor optional polish
  • Re-check fuzz target / corpus — typo fixed; reuse-path coverage still optional
  • Run cargo build / cargo test — cargo not in this action's allowlist; CI will verify
  • Post review

• Branch: fix-private-key-zeroization

@leighmcculloch
leighmcculloch disabled auto-merge May 8, 2026 04:55
@leighmcculloch leighmcculloch changed the title Zeroize PrivateKey seed and decode buffers Zeroize PrivateKey May 8, 2026
@leighmcculloch
leighmcculloch enabled auto-merge (squash) May 8, 2026 04:55
@leighmcculloch
leighmcculloch merged commit e06b997 into main May 8, 2026
13 checks passed
@leighmcculloch
leighmcculloch deleted the fix-private-key-zeroization branch May 8, 2026 04:55
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