Articles / Rust

  • rust
  • secrets
  • zeroize
  • debug
  • clone
  • security

Why Your Rust Key Type Leaks Through Debug and Clone

Here is a perfectly reasonable-looking Rust type that is quietly a liability. It wraps 32 bytes of key material, and the two derives on top are what hurt.

#[derive(Debug, Clone)]
pub struct ApiKey([u8; 32]);

Anything that formats it with {:?} prints the key. Anything that calls .clone() creates a second copy of the key that nobody is tracking. Neither is a bug in Rust; both are the derives doing exactly what you asked.

Debug prints more places than you think

You probably will not write println!("{:?}", key) on purpose. The leaks come from code that formats things for you:

  • dbg!(&config) where config holds an ApiKey three levels down
  • #[tracing::instrument], which records every argument with Debug unless you skip it
  • unwrap() and expect(), which print the error with Debug; if your error type embeds the key, it lands in the panic message
  • assert_eq! failures, which print both sides
  • a parent struct with #[derive(Debug)], which calls yours automatically

That last one is the sneaky one. The derive on the parent compiles only if every field implements Debug, so the pressure is to add the derive to the key, not to question it. One derive(Debug) on a key makes every struct that contains it a potential leak.

Write Debug by hand

Keep the trait (you need it for the parent derives) but make it say nothing:

use std::fmt;

pub struct ApiKey([u8; 32]);

impl fmt::Debug for ApiKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("ApiKey(<redacted>)")
    }
}

Do not implement Display at all. If someone needs the bytes, they should have to write an explicit accessor with an obvious name, which shows up in code review. A greppable expose_secret() beats a trait impl that fires invisibly.

Clone makes copies you cannot account for

The second problem is subtler. A key should live in one place and be wiped when it dies. Every clone() produces another heap or stack region holding the same bytes, and each one needs its own wipe.

Worse, a derived Clone is usually reached by accident: someone hits a borrow checker error, adds .clone(), and moves on. The compiler is happy. The key now exists twice.

So drop Clone. If code genuinely needs a second key, make it say so:

impl ApiKey {
    /// Deliberate duplicate; both copies are wiped on drop.
    pub fn duplicate(&self) -> Self {
        ApiKey(self.0)
    }
}

That is friction on purpose. Most of the time the right answer is to pass &ApiKey instead.

Quick detour: why Copy is worse

If you derived Copy too, you have an extra problem. A Copy type cannot implement Drop; the compiler rejects the combination outright. So you could never wipe it on drop even if you wanted to, and every pass-by-value silently duplicates the bytes. A key array like [u8; 32] is itself Copy, which is why wrapping it in a non-Copy struct matters.

Wipe on drop

With copies under control, add zeroing. The zeroize crate (with its derive feature enabled) handles the compiler-optimiser problem for you, so a plain loop of zeroes is not needed:

use zeroize::{Zeroize, ZeroizeOnDrop};

#[derive(Zeroize, ZeroizeOnDrop)]
pub struct ApiKey([u8; 32]);

Note what this does not cover. A move is a memcpy, and the old location is not wiped. In optimised builds the compiler often elides moves, but it is not guaranteed. If that matters for your threat model, put the key in a Box so only a pointer moves around.

Other derives that bite

  • Serialize: a derived impl will happily write the key into JSON, a cache or a log pipeline. Implement it only where you mean to persist the key.
  • PartialEq: the derived comparison is not constant time, so comparing a secret token with == can leak how many leading bytes matched. The subtle crate's ConstantTimeEq is the usual fix.
  • Hash: rarely wanted for a secret, and it invites putting keys in a HashMap where they live as long as the map does.

Check it with a test

A short test stops someone adding the derive back in six months:

#[test]
fn debug_does_not_print_key() {
    let key = ApiKey([0xAB; 32]);
    let out = format!("{:?}", key);
    assert!(!out.contains("171")); // 0xAB in decimal
    assert!(!out.contains("ab"));
}

Better still, a compile_fail doctest asserting that key.clone() does not compile. That documents the decision where the next person will read it.

None of this makes a key unrecoverable from a determined attacker with memory access. It removes the accidental routes: log lines, panic messages, forgotten copies. Those are the ones that actually turn up in incident reports.