← All writeups
High HackerOne · Lightspark Duplicate (Independent Discovery)

The Signer That Logged Its Own Keys: Master Seed & Signing-Key Disclosure in lightspark-rs (CWE-532)

cwe-532sensitive-data-exposurelightningbitcoinrustlogging

By Kavennesh Balachandar · HackerOne · Lightspark Bug Bounty Program

# TL;DR

lightspark-rs — Lightspark's Rust remote-signing SDK — writes private key material into application logs (CWE-532). Two distinct sinks: the library logs the derived per-operation signing key at debug on every signature, and the bundled reference server logs the entire Config — including the BIP32 master seed — at info on every startup. The reference server also pins its log subscriber to Level::DEBUG in code, so the per-signature leak is active out of the box with no misconfiguration. Anyone who can read the signer's logs recovers key material and can forge valid Lightning signatures to drain the channels those keys control. Severity: High. Reported honestly — including that the proof uses published test-vector values, not captured production data — and closed as a duplicate.

# Background: the one process that must never leak a key

A remote signer exists for exactly one reason: to be the only place private keys live. Everything else in the system — the node, the API layer, the webhook plumbing — is designed so it never has to touch raw key material. The signer accepts a request ("sign this message under this derivation path") and returns only a signature. The keys go in once and never come back out.

That design has a corollary that's easy to state and easy to violate: the signer's logs must never contain a key. Logs are the least-protected data a service produces. They stream to stdout, get scraped into a SIEM, shipped to a managed vendor, rotated into backups, and read by on-call engineers. If a secret reaches a log line, it has effectively left the vault and entered the part of the system with the widest audience. For a signer, a key in a log is not a lesser version of "key compromise" — it is key compromise.

lightspark-rs does this in two places.

# Sink A — the library logs the signing key on every operation

In lightspark-remote-signing/src/signer.rs, the core derive_key_and_sign function derives the private signing key, signs the message, and then logs the key:

let signing_key =
    self.derive_and_tweak_key(derivation_path.clone(), add_tweak, mul_tweak)?;
let msg = Message::from_slice(message.as_slice()).map_err(Error::Secp256k1Error)?;
let signature = secp.sign_ecdsa(&msg, &signing_key);

debug!("Derivation: {}", derivation_path);
debug!("Signing Key: {}", hex::encode(signing_key.as_ref()));   // signer.rs:176 — raw private key
debug!("Verification Key: {}", signing_key.public_key(&secp).to_string());
debug!("Message: {}", hex::encode(message.as_slice()));

derive_and_tweak_key returns a secp256k1::SecretKey — the private signing key. .as_ref() exposes its raw 32 bytes, which hex::encode + debug! write straight to the log. The line immediately below logs signing_key.public_key(&secp) as the "Verification Key" — the public half of the very same variable — which is what makes this unambiguous: the line above it is the private key, not a public identifier.

This is the more insidious of the two sinks because it lives in library code. Every integrator who builds on lightspark-remote-signing inherits it, and it fires on the hot path — once per signature, for every DERIVE_KEY_AND_SIGN sub-event the signer handles.

# Sink B — the reference server logs the master seed on every startup

In the bundled reference server, Config carries a blanket #[derive(Debug)] over fields that include the master seed and two other secrets:

#[derive(Debug, Clone)]                 // config.rs — blanket Debug over secret fields
pub struct Config {
    pub api_client_secret: String,      // secret
    pub webhook_secret: String,         // secret (webhook HMAC trust anchor)
    pub master_seed_hex: String,        // MASTER SEED — root of every derived key
    // ...
}

and main.rs logs the whole struct at info on startup:

info!(config = format!("{:?}", config), "Starting Remote Signer.");   // main.rs:92

So master_seed_hex, webhook_secret, and api_client_secret flow from env vars → Config{:?}info!. This is the more catastrophic sink: the master seed is the root from which every channel key is deterministically derived, and it's emitted at info — always on, no debug level required — every single time the process starts.

# The amplifier — the reference server ships at DEBUG

You might reasonably assume Sink A is dormant in production, since it's a debug line. It isn't — the reference server hardcodes the log level:

let subscriber = FmtSubscriber::builder()
    .with_max_level(Level::DEBUG)       // hardcoded, not env-driven
    .finish();

Because the canonical reference signer pins the subscriber to Level::DEBUG, a freshly deployed signer logs the private signing key for every payment with no misconfiguration on the operator's part. Sink A is on by default; Sink B is info and therefore unaffected by level in the first place. Both fire out of the box.

# Proof (published test vectors only)

Below is the deterministic output of the exact format strings at the cited lines, using BIP32 test vector 1 — seed 000102030405060708090a0b0c0d0e0f, derivation path m. These are published, independently verifiable values, so the demonstration exposes nothing real; it is not a capture of Lightspark production traffic.

INFO  lightspark_remote_signing_server: Starting Remote Signer. config="Config { ... master_seed_hex: \"000102030405060708090a0b0c0d0e0f\", ... }"
DEBUG lightspark_remote_signing::signer: Derivation: m
DEBUG lightspark_remote_signing::signer: Signing Key: e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35
DEBUG lightspark_remote_signing::signer: Verification Key: 0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2

The Signing Key hex is the private key of the master node of test vector 1; the Verification Key below it is that key's matching public value — both are published constants, and their pairing is what proves the line above is the secret. master_seed_hex appears in clear in the info line. In a real deployment those placeholders are the operator's actual seed and keys.

# Why it's an oversight, not a design choice

The strongest evidence that this is a slip rather than intended behaviour is that the same codebase already gets it right elsewhere:

  • The core lightspark-crypto-uniffi crate implements the same derive-and-sign logic with no key logging — the secret never reaches a log sink there.
  • Mnemonic, Seed, and LightsparkSigner deliberately do not derive Debug, precisely so a stray {:?} can't leak them.

The reference server's Config breaks that same discipline by deriving Debug over the master seed, and signer.rs breaks it by logging the derived key. The fix is to apply the project's own existing pattern to the two spots that missed it.

# Impact

Anyone able to read the signer's logs — a SIEM, container stdout, a log-shipping pipeline, a managed log vendor, log backups, or an attacker who reaches any of those — recovers key material without any memory-corruption or exotic primitive. It is a read of a log line.

  • The per-operation signing key (Sink A) compromises the channel(s) that key controls.
  • The master seed (Sink B) deterministically regenerates every key the node will ever derive → forge valid commitment / HTLC signatures → drain channel balances.

Severity: High. A representative vector lands around CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H (~7.x), but the realistic score depends heavily on how exposed the logs are in a given deployment — local-only debug output is very different from logs shipped to a central store an attacker can reach. Treat it as High with that caveat rather than a single fixed number claimed for all deployments; the leak does require log-read access, and that's stated plainly rather than glossed over.

# Remediation

  • Sink A: stop logging signing_key. If a debug breadcrumb is needed, log only the derivation path and/or the public key — never the secret.
  • Sink B: remove master_seed_hex / webhook_secret / api_client_secret from Config's Debug output (a custom redacting Debug impl, or a Secret<>-style wrapper that refuses to serialize), and don't log the whole Config.
  • Amplifier: don't hardcode Level::DEBUG in the reference server; make the level env-driven and default to a lower verbosity.
  • Regression guard: add a secret-in-logs lint / CI check for high-entropy strings so this can't silently return.

# Disclosure timeline

  • 2026-06-27 — Reported to Lightspark via HackerOne (#3827896), against repo HEAD fcb31f8580e9435e6114fac3ab56117aa3658556 (lightspark-remote-signing v0.3.0, SDK v0.10.2).
  • Closed as duplicate — independently discovered, reported concurrently with another researcher.
  • Public disclosure pending program approval.

# References