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")
outputs.dir(outputDir)
(System.getenv("CONVEYOR_SIGNING_KEY") ?: LocalProperties.get("conveyor.signing-key"))?.let {
environment("CONVEYOR_SIGNING_KEY", it)
}
(System.getenv("CONVEYOR_SIGNING_KEY") ?: LocalProperties.get("conveyor.signing-key"))
?.let { environment("CONVEYOR_SIGNING_KEY", it) }
(System.getenv("CONVEYOR_PAT") ?: LocalProperties.get("github.pat"))?.let {
environment("CONVEYOR_PAT", it)
@@ -87,10 +87,8 @@ val serviceModule = module {
if (path == Routes.DAEMON_BASE) return@intercept
val secret = IPC.getIPCSecret()
val user = System.getProperty("user.name")
val timestamp = System.currentTimeMillis() / 1000
// extract body string without destroying the payload
val bodyString =
when (payload) {
is TextContent -> payload.text
@@ -100,9 +98,9 @@ val serviceModule = module {
val signature = HmacProtector.generateSignature(secret, timestamp, bodyString)
context.header(Headers.HMAC_USER, user)
context.header(Headers.HMAC_TIMESTAMP, timestamp.toString())
context.header(Headers.HMAC_SIGNATURE, signature)
context.header(Headers.HMAC_KEY_PATH, IPC.getIpcKeyPath())
proceedWith(payload)
}
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.daemon.plugin
import co.touchlab.kermit.Logger
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.IPC
import com.zaneschepke.wireguardautotunnel.core.ipc.Routes
@@ -9,6 +10,7 @@ import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import java.nio.file.Paths
val hmacShieldPlugin =
createApplicationPlugin("HmacShield") {
@@ -28,21 +30,55 @@ val hmacShieldPlugin =
return@onCall
}
val userHint = call.request.headers[Headers.HMAC_USER]
val timestamp = call.request.headers[Headers.HMAC_TIMESTAMP]?.toLong() ?: 0L
val signature = call.request.headers[Headers.HMAC_SIGNATURE]
val keyPathStr =
call.request.headers[Headers.HMAC_KEY_PATH]
?: return@onCall call.respond(
HttpStatusCode.Unauthorized,
"Missing IPC key path",
)
if (userHint == null || signature == null) {
Logger.w { "Daemon: Rejecting request - Missing Headers" }
return@onCall call.respond(HttpStatusCode.Unauthorized, "Identity headers missing")
val keyFile =
try {
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 =
IPC.resolveKeyForUser(userHint)
?: return@onCall call.respond(
HttpStatusCode.Unauthorized,
"User not recognized",
)
keyFile.readText().trim().takeIf { it.isNotBlank() }
?: return@onCall call.respond(HttpStatusCode.Unauthorized, "Empty key file")
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()
@@ -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) {
runCatching {
val output =
@@ -4,4 +4,5 @@ object Headers {
const val HMAC_USER = "X-HMAC-User"
const val HMAC_TIMESTAMP = "X-HMAC-Timestamp"
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
import co.touchlab.kermit.Logger
import com.zaneschepke.wireguardautotunnel.core.crypto.Crypto
import com.zaneschepke.wireguardautotunnel.core.helper.PermissionsHelper
import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
import org.apache.commons.lang3.SystemUtils
object IPC {
@@ -14,28 +10,6 @@ object IPC {
const val USER_FOLDER = ".wgtunnel"
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
fun getIPCSecret(): String {
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 {
return when {
SystemUtils.IS_OS_WINDOWS -> "C:\\Users\\$user"
SystemUtils.IS_OS_MAC_OSX -> "/Users/$user"
else -> "/home/$user"
fun getIpcKeyPath(): String {
val dir = File(System.getProperty("user.home"), USER_FOLDER)
if (!dir.exists()) dir.mkdirs()
val keyFile = File(dir, KEY_FILE)
if (!keyFile.exists()) {
val secret = Crypto.generateRandomBase64(32)
keyFile.writeText(secret)
PermissionsHelper.setOwnerOnly(keyFile.toPath())
}
return keyFile.canonicalPath
}
}