The Signer That Logged Its Own Keys: Master Seed & Signing-Key Disclosure in lightspark-rs (CWE-532)
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-unifficrate implements the same derive-and-sign logic with no key logging — the secret never reaches a log sink there. Mnemonic,Seed, andLightsparkSignerdeliberately do not deriveDebug, 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_secretfromConfig'sDebugoutput (a custom redactingDebugimpl, or aSecret<>-style wrapper that refuses to serialize), and don't log the wholeConfig. - Amplifier: don't hardcode
Level::DEBUGin 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-signingv0.3.0, SDK v0.10.2). - Closed as duplicate — independently discovered, reported concurrently with another researcher.
- Public disclosure pending program approval.
# References
- CWE-532 — Insertion of Sensitive Information into Log File
- BIP32 — Hierarchical Deterministic Wallets (and its published test vectors)
- General guidance on keeping secrets out of
Debug/logs in Rust (redaction wrappers such as thesecrecycrate).