What changed, and why it matters
This commit tightens file and folder permissions for Eclair's sensitive data. It ensures that seed files (which protect the node's identity and Lightning channel funds) and the data directory are readable only by the owner on Linux/macOS systems. Previously, these files could inherit looser default permissions, potentially allowing other local users on the same machine to read secrets and steal funds. Windows is unaffected because it uses a different permission model.
Deploy this patch and verify that existing seed files and datadir permissions are manually restricted on POSIX systems, since the patch only sets permissions at creation/migration time and does not retroactively fix pre-existing files. Review multi-user and backup access patterns to ensure legitimate services still function under owner-only access.
Security signals we found
Hardening of seed file permissions to owner-only read/write
Hardening of datadir and chaindir permissions to owner-only
Migration path also re-permissions copied seed files
Test coverage added for generated and migrated seed permissions
Explicit no-op handling for non-POSIX filesystems
Evidence from the diff
The patch adds a setOwnerPermissions helper in package.scala that calls Files.setPosixFilePermissions and swallows UnsupportedOperationException for non-POSIX filesystems. It is invoked in NodeParams.writeSeedToFile and migrateSeedFile to set seed files to rw-------, and in Setup for the datadir and chaindir to rwx------. Tests verify that generated and migrated seed files carry owner-only POSIX permissions. This is a defense-in-depth hardening change against local information disclosure and privilege escalation, not a fix for a remote or network-based vulnerability.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scalaeclair-core/src/main/scala/fr/acinq/eclair/Setup.scalaeclair-core/src/main/scala/fr/acinq/eclair/package.scalaeclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scalaInspect captured patch +62 / −1
### eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
@@ -212,12 +212,16 @@ object NodeParams extends Logging {
private def writeSeedToFile(path: File, seed: ByteVector): Unit = {
Files.write(path.toPath, seed.toArray)
+ // Seed files derive the node identity key and all channel keys: they must never be readable by other local users.
+ setOwnerPermissions(path.toPath, "rw-------")
logger.info(s"create new seed file: ${path.getCanonicalPath}")
}
private def migrateSeedFile(source: File, destination: File): Unit = {
if (source.exists() && !destination.exists()) {
Files.copy(source.toPath, destination.toPath)
+ // The copied file inherits default umask permissions, so we restrict it to its owner like a freshly created seed.
+ setOwnerPermissions(destination.toPath, "rw-------")
logger.info(s"migrate seed file: ${source.getCanonicalPath} → ${destination.getCanonicalPath}")
}
}
### eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
@@ -96,6 +96,9 @@ class Setup(val datadir: File,
system.spawn(Behaviors.supervise(NotificationsLogger()).onFailure(typed.SupervisorStrategy.restart), "notifications-logger")
datadir.mkdirs()
+ // The datadir holds sensitive files (seeds, eclair.conf with rpc/api passwords): restrict it to its owner so that
+ // other local users cannot read its contents, even if individual files were created with a permissive umask.
+ setOwnerPermissions(datadir.toPath, "rwx------")
val config = system.settings.config.getConfig("eclair")
val Seeds(nodeSeed, channelSeed) = seeds_opt.getOrElse(NodeParams.getSeeds(datadir))
val chain = config.getString("chain")
@@ -109,6 +112,7 @@ class Setup(val datadir: File,
val chaindir = new File(datadir, chain)
chaindir.mkdirs()
+ setOwnerPermissions(chaindir.toPath, "rwx------")
val nodeKeyManager = LocalNodeKeyManager(nodeSeed, NodeParams.hashFromChain(chain))
val channelKeyManager = LocalChannelKeyManager(channelSeed, NodeParams.hashFromChain(chain))
### eclair-core/src/main/scala/fr/acinq/eclair/package.scala
@@ -24,10 +24,25 @@ import fr.acinq.eclair.payment.relay.Relayer.RelayFees
import scodec.Attempt
import scodec.bits.{BitVector, ByteVector}
+import java.nio.file.Path
+import java.nio.file.attribute.PosixFilePermissions
+
package object eclair {
val randomGen = new StrongRandom()
+ /**
+ * Restrict access to a file or directory to its owner, e.g. "rw-------" for a secret file or "rwx------" for a
+ * directory containing secrets. This is a no-op on file systems that don't support POSIX permissions (e.g. Windows),
+ * where access control is handled differently.
+ */
+ def setOwnerPermissions(path: Path, permissions: String): Unit =
+ try {
+ java.nio.file.Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(permissions))
+ } catch {
+ case _: UnsupportedOperationException => () // non-POSIX file system (e.g. Windows)
+ }
+
def randomBytes(length: Int): ByteVector = {
val buffer = new Array[Byte](length)
randomGen.nextBytes(buffer)
### eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala
@@ -25,10 +25,20 @@ import org.scalatest.funsuite.AnyFunSuite
import scodec.bits._
import java.io.File
-import java.nio.file.Files
+import java.nio.file.attribute.{PosixFileAttributeView, PosixFilePermissions}
+import java.nio.file.{Files, Path}
class LocalNodeKeyManagerSpec extends AnyFunSuite {
+ /** Returns the POSIX permissions of the given file, or `None` on file systems that don't support them (e.g. Windows). */
+ private def posixPermissions(path: Path): Option[String] = {
+ if (Files.getFileStore(path).supportsFileAttributeView(classOf[PosixFileAttributeView])) {
+ Some(PosixFilePermissions.toString(Files.getPosixFilePermissions(path)))
+ } else {
+ None
+ }
+ }
+
test("generate the same node id from the same seed") {
// if this test breaks it means that we will generate a different node id from
// the same seed, which could be a problem during an upgrade
@@ -63,6 +73,34 @@ class LocalNodeKeyManagerSpec extends AnyFunSuite {
assert(seed == nodeSeedContent)
}
+ test("create seed files with owner-only permissions") {
+ val datadir = new File(TestUtils.newIntegrationTmpDir(), "seed-permissions")
+ datadir.mkdirs()
+
+ val Seeds(_, _) = NodeParams.getSeeds(datadir)
+
+ val nodeSeedFile = new File(datadir, "node_seed.dat")
+ val channelSeedFile = new File(datadir, "channel_seed.dat")
+ assert(nodeSeedFile.exists())
+ assert(channelSeedFile.exists())
+ // On POSIX file systems the freshly generated seeds must not be readable by other users, otherwise a local user
+ // could steal the node's funds. On non-POSIX file systems (e.g. Windows) we can't assert anything here.
+ for (seedFile <- Seq(nodeSeedFile, channelSeedFile)) {
+ posixPermissions(seedFile.toPath).foreach(permissions => assert(permissions == "rw-------"))
+ }
+ }
+
+ test("restrict permissions of migrated seed file") {
+ val seed = hex"17b086b228025fa8f4416324b6ba2ec36e68570ae2fc3d392520969f2a9d0c1501"
+ val seedDatFile = TestUtils.createSeedFile("seed.dat", seed.toArray)
+
+ val Seeds(_, _) = NodeParams.getSeeds(seedDatFile.getParentFile)
+
+ val nodeSeedFile = new File(seedDatFile.getParentFile, "node_seed.dat")
+ assert(nodeSeedFile.exists())
+ posixPermissions(nodeSeedFile.toPath).foreach(permissions => assert(permissions == "rw-------"))
+ }
+
test("generate a signature from a digest") {
val seed = hex"deadbeef"
val testKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash)Why this scored 62/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.