From 0b35dc043f79753726d9a05e03587073e8992ad4 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 19 Jul 2026 08:31:29 +0200 Subject: [PATCH] feat(android): wire zero-touch device enrollment + fix renew/cache (B-track) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wire the enroll flow into the app UI (EnrollmentScreen + ViewModel + Hilt DI + host-menu "自动获取证书"), mirroring iOS — Android previously only had manual .p12 import; the enroll library was built but unreachable. - renew is now mTLS-only ({csr}-only body, no Authorization header) matching the /device/:id/renew contract (the enroll bearer is minutes-lived → silent rotation would have thrown weeks later). - enroll refreshes the identity-repository cache so a mid-session-enrolled cert is presented on the next mTLS handshake without a process restart. gradle :app:assembleDebug + api-client/client-tls-android unit tests + koverVerify green. On-device QA (keygen/enroll/present) is the operator's step. --- .../api/enroll/DeviceEnrollmentClient.kt | 17 +- .../api/enroll/DeviceEnrollmentClientTest.kt | 28 +- .../java/wang/yaojia/webterm/di/TlsModule.kt | 31 +- .../java/wang/yaojia/webterm/nav/NavGraph.kt | 5 + .../java/wang/yaojia/webterm/nav/Panes.kt | 34 ++ .../wang/yaojia/webterm/nav/SessionsHome.kt | 1 + .../webterm/screens/EnrollmentScreen.kt | 385 ++++++++++++++++++ .../webterm/screens/SessionListScreen.kt | 10 +- .../webterm/viewmodels/EnrollmentViewModel.kt | 265 ++++++++++++ .../yaojia/webterm/wiring/AppEnvironment.kt | 5 + .../webterm/wiring/EnrollmentFlowFactory.kt | 51 +++ .../viewmodels/EnrollmentViewModelTest.kt | 213 ++++++++++ .../tlsandroid/IdentityRepositoryTest.kt | 25 ++ .../webterm/tlsandroid/DeviceEnroller.kt | 23 +- .../webterm/tlsandroid/IdentityRepository.kt | 28 +- .../webterm/tlsandroid/DeviceEnrollerTest.kt | 39 +- 16 files changed, 1128 insertions(+), 32 deletions(-) create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/EnrollmentScreen.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/EnrollmentFlowFactory.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModelTest.kt diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt index f524c76..00ea5eb 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt @@ -77,11 +77,16 @@ public class DeviceEnrollmentClient( } /** - * Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` under the enroll - * [bearerToken] (silent-rotation seam). Required fields validated client-side before any I/O. + * Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam). + * + * The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is + * `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken] + * is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is + * null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the + * production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O. */ - public suspend fun renew(bearerToken: String, deviceId: String, csrDer: ByteArray): EnrollmentResult { - if (bearerToken.isEmpty() || deviceId.isEmpty() || csrDer.isEmpty()) { + public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult { + if (deviceId.isEmpty() || csrDer.isEmpty()) { throw DeviceEnrollmentError.InvalidRequest } // Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert @@ -106,7 +111,9 @@ public class DeviceEnrollmentClient( ): HttpRequest { val headers = LinkedHashMap() headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON - if (bearer != null) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer" + // Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty + // string must never emit a bare "Bearer " header. + if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer" return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody) } diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt index 8dd5010..4deadf2 100644 --- a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt @@ -208,20 +208,21 @@ class DeviceEnrollmentClientTest { assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers") } - // ── renew (silent rotation seam) ─────────────────────────────────────────────────────────── + // ── renew (silent rotation seam — mTLS-only, NO bearer) ───────────────────────────────────── @Test - fun renewTargetsDeviceIdRenewWithTheMinimalBody() = runTest { + fun renewTargetsDeviceIdRenewWithTheMinimalBodyAndNoAuthorizationHeader() = runTest { transport.queueSuccess( method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"), ) val csr = byteArrayOf(0x02) - val result = client.renew(BEARER, "dev-9", csr) + // Production renew passes NO bearer — the endpoint authenticates by the current device cert (mTLS). + val result = client.renew("dev-9", csr) val request = transport.recordedRequests.single() assertEquals("$BASE/device/dev-9/renew", request.url) - assertEquals("Bearer $BEARER", request.headers["Authorization"]) + assertNull(request.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header") val obj = bodyObject(request) assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content) // The server's /device/:id/renew authenticates by the presented mTLS device cert and its body @@ -234,10 +235,21 @@ class DeviceEnrollmentClientTest { } @Test - fun renewRejectsEmptyFieldsBeforeAnyNetworkIo() = runTest { - assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", "d", byteArrayOf(1)) }.exceptionOrNull()) - assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew(BEARER, "", byteArrayOf(1)) }.exceptionOrNull()) - assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew(BEARER, "d", ByteArray(0)) }.exceptionOrNull()) + fun renewForwardsAnExplicitBearerWhenTheOptionalSeamIsUsed() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"), + ) + + // The bearer is optional/absent by default; when a caller DOES pass one it rides as a header. + client.renew("dev-9", byteArrayOf(0x02), bearerToken = BEARER) + + assertEquals("Bearer $BEARER", transport.recordedRequests.single().headers["Authorization"]) + } + + @Test + fun renewRejectsEmptyDeviceIdOrCsrBeforeAnyNetworkIo() = runTest { + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", byteArrayOf(1)) }.exceptionOrNull()) + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("d", ByteArray(0)) }.exceptionOrNull()) assertTrue(transport.recordedRequests.isEmpty()) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt index 94c636f..3a25ba3 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt @@ -11,8 +11,11 @@ import okhttp3.OkHttpClient import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.TinkCertStore +import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore import javax.inject.Singleton /** @@ -41,16 +44,38 @@ public object TlsModule { @Singleton public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context) + /** The Tink-AEAD-encrypted enrollment record (deviceId + key alias) the zero-`.p12` renew path reads. */ @Provides @Singleton - public fun provideIdentityRepository( + public fun provideEnrollmentRecordStore( + @ApplicationContext context: Context, + ): EnrollmentRecordStore = TinkEnrollmentRecordStore(context) + + /** + * The ONE mTLS device-identity repository, provided as the concrete type so BOTH the + * [IdentityRepository] surface (import/rotate/remove/summary) and the narrow [IdentityCacheRefresher] + * seam (B4 enroll cache-freshness) resolve to the SAME singleton instance. + */ + @Provides + @Singleton + public fun provideAndroidIdentityRepository( importer: AndroidKeyStoreImporter, certStore: CertStore, connectionPool: ConnectionPool, - ): IdentityRepository { - // Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s + ): AndroidIdentityRepository { + // Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()/refresh's // `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8). val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build() return AndroidIdentityRepository(importer, certStore, evictClient) } + + @Provides + @Singleton + public fun provideIdentityRepository(repository: AndroidIdentityRepository): IdentityRepository = repository + + /** FIX 3: the enroll/renew commit refreshes THIS same repository's in-memory cache (no restart). */ + @Provides + @Singleton + public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher = + repository } diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt index 0ba6072..f50dea7 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt @@ -67,6 +67,8 @@ public fun WebTermNavHost( composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) } + composable(NavRoutes.ENROLL) { EnrollmentPane(env = env, onBack = { navController.popBackStack() }) } + composable( route = TERMINAL_ROUTE, arguments = listOf( @@ -211,6 +213,9 @@ public object NavRoutes { /** Device-certificate management (A27). */ public const val CERT: String = "cert" + /** Zero-`.p12` device enrollment (B4) — obtains a hardware-bound cert with no file. */ + public const val ENROLL: String = "enroll" + /** Terminal route pattern with the required host + session path args (A21). */ public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}" diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt index 5f22814..6ebbb5d 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt @@ -20,11 +20,13 @@ import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.screens.ClientCertScreen import wang.yaojia.webterm.screens.DiffScreen +import wang.yaojia.webterm.screens.EnrollmentScreen import wang.yaojia.webterm.screens.PairingScreen import wang.yaojia.webterm.screens.ProjectDetailScreen import wang.yaojia.webterm.viewmodels.ApiClientGitWriteGateway import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway import wang.yaojia.webterm.viewmodels.ClientCertViewModel +import wang.yaojia.webterm.viewmodels.EnrollmentViewModel import wang.yaojia.webterm.viewmodels.DiffViewModel import wang.yaojia.webterm.viewmodels.HttpDiffFetcher import wang.yaojia.webterm.viewmodels.PairingViewModel @@ -75,6 +77,38 @@ public fun ClientCertPane( ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier) } +// ── Device enrollment (B4, zero-.p12 auto-cert) ───────────────────────────────────────────────────── + +/** + * Hosts [EnrollmentScreen] over a fresh [EnrollmentViewModel] (mirrors iOS `AppCoordinator`'s + * `makeEnrollmentViewModel`). The enroll flow and the installed-summary read run OFF `Main` + * (`Dispatchers.IO`) — resolving the enroller resolves the lazy shared client (keystore/TLS I/O) and the + * enroll itself does hardware-keygen + network. The typed control-plane URL builds the enroller per attempt. + */ +@Composable +public fun EnrollmentPane( + env: AppEnvironment, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel = remember(env) { + EnrollmentViewModel( + enrollOperation = { password, subdomain, deviceName, controlPlaneUrl -> + withContext(Dispatchers.IO) { + env.enrollmentFlowFactory.create(controlPlaneUrl).enroll(password, subdomain, deviceName) + } + }, + loadSummary = { + withContext(Dispatchers.IO) { + runCatching { env.identityRepository.currentSummary() }.getOrNull() + } + }, + defaultDeviceName = android.os.Build.MODEL ?: "", + ) + } + EnrollmentScreen(viewModel = viewModel, onBack = onBack, modifier = modifier) +} + // ── Project detail (A23) ────────────────────────────────────────────────────────────────────────────── /** diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt index 4d6a300..3899926 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt @@ -87,6 +87,7 @@ public fun SessionsHome( onOpenSession = openSession, onNewSession = openNewSession, onPairHost = { navController.navigate(NavRoutes.PAIRING) }, + onEnroll = { navController.navigate(NavRoutes.ENROLL) }, onImportCert = { navController.navigate(NavRoutes.CERT) }, ) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/EnrollmentScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/EnrollmentScreen.kt new file mode 100644 index 0000000..cbaa73b --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/EnrollmentScreen.kt @@ -0,0 +1,385 @@ +package wang.yaojia.webterm.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import wang.yaojia.webterm.designsystem.Spacing +import wang.yaojia.webterm.designsystem.WebTermColors +import wang.yaojia.webterm.designsystem.WebTermTheme +import wang.yaojia.webterm.designsystem.WebTermType +import wang.yaojia.webterm.viewmodels.CertSummaryView +import wang.yaojia.webterm.viewmodels.EnrollError +import wang.yaojia.webterm.viewmodels.EnrollPhase +import wang.yaojia.webterm.viewmodels.EnrollmentUiState +import wang.yaojia.webterm.viewmodels.EnrollmentViewModel + +/** + * # EnrollmentScreen (B4) — zero-`.p12` device enrollment (the phone half of zero-touch). + * + * The Compose shell over [EnrollmentViewModel], reached from the session-list host menu "自动获取证书" + * (mirrors iOS `EnrollmentScreen` presented from `SessionListScreen`'s host menu). One operator login + * generates a NON-EXPORTABLE hardware key + CSR and obtains a device cert with no file at all; the cert is + * committed to the shared store and presented automatically on the existing mTLS path (cache-refreshed, so + * no restart). The manual `.p12` path ([ClientCertScreen]) remains available alongside this one. + * + * ### Secrets & trust discipline (plan §8) + * - The operator password is a masked field bound straight to the ViewModel and cleared after every + * attempt — never logged, persisted, or echoed. The private key is generated non-exportably in secure + * hardware inside the library; this screen never sees it. + * - Every cert-derived string (CNs, expiry) and every error message is inert [Text] — no autolink/markdown. + * Error copy is app-authored, never a server/exception string. + * + * The keystore/network I/O it drives is device-QA (plan §7); the state machine is JVM-tested in + * `EnrollmentViewModelTest`. + */ +@Composable +public fun EnrollmentScreen( + viewModel: EnrollmentViewModel, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(viewModel) { viewModel.bind(this) } + + EnrollmentScreen( + state = state, + onControlPlaneUrlChange = viewModel::onControlPlaneUrlChange, + onSubdomainChange = viewModel::onSubdomainChange, + onDeviceNameChange = viewModel::onDeviceNameChange, + onPasswordChange = viewModel::onPasswordChange, + onEnroll = viewModel::enroll, + onDismissError = viewModel::clearError, + modifier = modifier, + onBack = onBack, + ) +} + +/** Stateless body — pure inputs so a [Preview] and any host can drive it without the VM machinery. */ +@Composable +public fun EnrollmentScreen( + state: EnrollmentUiState, + onControlPlaneUrlChange: (String) -> Unit, + onSubdomainChange: (String) -> Unit, + onDeviceNameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onEnroll: () -> Unit, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(Spacing.lg16), + verticalArrangement = Arrangement.spacedBy(Spacing.md12), + ) { + Header(onBack = onBack) + + if (state.phase == EnrollPhase.LOADING) { + LoadingRow() + return@Column + } + + InstalledSection(summary = state.summary) + + state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) } + if (state.didSucceed && state.error == null) SuccessCard() + + EnrollForm( + state = state, + onControlPlaneUrlChange = onControlPlaneUrlChange, + onSubdomainChange = onSubdomainChange, + onDeviceNameChange = onDeviceNameChange, + onPasswordChange = onPasswordChange, + onEnroll = onEnroll, + ) + + Text( + text = EnrollCopy.FOOTER, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun Header(onBack: (() -> Unit)?) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + if (onBack != null) { + TextButton(onClick = onBack) { Text("返回") } + } + Text(text = EnrollCopy.TITLE, style = MaterialTheme.typography.headlineSmall) + } +} + +/** The currently-installed identity summary (or "none" copy) — the "已安装证书" section. */ +@Composable +private fun InstalledSection(summary: CertSummaryView?) { + if (summary == null) { + Text( + text = EnrollCopy.NONE_INSTALLED, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.lg16), + verticalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + SummaryRow(label = "设备(CN)", value = summary.subjectCommonName) + SummaryRow(label = "签发方(CN)", value = summary.issuerCommonName) + SummaryRow(label = "到期", value = summary.expiry) + if (summary.isExpired) { + HorizontalDivider(color = MaterialTheme.colorScheme.outline) + Text( + text = "⚠ 证书已过期,请重新注册。", + style = MaterialTheme.typography.bodyMedium, + color = WebTermColors.statusStuck, + ) + } + } + } +} + +@Composable +private fun SummaryRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.md12), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + // Cert-derived value: inert monospaced Text — no linkify/markdown (plan §8). + Text(text = value, style = WebTermType.monoTabular(13), color = MaterialTheme.colorScheme.onSurface) + } +} + +@Composable +private fun EnrollForm( + state: EnrollmentUiState, + onControlPlaneUrlChange: (String) -> Unit, + onSubdomainChange: (String) -> Unit, + onDeviceNameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onEnroll: () -> Unit, +) { + val enrolling = state.phase == EnrollPhase.ENROLLING + Text( + text = if (state.summary == null) EnrollCopy.ENROLL_HEADER else EnrollCopy.ROTATE_HEADER, + style = MaterialTheme.typography.titleSmall, + ) + OutlinedTextField( + value = state.controlPlaneUrl, + onValueChange = onControlPlaneUrlChange, + label = { Text(EnrollCopy.CONTROL_PLANE_URL) }, + singleLine = true, + enabled = !enrolling, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.subdomain, + onValueChange = onSubdomainChange, + label = { Text(EnrollCopy.SUBDOMAIN) }, + singleLine = true, + enabled = !enrolling, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.deviceName, + onValueChange = onDeviceNameChange, + label = { Text(EnrollCopy.DEVICE_NAME) }, + singleLine = true, + enabled = !enrolling, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.password, + onValueChange = onPasswordChange, + label = { Text(EnrollCopy.PASSWORD) }, + singleLine = true, + enabled = !enrolling, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = onEnroll, + enabled = state.canEnroll, + modifier = Modifier.fillMaxWidth(), + ) { + if (enrolling) { + CircularProgressIndicator(modifier = Modifier.padding(Spacing.xs4)) + } else { + Text(if (state.summary == null) EnrollCopy.ENROLL_ACTION else EnrollCopy.ROTATE_ACTION) + } + } +} + +@Composable +private fun ErrorCard(error: EnrollError, onDismiss: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.md12), + verticalArrangement = Arrangement.spacedBy(Spacing.xs4), + ) { + Text( + text = errorCopy(error), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("知道了") } + } + } +} + +@Composable +private fun SuccessCard() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Text( + text = EnrollCopy.SUCCESS, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.md12), + ) + } +} + +@Composable +private fun LoadingRow() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } +} + +/** Maps the coarse [EnrollError] to inert, app-authored copy (never an exception/server string, §8). */ +private fun errorCopy(error: EnrollError): String = when (error) { + EnrollError.INVALID_URL -> "控制面地址无效,请输入 https:// 开头的完整地址。" + EnrollError.MISSING_FIELDS -> "请填写子域名、设备名称和操作口令。" + EnrollError.BAD_CREDENTIAL -> "操作口令错误,请重试。" + EnrollError.SUBDOMAIN_NOT_OWNED -> "该账号未拥有此子域名,无法签发证书。" + EnrollError.RATE_LIMITED -> "请求过于频繁,请稍后再试。" + EnrollError.REJECTED -> "请求被拒绝:子域名或证书请求无效。" + EnrollError.ENROLL_FAILED -> "注册失败,请稍后重试。" + EnrollError.SERVER -> "服务器返回异常,请稍后重试。" + EnrollError.KEYGEN -> "生成硬件密钥失败(本设备可能不支持安全硬件)。" + EnrollError.UNKNOWN -> "注册失败,请重试。" +} + +/** User-facing copy (Chinese), mirroring iOS `EnrollmentCopy`. */ +private object EnrollCopy { + const val TITLE = "自动获取证书" + const val NONE_INSTALLED = "尚未安装设备证书。" + const val ENROLL_HEADER = "注册本设备" + const val ROTATE_HEADER = "重新注册" + const val CONTROL_PLANE_URL = "控制面地址" + const val SUBDOMAIN = "子域名(你拥有的隧道名)" + const val DEVICE_NAME = "设备名称" + const val PASSWORD = "操作口令" + const val ENROLL_ACTION = "注册本设备" + const val ROTATE_ACTION = "重新注册" + const val SUCCESS = "已注册,证书已保存到本设备安全硬件,连接隧道主机时将自动出示。" + const val FOOTER = + "首次登录一次即可:本设备在安全硬件(StrongBox / TEE)生成不可导出的私钥,向控制面申请证书并自动保存;" + + "之后连接隧道主机时自动出示,无需再手动导入 .p12。" +} + +@Preview(name = "EnrollmentScreen — none installed") +@Composable +private fun EnrollmentScreenNonePreview() { + WebTermTheme { + EnrollmentScreen( + state = EnrollmentUiState( + controlPlaneUrl = "https://cp.terminal.yaojia.wang", + subdomain = "alice", + deviceName = "Pixel 8", + phase = EnrollPhase.IDLE, + ), + onControlPlaneUrlChange = {}, + onSubdomainChange = {}, + onDeviceNameChange = {}, + onPasswordChange = {}, + onEnroll = {}, + onDismissError = {}, + ) + } +} + +@Preview(name = "EnrollmentScreen — installed + error") +@Composable +private fun EnrollmentScreenInstalledPreview() { + WebTermTheme { + EnrollmentScreen( + state = EnrollmentUiState( + controlPlaneUrl = "https://cp.terminal.yaojia.wang", + subdomain = "alice", + deviceName = "Pixel 8", + summary = CertSummaryView("alice-pixel", "webterm-device-ca", "2027年1月8日", isExpired = false), + phase = EnrollPhase.IDLE, + error = EnrollError.SUBDOMAIN_NOT_OWNED, + ), + onControlPlaneUrlChange = {}, + onSubdomainChange = {}, + onDeviceNameChange = {}, + onPasswordChange = {}, + onEnroll = {}, + onDismissError = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt index fde7fce..cc9e03c 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt @@ -77,6 +77,7 @@ import java.util.UUID * @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first). * @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal). * @param onPairHost host-menu **配对新主机** → the pairing flow (A19). + * @param onEnroll host-menu **自动获取证书** → the zero-`.p12` enrollment screen (B4). * @param onImportCert host-menu **设备证书** → the device-cert screen (A27). * @param thumbnails off-screen preview seam; production wires it to the active host's * [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles. @@ -87,6 +88,7 @@ public fun SessionListScreen( onOpenSession: (UUID) -> Unit, onNewSession: () -> Unit, onPairHost: () -> Unit, + onEnroll: () -> Unit, onImportCert: () -> Unit, modifier: Modifier = Modifier, thumbnails: SessionThumbnails? = null, @@ -110,6 +112,7 @@ public fun SessionListScreen( onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } }, onNewSession = onNewSession, onPairHost = onPairHost, + onEnroll = onEnroll, onImportCert = onImportCert, ) }, @@ -138,6 +141,7 @@ private fun SessionListTopBar( onSelectHost: (String) -> Unit, onNewSession: () -> Unit, onPairHost: () -> Unit, + onEnroll: () -> Unit, onImportCert: () -> Unit, ) { var menuOpen by remember { mutableStateOf(false) } @@ -156,6 +160,7 @@ private fun SessionListTopBar( onDismiss = { menuOpen = false }, onSelectHost = { menuOpen = false; onSelectHost(it) }, onPairHost = { menuOpen = false; onPairHost() }, + onEnroll = { menuOpen = false; onEnroll() }, onImportCert = { menuOpen = false; onImportCert() }, ) } @@ -163,7 +168,7 @@ private fun SessionListTopBar( ) } -/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the two actions. */ +/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */ @Composable private fun HostMenu( expanded: Boolean, @@ -171,6 +176,7 @@ private fun HostMenu( onDismiss: () -> Unit, onSelectHost: (String) -> Unit, onPairHost: () -> Unit, + onEnroll: () -> Unit, onImportCert: () -> Unit, ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { @@ -184,6 +190,8 @@ private fun HostMenu( } if (hosts.isNotEmpty()) HorizontalDivider() DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost) + // 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS. + DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll) DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModel.kt new file mode 100644 index 0000000..0debf4b --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModel.kt @@ -0,0 +1,265 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.clienttls.CertificateSummary +import java.net.URI +import java.security.GeneralSecurityException +import java.time.Instant +import java.time.ZoneId + +/** + * # EnrollmentViewModel (B4) — the zero-`.p12` device-enrollment presenter. + * + * The Android port of iOS `EnrollmentViewModel` (`EnrollmentScreen.swift`). Drives the "自动获取证书" + * screen reached from the session-list host menu: one operator login (password) → a short-lived + * `device:enroll` bearer → a NON-EXPORTABLE hardware key + PKCS#10 CSR → `POST /device/enroll` → the + * returned leaf is committed to the shared cert store and presented AUTOMATICALLY on the existing mTLS + * path (no manual `.p12` import). Cache freshness (FIX 3) means the enrolled cert is live without a restart. + * + * A **plain presenter** (mirrors [ClientCertViewModel] / [PairingViewModel]), NOT an + * `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no `Dispatchers.Main`. The + * enroll flow and the installed-summary read are injected as suspend closures ([enrollOperation] / + * [loadSummary]) so the VM is unit-testable without a keystore or network; production wires them (in the + * enrollment pane) to [wang.yaojia.webterm.wiring.EnrollmentFlowFactory] over a `Dispatchers.IO` hop. + * + * ### Secrets discipline (plan §8) + * The operator password lives only in [EnrollmentUiState] and is **cleared after every attempt** (success + * OR failure) so it never lingers in memory; it is never logged and never placed in error copy. Errors are + * a coarse [EnrollError] enum the screen maps to app-authored, inert copy — never a server/exception string. + * + * @param enrollOperation login → hardware keygen+CSR → enroll → commit, yielding the installed leaf summary. + * @param loadSummary the currently-installed device-cert summary (for the "已安装证书" section), or null. + * @param defaultControlPlaneUrl prefilled control-plane URL (the operator can edit it). + * @param defaultDeviceName prefilled device name (the Android device/model in production). + * @param zone / now formatting + expiry clock for the installed-cert summary (fixed in tests). + */ +public class EnrollmentViewModel( + private val enrollOperation: suspend (password: String, subdomain: String, deviceName: String, controlPlaneUrl: String) -> CertificateSummary, + private val loadSummary: suspend () -> CertificateSummary?, + defaultControlPlaneUrl: String = DEFAULT_CONTROL_PLANE_URL, + defaultDeviceName: String = "", + private val zone: ZoneId = ZoneId.systemDefault(), + private val now: () -> Instant = Instant::now, +) { + private val _uiState = MutableStateFlow( + EnrollmentUiState(controlPlaneUrl = defaultControlPlaneUrl, deviceName = defaultDeviceName), + ) + + /** The single snapshot the enrollment screen renders from. */ + public val uiState: StateFlow = _uiState.asStateFlow() + + private var scope: CoroutineScope? = null + private var job: Job? = null + + /** Bind the scope actions launch into (the screen passes a lifecycle scope) and load any installed cert. */ + public fun bind(scope: CoroutineScope) { + this.scope = scope + refresh() + } + + // ── Field edits (two-way bound from the Compose form) ───────────────────────────────────────────── + + public fun onControlPlaneUrlChange(value: String) { + _uiState.value = _uiState.value.copy(controlPlaneUrl = value) + } + + public fun onSubdomainChange(value: String) { + _uiState.value = _uiState.value.copy(subdomain = value) + } + + public fun onDeviceNameChange(value: String) { + _uiState.value = _uiState.value.copy(deviceName = value) + } + + public fun onPasswordChange(value: String) { + _uiState.value = _uiState.value.copy(password = value) + } + + /** Dismiss the current error banner. */ + public fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + private fun refresh() { + val scope = scope ?: return + job?.cancel() + _uiState.value = _uiState.value.copy(phase = EnrollPhase.LOADING) + job = scope.launch { + // loadSummary is designed to degrade to null on a storage fault (never throw); guard anyway. + val summary = runCatching { loadSummary() }.getOrNull() + _uiState.value = _uiState.value.copy(phase = EnrollPhase.IDLE, summary = summary?.toView()) + } + } + + /** + * Run enrollment: validate the control-plane URL (must be `https` with a host) and the fields BEFORE + * any network, then drive [enrollOperation]. The password is cleared after every attempt so it never + * lingers. A double-tap while an enroll is in flight is ignored. + */ + public fun enroll() { + val scope = scope ?: return + val snapshot = _uiState.value + if (snapshot.phase == EnrollPhase.ENROLLING) return + + val url = validControlPlaneUrl(snapshot.controlPlaneUrl) + if (url == null) { + _uiState.value = snapshot.copy(error = EnrollError.INVALID_URL, didSucceed = false) + return + } + val subdomain = snapshot.subdomain.trim() + val deviceName = snapshot.deviceName.trim() + val password = snapshot.password + if (subdomain.isEmpty() || deviceName.isEmpty() || password.isEmpty()) { + _uiState.value = snapshot.copy(error = EnrollError.MISSING_FIELDS, didSucceed = false) + return + } + + _uiState.value = snapshot.copy(phase = EnrollPhase.ENROLLING, error = null, didSucceed = false) + job?.cancel() + job = scope.launch { + val outcome = try { + Result.success(enrollOperation(password, subdomain, deviceName, url)) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + Result.failure(error) + } + // Clear the password whatever the outcome — never linger after an attempt. + _uiState.value = outcome.fold( + onSuccess = { summary -> + _uiState.value.copy( + phase = EnrollPhase.IDLE, + summary = summary.toView(), + password = "", + didSucceed = true, + error = null, + ) + }, + onFailure = { e -> + _uiState.value.copy( + phase = EnrollPhase.IDLE, + password = "", + didSucceed = false, + error = classifyEnroll(e), + ) + }, + ) + } + } + + /** Fields all present + not already enrolling → the button is enabled (deeper URL check on tap). */ + private fun CertificateSummary.toView() = toSummaryView(now(), zone) + + public companion object { + /** The default control-plane URL (matches iOS `EnrollmentCopy.defaultControlPlaneURL`). */ + public const val DEFAULT_CONTROL_PLANE_URL: String = "https://cp.terminal.yaojia.wang" + } +} + +/** + * Validate a control-plane URL exactly as iOS does before any network: it must parse, be `https`, and + * carry a non-blank host. Returns the trimmed URL on success, or null (→ [EnrollError.INVALID_URL]). + */ +internal fun validControlPlaneUrl(raw: String): String? { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return null + val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null + if (!"https".equals(uri.scheme, ignoreCase = true)) return null + if (uri.host.isNullOrBlank()) return null + return trimmed +} + +/** + * Map an enroll throwable to the coarse, non-secret [EnrollError] the screen renders inertly. Android's + * login + enroll share one [DeviceEnrollmentError.Http] type, so the HTTP status disambiguates (401 login + * vs 403 subdomain-not-owned vs 429 vs 400). Keystore/hardware faults (incl. StrongBox-unavailable, a + * [GeneralSecurityException] subclass) map to KEYGEN. Never carries the password/bearer. + */ +internal fun classifyEnroll(error: Throwable): EnrollError = when (error) { + is DeviceEnrollmentError.Http -> when (error.status) { + 401 -> EnrollError.BAD_CREDENTIAL + 403 -> EnrollError.SUBDOMAIN_NOT_OWNED + 429 -> EnrollError.RATE_LIMITED + 400 -> EnrollError.REJECTED + else -> EnrollError.ENROLL_FAILED + } + is DeviceEnrollmentError.MalformedResponse -> EnrollError.SERVER + is DeviceEnrollmentError -> EnrollError.ENROLL_FAILED // InvalidRequest etc. (post-validation, rare) + is GeneralSecurityException -> EnrollError.KEYGEN // hardware key / StrongBox / keystore fault + else -> EnrollError.UNKNOWN +} + +/** The load/action phase the enrollment screen renders. */ +public enum class EnrollPhase { + /** The initial installed-summary read is in flight. */ + LOADING, + + /** Idle — the form is editable and [EnrollmentUiState.summary] shows any installed cert. */ + IDLE, + + /** An enroll is in flight (login → keygen+CSR → enroll → commit). */ + ENROLLING, +} + +/** The coarse, non-secret enrollment failure taxonomy — the screen maps each to app-authored inert copy (§8). */ +public enum class EnrollError { + /** The control-plane URL is not a valid `https://…` URL with a host (set before any network). */ + INVALID_URL, + + /** A required field (subdomain / device name / password) was empty (set before any network). */ + MISSING_FIELDS, + + /** Operator login rejected (401) — the password is wrong. */ + BAD_CREDENTIAL, + + /** The account does not own the requested subdomain (403) — no cert can be issued. */ + SUBDOMAIN_NOT_OWNED, + + /** Too many requests (429) — back off and retry. */ + RATE_LIMITED, + + /** The subdomain or CSR was rejected (400). */ + REJECTED, + + /** Any other enroll failure (server-side or transport). */ + ENROLL_FAILED, + + /** The server returned a malformed response. */ + SERVER, + + /** Hardware key generation failed (no StrongBox/TEE, or a keystore fault). */ + KEYGEN, + + /** An unclassified failure. */ + UNKNOWN, +} + +/** The immutable snapshot the enrollment screen renders. */ +public data class EnrollmentUiState( + val controlPlaneUrl: String = "", + val subdomain: String = "", + val deviceName: String = "", + val password: String = "", + /** The currently-installed device identity's display summary, or `null` when none is installed. */ + val summary: CertSummaryView? = null, + val phase: EnrollPhase = EnrollPhase.LOADING, + /** The last enroll failure, or `null`. Surfaced as inert, app-authored copy. */ + val error: EnrollError? = null, + /** Whether the most recent enroll succeeded (drives the success affordance). */ + val didSucceed: Boolean = false, +) { + /** Every field present and no enroll in flight → the submit button is enabled. */ + val canEnroll: Boolean + get() = phase != EnrollPhase.ENROLLING && + controlPlaneUrl.trim().isNotEmpty() && + subdomain.trim().isNotEmpty() && + deviceName.trim().isNotEmpty() && + password.isNotEmpty() +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt index 2ac98da..7ef05a7 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -49,6 +49,11 @@ public class AppEnvironment @Inject constructor( public val apiClientFactory: ApiClientFactory, public val sessionEngineFactory: SessionEngineFactory, public val coldStartPolicy: ColdStartPolicy, + /** + * B4 · Builds a [DeviceEnroller][wang.yaojia.webterm.tlsandroid.DeviceEnroller] per control-plane URL + * for the zero-`.p12` enrollment screen (A-enroll). App-scoped; the screen calls it off `Main`. + */ + public val enrollmentFlowFactory: EnrollmentFlowFactory, private val identityRepositoryLazy: Lazy, private val httpTransportLazy: Lazy, private val termTransportLazy: Lazy, diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/EnrollmentFlowFactory.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/EnrollmentFlowFactory.kt new file mode 100644 index 0000000..3941c94 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/EnrollmentFlowFactory.kt @@ -0,0 +1,51 @@ +package wang.yaojia.webterm.wiring + +import dagger.Lazy +import okhttp3.OkHttpClient +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient +import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.DeviceEnroller +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher +import wang.yaojia.webterm.wire.HttpTransport +import javax.inject.Inject +import javax.inject.Singleton + +/** + * B4 · Builds a [DeviceEnroller] bound to a user-supplied control-plane URL, over the app's frozen object + * graph — the Android analogue of iOS `makeDeviceEnrollmentFlow` (`DeviceEnrollmentWiring.swift`). + * + * The control-plane base URL is a RUNTIME value (the operator types it on the enrollment screen), so the + * enroller cannot be a plain singleton; this factory is the singleton and mints one enroller per enroll + * attempt with the typed URL, wiring in the app-scoped collaborators: + * - the SAME shared [HttpTransport] / [OkHttpClient] the mTLS transports use, so the renew ride presents + * the current device cert and the pool eviction drops stale connections, + * - the shared [CertStore] + [EnrollmentRecordStore] the running [IdentityRepository] resolves from, and + * - the [IdentityCacheRefresher] (FIX 3) so a freshly enrolled leaf is presented with no restart. + * + * ### Off-`Main` discipline + * [httpTransport] and [sharedClient] are behind `dagger.Lazy` because resolving either builds the shared + * `OkHttpClient` (mTLS/keystore I/O). [create] therefore MUST be called off the UI thread — the enrollment + * ViewModel invokes it inside a `Dispatchers.IO` hop (mirroring `AppEnvironment.warmUp`). + */ +@Singleton +public class EnrollmentFlowFactory @Inject constructor( + private val httpTransport: Lazy, + private val sharedClient: Lazy, + private val certStore: CertStore, + private val recordStore: EnrollmentRecordStore, + private val cacheRefresher: IdentityCacheRefresher, +) { + /** + * Mint a [DeviceEnroller] targeting [controlPlaneBaseUrl] (any trailing slash is trimmed by the + * client). Call OFF `Main` — resolving the lazy transport/client does keystore/TLS I/O. + */ + public fun create(controlPlaneBaseUrl: String): DeviceEnroller = + DeviceEnroller( + client = DeviceEnrollmentClient(controlPlaneBaseUrl, httpTransport.get()), + certStore = certStore, + recordStore = recordStore, + sharedClient = sharedClient.get(), + cacheRefresher = cacheRefresher, + ) +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModelTest.kt new file mode 100644 index 0000000..a6d8061 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModelTest.kt @@ -0,0 +1,213 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.clienttls.CertificateSummary +import java.security.KeyStoreException +import java.time.Instant +import java.time.ZoneId + +/** + * [EnrollmentViewModel] / [validControlPlaneUrl] / [classifyEnroll] (B4) — the JVM-tested zero-`.p12` + * enrollment core, mirroring iOS `EnrollmentViewModelTests`. The enroll flow itself is covered headlessly + * in `DeviceEnrollerTest`; these cover the VM's boundary validation, success bookkeeping (summary + + * password-clear), and the error→[EnrollError] mapping — including that the password never lingers after + * an attempt. The keystore / network / Compose shell is device-QA (plan §7); THIS is the pure core. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class EnrollmentViewModelTest { + + /** Records enroll invocations and replays a scripted result/throwable (mirrors iOS EnrollScript). */ + private class EnrollScript(private val result: Result) { + val calls = mutableListOf() + data class Call(val password: String, val subdomain: String, val deviceName: String, val url: String) + + suspend fun run(password: String, subdomain: String, deviceName: String, url: String): CertificateSummary { + calls.add(Call(password, subdomain, deviceName, url)) + return result.getOrThrow() + } + } + + private val fixedNow = Instant.parse("2026-01-01T00:00:00Z") + private val utc = ZoneId.of("UTC") + + private fun summary( + subject: String? = "alice-pixel", + issuer: String? = "webterm-device-ca", + notAfter: Instant? = Instant.parse("2026-10-06T00:00:00Z"), + ) = CertificateSummary(subjectCommonName = subject, issuerCommonName = issuer, notAfter = notAfter) + + private fun newVm( + script: EnrollScript, + installed: CertificateSummary? = null, + controlPlaneUrl: String = "https://cp.terminal.yaojia.wang", + subdomain: String = "alice", + deviceName: String = "Pixel 8", + ): EnrollmentViewModel { + val vm = EnrollmentViewModel( + enrollOperation = { p, s, d, u -> script.run(p, s, d, u) }, + loadSummary = { installed }, + defaultControlPlaneUrl = controlPlaneUrl, + defaultDeviceName = deviceName, + zone = utc, + now = { fixedNow }, + ) + vm.onSubdomainChange(subdomain) + return vm + } + + // ── Success ───────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a successful enroll sets the summary, flags success and clears the password`() = + runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.success(summary(subject = "alice-pixel"))) + val vm = newVm(script) + vm.bind(backgroundScope) + vm.onPasswordChange("operator-secret") + + vm.enroll() + + val state = vm.uiState.value + assertEquals(1, script.calls.size) + assertEquals("operator-secret", script.calls.single().password, "the entered password reaches the flow") + assertEquals("alice-pixel", state.summary?.subjectCommonName) + assertTrue(state.didSucceed) + assertNull(state.error) + assertEquals("", state.password, "the password must never linger after a successful enroll") + assertEquals(EnrollPhase.IDLE, state.phase) + } + + // ── Boundary validation (no network) ────────────────────────────────────────────────────────────── + + @Test + fun `a non-https control-plane URL is rejected before any network`() = + runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.success(summary())) + val vm = newVm(script, controlPlaneUrl = "http://cp.terminal.yaojia.wang") + vm.bind(backgroundScope) + vm.onPasswordChange("pw") + + vm.enroll() + + assertTrue(script.calls.isEmpty(), "an insecure URL must never hit the network") + assertEquals(EnrollError.INVALID_URL, vm.uiState.value.error) + assertFalse(vm.uiState.value.didSucceed) + } + + @Test + fun `missing fields are rejected before any network`() = runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.success(summary())) + val vm = newVm(script, subdomain = " ") // whitespace-only subdomain + vm.bind(backgroundScope) + vm.onPasswordChange("pw") + + vm.enroll() + + assertTrue(script.calls.isEmpty()) + assertEquals(EnrollError.MISSING_FIELDS, vm.uiState.value.error) + } + + // ── Error → copy mapping (password still cleared) ───────────────────────────────────────────────── + + @Test + fun `a 403 subdomain-not-owned maps to actionable copy and clears the password`() = + runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(403, "rejected"))) + val vm = newVm(script) + vm.bind(backgroundScope) + vm.onPasswordChange("operator-secret") + + vm.enroll() + + val state = vm.uiState.value + assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, state.error) + assertFalse(state.didSucceed) + assertEquals("", state.password, "the password is cleared even after a failed enroll") + } + + @Test + fun `a 401 login maps to the bad-credential copy`() = runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(401, "rejected"))) + val vm = newVm(script) + vm.bind(backgroundScope) + vm.onPasswordChange("wrong") + + vm.enroll() + + assertEquals(EnrollError.BAD_CREDENTIAL, vm.uiState.value.error) + } + + @Test + fun `a hardware keystore fault maps to the keygen copy`() = runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.failure(KeyStoreException("no StrongBox / TEE"))) + val vm = newVm(script) + vm.bind(backgroundScope) + vm.onPasswordChange("operator-secret") + + vm.enroll() + + assertEquals(EnrollError.KEYGEN, vm.uiState.value.error) + } + + // ── canEnroll gating ────────────────────────────────────────────────────────────────────────────── + + @Test + fun `canEnroll requires every field including a non-empty password`() = + runTest(UnconfinedTestDispatcher()) { + val vm = newVm(EnrollScript(Result.success(summary()))) + vm.bind(backgroundScope) + + assertFalse(vm.uiState.value.canEnroll, "password empty → disabled") + vm.onPasswordChange("pw") + assertTrue(vm.uiState.value.canEnroll) + vm.onSubdomainChange(" ") + assertFalse(vm.uiState.value.canEnroll, "whitespace-only subdomain → disabled") + } + + @Test + fun `clearError dismisses a surfaced error`() = runTest(UnconfinedTestDispatcher()) { + val vm = newVm(newFailing()) + vm.bind(backgroundScope) + vm.onPasswordChange("pw") + vm.enroll() + assertEquals(EnrollError.REJECTED, vm.uiState.value.error) + + vm.clearError() + + assertNull(vm.uiState.value.error) + } + + private fun newFailing() = EnrollScript(Result.failure(DeviceEnrollmentError.Http(400, "rejected"))) + + // ── Pure helpers ────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `validControlPlaneUrl accepts https with a host and rejects everything else`() { + assertEquals("https://cp.terminal.yaojia.wang", validControlPlaneUrl(" https://cp.terminal.yaojia.wang ")) + assertNull(validControlPlaneUrl("http://cp.terminal.yaojia.wang"), "http is rejected") + assertNull(validControlPlaneUrl("https://"), "no host is rejected") + assertNull(validControlPlaneUrl("not a url"), "unparseable is rejected") + assertNull(validControlPlaneUrl(""), "empty is rejected") + } + + @Test + fun `classifyEnroll maps each throwable to its coarse EnrollError`() { + assertEquals(EnrollError.BAD_CREDENTIAL, classifyEnroll(DeviceEnrollmentError.Http(401, null))) + assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, classifyEnroll(DeviceEnrollmentError.Http(403, null))) + assertEquals(EnrollError.RATE_LIMITED, classifyEnroll(DeviceEnrollmentError.Http(429, null))) + assertEquals(EnrollError.REJECTED, classifyEnroll(DeviceEnrollmentError.Http(400, null))) + assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.Http(500, null))) + assertEquals(EnrollError.SERVER, classifyEnroll(DeviceEnrollmentError.MalformedResponse)) + assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.InvalidRequest)) + assertEquals(EnrollError.KEYGEN, classifyEnroll(KeyStoreException("x"))) + assertEquals(EnrollError.UNKNOWN, classifyEnroll(IllegalStateException("x"))) + } +} diff --git a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt index 6830306..810c83d 100644 --- a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt +++ b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt @@ -100,6 +100,31 @@ class IdentityRepositoryTest { assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias) } + /** + * FIX 3 (cache freshness): a device cert committed OUT OF BAND of a running repository (the zero-`.p12` + * [DeviceEnroller] writes the leaf straight into the shared [CertStore] + AndroidKeyStore) is picked up + * by [AndroidIdentityRepository.refreshFromStore] WITHOUT a process restart — the cached "no identity" + * flips to the freshly-committed leaf and is presented on the next handshake. + */ + @Test + fun refreshFromStore_publishesAnOutOfBandCommittedIdentity_withoutRestart() = runBlocking { + val running = newRepository() + // Touch it while nothing is installed — caches the (null) initial identity. + assertFalse(running.hasInstalledIdentity()) + + // Simulate DeviceEnroller committing an identity out of band (a second repo over the SAME stores). + newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE) + + // The running repo still shows its stale cache (no restart yet). + assertFalse("stale cache still reports no identity before a refresh", running.hasInstalledIdentity()) + + running.refreshFromStore() + + // The refresh re-read the committed live-pointer → the enrolled leaf is now live. + assertTrue("refreshFromStore must publish the out-of-band committed identity", running.hasInstalledIdentity()) + assertEquals(Fixtures.LEAF_SUBJECT_CN, running.currentSummary()?.subjectCommonName) + } + @Test fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking { val repository = newRepository() diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt index 0d86f39..6230355 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt @@ -39,6 +39,10 @@ public class DeviceEnroller( private val sharedClient: OkHttpClient, private val keyAlias: String = AndroidKeyStoreImporter.DEFAULT_ALIAS, private val keyProvider: DeviceKeyProvider = HardwareDeviceKeyProvider, + // FIX 3 (cache freshness): the in-memory identity cache (AndroidIdentityRepository) is refreshed + // AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no + // process restart. Optional so the JVM orchestration tests can construct the enroller without it. + private val cacheRefresher: IdentityCacheRefresher? = null, ) { private val commitMutex = Mutex() @@ -75,19 +79,20 @@ public class DeviceEnroller( /** * Silent rotation: re-CSR from the SAME hardware key and replace the leaf via - * `POST /device/:id/renew`. [bearerToken] is supplied by the caller (the app-layer rotation - * scheduler) — the renew-endpoint auth model (mTLS-with-current-cert vs. a fresh bearer) is the - * server's A6 concern, so this method does not bake in a credential policy; it only re-signs and - * re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to renew or the key - * is gone. + * `POST /device/:id/renew`. The renew endpoint authenticates by the CURRENT device certificate over + * mTLS (the presented client cert), so [bearerToken] is OPTIONAL and defaults to absent — the + * production caller passes none (mirrors iOS, which renews with `bearerToken: nil`). The seam still + * accepts a bearer for a hypothetical bearer-authenticated renew, but bakes in no credential policy; + * it only re-signs and re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to + * renew or the key is gone. */ - public suspend fun renew(bearerToken: String): CertificateSummary = commitMutex.withLock { + public suspend fun renew(bearerToken: String? = null): CertificateSummary = commitMutex.withLock { val record = recordStore.load() ?: throw EnrollmentStateException("no enrollment record — nothing to renew") val key = keyProvider.load(record.keyStoreAlias) ?: throw EnrollmentStateException("device key missing — a fresh enroll is required") val csr = CertificateSigningRequest.der(record.deviceName, key) - val result = client.renew(bearerToken, record.deviceId, csr) + val result = client.renew(record.deviceId, csr, bearerToken) commitIdentity(result, record.deviceName, record.keyStoreAlias) summaryOf(result) } @@ -127,6 +132,10 @@ public class DeviceEnroller( ), ) sharedClient.connectionPool.evictAll() + // FIX 3: re-read the just-committed live-pointer into the in-memory identity cache so the + // newly enrolled/renewed leaf is presented on the NEXT mTLS handshake without a restart. Done + // AFTER the durable commit + pool eviction so the cache can never publish an un-committed leaf. + cacheRefresher?.refreshFromStore() Log.i(TAG, "Device identity enrolled/renewed and committed for alias '$alias'") } diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt index c9c90ed..d5450c7 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt @@ -56,6 +56,18 @@ public interface IdentityRepository { public suspend fun remove() } +/** + * B4 · A narrow seam the zero-`.p12` enroll/renew commit ([DeviceEnroller]) fires so an in-memory + * identity cache re-reads the freshly-committed live-pointer and presents the new leaf on the NEXT + * mTLS handshake WITHOUT a process restart. Kept separate from [IdentityRepository] so the enroller + * depends only on this one operation (it never needs the import/rotate/remove surface). The production + * implementation is [AndroidIdentityRepository]; a JVM test uses a recording double. + */ +public fun interface IdentityCacheRefresher { + /** Reload the persisted live identity into the in-memory cache and drop stale pooled connections. */ + public fun refreshFromStore() +} + /** * Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted * live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`). @@ -90,7 +102,7 @@ public class AndroidIdentityRepository( private val importer: AndroidKeyStoreImporter, private val certStore: CertStore, private val sharedClient: OkHttpClient, -) : IdentityRepository { +) : IdentityRepository, IdentityCacheRefresher { /** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */ private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String) @@ -150,6 +162,20 @@ public class AndroidIdentityRepository( sharedClient.connectionPool.evictAll() } + /** + * FIX 3 (cache freshness) · Re-read the persisted live-pointer into the in-memory cache. Used when a + * device certificate is committed OUT OF BAND of this repository — the zero-`.p12` [DeviceEnroller] + * writes the leaf straight into the shared [CertStore] + AndroidKeyStore, so without this the running + * repo would keep presenting its cached (pre-enroll) identity until process restart. Publishing the + * freshly-loaded snapshot as [liveOverride] and evicting pooled connections makes the enrolled leaf + * present on the NEXT handshake. Reloading to `null` (a fault/absent pointer) is a valid outcome and + * simply reports "no identity". Not `suspend` — the enroller already runs this off the UI thread. + */ + override fun refreshFromStore() { + liveOverride = Box(loadInstalledOrNull()) + sharedClient.connectionPool.evictAll() + } + /** * Single-commit install/rotation (see the class KDoc). Validation throws before any mutation; * the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt index 25d9bec..f999228 100644 --- a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt @@ -29,7 +29,6 @@ class DeviceEnrollerTest { private companion object { const val BASE = "https://cp.terminal.yaojia.wang" const val ALIAS = "test-device-key" - const val BEARER = "device-enroll-token-abc" // Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory / // CertificateSummaryReader parse them exactly as they parse a server-issued leaf. @@ -75,6 +74,7 @@ class DeviceEnrollerTest { private val certStore = RecordingCertStore(events) private val recordStore = RecordingRecordStore(events) private val keyProvider = RecordingKeyProvider(events) + private val refresher = RecordingRefresher(events) private fun enroller(): DeviceEnroller = DeviceEnroller( @@ -84,6 +84,7 @@ class DeviceEnrollerTest { sharedClient = OkHttpClient(), keyAlias = ALIAS, keyProvider = keyProvider, + cacheRefresher = refresher, ) // ── enroll: commit sequencing (the security-critical invariant) ──────────────────────────── @@ -112,6 +113,22 @@ class DeviceEnrollerTest { assertEquals(LEAF_CN, summary.subjectCommonName) } + @Test + fun enrollRefreshesTheIdentityCacheAfterTheCommit() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody()) + + enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel") + + // FIX 3: the in-memory identity cache is refreshed AFTER the durable cert live-pointer flip, so + // the newly enrolled leaf is presented on the next handshake with no restart. + assertTrue(events.contains("cache.refresh"), "the identity cache must be refreshed on enroll") + assertTrue( + events.indexOf("cert.save") < events.indexOf("cache.refresh"), + "the cache refresh must run AFTER the cert live-pointer commit (never publish an un-committed leaf)", + ) + } + @Test fun enrollShapesTheLoginAndEnrollRequests() = runTest { transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) @@ -167,7 +184,7 @@ class DeviceEnrollerTest { @Test fun renewThrowsWhenNothingIsEnrolled() = runTest { - val error = runCatching { enroller().renew(BEARER) }.exceptionOrNull() + val error = runCatching { enroller().renew() }.exceptionOrNull() assertTrue(error is DeviceEnroller.EnrollmentStateException) assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew") } @@ -176,30 +193,32 @@ class DeviceEnrollerTest { fun renewThrowsWhenTheDeviceKeyIsMissing() = runTest { recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L)) // keyProvider has no key at ALIAS → load() returns null. - val error = runCatching { enroller().renew(BEARER) }.exceptionOrNull() + val error = runCatching { enroller().renew() }.exceptionOrNull() assertTrue(error is DeviceEnroller.EnrollmentStateException) assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone") } @Test - fun renewReCsrsFromTheSameKeyAndSendsACsrOnlyBody() = runTest { + fun renewReCsrsFromTheSameKeyOverMtlsWithNoBearerAndACsrOnlyBody() = runTest { recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L)) keyProvider.seed(ALIAS, softwareKey(ALIAS)) transport.queueSuccess(HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = enrollBody()) - enroller().renew(BEARER) + // FIX 2: production renew passes NO bearer — the endpoint authenticates by the current cert (mTLS). + enroller().renew() val renew = transport.recordedRequests.single() assertEquals("$BASE/device/dev-1/renew", renew.url) - assertEquals("Bearer $BEARER", renew.headers["Authorization"]) + assertNull(renew.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header") val body = renew.body!!.decodeToString() // The renew body is {csr}-only — the server's .strict() schema rejects any enroll-only extra. assertTrue(body.contains("\"csr\":"), "renew sends the fresh CSR") assertFalse(body.contains("keyAlg"), "renew must not send the enroll-only keyAlg") assertFalse(body.contains("subdomain"), "renew must not send subdomain") assertFalse(body.contains("deviceName"), "renew must not send deviceName") - // Same commit sequencing on the rotation path: record before cert. + // Same commit sequencing on the rotation path: record before cert, then cache refresh last. assertTrue(events.indexOf("record.save") < events.indexOf("cert.save")) + assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit") } // ── remove: full teardown ────────────────────────────────────────────────────────────────── @@ -260,6 +279,12 @@ class DeviceEnrollerTest { } } + private class RecordingRefresher(private val events: MutableList) : IdentityCacheRefresher { + override fun refreshFromStore() { + events += "cache.refresh" + } + } + private class RecordingKeyProvider(private val events: MutableList) : DeviceKeyProvider { private val keys = mutableMapOf() val generatedAliases = mutableListOf()