feat(android): device enrollment library + rotation (B4)
Hardware-backed (StrongBox/TEE) key + PKCS#10 CSR + /device/enroll client in
:api-client, presented via the existing X509KeyManager; renew body {csr}-only;
DeviceKeyProvider seam makes the orchestration JVM-testable. api-client tests +
koverVerify 80% gate pass.
This commit is contained in:
@@ -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>): ByteArray = tlv(TAG_SEQUENCE, concat(children))
|
||||||
|
|
||||||
|
fun set(children: List<ByteArray>): 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<Byte>()
|
||||||
|
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>): 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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<String, String>()
|
||||||
|
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 <T> decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer<T>): 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<Char> =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
|
||||||
|
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ByteArray>,
|
||||||
|
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<String> = 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()
|
||||||
|
}
|
||||||
@@ -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<Element> {
|
||||||
|
val elements = mutableListOf<Element>()
|
||||||
|
var index = parent.valueStart
|
||||||
|
while (index < parent.valueEnd) {
|
||||||
|
val element = read(bytes, index) ?: break
|
||||||
|
elements.add(element)
|
||||||
|
index = element.valueEnd
|
||||||
|
}
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ByteArray> = 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,17 +29,34 @@ android {
|
|||||||
minSdk = 29
|
minSdk = 29
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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 {
|
kotlin {
|
||||||
jvmToolchain(17)
|
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<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table),
|
// Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table),
|
||||||
// CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types.
|
// CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types.
|
||||||
// (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.)
|
// (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.)
|
||||||
api(project(":client-tls"))
|
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.tink.android)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
// Mutex serializes the two-store rotation commit (single-commit invariant, A11).
|
// 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.core)
|
||||||
androidTestImplementation(libs.androidx.test.runner)
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Element> {
|
||||||
|
val elements = mutableListOf<Element>()
|
||||||
|
var index = parent.valueStart
|
||||||
|
while (index < parent.valueEnd) {
|
||||||
|
val element = read(bytes, index) ?: break
|
||||||
|
elements.add(element)
|
||||||
|
index = element.valueEnd
|
||||||
|
}
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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<String>()
|
||||||
|
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<String>) : 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<String>) : 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<String>) : DeviceKeyProvider {
|
||||||
|
private val keys = mutableMapOf<String, HardwareBackedKey>()
|
||||||
|
val generatedAliases = mutableListOf<String>()
|
||||||
|
val deletedAliases = mutableListOf<String>()
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user