# How the ClosedClaw Android App Establishes Secure WebSocket Connections via mDNS

> Discover how the ClosedClaw Android app secures WebSocket connections using mDNS and SHA-256 certificate fingerprinting or TOFU for robust local and remote gateway discovery.

- Repository: [aSafeLobotomy/closedclaw](https://github.com/asafelobotomy/closedclaw)
- Tags: how-to-guide
- Published: 2026-02-25

---

**The ClosedClaw Android app discovers local and remote gateways using mDNS/NSD and wide-area DNS-SD, then establishes encrypted WebSocket connections validated via SHA-256 certificate fingerprinting or trust-on-first-use (TOFU) rather than traditional Certificate Authority chains.**

The [ClosedClaw](https://github.com/asafelobotomy/closedclaw) Android companion app implements a two-stage connection architecture that bridges local network discovery with cryptographic endpoint security. By combining Android's `NsdManager` for multicast DNS discovery with custom unicast DNS-SD queries for VPN environments, the app locates gateway services before initiating TLS-secured WebSocket sessions that verify server identity through pinned fingerprints.

## Stage 1: Discovering Gateways via mDNS and Wide-Area DNS-SD

The discovery layer operates on two distinct networking modes: local multicast for LAN environments and unicast for wide-area VPNs.

### Local Network Discovery with NsdManager

In [`GatewayDiscovery.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/GatewayDiscovery.kt), the app initiates service discovery using Android's `NsdManager` to browse for gateways advertising the service type `_ClosedClaw-gw._tcp.`. When a service is resolved, the implementation extracts host addresses, ports, and TXT records containing TLS configuration metadata.

```kotlin
// GatewayDiscovery.kt – start local NSD discovery
nsd.discoverServices(
    "_ClosedClaw-gw._tcp.",               // service type
    NsdManager.PROTOCOL_DNS_SD,
    discoveryListener
)

// When a service is found, extract the TXT records and build a GatewayEndpoint
override fun onServiceResolved(resolved: NsdServiceInfo) {
    val host = resolved.host?.hostAddress ?: return
    val port = resolved.port
    val serviceName = BonjourEscapes.decode(resolved.serviceName)
    val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)

    val endpoint = GatewayEndpoint(
        stableId = stableId(serviceName, "local."),
        name = displayName,
        host = host,
        port = port,
        tlsEnabled = txtBool(resolved, "gatewayTls"),
        tlsFingerprintSha256 = txt(resolved, "gatewayTlsSha256")
    )
    localById[endpoint.stableId] = endpoint
    publish()
}

```

The `GatewayEndpoint` data class captures critical security parameters including `tlsEnabled` and `tlsFingerprintSha256`, which determine whether the subsequent WebSocket connection requires encryption and which certificate fingerprint to expect.

### Cross-Network Discovery via Unicast DNS-SD

For scenarios involving Tailscale or other VPN mesh networks where multicast packets do not traverse subnets, the app implements wide-area discovery. When the environment variable `ClosedClaw_WIDE_AREA_DOMAIN` is set, [`GatewayDiscovery.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/GatewayDiscovery.kt) spawns a background coroutine that repeatedly queries the domain using unicast DNS-SD SRV/TXT lookups via the `dnsjava` library.

```kotlin
// Wide‑area unicast DNS‑SD (run every 5 s if WIDE_AREA_DOMAIN is set)
private suspend fun refreshUnicast(domain: String) {
    // Build SRV/TXT query using dnsjava (org.xbill.DNS)
    val lookup = Lookup("_ClosedClaw-gw._tcp.$domain", Type.SRV)
    val records = lookup.run() ?: return
    // …parse SRV and TXT, then put into unicastById
}

```

This dual-mode discovery ensures gateways are locatable whether the device shares a local broadcast domain with the gateway or connects through routed VPN infrastructure.

## Stage 2: Establishing Secure WebSocket Connections

Once discovery yields a `GatewayEndpoint`, the app constructs a WebSocket connection using OkHttp, applying custom TLS validation that bypasses traditional CA verification in favor of explicit fingerprint pinning.

### Building the WebSocket URL and Client

In [`GatewaySession.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/GatewaySession.kt), the connection logic inspects the endpoint's `tlsEnabled` flag to select the appropriate scheme (`wss://` or `ws://`), then constructs an OkHttp client with a custom `SSLSocketFactory` when cryptographic verification is required.

```kotlin
// GatewaySession.kt – choose scheme and create OkHttp client
suspend fun connect() {
    val scheme = if (tls != null) "wss" else "ws"
    val url = "$scheme://${endpoint.host}:${endpoint.port}"
    val request = Request.Builder().url(url).build()
    socket = client.newWebSocket(request, Listener())
}

// buildClient() injects TLS configuration when fingerprint is provided
private fun buildClient(): OkHttpClient {
    val builder = OkHttpClient.Builder()
    val tlsConfig = buildGatewayTlsConfig(tls) { fp -> onTlsFingerprint?.invoke(endpoint.stableId, fp) }
    tlsConfig?.let {
        builder.sslSocketFactory(it.sslSocketFactory, it.trustManager)
        builder.hostnameVerifier(it.hostnameVerifier)
    }
    return builder.build()
}

```

### Fingerprint-Based TLS Validation and TOFU

The [`GatewayTls.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/GatewayTls.kt) file defines the security model. Instead of trusting system root CAs, the app computes the SHA-256 hash of the leaf certificate and compares it against the fingerprint advertised in the mDNS TXT record or stored from a previous connection.

```kotlin
// GatewayTls.kt – custom TrustManager
val trustManager = object : X509TrustManager {
    override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
        if (chain.isEmpty()) throw CertificateException("empty certificate chain")
        val fingerprint = sha256Hex(chain[0].encoded)

        // Expected fingerprint supplied via discovery (or stored after first use)
        val expected = params.expectedFingerprint?.let(::normalizeFingerprint)

        when {
            expected != null && fingerprint != expected ->
                throw CertificateException("gateway TLS fingerprint mismatch")
            expected == null && params.allowTOFU -> {
                // Store the first‑seen fingerprint for later runs (TOFU)
                onStore?.invoke(fingerprint)
                return
            }
            else -> defaultTrust.checkServerTrusted(chain, authType) // normal CA validation
        }
    }
}

```

When `allowTOFU` is enabled and no prior fingerprint exists, the app implements **trust-on-first-use**, caching the observed fingerprint for subsequent validation. This allows self-signed certificates without requiring users to manually import Certificate Authority files.

## Runtime Integration in NodeRuntime

The [`NodeRuntime.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/NodeRuntime.kt) file serves as the orchestration layer, bridging discovery results with session initialization. It evaluates the endpoint metadata to determine TLS requirements and constructs the `GatewayTlsParams` object that configures the fingerprint validation strategy.

```kotlin
// NodeRuntime.kt – pick the chosen endpoint and start the session
val endpoint = selectedGatewayEndpoint
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
val tlsParams = if (hinted) {
    GatewayTlsParams(
        required = endpoint.tlsEnabled,
        expectedFingerprint = endpoint.tlsFingerprintSha256,
        allowTOFU = true,
        stableId = endpoint.stableId
    )
} else null

val session = GatewaySession(
    scope = coroutineScope,
    identityStore = deviceIdentityStore,
    deviceAuthStore = deviceAuthStore,
    onConnected = { … },
    onDisconnected = { … },
    onEvent = { … },
    onTlsFingerprint = ::storeFingerprint
)
session.connect(endpoint, token, password, clientInfo, tlsParams)
session.start()

```

This integration ensures that UI elements can display "Secure" or "Insecure" connection states based on whether fingerprint validation is active, providing user feedback about the cryptographic posture of the gateway link.

## Summary

- **Discovery Mechanism**: The app uses `NsdManager` for local `_ClosedClaw-gw._tcp.` multicast discovery and falls back to unicast DNS-SD when `ClosedClaw_WIDE_AREA_DOMAIN` is configured for VPN environments.
- **Endpoint Metadata**: TXT records transmit `gatewayTls` flags and SHA-256 fingerprints via the `GatewayEndpoint` data class in [`GatewayDiscovery.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/GatewayDiscovery.kt).
- **Transport Security**: WebSocket connections use OkHttp with `wss://` scheme selection based on the `tlsEnabled` flag in [`GatewaySession.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/GatewaySession.kt).
- **Validation Model**: [`GatewayTls.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/GatewayTls.kt) implements certificate pinning via fingerprint comparison, supporting both pre-shared fingerprints and trust-on-first-use (TOFU) modes.
- **Orchestration**: [`NodeRuntime.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/NodeRuntime.kt) coordinates discovery results with session parameters, enabling runtime decisions about TLS requirement severity.

## Frequently Asked Questions

### How does the ClosedClaw Android app discover gateways on different networks?

The app utilizes two complementary discovery mechanisms. For local networks, it employs Android's `NsdManager` to listen for multicast DNS advertisements of type `_ClosedClaw-gw._tcp.`. For remote networks accessed via VPNs like Tailscale, it checks the `ClosedClaw_WIDE_AREA_DOMAIN` environment variable and performs unicast DNS-SD queries using the `dnsjava` library to resolve SRV and TXT records across routed subnets.

### What security model does the app use instead of traditional Certificate Authorities?

Rather than relying on public CA chains, the app implements **fingerprint-based validation** using SHA-256 hashes of the gateway's leaf certificate. During the TLS handshake in [`GatewayTls.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/GatewayTls.kt), the `checkServerTrusted` method computes the certificate fingerprint and compares it against the value advertised in the mDNS TXT record or stored from a previous session. This eliminates the need for purchased certificates while maintaining cryptographic certainty about the server's identity.

### What is TOFU (Trust On First Use) and when does the app use it?

TOFU is a security paradigm where the client automatically trusts the first certificate encountered from a specific endpoint, then stores that fingerprint for future verification. In ClosedClaw, when the `allowTOFU` parameter is true in `GatewayTlsParams` and no pre-existing fingerprint is available, the app caches the SHA-256 hash of the first-seen certificate via the `onStore` callback. Subsequent connections must match this stored fingerprint, protecting against man-in-the-middle attacks after the initial encounter.

### Can the app connect to gateways without TLS encryption?

Yes. If the discovered `GatewayEndpoint` has `tlsEnabled` set to false and no fingerprint is provided, [`GatewaySession.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/GatewaySession.kt) constructs a plaintext `ws://` URL and skips custom TLS configuration. However, [`NodeRuntime.kt`](https://github.com/asafelobotomy/closedclaw/blob/main/NodeRuntime.kt) tracks this state to inform the UI layer, which typically warns users that the connection is insecure and unsuitable for production deployments.