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:
@@ -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<String, String>()
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
|
||||
@@ -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) ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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'")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>) : IdentityCacheRefresher {
|
||||
override fun refreshFromStore() {
|
||||
events += "cache.refresh"
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingKeyProvider(private val events: MutableList<String>) : DeviceKeyProvider {
|
||||
private val keys = mutableMapOf<String, HardwareBackedKey>()
|
||||
val generatedAliases = mutableListOf<String>()
|
||||
|
||||
Reference in New Issue
Block a user