How the ClosedClaw Android App Establishes Secure WebSocket Connections via mDNS

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 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, 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.

// 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 spawns a background coroutine that repeatedly queries the domain using unicast DNS-SD SRV/TXT lookups via the dnsjava library.

// 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, 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.

// 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 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.

// 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 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.

// 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.
  • Transport Security: WebSocket connections use OkHttp with wss:// scheme selection based on the tlsEnabled flag in GatewaySession.kt.
  • Validation Model: GatewayTls.kt implements certificate pinning via fingerprint comparison, supporting both pre-shared fingerprints and trust-on-first-use (TOFU) modes.
  • Orchestration: 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, 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 constructs a plaintext ws:// URL and skips custom TLS configuration. However, NodeRuntime.kt tracks this state to inform the UI layer, which typically warns users that the connection is insecure and unsuitable for production deployments.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →