feat(android): close the audit's remaining parity gaps

Seven items the earlier repair waves deliberately left alone, so that the
blocker fixes could land with a working safety net first.

CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a
CertificateRotationScheduler; Android had no counterpart, so a device
certificate simply expired and needed a manual re-enroll. The decision is a
pure total function over five answers, with RE_ENROLL_REQUIRED checked first so
it outranks any backoff. Renewal is single-flight — a second trigger coalesces,
and a cancelled leader releases the slot so cancellation cannot wedge rotation
for the process lifetime. Failure leaves the prior identity fully live (the
existing atomic ping-pong flip already guaranteed that) and is published rather
than swallowed.

Two real defects surfaced while building it:

  renew() decoded its 201 with EnrollResponseDto, whose deviceId is required —
  but /device/:id/renew returns only {cert, caChain, notAfter}; only
  /device/enroll echoes deviceId. Every silent renewal would have thrown
  MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the
  path segment we addressed.

  And because no renew 201 carries renewAfter, a client that persisted it would
  rotate exactly once and then report not-due until the cert died. That is why
  renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the
  control plane's own formula. This is a latent iOS defect that Android now
  avoids rather than inherits.

/device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443,
contract pinned by that suite's CP6e) and is now wired. It goes over a separate
NON-mTLS client that throws rather than falling back, because an expired client
certificate cannot authenticate its own recovery — the deadlock a previous
session already hit and recorded.

Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere
in control-plane/src; it lives only on the relay's WebSocket upgrade. On a
step-up host the SESSION is denied, not the rotation, so the certificate still
stays alive and that principal gap is orthogonal.

FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it
were missing, so Android could see "N queued" and not queue anything. One
outcome union covers both guarded writes and distinguishes full / too-large /
disabled / session-gone / rate-limited, because they need different copy. 429 is
surfaced and never auto-retried — POST and DELETE share one 20/min bucket.
Queued text is raw shell input and travels verbatim, proven with control
characters and CJK.

UNREAD WATERMARK now persists, so the unread state survives process death —
which is exactly when it matters, since the product premise is walking away and
coming back. It stores a plain map rather than an UnreadLedger: the reducer
lives in :session-core, which :host-registry may not depend on, so the logic is
not restated anywhere.

COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own
Copy handler dereferences the absent TerminalSession, so making it reachable
puts an NPE on the button — worse than no selection. The refusal was
re-verified from the bytecode, and the same disassembly showed the way out:
TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and
no view. A first-party selection layer now builds on that, with a pure
row-major model so a backwards drag normalises correctly. Terminal content
reaches the clipboard only on an explicit user copy; OSC 52 stays declined.

HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed
host kept receiving pushes. Removal is now confirm-gated and cleans everything
keyed by host id — a half-removed host is worse than none.

/config/ui is finally honoured. It was implemented and never called, so
allowAutoMode was ignored and the plan gate offered "approve + auto" even where
the operator had disabled it. It fails CLOSED: an unfetchable config treats auto
as disabled, because the permissive default is the dangerous one.

Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM
tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554
against live servers: 67 tests, 0 failures, 0 skipped — no device regression.
This commit is contained in:
Yaojia Wang
2026-07-30 15:37:09 +02:00
parent 8075d2c671
commit 390dd11202
70 changed files with 8096 additions and 141 deletions

View File

@@ -43,6 +43,17 @@ public class DeviceEnroller(
// 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,
/**
* A SECOND client over a PLAIN (non-mTLS) transport, used ONLY by [recover]. It must not be the
* same client as [client]: an expired leaf cannot authenticate its own re-issuance, and an mTLS
* transport would present that expired leaf, which nginx refuses to forward at all (bare 400).
* Null ⇒ recovery is unavailable and [recover] throws rather than falling back to mTLS.
*
* It MUST target the same control plane as [client] — the enrollment record stores [client]'s
* base URL as the rotation target, so a recovery pointed elsewhere would rewrite the identity
* while leaving the record naming the original host.
*/
private val recoveryClient: DeviceEnrollmentClient? = null,
) {
private val commitMutex = Mutex()
@@ -97,6 +108,40 @@ public class DeviceEnroller(
summaryOf(result)
}
/**
* Recover an EXPIRED leaf: re-CSR from the SAME hardware key and POST it together with the lapsed
* certificate to `POST /device/:id/recover` over the PLAIN (non-mTLS) [recoveryClient].
*
* This exists because `/device/:id/renew` is authenticated by the very certificate it renews, so a
* lapsed leaf deadlocks: no TLS terminator will even forward an expired client certificate (nginx
* answers a bare 400 under `ssl_verify_client optional`, and `optional_no_ca` tolerates only CHAIN
* errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). Without this call an expired device certificate
* means a full manual re-enroll. Possession is still proven — the CSR is self-signed by the same
* non-exportable key and the control plane enforces CSR proof-of-possession plus
* `CSR key == registered key` — and revocation, account/`:id` consistency and `notBefore` are all
* still enforced server-side; only `notAfter` is graced (30 days).
*
* Failure posture: everything before [commitIdentity]'s cert-store save is read-only, so a rejected
* or unreachable recovery leaves the prior identity and its key exactly as they were.
*
* Throws [EnrollmentStateException] when there is no [recoveryClient], no enrollment record, no
* stored certificate to present, or no hardware key — none of which a request could fix.
*/
public suspend fun recover(): CertificateSummary = commitMutex.withLock {
val recovery = recoveryClient
?: throw EnrollmentStateException("no plain-HTTPS recovery client — refusing to recover over mTLS")
val record = recordStore.load()
?: throw EnrollmentStateException("no enrollment record — nothing to recover")
val expiredLeaf = certStore.load()?.leafCertificate
?: throw EnrollmentStateException("no stored device certificate — a fresh enroll is required")
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 = recovery.recover(record.deviceId, expiredLeaf.encoded, csr)
commitIdentity(result, record.deviceName, record.keyStoreAlias)
summaryOf(result)
}
/** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */
public suspend fun remove(): Unit = commitMutex.withLock {
certStore.clear()
@@ -120,7 +165,13 @@ public class DeviceEnroller(
deviceId = result.deviceId,
deviceName = deviceName,
keyStoreAlias = alias,
// Only an enroll 201 carries renewAfter; a renew/recover 201 does not, so this is 0
// after a rotation. That is fine — DeviceRotationStore derives the live timing from
// the leaf certificate, never from this advisory field.
renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L,
// Persist WHICH control plane issued this identity so silent rotation (which has no UI
// to ask on) renews against the same one instead of a compiled-in default.
controlPlaneUrl = client.baseUrl,
),
)
certStore.save(

View File

@@ -0,0 +1,60 @@
package wang.yaojia.webterm.tlsandroid
import wang.yaojia.webterm.api.enroll.DeviceRenewalState
import wang.yaojia.webterm.api.enroll.RotationPolicy
/**
* Everything a rotation pass needs to know, read from storage: the pure [renewalState] the decision
* table consumes plus [controlPlaneUrl], the host to rotate against.
*
* Carries no credential: only the non-secret device id, the leaf's timing, and a URL. Safe to log.
*/
public data class DeviceRotationSnapshot(
val renewalState: DeviceRenewalState,
val controlPlaneUrl: String,
)
/**
* The READ side of silent certificate rotation — the Android counterpart of iOS
* `KeychainClientIdentityStore.renewalState()`, composing the two persisted stores into one snapshot.
*
* ### Why the timing comes from the LEAF, not the record
* `POST /device/:id/renew` and `POST /device/:id/recover` answer `{cert, caChain, notAfter}` — neither
* returns a `renewAfter` (only `/device/enroll` does). A driver that trusted
* [EnrollmentRecord.renewAfterEpochSeconds] would therefore see "unknown" after the very first
* rotation and report not-due until the certificate died — exactly once-and-never-again rotation. The
* live leaf's own `notBefore`/`notAfter` are always present and are what the TLS stack actually
* enforces, so they are the source of truth; [RotationPolicy.renewAfterFor] re-derives the advisory
* instant from them with the control plane's own 2/3-of-lifetime fraction.
*
* Deliberately I/O-only and framework-free (no `android.*` import), so the whole read path is JVM
* unit-tested. A store fault PROPAGATES rather than degrading to "nothing enrolled": silently
* reporting not-enrolled would disable rotation forever, whereas a surfaced failure is retried.
*/
public class DeviceRotationStore(
private val certStore: CertStore,
private val recordStore: EnrollmentRecordStore,
) {
/**
* The current rotation snapshot, or null when there is nothing to rotate — no enrollment record
* (fresh install / legacy `.p12`-only identity) or no live certificate (a renew has nothing to
* re-CSR for and a recovery has no lapsed leaf to present, so only a fresh enroll can help).
*
* Performs AndroidKeyStore/Tink-backed reads via the injected stores: call it OFF the main thread.
*/
public fun read(): DeviceRotationSnapshot? {
val record = recordStore.load() ?: return null
val leaf = certStore.load()?.leafCertificate ?: return null
return DeviceRotationSnapshot(
renewalState = DeviceRenewalState(
deviceId = record.deviceId,
notAfter = leaf.notAfter.toInstant(),
renewAfter = RotationPolicy.renewAfterFor(
notBefore = leaf.notBefore.toInstant(),
notAfter = leaf.notAfter.toInstant(),
),
),
controlPlaneUrl = record.controlPlaneUrl,
)
}
}

View File

@@ -8,12 +8,23 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.EOFException
/**
* B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted
* [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR
* subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign
* with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler.
* with, [renewAfterEpochSeconds] (0 = unknown) as issued at enroll time, and [controlPlaneUrl] —
* WHICH control plane to rotate against.
*
* [controlPlaneUrl] exists because rotation is silent: there is no screen to re-type the URL on, and
* falling back to a compiled-in default would renew against the wrong host. It is not a credential
* (iOS keeps the same value in plain `UserDefaults`); a record written before the field existed
* decodes to `""`, which the rotation driver treats as "cannot rotate" rather than guessing.
*
* [renewAfterEpochSeconds] is only ever ADVISORY and only ever set by an enroll: neither
* `/device/:id/renew` nor `/device/:id/recover` returns a `renewAfter`. The live timing therefore
* comes from the leaf certificate itself — see [DeviceRotationStore].
*
* This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert
* identity is what the handshake presents; this record only exists so renew can find the device and
@@ -24,6 +35,8 @@ public data class EnrollmentRecord(
val deviceName: String,
val keyStoreAlias: String,
val renewAfterEpochSeconds: Long,
/** Control-plane base URL this device enrolled against; `""` when unknown (a legacy record). */
val controlPlaneUrl: String = "",
) {
init {
require(deviceId.isNotBlank()) { "deviceId must not be blank" }
@@ -31,7 +44,7 @@ public data class EnrollmentRecord(
}
}
/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */
/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — four UTF strings + one long). */
public object EnrollmentRecordCodec {
public fun encode(record: EnrollmentRecord): ByteArray {
val out = ByteArrayOutputStream()
@@ -40,6 +53,7 @@ public object EnrollmentRecordCodec {
data.writeUTF(record.deviceName)
data.writeUTF(record.keyStoreAlias)
data.writeLong(record.renewAfterEpochSeconds)
data.writeUTF(record.controlPlaneUrl)
}
return out.toByteArray()
}
@@ -53,11 +67,25 @@ public object EnrollmentRecordCodec {
deviceName = data.readUTF(),
keyStoreAlias = data.readUTF(),
renewAfterEpochSeconds = data.readLong(),
controlPlaneUrl = readTrailingUtfOrBlank(data),
)
}
} catch (e: Exception) {
throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e)
}
/**
* Read the trailing [EnrollmentRecord.controlPlaneUrl], tolerating its ABSENCE (a blob written
* before the field existed) as `""`. Only end-of-blob is tolerated: a record that ends exactly
* after the long is a valid older layout, whereas a mangled one still fails the reads above and is
* reported corrupt. Declaring an existing enrollment corrupt would strand a working device.
*/
private fun readTrailingUtfOrBlank(data: DataInputStream): String =
try {
data.readUTF()
} catch (_: EOFException) {
""
}
}
/**

View File

@@ -5,15 +5,13 @@ import okhttp3.OkHttpClient
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.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HttpMethod
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
/**
* B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs
@@ -30,53 +28,32 @@ class DeviceEnrollerTest {
const val BASE = "https://cp.terminal.yaojia.wang"
const val ALIAS = "test-device-key"
// 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.
const val LEAF_CN = "t1-device"
const val CA_CN = "webterm-device-ca"
const val LEAF_B64 =
"MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
"ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
"WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
"cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
"HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
"ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
"cdoleWDlqcMKU"
const val CA_B64 =
"MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
"dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
"YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
"e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
"4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
"Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
"YloHuEAg+kngzA33m52aWtublai4L+eybg=="
// Real self-signed P-256 X.509 certs + a software key double, shared with the rotation tests.
const val LEAF_CN = RotationTestFixtures.LEAF_CN
const val CA_CN = RotationTestFixtures.CA_CN
fun loginBody(): ByteArray =
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
fun loginBody(): ByteArray = RotationTestFixtures.loginBody()
fun enrollBody(deviceId: String = "dev-1"): ByteArray =
"""
{"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
"notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
"renewAfter":"2026-09-05T00:00:00.000Z"}
""".trimIndent().toByteArray()
fun enrollBody(deviceId: String = "dev-1"): ByteArray = RotationTestFixtures.enrollBody(deviceId)
fun softwareKey(alias: String): HardwareBackedKey {
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1"))
val kp = kpg.generateKeyPair()
return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey)
}
fun softwareKey(alias: String): HardwareBackedKey = RotationTestFixtures.softwareKey(alias)
}
private val events = mutableListOf<String>()
private val transport = FakeHttpTransport()
/**
* The recovery transport is a SEPARATE double on purpose: `/device/:id/recover` must ride a PLAIN
* (non-mTLS) client, because no TLS terminator forwards an expired client certificate. Using two
* transports is what lets these tests prove the routing, not just the request shape.
*/
private val recoveryTransport = FakeHttpTransport()
private val certStore = RecordingCertStore(events)
private val recordStore = RecordingRecordStore(events)
private val keyProvider = RecordingKeyProvider(events)
private val refresher = RecordingRefresher(events)
private fun enroller(): DeviceEnroller =
private fun enroller(withRecovery: Boolean = true): DeviceEnroller =
DeviceEnroller(
client = DeviceEnrollmentClient(BASE, transport),
certStore = certStore,
@@ -85,6 +62,7 @@ class DeviceEnrollerTest {
keyAlias = ALIAS,
keyProvider = keyProvider,
cacheRefresher = refresher,
recoveryClient = if (withRecovery) DeviceEnrollmentClient(BASE, recoveryTransport) else null,
)
// ── enroll: commit sequencing (the security-critical invariant) ────────────────────────────
@@ -221,6 +199,166 @@ class DeviceEnrollerTest {
assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit")
}
@Test
fun enrollPersistsTheControlPlaneUrlSoRotationKnowsWhereToRenew() = 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")
// Silent rotation happens with no UI, so the control plane the device enrolled against must be
// persisted — a rotation that fell back to a compiled-in default would renew somewhere else.
assertEquals(BASE, recordStore.saved!!.controlPlaneUrl)
}
@Test
fun renewDecodesTheRealReissueResponseThatCarriesNoDeviceId() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
// The real /device/:id/renew 201 body — {cert, caChain, notAfter} with NO deviceId.
transport.queueSuccess(
HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = RotationTestFixtures.reissueBody(),
)
val summary = enroller().renew()
assertEquals(LEAF_CN, summary.subjectCommonName)
assertEquals("dev-1", recordStore.saved!!.deviceId, "the device id survives the re-issue")
assertEquals(BASE, recordStore.saved!!.controlPlaneUrl)
}
// ── recover: the EXPIRED-leaf escape hatch (plain HTTPS, cert in the body) ──────────────────
@Test
fun recoverPostsTheStoredLeafAndAFreshCsrOnThePlainTransportNotTheMtlsOne() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
certStore.seed(storedIdentity())
recoveryTransport.queueSuccess(
HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(),
)
val summary = enroller().recover()
// The request went out on the PLAIN transport. If it had gone on the mTLS one it would have
// presented the expired leaf and nginx would have answered a bare 400 — the production deadlock.
assertTrue(transport.recordedRequests.isEmpty(), "recovery must NEVER ride the mTLS transport")
val request = recoveryTransport.recordedRequests.single()
assertEquals("$BASE/device/dev-1/recover", request.url)
assertNull(request.headers["Authorization"], "recovery carries no bearer")
val body = request.body!!.decodeToString()
assertTrue(body.contains("\"cert\":"), "the lapsed leaf travels in the BODY")
assertTrue(body.contains(RotationTestFixtures.LEAF_B64.take(32)), "the body carries the stored leaf")
assertTrue(body.contains("\"csr\":"), "possession is proven by a fresh CSR over the same key")
assertEquals(LEAF_CN, summary.subjectCommonName)
}
@Test
fun recoverCommitsWithTheSameSequencingAsEnrollAndRenew() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
certStore.seed(storedIdentity())
recoveryTransport.queueSuccess(
HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(),
)
enroller().recover()
assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"), "record before the pointer flip")
assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh after the commit")
}
@Test
fun recoverReusesTheSameHardwareKeyAndNeverGeneratesANewOne() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
certStore.seed(storedIdentity())
recoveryTransport.queueSuccess(
HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(),
)
enroller().recover()
// The server enforces `CSR key == registered key`; generating a key here would guarantee a 403.
assertTrue(keyProvider.generatedAliases.isEmpty(), "recovery re-CSRs from the EXISTING key")
assertTrue(keyProvider.deletedAliases.isEmpty(), "a successful recovery deletes nothing")
}
@Test
fun recoverThrowsWhenNoPlainRecoveryClientIsConfigured() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
certStore.seed(storedIdentity())
val error = runCatching { enroller(withRecovery = false).recover() }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(recoveryTransport.recordedRequests.isEmpty())
assertTrue(transport.recordedRequests.isEmpty(), "and it must not silently fall back to mTLS")
}
@Test
fun recoverThrowsWhenThereIsNoStoredLeafToPresent() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
// No cert in the store → nothing to put in the body → a fresh enroll is the only way out.
val error = runCatching { enroller().recover() }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(recoveryTransport.recordedRequests.isEmpty(), "no pointless round-trip")
}
@Test
fun recoverThrowsWhenNothingIsEnrolled() = runTest {
val error = runCatching { enroller().recover() }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(recoveryTransport.recordedRequests.isEmpty())
}
@Test
fun aFailedRecoveryLeavesThePriorIdentityUntouched() = runTest {
val prior = storedIdentity()
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
certStore.seed(prior)
// Beyond the 30-day grace the control plane answers a uniform 401.
recoveryTransport.queueSuccess(
HttpMethod.POST, "$BASE/device/dev-1/recover", 401, body = """{"error":"rejected"}""".toByteArray(),
)
val error = runCatching { enroller().recover() }.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
assertSame(prior, certStore.load(), "a failed recovery must not touch the live cert pointer")
assertFalse(events.contains("cert.save"), "no pointer flip on failure")
assertFalse(events.contains("cache.refresh"), "and nothing is published to the in-memory cache")
assertTrue(keyProvider.deletedAliases.isEmpty(), "the key that still works is NEVER deleted")
}
@Test
fun theRecoveryFailureNeverCarriesTheCertificateBytes() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
certStore.seed(storedIdentity())
recoveryTransport.queueSuccess(
HttpMethod.POST, "$BASE/device/dev-1/recover", 403, body = """{"error":"rejected"}""".toByteArray(),
)
val error = runCatching { enroller().recover() }.exceptionOrNull()!!
val rendered = "${error.message} $error"
assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64.take(32)), "no cert bytes in the error")
assertFalse(rendered.contains("MII"), "no DER/PEM material in a loggable rendering")
}
private fun storedIdentity(): StoredIdentityMetadata =
StoredIdentityMetadata(
alias = ALIAS,
keyAlgorithm = "EC",
keyStoreAlias = ALIAS,
certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()),
)
// ── remove: full teardown ──────────────────────────────────────────────────────────────────
@Test
@@ -240,17 +378,25 @@ class DeviceEnrollerTest {
private class RecordingCertStore(private val events: MutableList<String>) : CertStore {
var saved: StoredIdentityMetadata? = null
var cleared = false
private var current: StoredIdentityMetadata? = null
/** Pre-existing on-disk identity (the lapsed leaf the recovery path must present). */
fun seed(metadata: StoredIdentityMetadata) {
current = metadata
}
override fun save(metadata: StoredIdentityMetadata) {
saved = metadata
current = metadata
events += "cert.save"
}
override fun load(): StoredIdentityMetadata? = saved
override fun load(): StoredIdentityMetadata? = current
override fun clear() {
cleared = true
saved = null
current = null
events += "cert.clear"
}
}

View File

@@ -0,0 +1,166 @@
package wang.yaojia.webterm.tlsandroid
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.IOException
import java.time.Duration
import java.time.Instant
/**
* [DeviceRotationStore] is the read side of silent rotation: it composes the two persisted stores into
* the timing snapshot the pure `RotationPolicy` consumes. The load-bearing behaviour is that the
* timing comes from the LIVE LEAF CERTIFICATE, not from the enrollment record — a record written by a
* renew/recover commit has no `renewAfter` at all (the server does not return one), so a store that
* trusted the record would rotate once and then report "not due" until the cert died.
*/
class DeviceRotationStoreTest {
private companion object {
const val ALIAS = "test-device-key"
const val CP_URL = "https://cp.terminal.yaojia.wang"
}
private val certStore = FakeCertStore()
private val recordStore = FakeRecordStore()
private val store = DeviceRotationStore(certStore, recordStore)
/** JUnit5's `assertNotNull` returns Unit, so assert-and-unwrap in one place. */
private fun readSnapshot(): DeviceRotationSnapshot {
val snapshot = store.read()
assertNotNull(snapshot, "expected an enrolled rotation snapshot")
return snapshot!!
}
private fun seedEnrolled(
controlPlaneUrl: String = CP_URL,
renewAfterEpochSeconds: Long = 0L,
) {
recordStore.current = EnrollmentRecord(
deviceId = "dev-1",
deviceName = "Alice Pixel",
keyStoreAlias = ALIAS,
renewAfterEpochSeconds = renewAfterEpochSeconds,
controlPlaneUrl = controlPlaneUrl,
)
certStore.current = StoredIdentityMetadata(
alias = ALIAS,
keyAlgorithm = "EC",
keyStoreAlias = ALIAS,
certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()),
)
}
@Test
fun readReturnsNullWhenNothingIsEnrolled() {
assertNull(store.read(), "a fresh install has nothing to rotate")
}
@Test
fun readReturnsNullWhenTheCertLivePointerIsAbsent() {
recordStore.current = EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, CP_URL)
// A record with no live cert cannot be renewed OR recovered (recovery needs the lapsed leaf to
// put in the body), so this is "nothing to rotate", not a failure.
assertNull(store.read())
}
@Test
fun readTakesTheExpiryFromTheLeafCertificate() {
seedEnrolled()
val snapshot = readSnapshot()
assertEquals("dev-1", snapshot.renewalState.deviceId)
assertEquals(
Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER),
snapshot.renewalState.notAfter,
"notAfter is the leaf's own hard expiry — the value the TLS stack actually enforces",
)
}
@Test
fun readDerivesRenewAfterFromTheLeafAndIgnoresTheStaleRecordValue() {
// A record committed by a renew carries renewAfterEpochSeconds = 0 (unknown) because the
// renew 201 has no renewAfter; a record committed by an enroll carries a value that is stale
// the moment the leaf is replaced. Either way the LEAF is the source of truth.
seedEnrolled(renewAfterEpochSeconds = Instant.parse("1999-01-01T00:00:00Z").epochSecond)
val notBefore = Instant.parse(RotationTestFixtures.LEAF_NOT_BEFORE)
val notAfter = Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER)
// The control plane's own formula: notBefore + 2/3 · lifetime.
val expected = notBefore.plus(Duration.between(notBefore, notAfter).multipliedBy(2).dividedBy(3))
val snapshot = readSnapshot()
assertEquals(expected, snapshot.renewalState.renewAfter)
assertTrue(snapshot.renewalState.renewAfter!!.isAfter(notBefore))
assertTrue(snapshot.renewalState.renewAfter!!.isBefore(notAfter))
}
@Test
fun readCarriesTheControlPlaneUrlSoRotationTargetsTheRightHost() {
seedEnrolled(controlPlaneUrl = "https://cp.example.test")
assertEquals("https://cp.example.test", readSnapshot().controlPlaneUrl)
}
@Test
fun readReportsABlankControlPlaneUrlRatherThanGuessingOne() {
// A record written before the URL was persisted decodes to "". Renewing against a DEFAULT host
// would silently talk to the wrong control plane, so the blank is surfaced verbatim and the
// caller must refuse to rotate.
seedEnrolled(controlPlaneUrl = "")
assertEquals("", readSnapshot().controlPlaneUrl)
}
@Test
fun readPropagatesAStoreFaultInsteadOfReportingNotEnrolled() {
seedEnrolled()
certStore.fault = IOException("tink blob unreadable")
// Degrading a decrypt fault to "nothing enrolled" would silently disable rotation forever; the
// caller surfaces it as a failure instead.
assertTrue(runCatching { store.read() }.exceptionOrNull() is IOException)
}
@Test
fun theSnapshotCarriesNoCredentialInItsToString() {
seedEnrolled()
val rendered = readSnapshot().toString()
assertTrue(rendered.contains("dev-1"))
assertTrue(rendered.contains("cp.terminal.yaojia.wang"))
assertFalse(rendered.contains("MIIB"), "no certificate DER may reach a loggable rendering")
assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64), "no leaf bytes in the snapshot")
}
// ── doubles ────────────────────────────────────────────────────────────────────────────────
private class FakeCertStore : CertStore {
var current: StoredIdentityMetadata? = null
var fault: Exception? = null
override fun save(metadata: StoredIdentityMetadata) {
current = metadata
}
override fun load(): StoredIdentityMetadata? {
fault?.let { throw it }
return current
}
override fun clear() {
current = null
}
}
private class FakeRecordStore : EnrollmentRecordStore {
var current: EnrollmentRecord? = null
override fun save(record: EnrollmentRecord) {
current = record
}
override fun load(): EnrollmentRecord? = current
override fun clear() {
current = null
}
}
}

View File

@@ -0,0 +1,53 @@
package wang.yaojia.webterm.tlsandroid
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
import java.io.DataOutputStream
/**
* [EnrollmentRecordCodec] round-trip, plus the one compatibility rule that matters: a blob written
* BEFORE `controlPlaneUrl` existed must still decode (to a blank URL) rather than being reported as
* corrupt — a corrupt-blob verdict on an existing enrollment would strand a working device.
*/
class EnrollmentRecordCodecTest {
@Test
fun roundTripsEveryField() {
val record = EnrollmentRecord(
deviceId = "dev-1",
deviceName = "Alice Pixel",
keyStoreAlias = "webterm-device-key",
renewAfterEpochSeconds = 1_790_000_000L,
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
)
assertEquals(record, EnrollmentRecordCodec.decode(EnrollmentRecordCodec.encode(record)))
}
@Test
fun aBlobWrittenBeforeTheControlPlaneUrlFieldDecodesToABlankUrl() {
// Exactly the four-field layout the previous codec version emitted.
val legacy = ByteArrayOutputStream().also { out ->
DataOutputStream(out).use { data ->
data.writeUTF("dev-1")
data.writeUTF("Alice Pixel")
data.writeUTF("webterm-device-key")
data.writeLong(0L)
}
}.toByteArray()
val decoded = EnrollmentRecordCodec.decode(legacy)
assertEquals("dev-1", decoded.deviceId)
assertEquals("", decoded.controlPlaneUrl, "an absent trailing field is unknown, not corrupt")
}
@Test
fun aTruncatedBlobIsStillReportedAsCorrupt() {
// Tolerating the ABSENT trailing field must not turn into tolerating a mangled record.
assertThrows(CorruptStoredIdentityException::class.java) {
EnrollmentRecordCodec.decode(byteArrayOf(0x00, 0x05, 0x64))
}
}
}

View File

@@ -0,0 +1,78 @@
package wang.yaojia.webterm.tlsandroid
import java.security.KeyPairGenerator
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.util.Base64
/**
* Shared JVM test fixtures for the enroll / rotate / recover orchestration: REAL self-signed P-256
* X.509 certificates (base64 DER) so `CertificateFactory`, `CertificateSummaryReader` and the
* rotation-timing reader parse them exactly as they parse a server-issued leaf — and a software P-256
* key double standing in for the non-exportable AndroidKeyStore key (which needs a device).
*/
internal object RotationTestFixtures {
const val LEAF_CN: String = "t1-device"
const val CA_CN: String = "webterm-device-ca"
/** Validity window baked into [LEAF_B64] (read back from the DER, asserted in the store tests). */
const val LEAF_NOT_BEFORE: String = "2026-07-18T11:21:11Z"
const val LEAF_NOT_AFTER: String = "2126-06-24T11:21:11Z"
const val LEAF_B64: String =
"MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
"ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
"WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
"cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
"HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
"ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
"cdoleWDlqcMKU"
const val CA_B64: String =
"MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
"dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
"YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
"e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
"4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
"Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
"YloHuEAg+kngzA33m52aWtublai4L+eybg=="
fun leafDer(): ByteArray = Base64.getDecoder().decode(LEAF_B64)
fun leafCertificate(): X509Certificate = parse(leafDer())
fun caCertificate(): X509Certificate = parse(Base64.getDecoder().decode(CA_B64))
/** The enroll 201 body — the ONLY route that returns deviceId/notBefore/renewAfter. */
fun enrollBody(deviceId: String = "dev-1"): ByteArray =
"""
{"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
"notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
"renewAfter":"2026-09-05T00:00:00.000Z"}
""".trimIndent().toByteArray()
/**
* The REAL `/device/:id/renew` and `/device/:id/recover` 201 body: `{cert, caChain, notAfter}` —
* no deviceId, no notBefore, no renewAfter (control-plane/src/api/renew.ts).
*/
fun reissueBody(): ByteArray =
"""
{"cert":"$LEAF_B64","caChain":["$CA_B64"],"notAfter":"2126-06-24T11:21:11.000Z"}
""".trimIndent().toByteArray()
fun loginBody(): ByteArray =
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
/** A software P-256 key standing in for the non-exportable AndroidKeyStore key (§7: no emulator). */
fun softwareKey(alias: String): HardwareBackedKey {
val generator = KeyPairGenerator.getInstance("EC")
generator.initialize(ECGenParameterSpec("secp256r1"))
val pair = generator.generateKeyPair()
return HardwareBackedKey(alias, pair.private, pair.public as ECPublicKey)
}
private fun parse(der: ByteArray): X509Certificate =
CertificateFactory.getInstance("X.509").generateCertificate(der.inputStream()) as X509Certificate
}