mirror of
https://github.com/Monkopedia/rouse-context.git
synced 2026-10-19 10:33:18 +00:00
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:
parent
2fdef14887
commit
591304fe81
4 changed files with 211 additions and 1 deletions
96
BLOCKERS.md
96
BLOCKERS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue