diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt
index fc798da..84fe50d 100644
--- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt
@@ -2,10 +2,12 @@ package com.gatecontrol.android.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.gatecontrol.android.R
import com.gatecontrol.android.data.LicenseRepository
import com.gatecontrol.android.data.SetupRepository
import com.gatecontrol.android.data.SettingsRepository
import com.gatecontrol.android.network.ApiClientProvider
+import com.gatecontrol.android.tunnel.WgConfigValidator
import com.gatecontrol.android.network.UpdateCheckResponse
import com.gatecontrol.android.common.Validation
import org.json.JSONArray
@@ -414,8 +416,12 @@ class SettingsViewModel @Inject constructor(
val input = context.contentResolver.openInputStream(uri)
val config = input?.bufferedReader()?.readText() ?: return@launch
input.close()
- if (!config.contains("[Interface]") || !config.contains("PrivateKey")) {
- _uiState.update { it.copy(error = "Invalid WireGuard config file") }
+ val validation = WgConfigValidator.validate(config)
+ if (!validation.ok) {
+ Timber.w("importConfigFromUri rejected: %s", validation.errors.joinToString(", "))
+ _uiState.update {
+ it.copy(error = context.getString(R.string.setup_invalid_config))
+ }
return@launch
}
setupRepository.saveWireGuardConfig(config)
diff --git a/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt
index cf05aa5..5ea65ae 100644
--- a/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt
+++ b/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt
@@ -2,9 +2,11 @@ package com.gatecontrol.android.ui.setup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.gatecontrol.android.R
import com.gatecontrol.android.data.SetupRepository
import com.gatecontrol.android.network.ApiClientProvider
import com.gatecontrol.android.network.RegisterRequest
+import com.gatecontrol.android.tunnel.WgConfigValidator
import android.content.Context
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -184,9 +186,14 @@ class SetupViewModel @Inject constructor(
fun importConfig(configText: String) {
viewModelScope.launch {
try {
- if (configText.isBlank() || !configText.contains("[Interface]")) {
+ val validation = WgConfigValidator.validate(configText)
+ if (!validation.ok) {
+ Timber.w("importConfig rejected: %s", validation.errors.joinToString(", "))
_uiState.update {
- it.copy(statusMessage = "Invalid WireGuard config", statusType = StatusType.ERROR)
+ it.copy(
+ statusMessage = context.getString(R.string.setup_invalid_config),
+ statusType = StatusType.ERROR,
+ )
}
return@launch
}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index a99ed47..b9dbdab 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -182,6 +182,7 @@
Registriere…
Erfolgreich registriert!
Registrierung fehlgeschlagen: %1$s
+ Ungültige WireGuard-Konfiguration
Protokolle
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7d09e40..068ea15 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -182,6 +182,7 @@
Registering…
Successfully registered!
Registration failed: %1$s
+ Invalid WireGuard config
Logs
diff --git a/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt
index 01a3c84..5e5fd85 100644
--- a/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt
+++ b/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt
@@ -269,7 +269,7 @@ class SetupViewModelTest {
@Test
fun `importConfig saves valid WireGuard config`() = runTest {
- val config = "[Interface]\nPrivateKey=abc\nAddress=10.0.0.1/32\n\n[Peer]\nPublicKey=xyz"
+ val config = "[Interface]\nPrivateKey = YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=\nAddress = 10.8.0.5/32\n\n[Peer]\nPublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE=\nEndpoint = vpn.example.com:51820\nAllowedIPs = 0.0.0.0/0"
viewModel.uiState.test {
awaitItem()
diff --git a/core/tunnel/build.gradle.kts b/core/tunnel/build.gradle.kts
index ca77e14..41b45af 100644
--- a/core/tunnel/build.gradle.kts
+++ b/core/tunnel/build.gradle.kts
@@ -53,6 +53,10 @@ dependencies {
testRuntimeOnly(libs.junit5.engine)
testImplementation(libs.mockk)
testImplementation(libs.coroutines.test)
+ // Lightweight JSON parser for loading the vendored WG-config golden fixtures
+ // in unit tests. No standalone JSON lib is in the version catalog and the
+ // org.json reference impl is dependency-free and JVM-friendly for unit tests.
+ testImplementation(libs.org.json)
}
kapt {
diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelConfig.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelConfig.kt
index 4fdf99d..dd1a07e 100644
--- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelConfig.kt
+++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelConfig.kt
@@ -38,6 +38,11 @@ data class TunnelConfig(
fun parse(raw: String): TunnelConfig {
require(raw.isNotBlank()) { "Config input must not be empty" }
+ val validation = WgConfigValidator.validate(raw)
+ require(validation.ok) {
+ "Invalid WireGuard config: ${validation.errors.joinToString(", ")}"
+ }
+
val lines = raw.lines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/WgConfigValidator.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/WgConfigValidator.kt
new file mode 100644
index 0000000..5486583
--- /dev/null
+++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/WgConfigValidator.kt
@@ -0,0 +1,386 @@
+package com.gatecontrol.android.tunnel
+
+/**
+ * Syntactic validator for WireGuard / wg-quick configuration text.
+ *
+ * This is the Kotlin port of the canonical specification in
+ * `gatecontrol-config-hash/spec/wg-config/SPEC.md` and a 1:1 behavioral
+ * mirror of the JavaScript/TypeScript implementation
+ * (`src/wg-config-validator.ts`). Both implementations are exercised against
+ * the same golden fixtures, so they MUST behave identically: same
+ * normalization order, same error codes, same regexes/semantics.
+ *
+ * Pure string/regex parsing only: NO Android APIs, NO I/O, NO network. The
+ * validator is fail-closed and never throws on malformed input — every
+ * structural problem is reported through `errors`.
+ *
+ * See SPEC.md for the normative rules. Error codes are a stable contract.
+ */
+
+/**
+ * Public result type returned by [WgConfigValidator.validate].
+ *
+ * Result of validating a WireGuard config (see SPEC §1).
+ */
+data class WgValidationResult(
+ /** true iff errors.isEmpty(). Nothing else may set this. */
+ val ok: Boolean,
+ /** Error codes (see SPEC §4); presence makes ok == false. */
+ val errors: List,
+ /** Warning codes (see SPEC §4); NEVER affect ok. */
+ val warnings: List,
+)
+
+object WgConfigValidator {
+
+ /** WG-base64 key material: exactly 43 base64 chars + one '=' pad. */
+ private val WG_BASE64 = Regex("^[A-Za-z0-9+/]{43}=$")
+
+ /** Optionally-signed base-10 integer string. */
+ private val INT_RE = Regex("^[+-]?\\d+$")
+
+ /** IPv4 octet shape: 1..3 decimal digits. */
+ private val IPV4_OCTET = Regex("^\\d{1,3}$")
+
+ /** IPv6 group shape: 1..4 hex digits. */
+ private val IPV6_GROUP = Regex("^[0-9A-Fa-f]{1,4}$")
+
+ /** Known [Interface] keys (canonical WireGuard spelling, case-sensitive). */
+ private val INTERFACE_KEYS = setOf(
+ "PrivateKey",
+ "Address",
+ "DNS",
+ "ListenPort",
+ "MTU",
+ )
+
+ /** Known [Peer] keys (canonical WireGuard spelling, case-sensitive). */
+ private val PEER_KEYS = setOf(
+ "PublicKey",
+ "PresharedKey",
+ "Endpoint",
+ "AllowedIPs",
+ "PersistentKeepalive",
+ )
+
+ /** A parsed config section. */
+ private data class Section(
+ /** Verbatim header name without brackets, e.g. "Interface". */
+ val name: String,
+ /** Parsed key/value pairs in order of appearance. */
+ val pairs: MutableList> = mutableListOf(),
+ )
+
+ private fun isWgBase64(s: String): Boolean = WG_BASE64.matches(s)
+
+ /** Optionally-signed base-10 integer, optional inclusive range. */
+ private fun isInt(s: String, min: Long? = null, max: Long? = null): Boolean {
+ if (!INT_RE.matches(s)) return false
+ if (min == null && max == null) return true
+ // toLongOrNull is safe and finite; mirrors JS Number()/Number.isFinite()
+ // for the integer strings INT_RE admits (range checks only use small bounds).
+ val n = s.toLongOrNull() ?: return false
+ if (min != null && n < min) return false
+ if (max != null && n > max) return false
+ return true
+ }
+
+ /** IPv4 dotted-quad, each octet 0..255. */
+ private fun isIpv4(s: String): Boolean {
+ val parts = s.split(".")
+ if (parts.size != 4) return false
+ for (p in parts) {
+ if (!IPV4_OCTET.matches(p)) return false
+ val n = p.toInt()
+ if (n < 0 || n > 255) return false
+ }
+ return true
+ }
+
+ /** IPv6 literal. Supports `::` compression and embedded IPv4 tails. */
+ private fun isIpv6(s: String): Boolean {
+ if (s.isEmpty()) return false
+ // At most one '::' compression marker.
+ val doubleColonCount = Regex("::").findAll(s).count()
+ if (doubleColonCount > 1) return false
+
+ // An embedded IPv4 tail (e.g. ::ffff:1.2.3.4) counts as two groups.
+ var head = s
+ var tailGroups = 0
+ val lastColon = head.lastIndexOf(':')
+ if (lastColon != -1 && head.substring(lastColon + 1).contains('.')) {
+ val v4 = head.substring(lastColon + 1)
+ if (!isIpv4(v4)) return false
+ tailGroups = 2
+ head = head.substring(0, lastColon + 1) // keep trailing ':' for split logic
+ }
+
+ if (doubleColonCount == 1) {
+ // JS String.split('::') yields exactly [left, right] for a single occurrence.
+ val sepIdx = head.indexOf("::")
+ val left = head.substring(0, sepIdx)
+ val right = head.substring(sepIdx + 2)
+ val leftGroups = if (left == "") emptyList() else left.split(":")
+ val rightGroups = if (right == "") emptyList() else right.split(":")
+ for (g in leftGroups + rightGroups) {
+ if (!IPV6_GROUP.matches(g)) return false
+ }
+ val total = leftGroups.size + rightGroups.size + tailGroups
+ // '::' must compress at least one group, so total groups < 8.
+ return total <= 7
+ }
+
+ // No compression: must be exactly 8 groups total.
+ val trimmed = if (head.endsWith(":")) head.substring(0, head.length - 1) else head
+ val groups = if (trimmed == "") emptyList() else trimmed.split(":")
+ for (g in groups) {
+ if (!IPV6_GROUP.matches(g)) return false
+ }
+ return groups.size + tailGroups == 8
+ }
+
+ /** A bare IP literal (v4 or v6), with NO prefix. */
+ private fun isIp(s: String): Boolean = isIpv4(s) || isIpv6(s)
+
+ /** CIDR: `ip/prefix` (v4 0..32, v6 0..128). A bare IP is NOT a CIDR. */
+ private fun isCidr(s: String): Boolean {
+ val slash = s.lastIndexOf('/')
+ if (slash == -1) return false
+ val ip = s.substring(0, slash)
+ val prefix = s.substring(slash + 1)
+ if (!INT_RE.matches(prefix)) return false
+ val p = prefix.toLongOrNull() ?: return false
+ if (isIpv4(ip)) return p in 0..32
+ if (isIpv6(ip)) return p in 0..128
+ return false
+ }
+
+ /** Comma-separated list; each trimmed entry must satisfy [pred]. Non-empty. */
+ private fun isList(value: String, pred: (String) -> Boolean): Boolean {
+ if (value.isBlank()) return false
+ return value.split(",").map { it.trim() }.all { it.isNotEmpty() && pred(it) }
+ }
+
+ /** host:port — split on the LAST ':'; host non-empty, port int 1..65535. */
+ private fun isHostPort(value: String): Boolean {
+ val idx = value.lastIndexOf(':')
+ if (idx == -1) return false
+ val host = value.substring(0, idx)
+ val port = value.substring(idx + 1)
+ if (host.isEmpty()) return false
+ return isInt(port, 1, 65535)
+ }
+
+ /**
+ * Normalize raw input per SPEC §2 (BOM strip, line-ending normalize, split,
+ * comment strip, trim, drop blank lines), then parse into ordered sections.
+ */
+ private fun parseSections(text: String?): List {
+ // 1. Strip BOM. Coerce null to empty so we never throw (SPEC §1).
+ var s = text ?: ""
+ if (s.isNotEmpty() && s[0].code == 0xFEFF) {
+ s = s.substring(1)
+ }
+ // 2. Normalize line endings (\r\n and lone \r → \n).
+ s = s.replace("\r\n", "\n").replace("\r", "\n")
+ // 3. Split into lines.
+ val rawLines = s.split("\n")
+
+ val sections = mutableListOf()
+ var current: Section? = null
+
+ for (raw in rawLines) {
+ // 4. Strip comment: first '#' or ';' to end of line.
+ var line = raw
+ val hashIdx = line.indexOf('#')
+ val semiIdx = line.indexOf(';')
+ val cut = when {
+ hashIdx != -1 && semiIdx != -1 -> minOf(hashIdx, semiIdx)
+ hashIdx != -1 -> hashIdx
+ semiIdx != -1 -> semiIdx
+ else -> -1
+ }
+ if (cut != -1) line = line.substring(0, cut)
+
+ // 5. Trim.
+ line = line.trim()
+
+ // 6. Ignore blank lines.
+ if (line.isEmpty()) continue
+
+ // Section header?
+ if (line.startsWith("[") && line.endsWith("]")) {
+ val name = line.substring(1, line.length - 1).trim()
+ current = Section(name)
+ sections.add(current)
+ continue
+ }
+
+ // Key = Value (split on first '=').
+ val eq = line.indexOf('=')
+ if (eq == -1) {
+ // Lines before the first header are ignored; the server emits none.
+ // A non-pair line inside a section is not specified — skip it.
+ continue
+ }
+ val key = line.substring(0, eq).trim()
+ val value = line.substring(eq + 1).trim()
+ // Pairs before the first header are ignored.
+ current?.pairs?.add(key to value)
+ }
+
+ return sections
+ }
+
+ private fun validateInterface(
+ section: Section,
+ errors: MutableList,
+ warnings: MutableList,
+ ) {
+ val values = LinkedHashMap()
+ for ((key, value) in section.pairs) {
+ if (key in INTERFACE_KEYS) {
+ values[key] = value
+ } else {
+ warnings.add("unknown_key:$key")
+ }
+ }
+
+ // PrivateKey (required, WG-base64).
+ val privateKey = values["PrivateKey"]
+ if (privateKey == null) {
+ errors.add("iface_privatekey_missing")
+ } else if (!isWgBase64(privateKey)) {
+ errors.add("iface_privatekey_format")
+ }
+
+ // Address (required, CIDR list).
+ val address = values["Address"]
+ if (address == null) {
+ errors.add("iface_address_missing")
+ } else if (!isList(address, ::isCidr)) {
+ errors.add("iface_address_cidr")
+ }
+
+ // DNS (optional, IP list).
+ val dns = values["DNS"]
+ if (dns != null && !isList(dns, ::isIp)) {
+ errors.add("iface_dns_ip")
+ }
+
+ // ListenPort / MTU (optional, integer).
+ val listenPort = values["ListenPort"]
+ if (listenPort != null && !isInt(listenPort)) {
+ errors.add("iface_int")
+ }
+ val mtu = values["MTU"]
+ if (mtu != null && !isInt(mtu)) {
+ errors.add("iface_int")
+ }
+ }
+
+ private fun validatePeer(
+ section: Section,
+ errors: MutableList,
+ warnings: MutableList,
+ ) {
+ val values = LinkedHashMap()
+ for ((key, value) in section.pairs) {
+ if (key in PEER_KEYS) {
+ values[key] = value
+ } else {
+ warnings.add("unknown_key:$key")
+ }
+ }
+
+ // PublicKey (required, WG-base64).
+ val publicKey = values["PublicKey"]
+ if (publicKey == null) {
+ errors.add("peer_publickey_missing")
+ } else if (!isWgBase64(publicKey)) {
+ errors.add("peer_publickey_format")
+ }
+
+ // PresharedKey (optional, WG-base64).
+ val psk = values["PresharedKey"]
+ if (psk != null && !isWgBase64(psk)) {
+ errors.add("peer_psk_format")
+ }
+
+ // Endpoint (required, host:port).
+ val endpoint = values["Endpoint"]
+ if (endpoint == null) {
+ errors.add("peer_endpoint_missing")
+ } else if (!isHostPort(endpoint)) {
+ errors.add("peer_endpoint_format")
+ }
+
+ // AllowedIPs (required, CIDR list).
+ val allowedIps = values["AllowedIPs"]
+ if (allowedIps == null) {
+ errors.add("peer_allowedips_missing")
+ } else if (!isList(allowedIps, ::isCidr)) {
+ errors.add("peer_allowedips_cidr")
+ }
+
+ // PersistentKeepalive (optional, integer 0..65535).
+ val keepalive = values["PersistentKeepalive"]
+ if (keepalive != null && !isInt(keepalive, 0, 65535)) {
+ errors.add("peer_keepalive")
+ }
+ }
+
+ /**
+ * Validate WireGuard / wg-quick config text for syntactic well-formedness.
+ *
+ * Fail-closed: never throws; all problems reported via `errors`. Warnings
+ * never affect `ok`. There is intentionally NO `trusted` flag and no options.
+ * Accepts null (treated as empty config) per SPEC §1.
+ */
+ fun validate(text: String?): WgValidationResult {
+ val errors = mutableListOf()
+ val warnings = mutableListOf()
+
+ val sections = parseSections(text)
+
+ var interfaceCount = 0
+ var peerCount = 0
+
+ for (section in sections) {
+ when (section.name) {
+ "Interface" -> {
+ interfaceCount += 1
+ validateInterface(section, errors, warnings)
+ }
+ "Peer" -> {
+ peerCount += 1
+ validatePeer(section, errors, warnings)
+ }
+ else -> {
+ // SPEC §3: unknown sections use the same 'unknown_key:' prefix as unknown field keys
+ warnings.add("unknown_key:${section.name}")
+ }
+ }
+ }
+
+ // Exactly one [Interface].
+ if (interfaceCount != 1) {
+ errors.add("interface_count")
+ }
+ // At least one [Peer].
+ if (peerCount == 0) {
+ errors.add("no_peer")
+ }
+
+ // Deduplicate (order-preserving) so a code appears at most once; this keeps
+ // the JS and Kotlin ports deterministic and identical (SPEC §1).
+ val uniqueErrors = errors.distinct()
+ val uniqueWarnings = warnings.distinct()
+
+ return WgValidationResult(
+ ok = uniqueErrors.isEmpty(),
+ errors = uniqueErrors,
+ warnings = uniqueWarnings,
+ )
+ }
+}
diff --git a/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/TunnelConfigTest.kt b/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/TunnelConfigTest.kt
index c5da98f..e3b5e7e 100644
--- a/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/TunnelConfigTest.kt
+++ b/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/TunnelConfigTest.kt
@@ -14,8 +14,8 @@ class TunnelConfigTest {
MTU = 1420
[Peer]
- PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA==
- PresharedKey = cHJlc2hhcmVka2V5YmFzZTY0ZW5jb2RlZHh4eHh4eA==
+ PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE=
+ PresharedKey = cHJlc2hhcmVka2V5YmFzZTY0ZW5jb2RlZHh4eHh4eCE=
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
@@ -35,8 +35,8 @@ class TunnelConfigTest {
fun `parse extracts peer fields`() {
val config = TunnelConfig.parse(validConfig)
- assertEquals("c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA==", config.publicKey)
- assertEquals("cHJlc2hhcmVka2V5YmFzZTY0ZW5jb2RlZHh4eHh4eA==", config.presharedKey)
+ assertEquals("c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE=", config.publicKey)
+ assertEquals("cHJlc2hhcmVka2V5YmFzZTY0ZW5jb2RlZHh4eHh4eCE=", config.presharedKey)
assertEquals("vpn.example.com:51820", config.endpoint)
assertEquals("0.0.0.0/0", config.allowedIps)
assertEquals(25, config.persistentKeepalive)
@@ -50,7 +50,7 @@ class TunnelConfigTest {
Address = 10.8.0.5/32
[Peer]
- PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA==
+ PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE=
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0
""".trimIndent()
@@ -74,7 +74,7 @@ class TunnelConfigTest {
assertTrue(output.contains("DNS = 1.1.1.1, 8.8.8.8"))
assertTrue(output.contains("MTU = 1420"))
assertTrue(output.contains("[Peer]"))
- assertTrue(output.contains("PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA=="))
+ assertTrue(output.contains("PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE="))
assertTrue(output.contains("Endpoint = vpn.example.com:51820"))
assertTrue(output.contains("AllowedIPs = 0.0.0.0/0"))
assertTrue(output.contains("PersistentKeepalive = 25"))
@@ -94,7 +94,7 @@ class TunnelConfigTest {
Address = 10.8.0.5/32
[Peer]
- PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA==
+ PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE=
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0
""".trimIndent()
diff --git a/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/WgConfigValidatorTest.kt b/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/WgConfigValidatorTest.kt
new file mode 100644
index 0000000..b00adc0
--- /dev/null
+++ b/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/WgConfigValidatorTest.kt
@@ -0,0 +1,197 @@
+package com.gatecontrol.android.tunnel
+
+import org.json.JSONObject
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.DynamicTest
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestFactory
+import java.io.File
+import java.security.MessageDigest
+
+/**
+ * Conformance tests for [WgConfigValidator], the Kotlin 1:1 port of the
+ * canonical JS validator. Both are exercised against the SAME golden fixtures,
+ * vendored into test resources at `wg-config-fixtures/`.
+ *
+ * Includes an integrity-hash assertion that detects local corruption of the
+ * vendored fixture copy.
+ */
+class WgConfigValidatorTest {
+
+ private companion object {
+ const val FIXTURES_DIR = "wg-config-fixtures"
+
+ /** A parsed fixture per SPEC §7. */
+ data class Fixture(
+ val name: String,
+ val config: String,
+ val expect: String,
+ val errorContains: String?,
+ )
+
+ /**
+ * Resolve the vendored fixtures directory on disk. We need the real
+ * directory (not just classpath streams) so we can enumerate the
+ * *.json files and read their RAW bytes for the integrity hash exactly
+ * the way the sync script does.
+ */
+ fun fixturesDir(): File {
+ val classLoader = checkNotNull(WgConfigValidatorTest::class.java.classLoader) {
+ "No classloader available to locate test resources"
+ }
+ val url = checkNotNull(classLoader.getResource(FIXTURES_DIR)) {
+ "Fixtures resource directory '$FIXTURES_DIR' not found on test classpath"
+ }
+ return File(url.toURI())
+ }
+
+ /** All *.json fixture files, sorted ascending by filename. */
+ fun fixtureJsonFiles(): List =
+ fixturesDir()
+ .listFiles { f -> f.isFile && f.name.endsWith(".json") }
+ ?.sortedBy { it.name }
+ ?: error("No fixture files found in $FIXTURES_DIR")
+
+ fun parseFixture(file: File): Fixture {
+ val json = JSONObject(file.readText(Charsets.UTF_8))
+ return Fixture(
+ name = json.getString("name"),
+ config = json.getString("config"),
+ expect = json.getString("expect"),
+ errorContains = if (json.has("errorContains")) json.getString("errorContains") else null,
+ )
+ }
+
+ fun loadFixtures(): List = fixtureJsonFiles().map { parseFixture(it) }
+ }
+
+ @TestFactory
+ fun `fixtures conform to the validator contract`(): List {
+ val fixtures = loadFixtures()
+ // Sanity: at least one fixture must load. The .fixtures-hash integrity
+ // test guards sync completeness; a magic count here breaks confusingly
+ // whenever a fixture is added.
+ assertTrue(fixtures.isNotEmpty(), "No fixtures loaded")
+ return fixtures.map { fx ->
+ DynamicTest.dynamicTest(fx.name) {
+ val result = WgConfigValidator.validate(fx.config)
+ val expectValid = fx.expect == "valid"
+ assertEquals(
+ expectValid,
+ result.ok,
+ "Fixture '${fx.name}': expected ok=$expectValid but was ${result.ok} " +
+ "(errors=${result.errors}, warnings=${result.warnings})",
+ )
+ if (!expectValid && fx.errorContains != null) {
+ assertTrue(
+ result.errors.contains(fx.errorContains),
+ "Fixture '${fx.name}': expected errors to contain " +
+ "'${fx.errorContains}' but errors=${result.errors}",
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `unknown key produces warning and stays ok`() {
+ val config = buildString {
+ appendLine("[Interface]")
+ appendLine("PrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=")
+ appendLine("Address = 10.8.0.2/32")
+ appendLine("SomeMadeUpKey = whatever")
+ appendLine()
+ appendLine("[Peer]")
+ appendLine("PublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=")
+ appendLine("Endpoint = gate.example.com:51820")
+ appendLine("AllowedIPs = 0.0.0.0/0")
+ }
+ val result = WgConfigValidator.validate(config)
+ assertTrue(result.ok, "Unknown key must not affect ok; errors=${result.errors}")
+ assertTrue(
+ result.warnings.any { it.startsWith("unknown_key:") },
+ "Expected an unknown_key warning; warnings=${result.warnings}",
+ )
+ assertTrue(result.warnings.contains("unknown_key:SomeMadeUpKey"))
+ }
+
+ @Test
+ fun `unknown section produces warning and stays ok`() {
+ val config = buildString {
+ appendLine("[Interface]")
+ appendLine("PrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=")
+ appendLine("Address = 10.8.0.2/32")
+ appendLine()
+ appendLine("[Peer]")
+ appendLine("PublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=")
+ appendLine("Endpoint = gate.example.com:51820")
+ appendLine("AllowedIPs = 0.0.0.0/0")
+ appendLine()
+ appendLine("[CustomSection]")
+ appendLine("Foo = bar")
+ }
+ val result = WgConfigValidator.validate(config)
+ assertTrue(result.ok, "Unknown section must not affect ok; errors=${result.errors}")
+ // SPEC §4: bare section name, no brackets.
+ assertTrue(result.warnings.contains("unknown_key:CustomSection"))
+ }
+
+ @Test
+ fun `null input is not ok and does not throw`() {
+ val result = WgConfigValidator.validate(null)
+ assertFalse(result.ok)
+ // Empty config: no interface and no peer.
+ assertTrue(result.errors.contains("interface_count"))
+ assertTrue(result.errors.contains("no_peer"))
+ }
+
+ @Test
+ fun `blank input is not ok and does not throw`() {
+ val result = WgConfigValidator.validate(" \n \r\n \t ")
+ assertFalse(result.ok)
+ assertTrue(result.errors.contains("interface_count"))
+ assertTrue(result.errors.contains("no_peer"))
+ }
+
+ @Test
+ fun `iface_int appears exactly once when both ListenPort and MTU are bad`() {
+ val fx = loadFixtures().first { it.name == "invalid_iface_int" }
+ val result = WgConfigValidator.validate(fx.config)
+ assertFalse(result.ok)
+ assertEquals(
+ 1,
+ result.errors.count { it == "iface_int" },
+ "iface_int must be deduplicated to a single occurrence; errors=${result.errors}",
+ )
+ }
+
+ @Test
+ fun `vendored fixtures integrity hash matches`() {
+ // Recompute the hash exactly the way scripts/sync-wg-fixtures.sh does:
+ // cat $(ls -1 *.json | sort) | sha256sum
+ // i.e. concatenate the RAW BYTES of the *.json files (ONLY .json,
+ // NOT .fixtures-hash / VENDORED.md) in ascending filename order, then
+ // SHA-256, hex-encoded.
+ val jsonFiles = fixtureJsonFiles()
+ val digest = MessageDigest.getInstance("SHA-256")
+ for (f in jsonFiles) {
+ digest.update(f.readBytes())
+ }
+ val computed = digest.digest().joinToString("") { "%02x".format(it) }
+
+ val hashFile = File(fixturesDir(), ".fixtures-hash")
+ assertTrue(hashFile.isFile, ".fixtures-hash must exist in vendored fixtures")
+ val expected = hashFile.readText(Charsets.UTF_8).trim()
+
+ assertNotNull(expected)
+ assertEquals(
+ expected,
+ computed,
+ "Vendored fixture integrity hash mismatch — the vendored copy was " +
+ "locally corrupted (or .fixtures-hash is stale).",
+ )
+ }
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/.fixtures-hash b/core/tunnel/src/test/resources/wg-config-fixtures/.fixtures-hash
new file mode 100644
index 0000000..82c247b
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/.fixtures-hash
@@ -0,0 +1 @@
+314336eb25d68050d061f9a978dae0042b5c17573c19d211d0ab8cad6e6ba18a
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/VENDORED.md b/core/tunnel/src/test/resources/wg-config-fixtures/VENDORED.md
new file mode 100644
index 0000000..3606ba2
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/VENDORED.md
@@ -0,0 +1,17 @@
+# Vendored WG-config golden fixtures
+
+These fixtures are a vendored copy of
+`gatecontrol-config-hash/spec/wg-config/fixtures`, synced via
+`scripts/sync-wg-fixtures.sh`. Do NOT edit directly.
+
+The `.fixtures-hash` file is asserted by `WgConfigValidatorTest`; a
+mismatch means the vendored copy was locally corrupted.
+
+NOTE: this hash only detects local corruption of the vendored copy — it
+does NOT detect a forgotten re-sync after the canonical fixtures change
+(the copy + hash would both be stale-but-consistent). The only real guard
+against a forgotten sync is the release checklist
+(see `gatecontrol-config-hash/spec/wg-config/SYNC.md`).
+
+- Integrity hash: `314336eb25d68050d061f9a978dae0042b5c17573c19d211d0ab8cad6e6ba18a`
+- Last synced (UTC): 2026-05-31
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_cidr.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_cidr.json
new file mode 100644
index 0000000..4008cba
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_cidr.json
@@ -0,0 +1,6 @@
+{
+ "name": "invalid_bad_cidr",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 10.0.0.0\n",
+ "expect": "invalid",
+ "errorContains": "peer_allowedips_cidr"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_endpoint.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_endpoint.json
new file mode 100644
index 0000000..0b14e33
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_endpoint.json
@@ -0,0 +1,6 @@
+{
+ "name": "invalid_bad_endpoint",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com\nAllowedIPs = 0.0.0.0/0\n",
+ "expect": "invalid",
+ "errorContains": "peer_endpoint_format"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_key.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_key.json
new file mode 100644
index 0000000..85a5dda
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_key.json
@@ -0,0 +1,6 @@
+{
+ "name": "invalid_bad_key",
+ "config": "[Interface]\nPrivateKey = tooshortkey\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n",
+ "expect": "invalid",
+ "errorContains": "iface_privatekey_format"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_iface_int.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_iface_int.json
new file mode 100644
index 0000000..1e5749a
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_iface_int.json
@@ -0,0 +1,6 @@
+{
+ "name": "invalid_iface_int",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nListenPort = notanint\nMTU = alsobad\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n",
+ "expect": "invalid",
+ "errorContains": "iface_int"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_no_interface.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_no_interface.json
new file mode 100644
index 0000000..464df31
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_no_interface.json
@@ -0,0 +1,6 @@
+{
+ "name": "invalid_no_interface",
+ "config": "[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n",
+ "expect": "invalid",
+ "errorContains": "interface_count"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_two_interface.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_two_interface.json
new file mode 100644
index 0000000..bd50e5c
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_two_interface.json
@@ -0,0 +1,6 @@
+{
+ "name": "invalid_two_interface",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\n\n[Interface]\nPrivateKey = T2UTKYFhz1rNVYXSguzk4cMyiW3Zkinvd3SM9hVICDg=\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n",
+ "expect": "invalid",
+ "errorContains": "interface_count"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_crlf.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_crlf.json
new file mode 100644
index 0000000..74c5676
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_crlf.json
@@ -0,0 +1,5 @@
+{
+ "name": "valid_crlf",
+ "config": "[Interface]\r\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\r\nAddress = 10.8.0.2/32\r\nDNS = 10.8.0.1\r\n\r\n[Peer]\r\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\r\nEndpoint = gate.example.com:51820\r\nAllowedIPs = 0.0.0.0/0\r\n",
+ "expect": "valid"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_ipv6.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_ipv6.json
new file mode 100644
index 0000000..50fede2
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_ipv6.json
@@ -0,0 +1,5 @@
+{
+ "name": "valid_ipv6",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32,fd00:8::2/128\nDNS = 10.8.0.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0,::/0\nPersistentKeepalive = 25\n",
+ "expect": "valid"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_minimal.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_minimal.json
new file mode 100644
index 0000000..37dce8e
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_minimal.json
@@ -0,0 +1,5 @@
+{
+ "name": "valid_minimal",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n",
+ "expect": "valid"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_basic.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_basic.json
new file mode 100644
index 0000000..f766df0
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_basic.json
@@ -0,0 +1,5 @@
+{
+ "name": "valid_server_basic",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n",
+ "expect": "valid"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_internal_dns.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_internal_dns.json
new file mode 100644
index 0000000..01ea902
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_internal_dns.json
@@ -0,0 +1,5 @@
+{
+ "name": "valid_server_internal_dns",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1,1.1.1.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n",
+ "expect": "valid"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_multi_peer.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_multi_peer.json
new file mode 100644
index 0000000..9afd0bb
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_multi_peer.json
@@ -0,0 +1,5 @@
+{
+ "name": "valid_server_multi_peer",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 10.8.0.0/24\nPersistentKeepalive = 25\n\n[Peer]\nPublicKey = wlj4+qAbkzqFiIfYX3eDL5Tf02F9DVmfapEMRrmTbVs=\nEndpoint = gate2.example.com:51820\nAllowedIPs = 10.8.0.3/32\nPersistentKeepalive = 25\n",
+ "expect": "valid"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_rdp_gateway.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_rdp_gateway.json
new file mode 100644
index 0000000..944a231
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_rdp_gateway.json
@@ -0,0 +1,5 @@
+{
+ "name": "valid_server_rdp_gateway",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.5/32\nDNS = 10.8.0.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 10.8.0.1/32\nPersistentKeepalive = 25\n",
+ "expect": "valid"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_unknown_keys.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_unknown_keys.json
new file mode 100644
index 0000000..23ec359
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_unknown_keys.json
@@ -0,0 +1,5 @@
+{
+ "name": "valid_unknown_keys",
+ "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\nSomeMadeUpKey = whatever\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n",
+ "expect": "valid"
+}
diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_whitespace.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_whitespace.json
new file mode 100644
index 0000000..aca1d59
--- /dev/null
+++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_whitespace.json
@@ -0,0 +1,5 @@
+{
+ "name": "valid_whitespace",
+ "config": "[Interface]\n PrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI= \nAddress = 10.8.0.2/32 # the tunnel address\nDNS = 10.8.0.1\n\n[Peer]\n PublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820 # server endpoint\n AllowedIPs = 0.0.0.0/0\n",
+ "expect": "valid"
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2dfd4f3..660da05 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -21,6 +21,7 @@ junit5 = "5.10.2"
mockk = "1.13.11"
turbine = "1.1.0"
robolectric = "4.12.2"
+org-json = "20240303"
[libraries]
# AndroidX Core
@@ -89,6 +90,7 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
+org-json = { group = "org.json", name = "json", version.ref = "org-json" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }