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()
|
||||
}
|
||||
Reference in New Issue
Block a user