Skip to content

fix(crypto): harden SharedKey against key material leakage#2963

Merged
chefsale merged 7 commits into
masterfrom
claude/sharedkey-crypto-security-qmhz25
Jun 29, 2026
Merged

fix(crypto): harden SharedKey against key material leakage#2963
chefsale merged 7 commits into
masterfrom
claude/sharedkey-crypto-security-qmhz25

Conversation

@chefsale

Copy link
Copy Markdown
Member

Description

This PR improves the security of the SharedKey struct by:

  1. Adding zeroize on drop: Implements a custom Drop trait that zeroizes the underlying SecretKey when SharedKey is dropped, ensuring sensitive key material is cleared from memory.

  2. Redacting Debug output: Replaces the derived Debug implementation with a custom one that redacts the key material, preventing accidental exposure of sensitive data in logs or error messages.

  3. Removing Copy trait: Removes the Copy derive to prevent unintended copies of the key in memory, making the ownership semantics more explicit.

These changes follow cryptographic best practices for handling sensitive key material in Rust.

Test plan

Existing tests pass. The changes are internal security improvements:

  • The Drop implementation is automatically tested by Rust's memory safety guarantees
  • The custom Debug implementation can be verified by printing a SharedKey instance (output will show [redacted] instead of key material)
  • Functionality remains unchanged; all existing crypto operations continue to work as before

Wire contract (SDK gate)

N/A — No HTTP wire DTOs or routes changed.

Documentation update

N/A — Internal security improvement with no public API changes.

https://claude.ai/code/session_018Zu9qL9KjCSzghNCKa4Q4h

- Remove Copy to prevent silent key duplication across the stack
- Implement Debug with redacted output instead of deriving it, so log
  statements never print raw key bytes
- Implement Drop calling zeroize() to clear key bytes from memory when
  the value is no longer needed
- Add zeroize workspace dependency to calimero-crypto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Zu9qL9KjCSzghNCKa4Q4h

@meroreviewer meroreviewer Bot 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.

🤖 MeroReviewer

Reviewed by 1 agents | Quality score: 28% | Review time: 35.4s

🟡 1 warnings. See inline comments.


🤖 Generated by MeroReviewer | Review ID: review-aee53720

Comment thread crates/crypto/src/lib.rs Outdated
}

impl Drop for SharedKey {
fn drop(&mut self) {

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.

🟡 Double-zeroize on Clone: cloned keys are not zeroized on drop

The Clone impl for SharedKey (derived) bitwise-copies the SecretKey bytes into a new heap allocation. The new clone gets its own Drop impl and will be zeroized when it is dropped, so this is not a memory-safety hole per se. However, the original intent of removing Copy was to prevent unintended key duplication in memory. The Clone derive still allows callers to trivially duplicate the key material with key.clone(), which may linger in memory longer than intended. Consider whether Clone is actually required by any caller; if not, removing it would strengthen the security posture. If it is required, document why cloning is acceptable.

Suggested fix:

If `Clone` is not needed by any caller, remove the `#[derive(Clone)]` from `SharedKey`. If it is needed, add a `// SAFETY:` comment explaining why cloning is acceptable and that the clone will be zeroized on its own drop.

@meroreviewer

meroreviewer Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Documentation Review

The following documentation may need updates based on the changes in this PR:

  • 🟡 AGENTS.md: AGENTS.md exists but was not updated — consider updating it to reflect the architecture changes in this PR.
  • 🟡 CONTRIBUTING.md: CONTRIBUTING.md exists but was not updated — consider updating it to reflect the architecture changes in this PR.
  • 🟡 architecture/crates/node.html: Files matching crates/node/** were changed but architecture/crates/node.html was not updated (per source_to_docs_mapping).
  • 🟡 architecture/crates/sync.html: Files matching crates/node/** were changed but architecture/crates/sync.html was not updated (per source_to_docs_mapping).
  • 🟡 docs/: Static HTML docs in docs/ may need updating — architecture-impacting changes detected. On merge, update-docs will scan this directory and open a PR if any pages need to change.

@meroreviewer meroreviewer Bot 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.

🤖 AI Code Reviewer

Reviewed by 1 agents | Quality score: 28% | Review time: 580.8s

🟡 1 warnings, 💡 1 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-2eec6756

Comment thread crates/crypto/src/lib.rs Outdated
}
}

impl Drop for SharedKey {

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.

🟡 Double-zeroize: manual Drop conflicts with ed25519-dalek's own Zeroize

ed25519-dalek's SecretKey (a [u8; 32] newtype) already derives Zeroize and implements ZeroizeOnDrop in recent versions. Adding a manual Drop that calls .zeroize() is harmless but redundant, and more importantly it means the key bytes are zeroized twice — once by this Drop impl and once by SecretKey's own Drop. While not a correctness bug today, it creates a maintenance hazard: if the inner type's zeroize semantics change (e.g., it stops implementing ZeroizeOnDrop), this manual impl may silently stop being the safety net it appears to be. The safer pattern is to wrap the key in zeroize::Zeroizing<[u8; 32]> (or use SecretBox) so the zeroize-on-drop guarantee is explicit and owned by this type, rather than relying on a transitive impl that could change.

Suggested fix:

Replace `key: SecretKey` with `key: zeroize::Zeroizing<[u8; 32]>` (or `zeroize::SecretBox<[u8; 32]>`). This makes the zeroize-on-drop guarantee self-contained and removes the need for the manual `Drop` impl. Adjust `new` and `from_sk` to store the raw bytes wrapped in `Zeroizing::new(...)`, and update `encrypt`/`decrypt` to dereference with `&*self.key`.

Comment thread crates/crypto/src/lib.rs Outdated

impl std::fmt::Debug for SharedKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SharedKey").field("key", &"[redacted]").finish()

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.

💡 Custom Debug impl leaks struct field name

The custom Debug impl uses f.debug_struct("SharedKey").field("key", &"[redacted]") which still reveals the field name key. While this is a minor point, the idiomatic approach for fully opaque sensitive types is to emit a single fixed string, e.g. write!(f, "SharedKey([redacted])"), which avoids any structural information leakage and is simpler.

Suggested fix:

Replace the body with: `write!(f, "SharedKey([redacted])")`

@chefsale chefsale changed the title Secure SharedKey with zeroize and redacted Debug output fix(crypto): harden SharedKey against key material leakage Jun 26, 2026
- Replace SecretKey field with Zeroizing<[u8; 32]> making zeroize-on-drop
  self-contained and removing the manual Drop impl
- Simplify Debug to write!(f, "SharedKey([redacted])") to avoid leaking
  field names
- Document why Clone is intentionally kept (EncryptionState derives Clone
  and encryption() returns by value)
- Fix EncryptionState::get() to use .clone() now that SharedKey is not Copy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Zu9qL9KjCSzghNCKa4Q4h
@cursor

cursor Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@github-actions

Copy link
Copy Markdown

E2E Rust Apps Failed

One or more E2E workflows (scaffolding-e2e, xcall-example) failed.

Please check the workflow logs for more details.

@meroreviewer meroreviewer Bot 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.

🤖 AI Code Reviewer

Reviewed by 2 agents | Quality score: 43% | Review time: 136.4s

🟡 1 warnings, 💡 1 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-4bdb6578

#[must_use]
pub fn get(&self) -> Option<(SharedKey, Nonce)> {
self.key_nonce
self.key_nonce.clone()

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.

🟡 EncryptionState::get clones the SharedKey on every call

The get method returns Option<(SharedKey, Nonce)> by value, which requires cloning the SharedKey (and its 32-byte secret) on every invocation. The callers in encrypt and decrypt (lines 130–145) only need a reference to the key and nonce — they never take ownership. This means every encrypt/decrypt operation produces an unnecessary clone of the key material in memory, which is both a performance concern and a mild security concern (more copies of the secret in memory at any given time).

Suggested fix:

Change `encrypt` and `decrypt` to borrow from `self.key_nonce` directly (e.g., `if let Some((key, nonce)) = &self.key_nonce { ... }`) instead of going through `get()`. The public `get` method can remain for external callers that genuinely need ownership, but internal helpers should avoid the clone.

Comment thread crates/crypto/src/lib.rs
key: SecretKey,
key: Zeroizing<[u8; 32]>,
}

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.

💡 Consider implementing Zeroize on SharedKey directly

The PR wraps the key in Zeroizing<[u8; 32]>, which correctly zeroizes on drop. However, SharedKey itself does not implement Zeroize or ZeroizeOnDrop. If a caller ever stores SharedKey in a Zeroizing<SharedKey> or a struct that derives ZeroizeOnDrop, the inner bytes won't be zeroized a second time (which is fine), but the type won't satisfy a Zeroize bound if one is ever needed. Deriving or implementing ZeroizeOnDrop on SharedKey directly (in addition to relying on Zeroizing<_>) would make the security contract explicit and composable with the rest of the zeroize ecosystem.

Suggested fix:

Add `#[derive(ZeroizeOnDrop)]` (from `zeroize`) to `SharedKey`, or implement `Zeroize for SharedKey` by delegating to `self.key.zeroize()`. This makes the guarantee self-documenting and allows `SharedKey` to be used inside other `Zeroizing<_>` wrappers correctly.

- Implement Zeroize for SharedKey delegating to the inner Zeroizing<[u8;32]>,
  making SharedKey composable with the zeroize ecosystem
- Fix blobs.rs loop sites that moved shared_key by value; clone explicitly
  since the send/recv API takes ownership and the key is reused each iteration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Zu9qL9KjCSzghNCKa4Q4h

@meroreviewer meroreviewer Bot 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.

🤖 AI Code Reviewer

Reviewed by 2 agents | Quality score: 47% | Review time: 78.0s

💡 2 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-92082817

Comment thread crates/crypto/src/lib.rs
#[derive(Copy, Clone, Debug)]
// Clone is intentional: callers store SharedKey in EncryptionState (which
// derives Clone) and return it by value from trait methods. Each clone owns
// its bytes and is zeroized independently on drop via Zeroizing<_>.

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.

💡 Redundant Zeroize impl — Zeroizing<T> already zeroizes on drop

The manual impl Zeroize for SharedKey delegates to self.key.zeroize(), which is exactly what Zeroizing<[u8;32]> already does on drop. The impl is not wrong, but it is dead weight: nothing in the changed code calls SharedKey::zeroize() directly, and the ZeroizeOnDrop guarantee comes from Zeroizing, not from this impl. If the intent is to allow callers to eagerly zeroize, the impl is fine but should be documented; otherwise it can be removed to avoid confusion.

Suggested fix:

Either remove the `impl Zeroize for SharedKey` block (the `Zeroizing` wrapper already guarantees zeroization on drop), or add a doc comment explaining the use-case for eager zeroization so future readers understand why it exists.

Comment thread crates/crypto/src/lib.rs
#[derive(Copy, Clone, Debug)]
// Clone is intentional: callers store SharedKey in EncryptionState (which
// derives Clone) and return it by value from trait methods. Each clone owns
// its bytes and is zeroized independently on drop via Zeroizing<_>.

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.

💡 Redundant Zeroize impl — Zeroizing<T> already zeroizes on drop

The manual impl Zeroize for SharedKey delegates to self.key.zeroize(), which is exactly what Zeroizing<[u8;32]> already does automatically on drop. The extra impl is harmless but adds dead surface area. The PR description says 'implements a custom Drop trait that zeroizes', but there is no explicit Drop impl — the zeroization happens through Zeroizing's own Drop. The manual Zeroize impl is only needed if callers explicitly call .zeroize() on a SharedKey; if that is not a use-case, the impl can be removed to reduce confusion.

Suggested fix:

Remove the `impl Zeroize for SharedKey` block unless callers need to explicitly call `.zeroize()` on a `SharedKey` value. The `Zeroizing<_>` wrapper already guarantees zeroization on drop.

The impl is intentional — it satisfies the Zeroize bound for callers
that need to eagerly wipe the key before drop, and makes SharedKey
composable with ZeroizeOnDrop-deriving structs. Add a comment to make
this explicit rather than appearing as dead code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Zu9qL9KjCSzghNCKa4Q4h

@meroreviewer meroreviewer Bot 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.

🤖 AI Code Reviewer

Reviewed by 2 agents | Quality score: 40% | Review time: 274.8s

🟡 1 warnings, 💡 1 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-e48b8178

Comment thread crates/crypto/src/lib.rs
pub fn from_sk(sk: &PrivateKey) -> Self {
Self { key: **sk }
Self {
key: Zeroizing::new(**sk),

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.

🟡 Intermediate scalar multiplication result not zeroized

In SharedKey::new, the expression (SigningKey::from_bytes(sk).to_scalar() * decompressed).compress().to_bytes() produces intermediate values (the Scalar, the EdwardsPoint result, and the compressed point) on the stack/heap that are not zeroized. Only the final [u8; 32] is wrapped in Zeroizing. The SigningKey is also a temporary that may leave key material in memory. While ed25519_dalek::SigningKey itself implements Zeroize on drop, the intermediate Scalar and EdwardsPoint values from to_scalar() and the multiplication do not, so the raw scalar bytes may linger in memory after the function returns.

Suggested fix:

Consider using `ed25519_dalek`'s `SigningKey` in a way that keeps it alive long enough to be zeroized, and check whether `curve25519_dalek`'s `Scalar` and `EdwardsPoint` implement `Zeroize` so they can be explicitly zeroized after use. At minimum, bind the `SigningKey` to a named variable so its `Drop` runs deterministically: `let signing_key = SigningKey::from_bytes(sk); let scalar = signing_key.to_scalar(); let result = (scalar * decompressed).compress().to_bytes();` and then zeroize `scalar` if the type supports it.

Comment thread crates/crypto/src/lib.rs
key: Zeroizing<[u8; 32]>,
}

// Explicit Zeroize impl so SharedKey satisfies a `Zeroize` bound and callers

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.

💡 Missing ZeroizeOnDrop bound — eager zeroize impl may not cover all drop paths

The Zeroizing<[u8; 32]> wrapper already zeroizes on drop, so the explicit Zeroize impl is correct for eager use. However, the comment says callers "can eagerly wipe the key" but SharedKey does not implement ZeroizeOnDrop (the marker trait). Without that marker, generic code that requires ZeroizeOnDrop (e.g. secrecy::Secret<T>) cannot use SharedKey directly. If the intent is to expose this as a hardened type, consider adding #[derive(ZeroizeOnDrop)] (or impl ZeroizeOnDrop for SharedKey {}) alongside the manual Zeroize impl so the type participates fully in the zeroize ecosystem.

Suggested fix:

Add `use zeroize::ZeroizeOnDrop;` and `impl ZeroizeOnDrop for SharedKey {}` (or use `#[derive(ZeroizeOnDrop)]` on the struct). This is a zero-cost marker that signals the type clears itself on drop.

- Bind SigningKey and Scalar to named variables in SharedKey::new so
  the Scalar (which implements Zeroize) is wrapped in Zeroizing<_> and
  cleared as soon as the ECDH step completes
- Add explicit Drop impl that calls self.zeroize() and implement the
  ZeroizeOnDrop marker so SharedKey is composable with generic code
  requiring ZeroizeOnDrop (e.g. secrecy::Secret<T>)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Zu9qL9KjCSzghNCKa4Q4h

@meroreviewer meroreviewer Bot 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.

🤖 AI Code Reviewer

Reviewed by 2 agents | Quality score: 40% | Review time: 124.6s

🟡 3 warnings. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-6bc116c9

Comment thread crates/crypto/src/lib.rs
fn zeroize(&mut self) {
self.key.zeroize();
}
}

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.

🟡 Double-zeroize on drop: manual Drop calls Zeroize which Zeroizing already does on its own drop

The manual Drop impl calls self.zeroize(), which calls self.key.zeroize(). But Zeroizing<[u8;32]> already implements ZeroizeOnDrop and will zeroize itself when it is dropped. The manual Drop impl therefore causes the bytes to be zeroed twice: once by the explicit call in drop(), and once again when the Zeroizing field is dropped as part of the struct's normal field-drop sequence. While not a correctness bug, it is redundant and the comment // Explicit Zeroize impl so SharedKey satisfies a Zeroize bound does not justify adding a Drop impl on top of ZeroizeOnDrop. The ZeroizeOnDrop marker trait is specifically designed to signal that the type zeroes on drop without requiring a manual Drop impl. The manual Drop impl also prevents SharedKey from being Copy (which is the stated goal), but the real mechanism for that is the removal of #[derive(Copy)], not the Drop impl. Consider removing the manual Drop impl and relying solely on Zeroizing's built-in ZeroizeOnDrop behaviour, keeping only the impl Zeroize (for eager wiping) and impl ZeroizeOnDrop (as a marker).

Suggested fix:

Remove the `impl Drop for SharedKey` block entirely. The `Zeroizing<[u8;32]>` field already zeroes itself on drop. Keep `impl Zeroize for SharedKey` (for eager wiping) and `impl ZeroizeOnDrop for SharedKey` (as a marker/bound). The bytes will still be zeroed exactly once, on drop of the `Zeroizing` field.

Comment thread crates/crypto/src/lib.rs
self.key.zeroize();
}
}

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.

🟡 Double-zeroize on drop: redundant Drop impl alongside ZeroizeOnDrop

The code manually implements both Drop (calling self.zeroize()) and ZeroizeOnDrop. ZeroizeOnDrop is a marker trait that signals to downstream code that the type zeroizes on drop, but it does not itself generate a Drop impl — so the explicit Drop impl is the actual mechanism and is correct. However, the combination is misleading: ZeroizeOnDrop is typically used with #[derive(ZeroizeOnDrop)] which does generate the Drop impl automatically. Having both a hand-written Drop and impl ZeroizeOnDrop {} is redundant and could confuse future maintainers into thinking the marker alone is sufficient if the Drop is ever removed. More importantly, Zeroizing<[u8; 32]> already implements Drop+Zeroize itself, so the outer Drop on SharedKey calls zeroize() which calls Zeroizing::zeroize(), which is a no-op because Zeroizing's own Drop will also run — the bytes end up being zeroed twice (harmless but wasteful). The real risk is that if a future refactor removes the explicit Drop impl while keeping impl ZeroizeOnDrop {}, the type would no longer actually zeroize (the marker trait alone does nothing without a Drop impl).

Suggested fix:

Either (a) remove the explicit `Drop` impl and `impl Zeroize` and instead use `#[derive(ZeroizeOnDrop)]` on the struct (which generates both), or (b) remove `impl ZeroizeOnDrop for SharedKey {}` and keep only the explicit `Drop`+`Zeroize` impls with a comment explaining why the marker is absent. Option (a) is idiomatic: `#[derive(ZeroizeOnDrop)] pub struct SharedKey { key: Zeroizing<[u8; 32]> }` — `Zeroizing` itself implements `ZeroizeOnDrop` so the derive will transitively handle it.

Comment thread crates/crypto/src/lib.rs
.decompress()
.ok_or(SharedKeyError::InvalidPublicKey)?;

let signing_key = SigningKey::from_bytes(sk);

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.

🟡 Intermediate scalar not zeroized before use in multiplication

At line 63, signing_key.to_scalar() returns a Scalar value which is wrapped in Zeroizing::new(...). However, Scalar from curve25519-dalek does not implement Zeroize in all versions, and Zeroizing<T> only zeroizes if T: Zeroize. If Scalar does not implement Zeroize, the Zeroizing wrapper is silently a no-op (it compiles because the bound is only checked at the zeroize() call site, not at construction). The scalar is the private key material and must be reliably cleared. Additionally, signing_key (line 63) is a local SigningKey that is dropped without explicit zeroization — SigningKey from ed25519-dalek does implement ZeroizeOnDrop, so that part is fine, but the intermediate Scalar deserves verification.

Suggested fix:

Verify that `curve25519_dalek::Scalar` implements `Zeroize` in the version pinned in Cargo.lock (it does in curve25519-dalek 4.x). Add a compile-time assertion or a comment citing the version: `// curve25519-dalek 4.x Scalar implements Zeroize — verified in Cargo.lock`. If uncertain, copy the scalar bytes into a `Zeroizing<[u8; 32]>` via `scalar.to_bytes()` and perform the multiplication from those bytes instead.

@meroreviewer meroreviewer Bot 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.

🤖 AI Code Reviewer

Reviewed by 2 agents | Quality score: 52% | Review time: 300.1s

🟡 1 warnings. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-20f7b21d

Comment thread crates/crypto/src/lib.rs
impl Zeroize for SharedKey {
fn zeroize(&mut self) {
self.key.zeroize();
}

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.

🟡 Double-zeroize on drop: custom Drop calls zeroize() which delegates to Zeroizing<>, but ZeroizeOnDrop marker also triggers Zeroizing<>'s own Drop

The struct implements both a manual Drop (which calls self.zeroize()) and ZeroizeOnDrop. Zeroizing<T> already implements Drop to zeroize its contents. The manual Drop on SharedKey calls self.key.zeroize() (via the Zeroize impl), and then when self.key is dropped, Zeroizing's own Drop runs again. The ZeroizeOnDrop marker trait is a signal to the compiler/derive macro that the type zeroizes on drop — it does not add any additional zeroing behavior by itself. So the actual concern is that the manual Drop + Zeroizing's Drop both run, which is harmless but redundant. More critically: the ZeroizeOnDrop marker is correct only if the type's Drop impl performs zeroization, which it does. However, the combination of manual Drop + ZeroizeOnDrop marker without the derive macro is unusual and may confuse future maintainers into thinking there is a derive-generated Drop in addition to the manual one. The real issue is that signing_key (a SigningKey) is created at line 61 but is NOT wrapped in Zeroizing — only the scalar extracted from it is. The SigningKey itself contains the private key bytes and will be dropped without explicit zeroization at the end of SharedKey::new. The ed25519-dalek SigningKey does implement ZeroizeOnDrop, so its drop will zeroize it, but this is an implicit dependency on a third-party crate's behavior rather than an explicit guarantee in this code.

Suggested fix:

The `signing_key` local variable in `SharedKey::new` is already covered by `ed25519_dalek::SigningKey`'s own `ZeroizeOnDrop` impl, so this is not a bug. However, to make the intent explicit and remove the redundant manual `Drop`, consider removing the manual `Drop` impl and instead using `#[derive(ZeroizeOnDrop)]` on the struct, which will generate the correct `Drop` impl. Alternatively, keep the manual `Drop` but remove the `ZeroizeOnDrop` marker (the marker alone adds no behavior without the derive).

@meroreviewer meroreviewer Bot 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.

🤖 AI Code Reviewer

Reviewed by 2 agents | Quality score: 50% | Review time: 173.1s

🟡 2 warnings. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-12a124ec

Comment thread crates/crypto/src/lib.rs
fn zeroize(&mut self) {
self.key.zeroize();
}
}

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.

🟡 ZeroizeOnDrop conflicts with Zeroizing<_> field's own Drop

The code implements both ZeroizeOnDrop for SharedKey and relies on Zeroizing<[u8; 32]>'s own Drop to clear the bytes. ZeroizeOnDrop generates a Drop impl that calls self.zeroize(), which in turn calls self.key.zeroize(). Then Zeroizing<_>'s own Drop calls zeroize() again on the inner array. This is a double-zeroize, not a correctness bug per se, but the comment at line 31 says "no manual Drop impl is needed (that would double-zeroize)" while simultaneously implementing ZeroizeOnDrop which does generate a Drop impl that causes exactly that double-zeroize. The comment is self-contradictory and the implementation is inconsistent: either rely solely on Zeroizing<_>'s Drop (drop ZeroizeOnDrop) or use ZeroizeOnDrop with a plain [u8; 32] field. The current approach is redundant and the misleading comment could cause future maintainers to make incorrect assumptions.

Suggested fix:

Remove `impl ZeroizeOnDrop for SharedKey {}` and the `impl Zeroize for SharedKey` if eager zeroization is not needed by any caller. The `Zeroizing<[u8; 32]>` field already guarantees zeroization on drop. If the `Zeroize` bound is needed for callers, keep the `Zeroize` impl but remove `ZeroizeOnDrop` and update the comment to accurately reflect that zeroization on drop is handled by the `Zeroizing<_>` wrapper field.

Comment thread crates/crypto/src/lib.rs
let signing_key = SigningKey::from_bytes(sk);
// curve25519-dalek 4.x Scalar implements Zeroize, so Zeroizing<Scalar>
// clears the private scalar bytes when it is dropped here.
let scalar = Zeroizing::new(signing_key.to_scalar());

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.

🟡 Intermediate shared secret bytes not zeroized before moving into Zeroizing

At line 63, let shared = (*scalar * decompressed).compress().to_bytes() produces a plain [u8; 32] on the stack. This value is then moved into Zeroizing::new(shared) at line 65. The plain stack copy shared is not explicitly zeroized before the move; the compiler may leave a copy in memory (e.g. in a register spill or stack frame) that is never cleared. The Zeroizing wrapper only clears the heap/stack location it owns, not any prior copies made during the move.

Suggested fix:

Compute the shared secret directly into a `Zeroizing<[u8; 32]>` or use `zeroize::Zeroizing` from the start. Alternatively, explicitly zeroize `shared` after the move: `let mut shared = (*scalar * decompressed).compress().to_bytes(); let key = Zeroizing::new(shared); shared.zeroize();` — though this is still subject to compiler optimizations. The most robust approach is to avoid the intermediate binding entirely if the API allows it.

@chefsale chefsale merged commit f784d5d into master Jun 29, 2026
70 checks passed
@chefsale chefsale deleted the claude/sharedkey-crypto-security-qmhz25 branch June 29, 2026 07:03
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