fix: IPC key bug on systems of various languages

Instead if passing the user to look up their home dir, client now passes full IPC key patch for daemon read.
This commit is contained in:
zaneschepke
2026-03-06 01:43:36 -05:00
parent 391d11959c
commit e7c33df9d2
6 changed files with 132 additions and 48 deletions
+2 -3
View File
@@ -13,9 +13,8 @@ fun Project.registerConveyorTask(
val outputDir = layout.buildDirectory.dir("conveyor/$subDir") val outputDir = layout.buildDirectory.dir("conveyor/$subDir")
outputs.dir(outputDir) outputs.dir(outputDir)
(System.getenv("CONVEYOR_SIGNING_KEY") ?: LocalProperties.get("conveyor.signing-key"))?.let { (System.getenv("CONVEYOR_SIGNING_KEY") ?: LocalProperties.get("conveyor.signing-key"))
environment("CONVEYOR_SIGNING_KEY", it) ?.let { environment("CONVEYOR_SIGNING_KEY", it) }
}
(System.getenv("CONVEYOR_PAT") ?: LocalProperties.get("github.pat"))?.let { (System.getenv("CONVEYOR_PAT") ?: LocalProperties.get("github.pat"))?.let {
environment("CONVEYOR_PAT", it) environment("CONVEYOR_PAT", it)
@@ -87,10 +87,8 @@ val serviceModule = module {
if (path == Routes.DAEMON_BASE) return@intercept if (path == Routes.DAEMON_BASE) return@intercept
val secret = IPC.getIPCSecret() val secret = IPC.getIPCSecret()
val user = System.getProperty("user.name")
val timestamp = System.currentTimeMillis() / 1000 val timestamp = System.currentTimeMillis() / 1000
// extract body string without destroying the payload
val bodyString = val bodyString =
when (payload) { when (payload) {
is TextContent -> payload.text is TextContent -> payload.text
@@ -100,9 +98,9 @@ val serviceModule = module {
val signature = HmacProtector.generateSignature(secret, timestamp, bodyString) val signature = HmacProtector.generateSignature(secret, timestamp, bodyString)
context.header(Headers.HMAC_USER, user)
context.header(Headers.HMAC_TIMESTAMP, timestamp.toString()) context.header(Headers.HMAC_TIMESTAMP, timestamp.toString())
context.header(Headers.HMAC_SIGNATURE, signature) context.header(Headers.HMAC_SIGNATURE, signature)
context.header(Headers.HMAC_KEY_PATH, IPC.getIpcKeyPath())
proceedWith(payload) proceedWith(payload)
} }
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.daemon.plugin
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.zaneschepke.wireguardautotunnel.core.crypto.HmacProtector import com.zaneschepke.wireguardautotunnel.core.crypto.HmacProtector
import com.zaneschepke.wireguardautotunnel.core.helper.PermissionsHelper
import com.zaneschepke.wireguardautotunnel.core.ipc.Headers import com.zaneschepke.wireguardautotunnel.core.ipc.Headers
import com.zaneschepke.wireguardautotunnel.core.ipc.IPC import com.zaneschepke.wireguardautotunnel.core.ipc.IPC
import com.zaneschepke.wireguardautotunnel.core.ipc.Routes import com.zaneschepke.wireguardautotunnel.core.ipc.Routes
@@ -9,6 +10,7 @@ import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import java.nio.file.Paths
val hmacShieldPlugin = val hmacShieldPlugin =
createApplicationPlugin("HmacShield") { createApplicationPlugin("HmacShield") {
@@ -28,21 +30,55 @@ val hmacShieldPlugin =
return@onCall return@onCall
} }
val userHint = call.request.headers[Headers.HMAC_USER] val keyPathStr =
val timestamp = call.request.headers[Headers.HMAC_TIMESTAMP]?.toLong() ?: 0L call.request.headers[Headers.HMAC_KEY_PATH]
val signature = call.request.headers[Headers.HMAC_SIGNATURE] ?: return@onCall call.respond(
HttpStatusCode.Unauthorized,
"Missing IPC key path",
)
if (userHint == null || signature == null) { val keyFile =
Logger.w { "Daemon: Rejecting request - Missing Headers" } try {
return@onCall call.respond(HttpStatusCode.Unauthorized, "Identity headers missing") Paths.get(keyPathStr).normalize().toAbsolutePath().toFile()
} catch (e: Exception) {
Logger.w { "Daemon: Invalid path format: $keyPathStr" }
return@onCall call.respond(HttpStatusCode.Unauthorized, "Invalid key path")
}
if (keyFile.name != IPC.KEY_FILE || keyFile.parentFile?.name != IPC.USER_FOLDER) {
Logger.w {
"Daemon: Path does not match expected structure: ${keyFile.absolutePath}"
}
return@onCall call.respond(
HttpStatusCode.Unauthorized,
"Invalid key path structure",
)
}
if (!keyFile.isFile) {
Logger.w { "Daemon: Key file does not exist: ${keyFile.absolutePath}" }
return@onCall call.respond(HttpStatusCode.Unauthorized, "Key file not found")
}
// ensure it is user only owned, as we expect
if (!PermissionsHelper.isOwnerOnly(keyFile.toPath())) {
Logger.e { "Daemon: Key file permissions are not 0600: ${keyFile.absolutePath}" }
return@onCall call.respond(
HttpStatusCode.Unauthorized,
"Invalid key file permissions",
)
} }
val secret = val secret =
IPC.resolveKeyForUser(userHint) keyFile.readText().trim().takeIf { it.isNotBlank() }
?: return@onCall call.respond( ?: return@onCall call.respond(HttpStatusCode.Unauthorized, "Empty key file")
HttpStatusCode.Unauthorized,
"User not recognized", val timestamp = call.request.headers[Headers.HMAC_TIMESTAMP]?.toLong() ?: 0L
) val signature = call.request.headers[Headers.HMAC_SIGNATURE]
if (signature == null) {
Logger.w { "Daemon: Missing HMAC signature" }
return@onCall call.respond(HttpStatusCode.Unauthorized, "Missing signature")
}
val bodyText = call.receiveText() val bodyText = call.receiveText()
@@ -277,6 +277,77 @@ object PermissionsHelper {
} }
} }
fun isOwnerOnly(path: Path): Boolean {
if (!Files.exists(path) || !Files.isRegularFile(path)) {
Logger.w { "isOwnerOnly check failed basic validation: $path" }
return false
}
return try {
if (SystemUtils.IS_OS_WINDOWS) {
isOwnerOnlyWindows(path)
} else {
isOwnerOnlyPosix(path)
}
} catch (e: Exception) {
Logger.e(e) { "Failed to verify owner-only permissions for: $path" }
false
}
}
private fun isOwnerOnlyPosix(path: Path): Boolean {
return try {
val perms = Files.getPosixFilePermissions(path)
// Exact match to what setOwnerOnly applies
perms == PosixFilePermissions.fromString(OWNER_ONLY_PRIVATE_FILE)
} catch (e: Exception) {
Logger.w(e) { "POSIX permission read failed for $path" }
false
}
}
private fun isOwnerOnlyWindows(path: Path): Boolean {
return try {
val process = ProcessBuilder(ICACLS, path.toString()).start()
val output = process.inputStream.bufferedReader().readText().trim()
val exitCode = process.waitFor()
if (exitCode != 0) {
Logger.w { "icacls failed (exit $exitCode) while checking $path" }
return false
}
val currentUser = System.getProperty("user.name").lowercase()
// Owner must have full control
val ownerHasFullControl =
output.contains("$currentUser:(F)", ignoreCase = true) ||
output.contains("$currentUser:(M)", ignoreCase = true)
// Block dangerous groups
val dangerousGroups = listOf("everyone", "users", "authenticated users", "s-1-5-32-545")
val hasDangerousWrite =
dangerousGroups.any { group ->
output.contains(group, ignoreCase = true) &&
(output.contains("$group:(F)", ignoreCase = true) ||
output.contains("$group:(M)", ignoreCase = true) ||
output.contains("$group:(W)", ignoreCase = true))
}
if (!ownerHasFullControl) {
Logger.w { "IPC key owner does not have full control: $path" }
}
if (hasDangerousWrite) {
Logger.w { "Dangerous group write access found on IPC key: $path" }
}
ownerHasFullControl && !hasDangerousWrite
} catch (e: Exception) {
Logger.w(e) { "Windows ACL check failed for $path" }
false
}
}
private fun logWindowsACLs(path: String) { private fun logWindowsACLs(path: String) {
runCatching { runCatching {
val output = val output =
@@ -4,4 +4,5 @@ object Headers {
const val HMAC_USER = "X-HMAC-User" const val HMAC_USER = "X-HMAC-User"
const val HMAC_TIMESTAMP = "X-HMAC-Timestamp" const val HMAC_TIMESTAMP = "X-HMAC-Timestamp"
const val HMAC_SIGNATURE = "X-HMAC-Signature" const val HMAC_SIGNATURE = "X-HMAC-Signature"
const val HMAC_KEY_PATH = "X-Ipc-Key-Path"
} }
@@ -1,12 +1,8 @@
package com.zaneschepke.wireguardautotunnel.core.ipc package com.zaneschepke.wireguardautotunnel.core.ipc
import co.touchlab.kermit.Logger
import com.zaneschepke.wireguardautotunnel.core.crypto.Crypto import com.zaneschepke.wireguardautotunnel.core.crypto.Crypto
import com.zaneschepke.wireguardautotunnel.core.helper.PermissionsHelper import com.zaneschepke.wireguardautotunnel.core.helper.PermissionsHelper
import java.io.File import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
import org.apache.commons.lang3.SystemUtils
object IPC { object IPC {
@@ -14,28 +10,6 @@ object IPC {
const val USER_FOLDER = ".wgtunnel" const val USER_FOLDER = ".wgtunnel"
const val SOCKET_FILE_NAME = "daemon.sock" const val SOCKET_FILE_NAME = "daemon.sock"
fun resolveKeyForUser(user: String): String? {
if (!user.matches(Regex("^[a-zA-Z0-9._-]+$"))) {
Logger.w { "Invalid username format: $user" }
return null
}
return try {
val userHome = getUserHome(user)
val keyPath = Paths.get(userHome, USER_FOLDER, KEY_FILE)
if (Files.exists(keyPath)) {
keyPath.toFile().readText().trim().takeIf { it.isNotBlank() }
} else {
Logger.w { "IPC key not found for user: $user$keyPath" }
null
}
} catch (e: Exception) {
Logger.e(e) { "Failed to resolve IPC key for user: $user" }
null
}
}
// should be called by client ONLY // should be called by client ONLY
fun getIPCSecret(): String { fun getIPCSecret(): String {
val ipcFile = File(System.getProperty("user.home"), "${IPC.USER_FOLDER}/${IPC.KEY_FILE}") val ipcFile = File(System.getProperty("user.home"), "${IPC.USER_FOLDER}/${IPC.KEY_FILE}")
@@ -52,11 +26,16 @@ object IPC {
} }
} }
private fun getUserHome(user: String): String { fun getIpcKeyPath(): String {
return when { val dir = File(System.getProperty("user.home"), USER_FOLDER)
SystemUtils.IS_OS_WINDOWS -> "C:\\Users\\$user" if (!dir.exists()) dir.mkdirs()
SystemUtils.IS_OS_MAC_OSX -> "/Users/$user" val keyFile = File(dir, KEY_FILE)
else -> "/home/$user"
if (!keyFile.exists()) {
val secret = Crypto.generateRandomBase64(32)
keyFile.writeText(secret)
PermissionsHelper.setOwnerOnly(keyFile.toPath())
} }
return keyFile.canonicalPath
} }
} }