Configure mTLS client cert for relay WebSocket connection

TunnelClientImpl was using a bare HttpClient with no client certificate,
causing the relay to return 401 on /ws (requires DeviceIdentity from
mTLS). Add MtlsHttpClientFactory that loads the device cert from
rouse_cert.pem and private key from Android Keystore, configuring the
Ktor CIO engine's TLS certificates list.

The relay still uses StubAcme so the device only has "stub-cert" on
disk, which means the 401 persists until real ACME cert issuance is
implemented relay-side. Updated BLOCKERS.md with diagnosis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Monk 2026-04-05 19:58:55 -04:00
parent 2fdef14887
commit 591304fe81
4 changed files with 211 additions and 1 deletions

View file

@ -92,3 +92,99 @@ connection to the local relay, then checks `/status` for `active_mux_connections
- Handle the ws:// vs wss:// scheme based on whether TLS is configured
- Ensure the `TunnelForegroundService` is declared in a manifest (currently the
`:work` module's `AndroidManifest.xml` is empty)
---
# Blockers for FCM Wake Flow (April 2026)
## Summary
The FCM wake flow was tested end-to-end on a Pixel 2 XL (API 30) with
package `com.rousecontext.debug`. The flow starts correctly but the WebSocket
connection to the relay is rejected with HTTP 401 because the device cannot
present a valid mTLS client certificate.
## What works
1. **ADB test broadcast**: `TestWakeReceiver` correctly receives the broadcast
and starts `TunnelForegroundService`.
2. **Foreground service**: The service creates successfully, shows a foreground
notification, and attempts to connect via `TunnelClientImpl.connect()`.
3. **Koin DI**: All dependencies (TunnelClient, WakelockManager,
IdleTimeoutManager, relayUrl) resolve correctly at runtime.
4. **Relay connectivity**: The device can reach `relay.rousecontext.com:443`
and the TLS handshake to the relay completes (the 401 comes at HTTP level,
not TLS level, because the relay uses `allow_unauthenticated()` for TLS).
5. **Onboarding flow**: The UI correctly detects missing subdomain and presents
the onboarding screen. Tapping "Get Started" triggers the full flow:
Firebase anonymous auth, FCM token retrieval, CSR generation, and relay
`/register` call. The relay responds with a subdomain.
## Blockers
### 1. Relay ACME client is stubbed (relay-side)
**File**: `relay/src/main.rs` line 95
```rust
let acme: Arc<dyn rouse_relay::acme::AcmeClient> = Arc::new(StubAcme);
```
The production relay at `relay.rousecontext.com` uses `StubAcme`, which returns
`"stub-cert"` instead of a real PEM certificate chain. This means:
- The device's `rouse_cert.pem` file contains the literal string `stub-cert`
- There is no valid X.509 certificate for the device to present during mTLS
- The relay's `/ws` handler checks for a `DeviceIdentity` extension (extracted
from the client cert CN/SAN) and returns 401 when none is present
**To fix**: Wire up a real ACME client (Let's Encrypt + Cloudflare DNS-01) in
the relay. The `AcmeClient` trait already exists in `relay/src/acme.rs`; a
real implementation needs to be built and connected in `main.rs`.
### 2. mTLS HttpClient was not configured (FIXED in this branch)
**Before this fix**: `TunnelClientImpl` used a bare `HttpClient` with no client
certificate configuration. Even with a valid cert on disk, it would never be
presented during the TLS handshake.
**After this fix**: The Koin module creates an `HttpClient` via
`MtlsHttpClientFactory` which loads the device cert from `rouse_cert.pem` and
the private key from Android Keystore, then configures the Ktor CIO engine's
TLS `certificates` list with a `CertificateAndKey`.
## ADB test command
```bash
# Force-stop ensures fresh Koin initialization
adb shell am force-stop com.rousecontext.debug
# Send wake broadcast (must include type=wake extra)
adb shell am broadcast \
-n com.rousecontext.debug/com.rousecontext.app.debug.TestWakeReceiver \
-a com.rousecontext.action.TEST_WAKE \
--es type wake
# Watch logs
adb logcat -s TestWakeReceiver:* TunnelForegroundService:* MtlsHttpClientFactory:*
```
## Observed log sequence (with fix, stub cert)
```
MtlsHttpClientFactory: Certificate chain is empty
MtlsHttpClientFactory: No device cert available, creating plain HttpClient
TunnelForegroundService: TunnelForegroundService created
TunnelForegroundService: Tunnel disconnected, stopping service
TunnelForegroundService: Failed to connect to relay
ConnectionFailed: Handshake exception, expected status code 101 but was 401
TunnelForegroundService: TunnelForegroundService destroyed
```
## Next steps
1. Implement real ACME cert issuance in the relay (wire `AcmeClient` trait
to Let's Encrypt + Cloudflare DNS-01)
2. Re-onboard the device to get a real certificate
3. Re-test the wake flow -- with a valid cert, `MtlsHttpClientFactory` will
configure the HttpClient and the relay should accept the mTLS connection

View file

@ -99,6 +99,10 @@ dependencies {
implementation(project(":notifications"))
implementation(project(":work"))
// Ktor (mTLS WebSocket client for tunnel)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity)

View file

@ -0,0 +1,106 @@
package com.rousecontext.app.cert
import android.content.Context
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.network.tls.CertificateAndKey
import java.io.File
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
/**
* Creates a Ktor [HttpClient] configured with the device's mTLS client certificate
* for connecting to the relay WebSocket endpoint.
*
* The private key lives in the Android Keystore (hardware-backed HSM).
* The cert chain is stored as PEM on disk.
*/
object MtlsHttpClientFactory {
private const val TAG = "MtlsHttpClientFactory"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "rouse_device_key"
private const val CERT_PEM_FILE = "rouse_cert.pem"
/**
* Build an [HttpClient] that presents the device client certificate during TLS handshake.
*
* Reads the cert from disk and the private key from Android Keystore synchronously.
* Safe to call from Koin module initialization (runs on main thread during app startup).
*
* @param context Android context for accessing filesDir.
* @return An [HttpClient] configured for mTLS, or a plain client if no cert is available.
*/
fun create(context: Context): HttpClient {
val certAndKey = loadCertAndKey(context)
return if (certAndKey != null) {
Log.i(TAG, "Creating mTLS-configured HttpClient")
HttpClient(CIO) {
engine {
https {
certificates.add(certAndKey)
}
}
install(WebSockets)
}
} else {
Log.w(TAG, "No device cert available, creating plain HttpClient")
HttpClient(CIO) {
install(WebSockets)
}
}
}
private fun loadCertAndKey(context: Context): CertificateAndKey? {
val certFile = File(context.filesDir, CERT_PEM_FILE)
if (!certFile.exists()) {
Log.w(TAG, "No certificate file found at ${certFile.absolutePath}")
return null
}
val pemChain = certFile.readText()
val certs = parsePemCertificates(pemChain)
if (certs.isEmpty()) {
Log.w(TAG, "Certificate chain is empty")
return null
}
val androidKeyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
androidKeyStore.load(null)
if (!androidKeyStore.containsAlias(KEY_ALIAS)) {
Log.w(TAG, "Private key alias '$KEY_ALIAS' not found in Android Keystore")
return null
}
val privateKey = androidKeyStore.getKey(KEY_ALIAS, null) as? PrivateKey
if (privateKey == null) {
Log.w(TAG, "Key entry '$KEY_ALIAS' is not a PrivateKey")
return null
}
Log.i(TAG, "Loaded device cert (${certs.size} certs in chain) and private key")
return CertificateAndKey(certs.toTypedArray(), privateKey)
}
private fun parsePemCertificates(pem: String): List<X509Certificate> {
val factory = CertificateFactory.getInstance("X.509")
val certs = mutableListOf<X509Certificate>()
val regex = Regex(
"-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----",
RegexOption.DOT_MATCHES_ALL
)
for (match in regex.findAll(pem)) {
val base64 = match.groupValues[1].replace("\\s".toRegex(), "")
val der = java.util.Base64.getDecoder().decode(base64)
val cert = factory.generateCertificate(der.inputStream()) as X509Certificate
certs.add(cert)
}
return certs
}
}

View file

@ -5,6 +5,7 @@ import com.rousecontext.api.McpIntegration
import com.rousecontext.api.NotificationSettingsProvider
import com.rousecontext.app.BuildConfig
import com.rousecontext.app.cert.FileCertificateStore
import com.rousecontext.app.cert.MtlsHttpClientFactory
import com.rousecontext.app.health.RealHealthConnectRepository
import com.rousecontext.app.registry.HealthConnectIntegration
import com.rousecontext.app.registry.IntegrationProviderRegistry
@ -109,7 +110,10 @@ val appModule = module {
}
single<TunnelClient> {
TunnelClientImpl(scope = get(named("appScope")))
TunnelClientImpl(
scope = get(named("appScope")),
httpClient = MtlsHttpClientFactory.create(androidContext())
)
}
single {