diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequest.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequest.kt new file mode 100644 index 0000000..d9ac0c5 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequest.kt @@ -0,0 +1,172 @@ +package wang.yaojia.webterm.api.enroll + +/** + * B4 · Manual, canonical-DER encoder for a P-256 PKCS#10 `CertificationRequest` — a byte-for-byte + * port of the iOS `ClientTLS.CertificateSigningRequest`. + * + * Built by hand (no JCA CSR helper) so the exact bytes are under our control and the request is + * signed by the [CsrSigner] (an AndroidKeyStore hardware key in production, a software P-256 key in + * tests) via `SHA256withECDSA`. The output must satisfy the control-plane `verifyCsrPoPEc`: an EC + * P-256 `SubjectPublicKeyInfo` (`id-ecPublicKey` + `prime256v1`), an `ecdsa-with-SHA256` + * self-signature, and a valid PoP. Encoding is strictly canonical DER (minimal lengths) so the + * server's re-serialization of `CertificationRequestInfo` matches the bytes we signed. + * + * ``` + * CertificationRequest ::= SEQUENCE { + * certificationRequestInfo CertificationRequestInfo, + * signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256 + * signature BIT STRING } -- X9.62 DER ECDSA-Sig + * + * CertificationRequestInfo ::= SEQUENCE { + * version INTEGER { v1(0) }, + * subject Name, + * subjectPKInfo SubjectPublicKeyInfo, + * attributes [0] IMPLICIT SET OF Attribute } -- empty + * ``` + */ +public object CertificateSigningRequest { + /** P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes. */ + private const val UNCOMPRESSED_P256_POINT_LENGTH = 65 + + /** + * Build and self-sign a P-256 PKCS#10 CSR DER for [signer]'s key. + * + * @param subjectCommonName the CSR subject CN. The device leaf's identity is driven server-side + * by the ownership-verified subdomain SAN, so this is descriptive only; it must be non-empty. + * @param signer the P-256 hardware key that provides the public key and signs the + * `CertificationRequestInfo`. + * @throws CsrException.InvalidSubject on an empty CN; [CsrException.InvalidPublicKey] if the + * signer's public key is not a 65-byte X9.63 P-256 point. + */ + public fun der(subjectCommonName: String, signer: CsrSigner): ByteArray { + if (subjectCommonName.isEmpty()) throw CsrException.InvalidSubject + + val publicPoint = signer.publicKeyX963() + if (publicPoint.size != UNCOMPRESSED_P256_POINT_LENGTH || publicPoint[0].toInt() != 0x04) { + throw CsrException.InvalidPublicKey + } + + val requestInfo = certificationRequestInfo(subjectCommonName, publicPoint) + val signature = signer.sign(requestInfo) + + return DerWriter.sequence( + listOf( + requestInfo, + ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER, + DerWriter.bitString(signature), + ), + ) + } + + // ── CertificationRequestInfo ──────────────────────────────────────────────────────────── + + private fun certificationRequestInfo(subjectCommonName: String, publicPoint: ByteArray): ByteArray = + DerWriter.sequence( + listOf( + DerWriter.INTEGER_0, // version v1(0) + name(subjectCommonName), + subjectPublicKeyInfo(publicPoint), + DerWriter.EMPTY_ATTRIBUTES_CONTEXT0, // [0] IMPLICIT SET OF Attribute (empty) + ), + ) + + /** `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN. */ + private fun name(commonName: String): ByteArray { + val attribute = DerWriter.sequence( + listOf(DerWriter.oid(Oid.COMMON_NAME), DerWriter.utf8String(commonName)), + ) + val rdn = DerWriter.set(listOf(attribute)) + return DerWriter.sequence(listOf(rdn)) + } + + /** + * `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` + `prime256v1` named curve, then + * the uncompressed point as a BIT STRING. + */ + private fun subjectPublicKeyInfo(publicPoint: ByteArray): ByteArray { + val algorithm = DerWriter.sequence( + listOf(DerWriter.oid(Oid.EC_PUBLIC_KEY), DerWriter.oid(Oid.PRIME256V1)), + ) + return DerWriter.sequence(listOf(algorithm, DerWriter.bitString(publicPoint))) + } + + /** + * `AlgorithmIdentifier` for `ecdsa-with-SHA256` — no parameters (absent, per RFC 5758), which is + * exactly what the server's verifier expects. + */ + private val ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER: ByteArray = + DerWriter.sequence(listOf(DerWriter.oid(Oid.ECDSA_WITH_SHA256))) +} + +/** Object identifiers (DER content bytes; tag/length added by [DerWriter.oid]). */ +private object Oid { + /** 1.2.840.10045.2.1 — id-ecPublicKey. */ + val EC_PUBLIC_KEY = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01) + + /** 1.2.840.10045.3.1.7 — prime256v1 / secp256r1. */ + val PRIME256V1 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07) + + /** 1.2.840.10045.4.3.2 — ecdsa-with-SHA256. */ + val ECDSA_WITH_SHA256 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02) + + /** 2.5.4.3 — id-at-commonName. */ + val COMMON_NAME = byteArrayOf(0x55, 0x04, 0x03) +} + +/** + * A tiny canonical-DER encoder. Every helper returns a fully-formed TLV so callers just concatenate + * children — canonical minimal-length encoding throughout. Internal so its byte layout is + * unit-testable in isolation. + */ +internal object DerWriter { + private const val TAG_INTEGER: Byte = 0x02 + private const val TAG_BIT_STRING: Byte = 0x03 + private const val TAG_OID: Byte = 0x06 + private const val TAG_UTF8_STRING: Byte = 0x0C + private const val TAG_SEQUENCE: Byte = 0x30 + private const val TAG_SET: Byte = 0x31 + private const val TAG_CONTEXT0_CONSTRUCTED: Byte = 0xA0.toByte() + + /** `INTEGER 0` — the fixed PKCS#10 version v1(0). */ + val INTEGER_0: ByteArray = byteArrayOf(TAG_INTEGER, 0x01, 0x00) + + /** `[0] IMPLICIT SET OF Attribute`, empty — `A0 00`. */ + val EMPTY_ATTRIBUTES_CONTEXT0: ByteArray = byteArrayOf(TAG_CONTEXT0_CONSTRUCTED, 0x00) + + fun sequence(children: List): ByteArray = tlv(TAG_SEQUENCE, concat(children)) + + fun set(children: List): ByteArray = tlv(TAG_SET, concat(children)) + + fun oid(content: ByteArray): ByteArray = tlv(TAG_OID, content) + + fun utf8String(value: String): ByteArray = tlv(TAG_UTF8_STRING, value.encodeToByteArray()) + + /** BIT STRING with zero unused bits (all our bit strings are byte-aligned). */ + fun bitString(content: ByteArray): ByteArray = tlv(TAG_BIT_STRING, byteArrayOf(0x00) + content) + + /** Tag-Length-Value with canonical DER length encoding. */ + private fun tlv(tag: Byte, value: ByteArray): ByteArray = byteArrayOf(tag) + length(value.size) + value + + /** DER length: short form (<128) or long form (`0x80 | byteCount`, big-endian). */ + private fun length(count: Int): ByteArray { + if (count < 0x80) return byteArrayOf(count.toByte()) + var value = count + val bytes = ArrayDeque() + while (value > 0) { + bytes.addFirst((value and 0xFF).toByte()) + value = value ushr 8 + } + return byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray() + } + + private fun concat(chunks: List): ByteArray { + val total = chunks.sumOf { it.size } + val out = ByteArray(total) + var offset = 0 + for (chunk in chunks) { + System.arraycopy(chunk, 0, out, offset, chunk.size) + offset += chunk.size + } + return out + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CsrSigner.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CsrSigner.kt new file mode 100644 index 0000000..d266a70 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CsrSigner.kt @@ -0,0 +1,37 @@ +package wang.yaojia.webterm.api.enroll + +/** + * B4 · The signing key abstraction the PKCS#10 CSR encoder ([CertificateSigningRequest]) drives — + * the Android analogue of iOS `P256HardwareKey`. + * + * In production this is backed by a NON-EXPORTABLE P-256 key living inside AndroidKeyStore + * (StrongBox → TEE), so `sign` runs inside secure hardware and the private key never leaves it + * (`:client-tls-android` `HardwareBackedKey`). In JVM unit tests it is backed by a software P-256 + * key via the SAME `Signature("SHA256withECDSA")` path, so the CSR-encoding bytes are exercised + * identically without an emulator. + */ +public interface CsrSigner { + /** + * The public key in ANSI X9.63 uncompressed form: `0x04 || X(32) || Y(32)` (65 bytes for + * P-256). This is exactly what wraps into the CSR's `SubjectPublicKeyInfo` BIT STRING. + */ + public fun publicKeyX963(): ByteArray + + /** + * ECDSA-sign `message` over SHA-256, returning the X9.62 DER signature + * (`SEQUENCE { r INTEGER, s INTEGER }`) — exactly the shape a PKCS#10 `signature` BIT STRING + * and the server's `verifyCsrPoPEc` expect. The digest is computed by the algorithm + * (`SHA256withECDSA`), so callers pass the raw message (the DER of `CertificationRequestInfo`), + * NOT a pre-hash. + */ + public fun sign(message: ByteArray): ByteArray +} + +/** Structural failures building a CSR — the client refuses to emit a malformed request. */ +public sealed class CsrException(message: String) : Exception(message) { + /** The signer's public key was not the expected 65-byte X9.63 uncompressed P-256 point. */ + public data object InvalidPublicKey : CsrException("CSR public key is not a 65-byte X9.63 P-256 point") + + /** The subject CN was empty (not encodable / rejected by the server). */ + public data object InvalidSubject : CsrException("CSR subject common name must not be empty") +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt new file mode 100644 index 0000000..f524c76 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt @@ -0,0 +1,177 @@ +package wang.yaojia.webterm.api.enroll + +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport + +/** + * B4 · Talks to the control-plane device-enrollment API over the shared [HttpTransport] seam (the + * same seam `:transport-okhttp` implements and `:test-support` fakes), so the enroll flow rides the + * app's normal HTTP stack. Android analogue of iOS `DeviceEnrollmentClient`, extended with the login + * step (B4 pinned contract): + * + * `POST /auth/login` `{ password }` → 201 `{ enrollToken, accountId, expiresIn }` + * `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain, + * deviceName, attestation? }` → 201 `{ deviceId, cert, caChain, + * notBefore, notAfter, renewAfter }` + * `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The + * server schema is `.strict()`; NO keyAlg/subdomain/deviceName. + * + * Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is + * sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs + * are standard-base64 (`bytesToBase64` = Node `Buffer.toString('base64')`). + * + * Immutable: constructed once with a [baseUrl] + [http]; the short-lived enroll bearer is passed + * per-call and never held/logged (leaked-bearer blast radius). + */ +public class DeviceEnrollmentClient( + baseUrl: String, + private val http: HttpTransport, +) { + /** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */ + private val base: String = baseUrl.trim().trimEnd('/') + + /** + * One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is + * rejected client-side (`InvalidRequest`) before any network I/O — never send a blank credential. + */ + public suspend fun login(password: String): LoginResult { + if (password.isEmpty()) throw DeviceEnrollmentError.InvalidRequest + val body = EnrollJson.encodeToString(LoginRequestBody.serializer(), LoginRequestBody(password)) + val response = http.send(jsonRequest(HttpMethod.POST, PATH_LOGIN, body.encodeToByteArray(), bearer = null)) + val dto = decodeOn201(response, LoginResponseDto.serializer()) + return LoginResult( + enrollToken = dto.enrollToken, + accountId = dto.accountId, + expiresInSeconds = dto.expiresIn, + ) + } + + /** + * Enroll a freshly-generated hardware key: POST the [csrDer] under the enroll [bearerToken], + * receive the leaf. Required fields are validated client-side (`InvalidRequest`) before any I/O. + */ + public suspend fun enroll( + bearerToken: String, + csrDer: ByteArray, + subdomain: String, + deviceName: String, + attestation: String? = null, + ): EnrollmentResult { + if (bearerToken.isEmpty() || csrDer.isEmpty() || subdomain.isEmpty() || deviceName.isEmpty()) { + throw DeviceEnrollmentError.InvalidRequest + } + val body = EnrollJson.encodeToString( + EnrollRequestBody.serializer(), + EnrollRequestBody( + csr = base64(csrDer), + keyAlg = KEY_ALG_EC_P256, + subdomain = subdomain, + deviceName = deviceName, + attestation = attestation, + ), + ) + val response = http.send(jsonRequest(HttpMethod.POST, PATH_ENROLL, body.encodeToByteArray(), bearerToken)) + return toResult(decodeOn201(response, EnrollResponseDto.serializer())) + } + + /** + * 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. + */ + public suspend fun renew(bearerToken: String, deviceId: String, csrDer: ByteArray): EnrollmentResult { + if (bearerToken.isEmpty() || deviceId.isEmpty() || csrDer.isEmpty()) { + throw DeviceEnrollmentError.InvalidRequest + } + // Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert + // and its schema is `.strict()`, so any enroll-only extra (keyAlg/subdomain/deviceName) is + // rejected. Identity/key come from the current cert + registry record, never the body. + val body = EnrollJson.encodeToString( + RenewRequestBody.serializer(), + RenewRequestBody(csr = base64(csrDer)), + ) + val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew" + val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken)) + return toResult(decodeOn201(response, EnrollResponseDto.serializer())) + } + + // ── Request/response plumbing ──────────────────────────────────────────────────────────── + + private fun jsonRequest( + method: HttpMethod, + path: String, + jsonBody: ByteArray, + bearer: String?, + ): HttpRequest { + val headers = LinkedHashMap() + headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON + if (bearer != null) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer" + return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody) + } + + /** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error` + * code (never the raw body); an undecodable 201 body → [DeviceEnrollmentError.MalformedResponse]. */ + private fun decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer): T { + if (response.status != HTTP_CREATED) { + throw DeviceEnrollmentError.Http(response.status, errorCode(response.body)) + } + return runCatching { EnrollJson.decodeFromString(serializer, response.body.decodeToString()) } + .getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse + } + + private fun toResult(dto: EnrollResponseDto): EnrollmentResult { + val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse + val chain = dto.caChain.map { entry -> + decodeBase64OrNull(entry) ?: throw DeviceEnrollmentError.MalformedResponse + } + return EnrollmentResult( + deviceId = dto.deviceId, + certificate = certificate, + caChain = chain, + notBefore = parseInstantOrNull(dto.notBefore), + notAfter = parseInstantOrNull(dto.notAfter), + renewAfter = parseInstantOrNull(dto.renewAfter), + ) + } + + private fun errorCode(body: ByteArray): String? = + runCatching { EnrollJson.decodeFromString(ErrorDto.serializer(), body.decodeToString()).error }.getOrNull() + + private companion object { + const val PATH_LOGIN = "/auth/login" + const val PATH_ENROLL = "/device/enroll" + const val PATH_DEVICE = "/device" + const val KEY_ALG_EC_P256 = "ec-p256" + const val HTTP_CREATED = 201 + + const val HEADER_CONTENT_TYPE = "Content-Type" + const val HEADER_AUTHORIZATION = "Authorization" + const val CONTENT_TYPE_JSON = "application/json" + const val BEARER_PREFIX = "Bearer " + + private val BASE64_ENCODER = java.util.Base64.getEncoder() + private val BASE64_DECODER = java.util.Base64.getDecoder() + + fun base64(bytes: ByteArray): String = BASE64_ENCODER.encodeToString(bytes) + + fun decodeBase64OrNull(text: String): ByteArray? = + runCatching { BASE64_DECODER.decode(text) }.getOrNull() + + /** Percent-encode a `:id` path segment's non-unreserved bytes (defence: device ids are + * server-minted UUIDs, but never build a URL from an unescaped field). */ + fun encodePathSegment(value: String): String { + val sb = StringBuilder() + for (byte in value.encodeToByteArray()) { + val code = byte.toInt() and 0xFF + val ch = code.toChar() + if (ch in UNRESERVED) sb.append(ch) else sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F]) + } + return sb.toString() + } + + private val UNRESERVED: Set = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet() + private val HEX = "0123456789ABCDEF".toCharArray() + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncoding.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncoding.kt new file mode 100644 index 0000000..8859989 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncoding.kt @@ -0,0 +1,54 @@ +package wang.yaojia.webterm.api.enroll + +import java.math.BigInteger +import java.security.interfaces.ECPublicKey + +/** + * B4 · Pure encoder from a JCA [ECPublicKey] to the ANSI X9.63 uncompressed point + * `0x04 || X || Y` that a P-256 `SubjectPublicKeyInfo` BIT STRING carries. + * + * Kept in the pure `:api-client` module (no Android dependency) so it is reused by BOTH the + * JVM-unit-test software signer AND the framework `HardwareBackedKey` (`:client-tls-android`), + * and so this security-load-bearing byte layout is unit-tested at JVM speed. + */ +public object EcPointEncoding { + /** P-256 field element width in bytes (256 bits). */ + public const val P256_COORDINATE_BYTES: Int = 32 + + /** Uncompressed-point prefix (`0x04`) per SEC 1 §2.3.3. */ + private const val UNCOMPRESSED_PREFIX: Byte = 0x04 + + /** + * Encode [publicKey]'s affine (x, y) as `0x04 || X(32) || Y(32)` (65 bytes). Each coordinate is + * an unsigned big-endian integer left-padded (or, defensively, high-byte-trimmed) to exactly + * [P256_COORDINATE_BYTES]. Throws [IllegalArgumentException] if a coordinate genuinely does not + * fit 32 bytes (i.e. the key is not on a 256-bit curve). + */ + public fun x963(publicKey: ECPublicKey): ByteArray { + val point = publicKey.w + val x = toFixedLengthUnsigned(point.affineX, P256_COORDINATE_BYTES) + val y = toFixedLengthUnsigned(point.affineY, P256_COORDINATE_BYTES) + val out = ByteArray(1 + P256_COORDINATE_BYTES * 2) + out[0] = UNCOMPRESSED_PREFIX + System.arraycopy(x, 0, out, 1, P256_COORDINATE_BYTES) + System.arraycopy(y, 0, out, 1 + P256_COORDINATE_BYTES, P256_COORDINATE_BYTES) + return out + } + + /** + * Convert a non-negative [value] to a big-endian byte array of exactly [length] bytes. A + * `BigInteger` may carry a leading 0x00 sign byte (drop it) or be shorter than [length] + * (left-pad with zeros). A value that needs MORE than [length] significant bytes is rejected — + * silently truncating a coordinate would corrupt the key. + */ + internal fun toFixedLengthUnsigned(value: BigInteger, length: Int): ByteArray { + require(value.signum() >= 0) { "EC coordinate must be non-negative" } + val raw = value.toByteArray() // big-endian, possibly with a leading 0x00 sign byte + val start = if (raw.size > length && raw[0].toInt() == 0) 1 else 0 + val significant = raw.size - start + require(significant <= length) { "EC coordinate does not fit $length bytes (got $significant)" } + val out = ByteArray(length) + System.arraycopy(raw, start, out, length - significant, significant) + return out + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt new file mode 100644 index 0000000..27148a8 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt @@ -0,0 +1,124 @@ +package wang.yaojia.webterm.api.enroll + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.time.Instant + +/** + * B4 · Typed result of the one-time operator login (`POST /auth/login`). The [enrollToken] is a + * short-lived `device:enroll` bearer — hold it ONLY for the immediately-following enroll call and + * NEVER persist or log it. [accountId] identifies the tenant the device will be scoped under. + */ +public data class LoginResult( + val enrollToken: String, + val accountId: String, + val expiresInSeconds: Long, +) + +/** + * B4 · Typed result of a successful `POST /device/enroll` (or `/device/:id/renew`): the issued leaf + * plus its issuer chain and rotation timing. The private key is NOT here — it stays non-exportable + * in AndroidKeyStore. Mirrors iOS `EnrollmentResult`. + * + * NOTE: [certificate]/[caChain] are `ByteArray`, so the generated `data class` equality is by + * reference (transient DER carriers, not value-equality keys) — compare with `contentEquals`. + */ +public data class EnrollmentResult( + val deviceId: String, + /** Leaf certificate DER (decoded from the response's base64). */ + val certificate: ByteArray, + /** Issuer chain DERs (device-CA etc.), leaf excluded. */ + val caChain: List, + val notBefore: Instant?, + val notAfter: Instant?, + /** When to renew from the same hardware key (~2/3 of the lifetime). */ + val renewAfter: Instant?, +) { + /** + * The rotation seam: is the leaf due for renewal as of [now]? A missing [renewAfter] never + * triggers (fail-safe — the TLS stack is the real gate; the scheduler only pre-empts expiry). + */ + public fun isRenewalDue(now: Instant = Instant.now()): Boolean { + val due = renewAfter ?: return false + return !now.isBefore(due) // now >= renewAfter + } +} + +/** Typed failures for the device-enrollment surface. Transport-level errors propagate UNWRAPPED. */ +public sealed class DeviceEnrollmentError(message: String) : Exception(message) { + /** + * A non-success HTTP status with the server's uniform `{ error }` code, if any (401 + * missing/rejected token, 403 subdomain-not-owned, 429 rate_limited, 400 rejected + * CSR/subdomain). Never leaks the response body. + */ + public data class Http(val status: Int, val code: String?) : + DeviceEnrollmentError("device enrollment rejected: HTTP $status" + (code?.let { " ($it)" } ?: "")) + + /** A success body that did not decode to the expected shape (or an undecodable base64 cert). */ + public data object MalformedResponse : + DeviceEnrollmentError("device enrollment response was not the expected shape") + + /** A required request field was empty — rejected client-side BEFORE any network I/O. */ + public data object InvalidRequest : + DeviceEnrollmentError("device enrollment request was missing a required field") +} + +// ── Wire DTOs + JSON config (internal to the enroll package) ───────────────────────────────────── + +/** + * ENCODE omits absent optionals (`encodeDefaults = false` drops the default-null `attestation`; + * `explicitNulls = false` never writes an explicit `null`) and DECODE is tolerant of unknown keys + * (the server is untrusted at this boundary). `keyAlg` carries NO default, so it is ALWAYS encoded. + */ +internal val EnrollJson: Json = Json { + encodeDefaults = false + explicitNulls = false + ignoreUnknownKeys = true + isLenient = true +} + +@Serializable +internal data class LoginRequestBody(val password: String) + +@Serializable +internal data class EnrollRequestBody( + val csr: String, + val keyAlg: String, + val subdomain: String, + val deviceName: String, + val attestation: String? = null, +) + +/** + * The `/device/:id/renew` request body. The endpoint authenticates by the presented mTLS device cert + * and its server schema is `{ csr }` ONLY (`.strict()`), so it carries the single new CSR and NO + * enroll-only fields (keyAlg/subdomain/deviceName) — an extra key would be rejected as a 400. + */ +@Serializable +internal data class RenewRequestBody(val csr: String) + +@Serializable +internal data class LoginResponseDto( + val enrollToken: String, + val accountId: String, + val expiresIn: Long, +) + +@Serializable +internal data class EnrollResponseDto( + val deviceId: String, + val cert: String, + val caChain: List = emptyList(), + val notBefore: String? = null, + val notAfter: String? = null, + val renewAfter: String? = null, +) + +@Serializable +internal data class ErrorDto(val error: String? = null) + +/** Parse an ISO-8601 instant, degrading an absent/unparseable value to null (dates are advisory). */ +internal fun parseInstantOrNull(text: String?): Instant? { + if (text == null) return null + return runCatching { Instant.parse(text) }.getOrNull() +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequestTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequestTest.kt new file mode 100644 index 0000000..70df2fc --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequestTest.kt @@ -0,0 +1,211 @@ +package wang.yaojia.webterm.api.enroll + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec + +/** + * B4 · Proves the manual PKCS#10 encoder produces a well-formed, self-signed P-256 CSR that the + * control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1 SPKI, ecdsa-with-SHA256 + * self-signature) accepts. Runs headless with a SOFTWARE P-256 key via the SAME + * `Signature("SHA256withECDSA")` path the on-device AndroidKeyStore key uses — so the signing path + * is byte-identical. Real StrongBox keygen is device-only (`:client-tls-android`). + */ +class CertificateSigningRequestTest { + /** Software P-256 signer via the SAME JCA `SHA256withECDSA` path used on-device (no StrongBox). */ + private class SoftwareEcSigner : CsrSigner { + val keyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + + override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(keyPair.public as ECPublicKey) + + override fun sign(message: ByteArray): ByteArray = + Signature.getInstance("SHA256withECDSA").apply { + initSign(keyPair.private) + update(message) + }.sign() + } + + @Test + fun csrIsCanonicalPkcs10SequenceOfExactlyThreeElements() { + val signer = SoftwareEcSigner() + + val der = CertificateSigningRequest.der("web-terminal-device", signer) + + val outer = TestDer.read(der, 0)!! + assertEquals(0x30, outer.tag, "outer CertificationRequest is a SEQUENCE") + assertEquals(der.size, outer.end, "no trailing garbage after the CSR") + val parts = TestDer.children(der, outer) + assertEquals(3, parts.size) + assertEquals(0x30, parts[0].tag) // certificationRequestInfo + assertEquals(0x30, parts[1].tag) // signatureAlgorithm + assertEquals(0x03, parts[2].tag) // signature BIT STRING + } + + @Test + fun csrSelfSignatureVerifiesAgainstTheEmbeddedP256Key() { + val signer = SoftwareEcSigner() + + val der = CertificateSigningRequest.der("web-terminal-device", signer) + + // Extract the exact CertificationRequestInfo bytes that were signed and the ECDSA signature + // (the same crypto check verifyCsrPoPEc's req.verify() runs). + val outer = TestDer.read(der, 0)!! + val parts = TestDer.children(der, outer) + val infoBytes = der.copyOfRange(parts[0].start, parts[0].end) + val bitString = parts[2] // BIT STRING: first content byte is unused-bits (0x00) + val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd) + + val ok = Signature.getInstance("SHA256withECDSA").apply { + initVerify(signer.keyPair.public) + update(infoBytes) + }.verify(signature) + assertTrue(ok, "the CSR self-signature must verify against its own SubjectPublicKeyInfo") + } + + @Test + fun csrEmbedsAP256SubjectPublicKeyInfoTheServerVerifierAccepts() { + val signer = SoftwareEcSigner() + val point = signer.publicKeyX963() + + val der = CertificateSigningRequest.der("web-terminal-device", signer) + + val outer = TestDer.read(der, 0)!! + val info = TestDer.children(der, outer)[0] + val infoChildren = TestDer.children(der, info) + assertEquals(4, infoChildren.size) + // version v1(0) + assertArrayEquals(byteArrayOf(0x02, 0x01, 0x00), der.copyOfRange(infoChildren[0].start, infoChildren[0].end)) + assertEquals(0xA0, infoChildren[3].tag) // [0] IMPLICIT attributes + assertEquals(0, infoChildren[3].valueEnd - infoChildren[3].valueStart) // empty SET + + // subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point } + val spki = infoChildren[2] + val spkiChildren = TestDer.children(der, spki) + assertEquals(2, spkiChildren.size) + val algIdChildren = TestDer.children(der, spkiChildren[0]) + // AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs verifyCsrPoPEc pins. + assertArrayEquals( + byteArrayOf(0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01), + der.copyOfRange(algIdChildren[0].start, algIdChildren[0].end), + ) + assertArrayEquals( + byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07), + der.copyOfRange(algIdChildren[1].start, algIdChildren[1].end), + ) + // BIT STRING content = 0x00 unused-bits + the exact 65-byte point. + val bitString = spkiChildren[1] + assertEquals(0x03, bitString.tag) + assertEquals(0x00, der[bitString.valueStart].toInt() and 0xFF) + assertArrayEquals(point, der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)) + } + + @Test + fun signatureAlgorithmIsEcdsaWithSha256() { + val signer = SoftwareEcSigner() + val der = CertificateSigningRequest.der("web-terminal-device", signer) + val outer = TestDer.read(der, 0)!! + val algId = TestDer.children(der, outer)[1] + val oid = TestDer.children(der, algId)[0] + assertArrayEquals( + byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02), + der.copyOfRange(oid.start, oid.end), + ) + } + + @Test + fun subjectCommonNameIsEncodedAsUtf8String() { + val signer = SoftwareEcSigner() + val der = CertificateSigningRequest.der("my-pixel", signer) + val outer = TestDer.read(der, 0)!! + val info = TestDer.children(der, outer)[0] + val name = TestDer.children(der, info)[1] // subject Name + val rdn = TestDer.children(der, name)[0] // SET + val attr = TestDer.children(der, rdn)[0] // SEQUENCE { OID, value } + val attrChildren = TestDer.children(der, attr) + // OID 2.5.4.3 (commonName), then a UTF8String (tag 0x0C) carrying the CN bytes. + assertArrayEquals(byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03), der.copyOfRange(attrChildren[0].start, attrChildren[0].end)) + assertEquals(0x0C, attrChildren[1].tag) + assertArrayEquals("my-pixel".toByteArray(), der.copyOfRange(attrChildren[1].valueStart, attrChildren[1].valueEnd)) + } + + @Test + fun emptySubjectCommonNameIsRejected() { + val signer = SoftwareEcSigner() + assertThrows(CsrException.InvalidSubject::class.java) { + CertificateSigningRequest.der("", signer) + } + } + + @Test + fun aNon65BytePublicKeyIsRejected() { + val badSigner = object : CsrSigner { + override fun publicKeyX963(): ByteArray = ByteArray(64) { 0x04 } // wrong length + override fun sign(message: ByteArray): ByteArray = ByteArray(0) + } + assertThrows(CsrException.InvalidPublicKey::class.java) { + CertificateSigningRequest.der("d", badSigner) + } + } + + @Test + fun aPublicKeyWithoutTheUncompressedPrefixIsRejected() { + val badSigner = object : CsrSigner { + override fun publicKeyX963(): ByteArray = ByteArray(65) { 0x02 } // right length, wrong prefix + override fun sign(message: ByteArray): ByteArray = ByteArray(0) + } + assertThrows(CsrException.InvalidPublicKey::class.java) { + CertificateSigningRequest.der("d", badSigner) + } + } +} + +/** + * A throwaway canonical-DER reader for structural assertions (the enroll path itself does no DER + * parsing — the server verifies; this mirrors the iOS `TestDER` test helper). + */ +internal object TestDer { + data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) { + val end: Int get() = valueEnd + } + + fun read(bytes: ByteArray, start: Int): Element? { + if (start < 0 || start + 1 >= bytes.size) return null + val tag = bytes[start].toInt() and 0xFF + var index = start + 1 + val first = bytes[index].toInt() and 0xFF + index += 1 + var length = 0 + if (first and 0x80 == 0) { + length = first + } else { + val count = first and 0x7F + if (count == 0 || count > 4 || index + count > bytes.size) return null + repeat(count) { + length = (length shl 8) or (bytes[index].toInt() and 0xFF) + index += 1 + } + } + val valueEnd = index + length + if (valueEnd > bytes.size) return null + return Element(tag = tag, start = start, valueStart = index, valueEnd = valueEnd) + } + + fun children(bytes: ByteArray, parent: Element): List { + val elements = mutableListOf() + var index = parent.valueStart + while (index < parent.valueEnd) { + val element = read(bytes, index) ?: break + elements.add(element) + index = element.valueEnd + } + return elements + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt new file mode 100644 index 0000000..8dd5010 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt @@ -0,0 +1,256 @@ +package wang.yaojia.webterm.api.enroll + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +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.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.time.Instant +import java.util.Base64 + +/** + * B4 · DeviceEnrollmentClient request-building + response-mapping against the pinned login/enroll + * contract, driven by the shared `FakeHttpTransport` (no network). Mirrors the iOS + * `DeviceEnrollmentClientTests`, extended with the login step. + */ +class DeviceEnrollmentClientTest { + private companion object { + const val BASE = "https://cp.terminal.yaojia.wang" + const val BEARER = "device-enroll-token-abc" + } + + private val transport = FakeHttpTransport() + private val client = DeviceEnrollmentClient(BASE, transport) + + private fun bodyObject(request: HttpRequest): JsonObject = + Json.parseToJsonElement(request.body!!.decodeToString()) as JsonObject + + private fun enrollResponse( + deviceId: String = "dev-1", + cert: ByteArray = byteArrayOf(0x30, 0x01, 0x02), + caChain: List = listOf(byteArrayOf(0x30, 0xAA.toByte())), + notBefore: String = "2026-07-08T00:00:00.000Z", + notAfter: String = "2026-10-06T00:00:00.000Z", + renewAfter: String = "2026-09-05T00:00:00.000Z", + ): ByteArray { + val b64 = Base64.getEncoder() + val chainJson = caChain.joinToString(",") { "\"${b64.encodeToString(it)}\"" } + return """ + {"deviceId":"$deviceId","cert":"${b64.encodeToString(cert)}","caChain":[$chainJson], + "notBefore":"$notBefore","notAfter":"$notAfter","renewAfter":"$renewAfter"} + """.trimIndent().toByteArray() + } + + // ── login ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun loginPostsPasswordAndMapsThe201Bearer() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, + url = "$BASE/auth/login", + status = 201, + body = """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray(), + ) + + val result = client.login("hunter2") + + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/auth/login", request.url) + assertEquals("application/json", request.headers["Content-Type"]) + assertNull(request.headers["Authorization"], "login carries no bearer") + assertEquals("hunter2", bodyObject(request)["password"]!!.jsonPrimitive.content) + + assertEquals("tok-xyz", result.enrollToken) + assertEquals("acct-1", result.accountId) + assertEquals(600L, result.expiresInSeconds) + } + + @Test + fun loginRejectsAnEmptyPasswordBeforeAnyNetworkIo() = runTest { + val error = runCatching { client.login("") }.exceptionOrNull() + assertEquals(DeviceEnrollmentError.InvalidRequest, error) + assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty password") + } + + @Test + fun loginSurfacesA401AsHttpWithTheServerCode() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, + url = "$BASE/auth/login", + status = 401, + body = """{"error":"rejected"}""".toByteArray(), + ) + val error = runCatching { client.login("wrong") }.exceptionOrNull() + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + } + + // ── enroll ─────────────────────────────────────────────────────────────────────────────── + + @Test + fun enrollBuildsABearerAuthenticatedPostWithTheContractBody() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse(), + ) + val csr = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + + client.enroll(BEARER, csr, subdomain = "alice", deviceName = "Alice Pixel") + + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/device/enroll", request.url) + assertEquals("Bearer $BEARER", request.headers["Authorization"]) + assertEquals("application/json", request.headers["Content-Type"]) + + val obj = bodyObject(request) + assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content) + assertEquals("ec-p256", obj["keyAlg"]!!.jsonPrimitive.content) + assertEquals("alice", obj["subdomain"]!!.jsonPrimitive.content) + assertEquals("Alice Pixel", obj["deviceName"]!!.jsonPrimitive.content) + assertFalse(obj.containsKey("attestation"), "attestation is omitted when not provided") + } + + @Test + fun enrollForwardsAttestationWhenProvided() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse()) + client.enroll(BEARER, byteArrayOf(0x01), "a", "d", attestation = "attest-blob") + assertEquals("attest-blob", bodyObject(transport.recordedRequests.single())["attestation"]!!.jsonPrimitive.content) + } + + @Test + fun enrollMapsA201IntoATypedResult() = runTest { + val cert = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x23) + val ca = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00) + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, + body = enrollResponse(deviceId = "dev-xyz", cert = cert, caChain = listOf(ca)), + ) + + val result = client.enroll(BEARER, byteArrayOf(0x01), "alice", "Pixel") + + assertEquals("dev-xyz", result.deviceId) + assertArrayEquals(cert, result.certificate) + assertEquals(1, result.caChain.size) + assertArrayEquals(ca, result.caChain.single()) + assertTrue(result.renewAfter!!.isBefore(result.notAfter)) + } + + @Test + fun enrollRejectsEmptyRequiredFieldsBeforeAnyNetworkIo() = runTest { + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll("", byteArrayOf(1), "a", "d") }.exceptionOrNull()) + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, ByteArray(0), "a", "d") }.exceptionOrNull()) + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "", "d") }.exceptionOrNull()) + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "") }.exceptionOrNull()) + assertTrue(transport.recordedRequests.isEmpty()) + } + + @Test + fun enrollSurfacesA403SubdomainNotOwnedWithTheServerCode() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 403, + body = """{"error":"rejected"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.Http(403, "rejected"), + runCatching { client.enroll(BEARER, byteArrayOf(1), "bob", "d") }.exceptionOrNull(), + ) + } + + @Test + fun enrollSurfacesA429RateLimited() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 429, + body = """{"error":"rate_limited"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.Http(429, "rate_limited"), + runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(), + ) + } + + @Test + fun enrollThrowsMalformedResponseOnANonJson201Body() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = "not json".toByteArray()) + assertEquals( + DeviceEnrollmentError.MalformedResponse, + runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(), + ) + } + + @Test + fun enrollThrowsMalformedResponseWhenTheCertIsNotValidBase64() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, + body = """{"deviceId":"d","cert":"@@not-base64@@","caChain":[]}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.MalformedResponse, + runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(), + ) + } + + @Test + fun enrollDegradesAbsentDatesToNull() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, + body = """{"deviceId":"d","cert":"MAEC","caChain":[]}""".toByteArray(), + ) + val result = client.enroll(BEARER, byteArrayOf(1), "a", "d") + assertNull(result.notAfter) + assertNull(result.renewAfter) + assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers") + } + + // ── renew (silent rotation seam) ─────────────────────────────────────────────────────────── + + @Test + fun renewTargetsDeviceIdRenewWithTheMinimalBody() = 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) + + val request = transport.recordedRequests.single() + assertEquals("$BASE/device/dev-9/renew", request.url) + assertEquals("Bearer $BEARER", request.headers["Authorization"]) + 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 + // schema is `{ csr }` ONLY (.strict()) — any enroll-only extra (keyAlg/subdomain/deviceName) + // is rejected. The renew wire body must therefore carry the single `csr` key and nothing else. + assertEquals(setOf("csr"), obj.keys, "renew body is {csr}-only — no keyAlg/subdomain/deviceName") + assertFalse(obj.containsKey("keyAlg"), "renew must not send the enroll-only keyAlg field") + assertFalse(obj.containsKey("subdomain"), "renew body carries no subdomain/deviceName") + assertEquals("dev-9", result.deviceId) + } + + @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()) + assertTrue(transport.recordedRequests.isEmpty()) + } + + // ── isRenewalDue seam ────────────────────────────────────────────────────────────────────── + + @Test + fun isRenewalDueFlipsAtRenewAfter() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse()) + val result = client.enroll(BEARER, byteArrayOf(1), "a", "d") + assertFalse(result.isRenewalDue(Instant.parse("2026-09-04T00:00:00Z"))) + assertTrue(result.isRenewalDue(Instant.parse("2026-09-06T00:00:00Z"))) + } + + private fun assertArrayEquals(expected: ByteArray, actual: ByteArray) = + org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual) +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncodingTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncodingTest.kt new file mode 100644 index 0000000..74b3e22 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncodingTest.kt @@ -0,0 +1,57 @@ +package wang.yaojia.webterm.api.enroll + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec + +/** B4 · The X9.63 uncompressed-point encoder — the security-load-bearing SubjectPublicKeyInfo bytes. */ +class EcPointEncodingTest { + @Test + fun encodesAGeneratedP256KeyAs65UncompressedBytesRoundTrippingToTheCoordinates() { + val kp = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + val pub = kp.public as ECPublicKey + + val encoded = EcPointEncoding.x963(pub) + + assertEquals(65, encoded.size, "0x04 || X(32) || Y(32)") + assertEquals(0x04, encoded[0].toInt() and 0xFF, "uncompressed-point prefix") + // The 32-byte big-endian halves must be exactly the affine coordinates. + val x = BigInteger(1, encoded.copyOfRange(1, 33)) + val y = BigInteger(1, encoded.copyOfRange(33, 65)) + assertEquals(pub.w.affineX, x) + assertEquals(pub.w.affineY, y) + } + + @Test + fun leftPadsAShortCoordinateToTheFixedWidth() { + // A small value must be left-padded with leading zeros to exactly 32 bytes. + val padded = EcPointEncoding.toFixedLengthUnsigned(BigInteger.valueOf(1), 32) + assertEquals(32, padded.size) + assertEquals(1, padded[31].toInt()) + assertEquals(0, padded[0].toInt()) + } + + @Test + fun dropsTheBigIntegerSignByteWhenPresent() { + // A value whose top bit is set carries a leading 0x00 sign byte in BigInteger.toByteArray(); + // it must be dropped, not counted toward the width. + val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() }) + val encoded = EcPointEncoding.toFixedLengthUnsigned(highBit, 32) + assertEquals(32, encoded.size) + assertEquals(0xFF, encoded[0].toInt() and 0xFF) + } + + @Test + fun rejectsACoordinateThatDoesNotFit() { + val tooBig = BigInteger.ONE.shiftLeft(256) // needs 33 bytes + assertThrows(IllegalArgumentException::class.java) { + EcPointEncoding.toFixedLengthUnsigned(tooBig, 32) + } + } +} diff --git a/android/client-tls-android/build.gradle.kts b/android/client-tls-android/build.gradle.kts index 809163d..e5dc88d 100644 --- a/android/client-tls-android/build.gradle.kts +++ b/android/client-tls-android/build.gradle.kts @@ -29,17 +29,34 @@ android { minSdk = 29 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + testOptions { + unitTests { + // The device-enroll orchestration commit logs via android.util.Log — let the JVM unit + // tests stub it (return 0) instead of throwing "not mocked". The security-critical paths + // (commit sequencing, error handling) run on the JVM with a software key double. + isReturnDefaultValues = true + } + } } kotlin { jvmToolchain(17) } +// JVM (local) unit tests use JUnit 5 (matching the pure modules); AGP's testDebug/ReleaseUnitTest +// tasks are `Test` tasks, so opt them into the JUnit Platform. +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { // Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table), // CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types. // (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.) api(project(":client-tls")) + // B4 device-enroll: the pure CSR encoder + login/enroll/renew client + HttpTransport seam live in + // :api-client (JVM-unit-tested); the framework HardwareBackedKey/DeviceEnroller drive them. + implementation(project(":api-client")) implementation(libs.tink.android) implementation(libs.okhttp) // Mutex serializes the two-store rotation commit (single-commit invariant, A11). @@ -50,4 +67,10 @@ dependencies { androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators + + // Local JVM unit tests (src/test) — the DeviceEnroller enroll/commit orchestration driven with a + // software P-256 key double + the shared FakeHttpTransport (no emulator, no AndroidKeyStore). + testImplementation(project(":test-support")) + testImplementation(libs.bundles.unit.test) + testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKeyTest.kt b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKeyTest.kt new file mode 100644 index 0000000..f76af67 --- /dev/null +++ b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKeyTest.kt @@ -0,0 +1,138 @@ +package wang.yaojia.webterm.tlsandroid + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.security.Signature +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.api.enroll.CertificateSigningRequest + +/** + * B4 · Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) proof that the generated + * device key is hardware-backed, NON-EXPORTABLE, and produces a self-signed P-256 CSR the + * control-plane accepts. COMPILES in CI here; RUNS on a device/emulator during device QA + * (StrongBox availability is device-dependent — [HardwareKeyStore.generate] falls back to the TEE). + */ +@RunWith(AndroidJUnit4::class) +class HardwareBackedKeyTest { + private val alias = "test-device-enroll-key" + + @Before + fun clean() = HardwareKeyStore.delete(alias) + + @After + fun tearDown() = HardwareKeyStore.delete(alias) + + @Test + fun generate_producesA65ByteX963PublicPoint() { + val key = HardwareKeyStore.generate(alias) + val point = key.publicKeyX963() + assertEquals(65, point.size) + assertEquals(0x04, point[0].toInt() and 0xFF) + } + + @Test + fun generatedKeyIsNonExportable() { + HardwareKeyStore.generate(alias) + val loaded = HardwareKeyStore.load(alias) + assertNotNull(loaded) + // AndroidKeyStore private keys have no exportable encoding — the material never leaves HW. + assertNull("AndroidKeyStore key must expose no encoded form", loaded!!.keyHandle.encoded) + } + + @Test + fun csrSignedByHardwareKeySelfVerifies() { + val key = HardwareKeyStore.generate(alias) + + val der = CertificateSigningRequest.der("t1-android", key) + + // Re-parse the CertificationRequestInfo + signature and verify with the embedded public key. + val outer = TestDer.read(der, 0)!! + val parts = TestDer.children(der, outer) + val info = der.copyOfRange(parts[0].start, parts[0].end) + val bitString = parts[2] + val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd) + + // Rebuild a JCA public key from the X9.63 point to run the same crypto check the server does. + val point = key.publicKeyX963() + val pub = X963PublicKeys.p256(point) + val ok = Signature.getInstance("SHA256withECDSA").apply { + initVerify(pub) + update(info) + }.verify(signature) + assertTrue("hardware-signed CSR must self-verify", ok) + } + + @Test + fun loadAfterGenerateReturnsAKeyWithTheSamePublicPoint() { + val generated = HardwareKeyStore.generate(alias) + val reloaded = HardwareKeyStore.load(alias) + assertNotNull(reloaded) + assertArrayEquals(generated.publicKeyX963(), reloaded!!.publicKeyX963()) + } + + @Test + fun loadReturnsNullWhenNoKeyExists() { + assertNull(HardwareKeyStore.load("absent-alias-xyz")) + } +} + +/** Reconstruct a P-256 public key from an X9.63 uncompressed point, for on-device signature checks. */ +private object X963PublicKeys { + fun p256(point: ByteArray): java.security.PublicKey { + val params = java.security.AlgorithmParameters.getInstance("EC").apply { + init(java.security.spec.ECGenParameterSpec("secp256r1")) + } + val spec = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java) + val x = java.math.BigInteger(1, point.copyOfRange(1, 33)) + val y = java.math.BigInteger(1, point.copyOfRange(33, 65)) + val pubSpec = java.security.spec.ECPublicKeySpec(java.security.spec.ECPoint(x, y), spec) + return java.security.KeyFactory.getInstance("EC").generatePublic(pubSpec) + } +} + +/** A throwaway canonical-DER reader for structural assertions (device-side mirror of the JVM test). */ +private object TestDer { + data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) { + val end: Int get() = valueEnd + } + + fun read(bytes: ByteArray, start: Int): Element? { + if (start < 0 || start + 1 >= bytes.size) return null + val tag = bytes[start].toInt() and 0xFF + var index = start + 1 + val first = bytes[index].toInt() and 0xFF + index += 1 + var length = 0 + if (first and 0x80 == 0) { + length = first + } else { + val count = first and 0x7F + if (count == 0 || count > 4 || index + count > bytes.size) return null + repeat(count) { + length = (length shl 8) or (bytes[index].toInt() and 0xFF) + index += 1 + } + } + val valueEnd = index + length + if (valueEnd > bytes.size) return null + return Element(tag, start, index, valueEnd) + } + + fun children(bytes: ByteArray, parent: Element): List { + val elements = mutableListOf() + var index = parent.valueStart + while (index < parent.valueEnd) { + val element = read(bytes, index) ?: break + elements.add(element) + index = element.valueEnd + } + return elements + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt new file mode 100644 index 0000000..0d86f39 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt @@ -0,0 +1,146 @@ +package wang.yaojia.webterm.tlsandroid + +import android.util.Log +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import wang.yaojia.webterm.api.enroll.CertificateSigningRequest +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient +import wang.yaojia.webterm.api.enroll.EnrollmentResult +import wang.yaojia.webterm.clienttls.CertificateSummary +import wang.yaojia.webterm.clienttls.CertificateSummaryReader + +/** + * B4 · The Android device-enroll orchestrator — the `.p12`-free path that mirrors iOS + * `KeychainClientIdentityStore.enroll/renew`. It composes the five B4 pieces: + * + * 1. generate a NON-EXPORTABLE hardware key ([HardwareKeyStore]: StrongBox → TEE), + * 2. self-sign a P-256 PKCS#10 CSR with it ([CertificateSigningRequest], `:api-client`), + * 3. run the login → `POST /device/enroll` flow ([DeviceEnrollmentClient], `:api-client`), + * 4. store the returned leaf + issuer chain into the SAME [CertStore] + AndroidKeyStore slot the + * existing [AndroidIdentityRepository] resolves from — so it is presented on the EXISTING + * re-reading `X509KeyManager` mTLS path with no change to that module, and + * 5. expose a silent [renew] against `/device/:id/renew` using the SAME hardware key. + * + * The mutating methods are serialized by a [Mutex] so an enroll and a rotation can never interleave + * the two-store commit (cert live-pointer + enrollment record). + * + * ### The commit + * The cert-store save is THE durable live-pointer flip (identical to the import/rotation path). It is + * written LAST, after the enrollment record, so a successful cert-store save always means the mTLS + * identity is fully live; the pool is then evicted so the next handshake presents the new leaf. + */ +public class DeviceEnroller( + private val client: DeviceEnrollmentClient, + private val certStore: CertStore, + private val recordStore: EnrollmentRecordStore, + private val sharedClient: OkHttpClient, + private val keyAlias: String = AndroidKeyStoreImporter.DEFAULT_ALIAS, + private val keyProvider: DeviceKeyProvider = HardwareDeviceKeyProvider, +) { + private val commitMutex = Mutex() + + /** Raised when a state-changing enroll/renew precondition is not met. Never leaks a secret. */ + public class EnrollmentStateException(message: String) : Exception(message) + + /** + * One-time enrollment: login (operator password → short-lived `device:enroll` bearer) → generate + * a non-exportable hardware key → CSR → `POST /device/enroll` → store the leaf + present it. + * Returns the installed leaf's display summary. The bearer is held only for this call, never + * persisted or logged. + */ + public suspend fun enroll( + password: String, + subdomain: String, + deviceName: String, + ): CertificateSummary = commitMutex.withLock { + val login = client.login(password) + // Generate the hardware key ONLY after a successful login, so a rejected credential never + // burns a fresh key slot; overwrites any stale key at the alias. + val key = keyProvider.generate(keyAlias) + try { + val csr = CertificateSigningRequest.der(deviceName, key) + val result = client.enroll(login.enrollToken, csr, subdomain, deviceName) + commitIdentity(result, deviceName, key.alias) + summaryOf(result) + } catch (e: Exception) { + // The enroll failed AFTER keygen: drop the orphan key so a retry starts clean and no + // unreferenced key lingers in secure hardware. + runCatching { keyProvider.delete(key.alias) } + throw e + } + } + + /** + * 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. + */ + public suspend fun renew(bearerToken: String): 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) + 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() + recordStore.clear() + keyProvider.delete(keyAlias) + sharedClient.connectionPool.evictAll() + } + + /** + * Persist the enrollment record (deviceId → renew), THEN commit the cert live-pointer (the mTLS + * flip), THEN evict pooled/resumed connections so the next handshake presents the new leaf via + * the existing re-reading `X509KeyManager`. The key already lives in AndroidKeyStore at [alias]; + * the private key never enters storage. + */ + private fun commitIdentity(result: EnrollmentResult, deviceName: String, alias: String) { + val leaf = parseCertificate(result.certificate) + val issuers = result.caChain.map { parseCertificate(it) } + + recordStore.save( + EnrollmentRecord( + deviceId = result.deviceId, + deviceName = deviceName, + keyStoreAlias = alias, + renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L, + ), + ) + certStore.save( + StoredIdentityMetadata( + alias = alias, + keyAlgorithm = KEY_ALGORITHM_EC, + keyStoreAlias = alias, + certificateChain = listOf(leaf) + issuers, + ), + ) + sharedClient.connectionPool.evictAll() + Log.i(TAG, "Device identity enrolled/renewed and committed for alias '$alias'") + } + + private fun summaryOf(result: EnrollmentResult): CertificateSummary = + CertificateSummaryReader.summarize(parseCertificate(result.certificate)) + + private fun parseCertificate(der: ByteArray): X509Certificate = + CertificateFactory.getInstance(X509).generateCertificate(der.inputStream()) as X509Certificate + + private companion object { + const val TAG = "DeviceEnroller" + const val X509 = "X.509" + + /** AndroidKeyStore EC keys report algorithm "EC" — matched by `ClientKeyManagerLogic`. */ + const val KEY_ALGORITHM_EC = "EC" + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceKeyProvider.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceKeyProvider.kt new file mode 100644 index 0000000..e2e1b9b --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceKeyProvider.kt @@ -0,0 +1,33 @@ +package wang.yaojia.webterm.tlsandroid + +/** + * B4 · A seam over the three non-exportable hardware-key operations [DeviceEnroller]'s + * enroll/renew orchestration needs. Production wires the real AndroidKeyStore-backed + * [HardwareKeyStore] (StrongBox → TEE); a JVM unit test wires a software P-256 double, so the + * enroll/commit orchestration (request shaping, error handling, the two-store commit sequencing) + * can be exercised without an emulator. NOTHING about the hardware-key policy leaks through this + * seam beyond generate/load/delete — the key stays non-exportable in the real implementation. + */ +public interface DeviceKeyProvider { + /** Generate a fresh non-exportable key at [alias], overwriting any prior entry there. */ + public fun generate(alias: String): HardwareBackedKey + + /** Load a previously-generated key by [alias], or null if no entry exists (pre-enroll state). */ + public fun load(alias: String): HardwareBackedKey? + + /** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */ + public fun delete(alias: String) +} + +/** + * The production [DeviceKeyProvider] — a thin delegate to the AndroidKeyStore-backed + * [HardwareKeyStore]. Kept as a stateless object so it can be the [DeviceEnroller] constructor + * default without any wiring. + */ +public object HardwareDeviceKeyProvider : DeviceKeyProvider { + override fun generate(alias: String): HardwareBackedKey = HardwareKeyStore.generate(alias) + + override fun load(alias: String): HardwareBackedKey? = HardwareKeyStore.load(alias) + + override fun delete(alias: String): Unit = HardwareKeyStore.delete(alias) +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt new file mode 100644 index 0000000..d027574 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt @@ -0,0 +1,143 @@ +package wang.yaojia.webterm.tlsandroid + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.RegistryConfiguration +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream + +/** + * 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. + * + * 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 + * its key. The private key is never here — it stays non-exportable in AndroidKeyStore. + */ +public data class EnrollmentRecord( + val deviceId: String, + val deviceName: String, + val keyStoreAlias: String, + val renewAfterEpochSeconds: Long, +) { + init { + require(deviceId.isNotBlank()) { "deviceId must not be blank" } + require(keyStoreAlias.isNotBlank()) { "keyStoreAlias must not be blank" } + } +} + +/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */ +public object EnrollmentRecordCodec { + public fun encode(record: EnrollmentRecord): ByteArray { + val out = ByteArrayOutputStream() + DataOutputStream(out).use { data -> + data.writeUTF(record.deviceId) + data.writeUTF(record.deviceName) + data.writeUTF(record.keyStoreAlias) + data.writeLong(record.renewAfterEpochSeconds) + } + return out.toByteArray() + } + + /** Decode [bytes]; any structural failure → [CorruptStoredIdentityException]. */ + public fun decode(bytes: ByteArray): EnrollmentRecord = + try { + DataInputStream(ByteArrayInputStream(bytes)).use { data -> + EnrollmentRecord( + deviceId = data.readUTF(), + deviceName = data.readUTF(), + keyStoreAlias = data.readUTF(), + renewAfterEpochSeconds = data.readLong(), + ) + } + } catch (e: Exception) { + throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e) + } +} + +/** + * Storage contract for the [EnrollmentRecord] — repository pattern so [DeviceEnroller] depends on + * the operation set and a fault/blank can be injected in tests. Idempotent [clear]. + */ +public interface EnrollmentRecordStore { + public fun save(record: EnrollmentRecord) + + public fun load(): EnrollmentRecord? + + public fun clear() +} + +/** + * Tink-AEAD-encrypted [EnrollmentRecordStore] over an app-private `SharedPreferences` file, mirroring + * [TinkCertStore]'s custody model (AndroidKeystore-wrapped master key; uninstall-wiped; useless off + * this device). Kept in its own key/file namespace so it never collides with the cert live-pointer. + */ +public class TinkEnrollmentRecordStore( + context: Context, + private val keysetName: String = DEFAULT_KEYSET_NAME, + private val prefFileName: String = DEFAULT_PREF_FILE, + private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI, +) : EnrollmentRecordStore { + private val appContext: Context = context.applicationContext + private val aead: Aead by lazy { buildAead() } + + override fun save(record: EnrollmentRecord) { + val ciphertext = aead.encrypt(EnrollmentRecordCodec.encode(record), ASSOCIATED_DATA) + val committed = prefs().edit() + .putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP)) + .commit() + if (!committed) throw java.io.IOException("Failed to durably persist the device enrollment record") + } + + override fun load(): EnrollmentRecord? { + val encoded = prefs().getString(BLOB_KEY, null) ?: return null + val ciphertext = try { + Base64.decode(encoded, Base64.NO_WRAP) + } catch (e: IllegalArgumentException) { + throw CorruptStoredIdentityException("Enrollment record blob was not valid base64", e) + } + val plaintext = try { + aead.decrypt(ciphertext, ASSOCIATED_DATA) + } catch (e: java.security.GeneralSecurityException) { + throw CorruptStoredIdentityException("Enrollment record blob failed AEAD decryption", e) + } + return EnrollmentRecordCodec.decode(plaintext) + } + + override fun clear() { + val committed = prefs().edit().remove(BLOB_KEY).commit() + if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record") + } + + private fun buildAead(): Aead { + AeadConfig.register() + val keysetHandle = AndroidKeysetManager.Builder() + .withSharedPref(appContext, keysetName, prefFileName) + .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE)) + .withMasterKeyUri(masterKeyUri) + .build() + .keysetHandle + return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + } + + private fun prefs(): SharedPreferences = + appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) + + public companion object { + private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset" + private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs" + private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key" + private const val AEAD_KEY_TEMPLATE = "AES256_GCM" + private const val BLOB_KEY = "enrollment_record_blob" + private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray() + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKey.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKey.kt new file mode 100644 index 0000000..2f91115 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKey.kt @@ -0,0 +1,122 @@ +package wang.yaojia.webterm.tlsandroid + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import android.util.Log +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Signature +import java.security.cert.X509Certificate +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import wang.yaojia.webterm.api.enroll.CsrSigner +import wang.yaojia.webterm.api.enroll.EcPointEncoding + +/** + * B4 · A P-256 signing key that lives ENTIRELY inside AndroidKeyStore and is NON-EXPORTABLE by + * construction (AndroidKeyStore has no key-material getter). It is the Android analogue of the iOS + * `SecureEnclaveKey`: `sign` runs inside secure hardware (StrongBox → TEE) and drives the same + * `Signature("SHA256withECDSA")` path the JVM-unit-test software key uses, so [CsrSigner] callers + * (`CertificateSigningRequest`) are exercised identically. + * + * The wrapped [privateKey] is the opaque AndroidKeyStore handle — presented to the re-reading + * `X509KeyManager` for the TLS `CertificateVerify` and never exported. [publicKey] is only used to + * emit the CSR's `SubjectPublicKeyInfo`. + */ +public class HardwareBackedKey internal constructor( + public val alias: String, + private val privateKey: PrivateKey, + private val publicKey: ECPublicKey, +) : CsrSigner { + + override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(publicKey) + + override fun sign(message: ByteArray): ByteArray = + Signature.getInstance(SIGNATURE_ALGORITHM).apply { + initSign(privateKey) + update(message) + }.sign() + + /** The opaque, non-exportable AndroidKeyStore private-key handle presented on the mTLS path. */ + public val keyHandle: PrivateKey get() = privateKey + + public companion object { + private const val SIGNATURE_ALGORITHM = "SHA256withECDSA" + } +} + +/** + * Creates / loads / deletes the device's non-exportable P-256 key in AndroidKeyStore. + * + * Generation prefers **StrongBox** (dedicated secure element) and falls back to the **TEE** when the + * device has no StrongBox — the security posture (non-exportable, hardware-backed, silent-signing) + * is identical either way; StrongBox is a hardening bonus, not a requirement. The key is + * `PURPOSE_SIGN` only with a broad digest set so TLS 1.2/1.3 signature negotiation for the client + * `CertificateVerify` works, and NO user-authentication is required so silent enroll/renew never + * blocks on a biometric prompt. + */ +public object HardwareKeyStore { + private const val TAG = "HardwareKeyStore" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val CURVE = "secp256r1" + + /** + * Generate a fresh non-exportable P-256 key at [alias], overwriting any prior entry there. + * StrongBox-backed when available, else TEE-backed. Throws the underlying keystore exception if + * BOTH paths fail (never returns a half-generated key). + */ + public fun generate(alias: String): HardwareBackedKey { + val keyPair = try { + generateKeyPair(alias, strongBox = true) + } catch (_: StrongBoxUnavailableException) { + Log.i(TAG, "StrongBox unavailable; generating a TEE-backed device key (non-exportable)") + delete(alias) // clear any partial StrongBox entry before the TEE retry + generateKeyPair(alias, strongBox = false) + } + return HardwareBackedKey(alias, keyPair.private, keyPair.public as ECPublicKey) + } + + /** + * Load a previously-generated key by [alias] (renew path, after relaunch). The public key is + * recovered from the self-signed placeholder certificate AndroidKeyStore stored at generation. + * Returns null if no key entry exists (the normal pre-enroll state). + */ + public fun load(alias: String): HardwareBackedKey? { + val keyStore = androidKeyStore() + val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: return null + val publicKey = (keyStore.getCertificate(alias) as? X509Certificate)?.publicKey as? ECPublicKey + ?: return null + return HardwareBackedKey(alias, privateKey, publicKey) + } + + /** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */ + public fun delete(alias: String) { + val keyStore = androidKeyStore() + if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias) + } + + /** Cheap existence check (does NOT read key material). */ + public fun exists(alias: String): Boolean = androidKeyStore().containsAlias(alias) + + private fun generateKeyPair(alias: String, strongBox: Boolean): KeyPair { + val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) + .setAlgorithmParameterSpec(ECGenParameterSpec(CURVE)) + .setDigests( + KeyProperties.DIGEST_NONE, + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA384, + KeyProperties.DIGEST_SHA512, + ) + .setIsStrongBoxBacked(strongBox) + .build() + val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE) + generator.initialize(spec) + return generator.generateKeyPair() + } + + private fun androidKeyStore(): KeyStore = + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } +} diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt new file mode 100644 index 0000000..25d9bec --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt @@ -0,0 +1,288 @@ +package wang.yaojia.webterm.tlsandroid + +import kotlinx.coroutines.test.runTest +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.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 + * the security-critical two-store commit. Driven with a software P-256 key ([DeviceKeyProvider] + * double) + the shared [FakeHttpTransport], so request shaping, error handling, and — most + * importantly — the commit SEQUENCING run without an emulator or a real AndroidKeyStore. + * + * The security-critical invariant under test: the enrollment record is persisted BEFORE the cert + * live-pointer flip (the mTLS commit), so a successful cert-store save always means the identity is + * fully live (see [DeviceEnroller.commitIdentity]). + */ +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. + 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==" + + fun loginBody(): ByteArray = + """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray() + + 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 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) + } + } + + private val events = mutableListOf() + private val transport = FakeHttpTransport() + private val certStore = RecordingCertStore(events) + private val recordStore = RecordingRecordStore(events) + private val keyProvider = RecordingKeyProvider(events) + + private fun enroller(): DeviceEnroller = + DeviceEnroller( + client = DeviceEnrollmentClient(BASE, transport), + certStore = certStore, + recordStore = recordStore, + sharedClient = OkHttpClient(), + keyAlias = ALIAS, + keyProvider = keyProvider, + ) + + // ── enroll: commit sequencing (the security-critical invariant) ──────────────────────────── + + @Test + fun enrollPersistsTheRecordBeforeTheCertLivePointerFlip() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody()) + + val summary = enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel") + + // Record.save strictly precedes cert.save — the mTLS pointer flip is written LAST. + assertTrue(events.contains("record.save") && events.contains("cert.save")) + assertTrue( + events.indexOf("record.save") < events.indexOf("cert.save"), + "the enrollment record must be committed BEFORE the cert live-pointer flip", + ) + // Both stores received the issued identity; the stored chain is leaf + issuer (from caChain). + assertEquals("dev-1", recordStore.saved!!.deviceId) + assertEquals(ALIAS, recordStore.saved!!.keyStoreAlias) + val chain = certStore.saved!!.certificateChain + assertEquals(2, chain.size, "stored chain = leaf + one caChain issuer") + assertTrue(chain[0].subjectX500Principal.name.contains(LEAF_CN), "chain[0] is the leaf") + assertTrue(chain[1].subjectX500Principal.name.contains(CA_CN), "chain[1] is the device-CA issuer") + // The install summary is read off the leaf via the production CertificateSummaryReader. + assertEquals(LEAF_CN, summary.subjectCommonName) + } + + @Test + fun enrollShapesTheLoginAndEnrollRequests() = 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") + + val login = transport.recordedRequests[0] + assertEquals("$BASE/auth/login", login.url) + assertTrue(login.body!!.decodeToString().contains("\"password\":\"hunter2\"")) + + val enroll = transport.recordedRequests[1] + assertEquals("$BASE/device/enroll", enroll.url) + assertEquals("Bearer tok-xyz", enroll.headers["Authorization"], "enroll rides the login bearer") + val enrollBodyStr = enroll.body!!.decodeToString() + assertTrue(enrollBodyStr.contains("\"subdomain\":\"alice\"")) + assertTrue(enrollBodyStr.contains("\"deviceName\":\"Alice Pixel\"")) + } + + // ── enroll: error handling ───────────────────────────────────────────────────────────────── + + @Test + fun enrollDropsTheOrphanKeyWhenEnrollFailsAfterKeygen() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 403, body = """{"error":"rejected"}""".toByteArray()) + + val error = runCatching { + enroller().enroll(password = "hunter2", subdomain = "bob", deviceName = "Bob Pixel") + }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(403, "rejected"), error) + assertEquals(listOf(ALIAS), keyProvider.generatedAliases, "the key was generated after login") + assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the orphan key is dropped on enroll failure") + assertNull(recordStore.saved, "no record is committed when enroll fails") + assertNull(certStore.saved, "the cert live-pointer is never flipped when enroll fails") + } + + @Test + fun enrollNeverBurnsAKeyWhenLoginIsRejected() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 401, body = """{"error":"rejected"}""".toByteArray()) + + val error = runCatching { + enroller().enroll(password = "wrong", subdomain = "alice", deviceName = "Alice Pixel") + }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + assertTrue(keyProvider.generatedAliases.isEmpty(), "a rejected credential must not burn a key slot") + assertNull(recordStore.saved) + assertNull(certStore.saved) + } + + // ── renew: preconditions + request shaping + commit ──────────────────────────────────────── + + @Test + fun renewThrowsWhenNothingIsEnrolled() = runTest { + val error = runCatching { enroller().renew(BEARER) }.exceptionOrNull() + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew") + } + + @Test + 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() + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone") + } + + @Test + fun renewReCsrsFromTheSameKeyAndSendsACsrOnlyBody() = 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) + + val renew = transport.recordedRequests.single() + assertEquals("$BASE/device/dev-1/renew", renew.url) + assertEquals("Bearer $BEARER", renew.headers["Authorization"]) + 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. + assertTrue(events.indexOf("record.save") < events.indexOf("cert.save")) + } + + // ── remove: full teardown ────────────────────────────────────────────────────────────────── + + @Test + fun removeClearsBothStoresAndDeletesTheKey() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + + enroller().remove() + + assertTrue(certStore.cleared) + assertTrue(recordStore.cleared) + assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the hardware key is deleted on remove") + } + + // ── recording doubles ────────────────────────────────────────────────────────────────────── + + private class RecordingCertStore(private val events: MutableList) : CertStore { + var saved: StoredIdentityMetadata? = null + var cleared = false + + override fun save(metadata: StoredIdentityMetadata) { + saved = metadata + events += "cert.save" + } + + override fun load(): StoredIdentityMetadata? = saved + + override fun clear() { + cleared = true + saved = null + events += "cert.clear" + } + } + + private class RecordingRecordStore(private val events: MutableList) : EnrollmentRecordStore { + var saved: EnrollmentRecord? = null + var cleared = false + private var current: EnrollmentRecord? = null + + fun seed(record: EnrollmentRecord) { + current = record + } + + override fun save(record: EnrollmentRecord) { + saved = record + current = record + events += "record.save" + } + + override fun load(): EnrollmentRecord? = current + + override fun clear() { + cleared = true + current = null + events += "record.clear" + } + } + + private class RecordingKeyProvider(private val events: MutableList) : DeviceKeyProvider { + private val keys = mutableMapOf() + val generatedAliases = mutableListOf() + val deletedAliases = mutableListOf() + + fun seed(alias: String, key: HardwareBackedKey) { + keys[alias] = key + } + + override fun generate(alias: String): HardwareBackedKey { + val key = softwareKey(alias) + keys[alias] = key + generatedAliases += alias + events += "generate:$alias" + return key + } + + override fun load(alias: String): HardwareBackedKey? = keys[alias] + + override fun delete(alias: String) { + keys.remove(alias) + deletedAliases += alias + events += "delete:$alias" + } + } +}