feat(android): wire zero-touch device enrollment + fix renew/cache (B-track)

- 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.
This commit is contained in:
Yaojia Wang
2026-07-19 08:31:29 +02:00
parent c98f5e6a1f
commit 0b35dc043f
16 changed files with 1128 additions and 32 deletions

View File

@@ -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
}

View File

@@ -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}}"

View File

@@ -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) ──────────────────────────────────────────────────────────────────────────────
/**

View File

@@ -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) },
)
}

View File

@@ -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 = {},
)
}
}

View File

@@ -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)
}
}

View File

@@ -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<EnrollmentUiState> = _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()
}

View File

@@ -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<IdentityRepository>,
private val httpTransportLazy: Lazy<HttpTransport>,
private val termTransportLazy: Lazy<TermTransport>,

View File

@@ -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<HttpTransport>,
private val sharedClient: Lazy<OkHttpClient>,
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,
)
}

View File

@@ -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<CertificateSummary>) {
val calls = mutableListOf<Call>()
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")))
}
}