Skip to content

fix(platform-wallet): zeroize private keys when freeing preview rows#3797

Merged
QuantumExplorer merged 3 commits into
v3.1-devfrom
claude/fix-ffi-zeroize-identity-key-preview
Jun 5, 2026
Merged

fix(platform-wallet): zeroize private keys when freeing preview rows#3797
QuantumExplorer merged 3 commits into
v3.1-devfrom
claude/fix-ffi-zeroize-identity-key-preview

Conversation

@QuantumExplorer
Copy link
Copy Markdown
Member

@QuantumExplorer QuantumExplorer commented Jun 4, 2026

Issue being fixed or feature implemented

Private-key material left un-zeroized in freed heap memory (mobile wallet).

release_row in identity_key_preview.rs freed private_key_wif (a *mut c_char holding the WIF private key in clear text) and dropped the row's inline private_key_bytes: [u8; 32] (the raw ECDSA scalar) without zeroizing either, returning freed pages that still contain real private keys to the allocator. This runs on every platform_wallet_preview_identity_registration_keys_free round-trip (the "scan/preview identity registration keys" UI path). The mid-loop error cleanup closure in identity_keys_from_mnemonic.rs had the same gap.

The structurally identical sibling dash_sdk_derive_identity_keys_from_mnemonic_free already zeroizes both correctly — this aligns the lagging paths with it.

What was done?

  • release_row now takes &mut IdentityKeyPreviewFFI, scrubs the WIF backing bytes (CString::into_bytes_with_nul() + zeroize::Zeroize::zeroize), zeroizes private_key_bytes in place, and nulls every owned pointer after reclaiming it (preserving double-free idempotency). Call sites (platform_wallet_preview_identity_registration_keys_free, free_rows) updated to iter_mut/&mut.
  • A shared zeroize_and_free_row(&mut IdentityKeyPreviewFFI) helper is factored out in identity_keys_from_mnemonic.rs and used by both the mid-loop cleanup closure and the public dash_sdk_derive_identity_keys_from_mnemonic_free, so the two paths can't drift.
  • Public extern "C" ABI is unchanged — only internal helpers changed.

How Has This Been Tested?

cargo test -p platform-wallet-ffi identity_key_preview3 passed, 0 failed:

  • release_row_zeroizes_secret_and_is_idempotent: asserts the inline scalar is wiped to all-zero, every owned pointer is nulled, and a second release_row is a safe no-op.
  • public_free_zeroizes_and_resets: drives rows through the real _free entry point; asserts the outer struct is reset and a second free no-ops.
  • free_rows_zeroizes_partial_list: mid-loop cleanup path releases a partial list without panicking/double-freeing.

Crate builds clean; cargo fmt --all applied.

Breaking Changes

None. Internal helper signatures only; the extern "C" FFI surface is unchanged.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Summary by CodeRabbit

  • Bug Fixes

    • Improved cleanup of sensitive cryptographic material to ensure secrets are securely erased and released, preventing leaks and making release operations safe to repeat.
    • Ensured consistent, safe cleanup path for partly-constructed key data to avoid panics on error paths.
  • Tests

    • Added unit tests verifying secure zeroization, pointer/reset idempotency, and robust free behavior.

`release_row` in identity_key_preview.rs freed `private_key_wif` (a
human-readable WIF private key) and dropped the inline
`private_key_bytes: [u8; 32]` ECDSA scalar without zeroizing either,
leaving real private keys in freed heap pages on every
`platform_wallet_preview_identity_registration_keys[_free]` call. The
mid-loop error `cleanup` closure in identity_keys_from_mnemonic.rs had
the same gap.

The sibling `dash_sdk_derive_identity_keys_from_mnemonic_free` already
zeroizes correctly; this aligns both paths:

- `release_row` now takes `&mut`, scrubs the WIF backing bytes
  (`into_bytes_with_nul()` + `Zeroize::zeroize`), zeroizes
  `private_key_bytes` in place, and nulls every owned pointer
  (preserving double-free idempotency).
- A shared `zeroize_and_free_row` helper is factored out and used by
  both the mid-loop cleanup closure and the public `_free`, so the
  paths can't drift.

Public `extern "C"` ABI is unchanged (only internal helpers changed).
Adds tests asserting the scalar is zeroized, pointers are nulled, and a
second free is a safe no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 4, 2026

Review Change Stack

Warning

Review limit reached

@QuantumExplorer, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 14 minutes and 26 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb10ed56-7d19-4f41-87d0-d6dc1efdb810

📥 Commits

Reviewing files that changed from the base of the PR and between 359bbad and cbaddeb.

📒 Files selected for processing (1)
  • packages/rs-platform-wallet-ffi/src/identity_key_preview.rs
📝 Walkthrough

Walkthrough

Centralizes zeroization/free of FFI identity key preview rows via a new idempotent helper and switches all modules (mnemonic derivation, preview, derive-at-slot, registration-with-signer) to call it; adds tests verifying zeroization, pointer nulling, and idempotency.

Changes

Sensitive Key Material Cleanup

Layer / File(s) Summary
Shared mnemonic cleanup helper and integration
packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs
New unsafe zeroize_and_free_row helper zeroizes inline private_key_bytes, frees and nulls derivation_path, public_key, and private_key_wif. Integrated into the in-derivation cleanup closure and the public free entry point.
Preview row cleanup refactor and idempotent release
packages/rs-platform-wallet-ffi/src/identity_key_preview.rs
Switches preview free to iterate a mutable boxed slice, adds release_row(&mut IdentityKeyPreviewFFI) that frees heap-backed fields, nulls pointers, zeroizes inline 32-byte secret, and is safe to call repeatedly; adds unit tests for these behaviors.
derive-at-slot: delegate free to shared helper
packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs
Import zeroize_and_free_row and route dash_sdk_derive_identity_key_at_slot_free to call it; remove prior inline cleanup and add tests asserting zeroization, nulling, idempotency, and null-pointer safety.
Identity registration with signer: use shared helper and tests
packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs
Replace inline mid-loop and public free cleanup with zeroize_and_free_row, update imports, remove deleted derivation block, and add tests verifying zeroize/null/idempotency for owned rows and exported free function.

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

ready for final review

Suggested reviewers

  • thepastaclaw
  • shumkov

Poem

🐰 I hopped through heaps with careful paws,
Scrubbed each secret without a pause,
Pointers nulled and bytes made clean,
No secret trace remains unseen,
Hooray — the memory's safe, pristine!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(platform-wallet): zeroize private keys when freeing preview rows' directly and clearly summarizes the main objective of the pull request—ensuring private keys are zeroized during cleanup of preview rows.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fix-ffi-zeroize-identity-key-preview

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@thepastaclaw
Copy link
Copy Markdown
Collaborator

thepastaclaw commented Jun 4, 2026

✅ Review complete (commit cbaddeb)

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/rs-platform-wallet-ffi/src/identity_key_preview.rs (1)

297-340: ⚡ Quick win

Consider reusing zeroize_and_free_row from the sibling module.

release_row duplicates the logic of crate::identity_keys_from_mnemonic::zeroize_and_free_row, which is already pub(crate). Consolidating to a single implementation eliminates the drift risk noted in the doc comment.

♻️ Suggested refactor
+use crate::identity_keys_from_mnemonic::zeroize_and_free_row;
+
 /// Release an [`IdentityKeyPreviewsFFI`] ...
 #[no_mangle]
 pub unsafe extern "C" fn platform_wallet_preview_identity_registration_keys_free(
     previews: *mut IdentityKeyPreviewsFFI,
 ) {
     // ... existing null checks ...
     unsafe {
         let slice = std::slice::from_raw_parts_mut(previews.items, previews.count);
         let mut boxed: Box<[IdentityKeyPreviewFFI]> = Box::from_raw(slice);
         for row in boxed.iter_mut() {
-            release_row(row);
+            zeroize_and_free_row(row);
         }
         drop(boxed);
     }
     // ...
 }

-/// Reclaim the heap allocations owned by a single preview row ...
-unsafe fn release_row(row: &mut IdentityKeyPreviewFFI) {
-    // ... entire function body ...
-}

 /// Release every row in a partially-built preview list ...
 fn free_rows(mut rows: Vec<IdentityKeyPreviewFFI>) {
     for row in &mut rows {
-        unsafe { release_row(row) };
+        unsafe { zeroize_and_free_row(row) };
     }
     drop(rows);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-ffi/src/identity_key_preview.rs` around lines 297
- 340, The function release_row duplicates the zeroization/free logic already
implemented as pub(crate) zeroize_and_free_row in
crate::identity_keys_from_mnemonic; replace the body of unsafe fn release_row
(and any duplicate drop/zeroize steps) with a call to
crate::identity_keys_from_mnemonic::zeroize_and_free_row(row) so the single
canonical implementation is used, ensure the module imports or fully-qualify
that symbol and that the signature of release_row matches the expected argument
type (or adapt by forwarding/converting to the existing function) and remove the
duplicated cleanup code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/rs-platform-wallet-ffi/src/identity_key_preview.rs`:
- Around line 297-340: The function release_row duplicates the zeroization/free
logic already implemented as pub(crate) zeroize_and_free_row in
crate::identity_keys_from_mnemonic; replace the body of unsafe fn release_row
(and any duplicate drop/zeroize steps) with a call to
crate::identity_keys_from_mnemonic::zeroize_and_free_row(row) so the single
canonical implementation is used, ensure the module imports or fully-qualify
that symbol and that the signature of release_row matches the expected argument
type (or adapt by forwarding/converting to the existing function) and remove the
duplicated cleanup code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f11d8aa6-30e8-4c77-90f2-ac27c775993a

📥 Commits

Reviewing files that changed from the base of the PR and between 525674a and ef9d5d0.

📒 Files selected for processing (2)
  • packages/rs-platform-wallet-ffi/src/identity_key_preview.rs
  • packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Defensive-security fix that correctly closes a residual-secret leak across two FFI cleanup paths: release_row in identity_key_preview.rs and the shared zeroize_and_free_row helper in identity_keys_from_mnemonic.rs both now scrub the WIF backing bytes and the inline 32-byte ECDSA scalar before allocations are returned to the allocator, and pointer-nulling preserves double-free idempotency. The one in-scope concern is that the PR's stated 'no-drift' goal is only partially realized — two near-identical helpers for the same #[repr(C)] row type now coexist (one per file) instead of a single shared implementation.

🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-ffi/src/identity_key_preview.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/identity_key_preview.rs:312-340: release_row duplicates zeroize_and_free_row for the same struct — the drift surface the PR claims to remove still exists
  The PR description states a shared `zeroize_and_free_row(&mut IdentityKeyPreviewFFI)` helper was factored out so the cleanup and public free paths 'can't drift'. That guarantee only holds inside `identity_keys_from_mnemonic.rs`. In `identity_key_preview.rs`, `release_row` is a second hand-written copy of the same operation against the same `IdentityKeyPreviewFFI` struct — same fields reclaimed, same zeroization performed, with a slightly different field-handling order (path → wif → public_key → scalar vs. path → public_key → wif → scalar). The doc comment on `release_row` even claims it 'mirrors' the other implementation. Because both helpers operate on identical struct layout and ownership semantics, a future change to one (e.g., adding a new sensitive `*mut c_char` field, or moving `private_key_bytes` behind a `Zeroizing` wrapper) can silently leave the other path leaking key material across the FFI boundary — the exact class of bug this PR is fixing today. Resolve by having `release_row` delegate to the already `pub(crate)` `zeroize_and_free_row`, or move a single canonical impl into a shared submodule. The `TODO` at identity_key_preview.rs:230 about implementing `Drop` for the row hints at the cleanest endpoint: a `Drop` impl on `IdentityKeyPreviewFFI` itself (combined with the existing `mem::forget` discipline at the FFI cliff) would make exactly-one zeroization site by construction.

Comment thread packages/rs-platform-wallet-ffi/src/identity_key_preview.rs
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 4, 2026

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "413f7c85dddc1ab4dcac783c4b8c3b6c5ab60dbfe22418a6edee7a7e99cc8d33"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

Adversarial review found the original fix was incomplete: two more
producers of IdentityKeyPreviewFFI (which carries a clear-text WIF
private key and the raw 32-byte ECDSA scalar) released that material
without proper zeroization.

- identity_registration_with_signer.rs: the mid-loop `cleanup` closure
  (error path when key_count>1 and derivation fails at index N>0) freed
  the WIF in clear text and never scrubbed private_key_bytes, leaving
  rows 0..N-1's secrets in freed heap. Now routes through the shared
  zeroize_and_free_row helper, as does its public _free (so success and
  error paths can't drift).
- derive_identity_key_at_slot.rs: its _free scrubbed with non-volatile
  std::ptr::write_bytes + a manual `*byte = 0` loop, both of which the
  optimizer may elide. Now uses zeroize_and_free_row (volatile zeroize).

Verified the remaining producer (identity_derive_and_persist.rs) builds
redacted rows (null WIF, zero scalar; the real scalar is zeroized
locally), so it has nothing to scrub. Public extern "C" ABI unchanged.
Adds tests for both fixed paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 5, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.19%. Comparing base (9eca622) to head (cbaddeb).
⚠️ Report is 6 commits behind head on v3.1-dev.

Additional details and impacted files
@@            Coverage Diff             @@
##           v3.1-dev    #3797    +/-   ##
==========================================
  Coverage     87.18%   87.19%            
==========================================
  Files          2624     2624            
  Lines        321014   321186   +172     
==========================================
+ Hits         279892   280046   +154     
- Misses        41122    41140    +18     
Components Coverage Δ
dpp 87.73% <ø> (ø)
drive 86.05% <ø> (+<0.01%) ⬆️
drive-abci 89.54% <ø> (+<0.01%) ⬆️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.17% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 47.85% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…_and_free_row

Review follow-up: `release_row` in identity_key_preview.rs was a second
hand-written copy of the same zeroize-and-free operation on the same
`IdentityKeyPreviewFFI` struct as the shared `zeroize_and_free_row`
helper (only the field-handling order differed). That left the exact
drift surface this PR set out to remove: a future change to one path
(a new sensitive `*mut c_char` field, wrapping `private_key_bytes` in
`Zeroizing`, etc.) could silently leave the other leaking key material.

`release_row` now delegates to the single canonical `zeroize_and_free_row`,
so all four free paths (this file's public free + build-loop cleanup, plus
identity_keys_from_mnemonic, identity_registration_with_signer, and
derive_identity_key_at_slot) share exactly one implementation. The
existing `release_row_zeroizes_secret_and_is_idempotent` test still
passes, now exercising the delegation. (The `Drop`-impl endpoint noted in
the build-loop TODO remains the eventual single-site-by-construction goal.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Carried-forward prior finding (f0bf1095f995, release_row duplication) is fixed at cbaddeb by making release_row a thin delegation to the canonical zeroize_and_free_row helper. All in-scope IdentityKeyPreviewFFI free paths now route through one implementation. No new in-scope defects in the latest delta; the resolver-length validation gap flagged by codex-ffi-engineer at derive_identity_key_at_slot.rs:183 is pre-existing dispatch code untouched by this zeroize-on-free PR and is recorded only as an out-of-scope follow-up.

Copy link
Copy Markdown
Member Author

@QuantumExplorer QuantumExplorer left a comment

Choose a reason for hiding this comment

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

Reviewed

@QuantumExplorer QuantumExplorer merged commit 04633b6 into v3.1-dev Jun 5, 2026
25 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/fix-ffi-zeroize-identity-key-preview branch June 5, 2026 12:25
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.

2 participants