What changed, and why it matters
This patch fixes a bug in the Eclair Bitcoin Lightning node where, if the node crashed while creating its secret seed file, it could leave an empty file behind. On the next restart, Eclair would silently use that empty seed to generate all the node's private keys, producing predictable keys that could let an attacker steal funds. The fix now refuses to start unless the seed file contains exactly 32 bytes, while still accepting older 33-byte legacy seed files.
Upgrade to a release containing this commit. Operators should verify that existing seed files are 32 bytes (or 33-byte legacy seeds ending in 0x01) and have secure filesystem permissions. If an empty or short seed file was ever used, treat derived keys as compromised and rotate funds to a new node with a freshly generated 32-byte seed.
Security signals we found
Use of weak/predictable cryptographic seed for key derivation
Missing input validation on security-critical seed file
Non-atomic file write creating corrupt/empty secret material
Patch adds explicit length validation and startup failure on invalid seed
Test cases added for empty seed rejection and legacy seed compatibility
Evidence from the diff
NodeParams.readSeedFromFile() previously returned ByteVector(Files.readAllBytes(…)) without validating length. Because seed file creation is non-atomic, a crash between create and write could leave a 0-byte seed.dat. The node would then derive deterministic node and channel keys from an empty or short seed, yielding weak/collidable keys. The patch adds a length check: require(seed.length == 32 || (seed.length == 33 && seed.last == 0x01)) and throws IllegalArgumentException otherwise. Tests verify rejection of empty seeds and acceptance of legacy 33-byte compressed-private-key seeds.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scalareadSeedFromFile()getSeeds()node_seed.dat / channel_seed.dat handlingInspect captured patch +31 / −1
### eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
@@ -217,7 +217,11 @@ object NodeParams extends Logging {
private def readSeedFromFile(seedPath: File): ByteVector = {
logger.info(s"use seed file: ${seedPath.getCanonicalPath}")
- ByteVector(Files.readAllBytes(seedPath.toPath))
+ val seed = ByteVector(Files.readAllBytes(seedPath.toPath))
+ // Legacy seed.dat files contain a serialized private key: 32 bytes followed by the 0x01 compression flag.
+ val isLegacySeed = seed.length == 33 && seed.last == 0x01
+ require(seed.length == 32 || isLegacySeed, s"invalid seed file: ${seedPath.getCanonicalPath} must contain exactly 32 bytes")
+ seed
}
private def writeSeedToFile(path: File, seed: ByteVector): Unit = {
### eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala
@@ -90,6 +90,32 @@ class LocalNodeKeyManagerSpec extends AnyFunSuite {
}
}
+ test("reject empty seed files") {
+ val datadir = new File(TestUtils.newIntegrationTmpDir(), "empty-seed")
+ datadir.mkdirs()
+ val nodeSeedFile = new File(datadir, "node_seed.dat")
+ val channelSeedFile = new File(datadir, "channel_seed.dat")
+ Files.write(nodeSeedFile.toPath, Array.empty[Byte])
+ Files.write(channelSeedFile.toPath, Array.fill[Byte](32)(1.toByte))
+
+ assertThrows[IllegalArgumentException](NodeParams.getSeeds(datadir))
+ }
+
+ test("accept legacy 33-byte seed files") {
+ val datadir = new File(TestUtils.newIntegrationTmpDir(), "legacy-seed")
+ datadir.mkdirs()
+ val nodeSeedFile = new File(datadir, "node_seed.dat")
+ val channelSeedFile = new File(datadir, "channel_seed.dat")
+ val legacySeed = hex"17b086b228025fa8f4416324b6ba2ec36e68570ae2fc3d392520969f2a9d0c1501"
+ Files.write(nodeSeedFile.toPath, legacySeed.toArray)
+ Files.write(channelSeedFile.toPath, legacySeed.toArray)
+ assert(NodeParams.getSeeds(datadir) == Seeds(legacySeed, legacySeed))
+
+ // Only the legacy private key encoding is accepted.
+ Files.write(nodeSeedFile.toPath, legacySeed.dropRight(1).:+(0x02.toByte).toArray)
+ assertThrows[IllegalArgumentException](NodeParams.getSeeds(datadir))
+ }
+
test("restrict permissions of migrated seed file") {
val seed = hex"17b086b228025fa8f4416324b6ba2ec36e68570ae2fc3d392520969f2a9d0c1501"
val seedDatFile = TestUtils.createSeedFile("seed.dat", seed.toArray)Why this scored 66/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.