Added subject alternative name (SAN) support to SCEP enrollment on Android agent (#44968)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #44967 

Video demo: https://www.youtube.com/watch?v=AnwAXPS9Ys0

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Android SCEP enrollment now supports Subject Alternative Name (SAN)
attributes on certificate templates (DNS, email, URI, IP, and Microsoft
UPN); SAN is optional and forwarded when present.
* **Tests**
* Added unit and integration tests for SAN parsing, CSR generation, and
end-to-end enrollment verification.
* **Documentation**
  * Added a change note describing SAN support and CSR behavior.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/44968)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-05-14 16:43:17 -05:00
committed by GitHub
parent af5e240e83
commit 9e6ebdc249
9 changed files with 549 additions and 5 deletions
@@ -606,6 +606,9 @@ data class GetCertificateTemplateResponse(
@SerialName("subject_name")
val subjectName: String,
@SerialName("subject_alternative_name")
val subjectAlternativeName: String? = null,
@SerialName("certificate_authority_type")
val certificateAuthorityType: String,
@@ -4,6 +4,8 @@ import com.fleetdm.agent.GetCertificateTemplateResponse
import org.bouncycastle.asn1.DERPrintableString
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.Extension
import org.bouncycastle.asn1.x509.ExtensionsGenerator
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
import org.bouncycastle.jce.provider.BouncyCastleProvider
@@ -33,7 +35,10 @@ import kotlinx.coroutines.withContext
class ScepClientImpl : ScepClient {
companion object {
private const val SCEP_PROFILE = "NDESCA" // Network Device Enrollment Service CA
// SCEP `message=` value (CA identifier) sent on GetCACaps and GetCACert. Per RFC 8894
// this is OPTIONAL and only meaningful when the endpoint represents multiple CAs.
// Sending null causes jScep to omit the parameter; the server returns its default CA.
private val SCEP_PROFILE: String? = null
private const val SELF_SIGNED_CERT_VALIDITY_DAYS = 100L
init {
@@ -79,7 +84,13 @@ class ScepClientImpl : ScepClient {
val client = Client(server, verifier)
// Step 5: Build Certificate Signing Request (CSR)
val csr = buildCsr(entity, keyPair, config.scepChallenge ?: "", config.signatureAlgorithm)
val csr = buildCsr(
entity,
keyPair,
config.scepChallenge ?: "",
config.signatureAlgorithm,
config.subjectAlternativeName,
)
// Step 6: Send enrollment request
val response = try {
@@ -165,15 +176,36 @@ class ScepClientImpl : ScepClient {
throw ScepCertificateException("Failed to create self-signed certificate", e)
}
private fun buildCsr(entity: X500Name, keyPair: java.security.KeyPair, challenge: String, signatureAlgorithm: String) = try {
internal fun buildCsr(
entity: X500Name,
keyPair: java.security.KeyPair,
challenge: String,
signatureAlgorithm: String,
subjectAlternativeName: String?,
) = try {
val csrBuilder = JcaPKCS10CertificationRequestBuilder(entity, keyPair.public)
// Add challenge password attribute
val passwordAttr = DERPrintableString(challenge)
csrBuilder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_challengePassword, passwordAttr)
// Add Subject Alternative Name extension if a non-empty SAN string was provided.
val generalNames = try {
SubjectAlternativeNameParser.parse(subjectAlternativeName)
} catch (e: IllegalArgumentException) {
throw ScepCsrException("Invalid subject alternative name: ${e.message}", e)
}
if (generalNames != null) {
val extensions = ExtensionsGenerator().apply {
addExtension(Extension.subjectAlternativeName, false, generalNames)
}.generate()
csrBuilder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensions)
}
val contentSigner = JcaContentSignerBuilder(signatureAlgorithm).build(keyPair.private)
csrBuilder.build(contentSigner)
} catch (e: ScepCsrException) {
throw e
} catch (e: Exception) {
throw ScepCsrException("Failed to build Certificate Signing Request", e)
}
@@ -0,0 +1,88 @@
package com.fleetdm.agent.scep
import org.bouncycastle.asn1.ASN1ObjectIdentifier
import org.bouncycastle.asn1.DERIA5String
import org.bouncycastle.asn1.DERSequence
import org.bouncycastle.asn1.DERTaggedObject
import org.bouncycastle.asn1.DERUTF8String
import org.bouncycastle.asn1.x509.GeneralName
import org.bouncycastle.asn1.x509.GeneralNames
import org.bouncycastle.util.IPAddress
import java.util.Locale
/**
* Parses the comma-separated KEY=value SAN string carried on the certificate template
* (e.g. "DNS=example.com, UPN=user@corp.example.com") into a BouncyCastle GeneralNames
* structure suitable for inclusion in a PKCS#10 CSR's subjectAltName extension.
*
* Supported KEYs (case-insensitive):
* DNS -> dNSName (DERIA5String)
* EMAIL -> rfc822Name (DERIA5String)
* URI -> uniformResourceIdentifier (DERIA5String)
* IP -> iPAddress (DEROctetString, 4 bytes IPv4 or 16 bytes IPv6)
* UPN -> otherName (OID 1.3.6.1.4.1.311.20.2.3, [0] EXPLICIT DERUTF8String)
* per Microsoft KB258605 / RFC 4556 §3.2.1.
*
* Returns null for null / empty / blank input so the caller can skip adding the extension.
* Throws IllegalArgumentException for unknown KEYs, malformed tokens, empty values, or
* unparseable IP literals.
*
* Reserved characters in values: a literal `,` would split the token, so values that need
* one (most commonly URI paths/queries) must be percent-encoded per RFC 3986 `%2C`. The
* encoded form is preserved verbatim on the issued cert, which is the canonical wire form
* per RFC 5280 §4.2.1.6. This mirrors OpenSSL's `openssl.cnf` SAN syntax.
*/
object SubjectAlternativeNameParser {
private const val MICROSOFT_UPN_OID = "1.3.6.1.4.1.311.20.2.3"
fun parse(sanString: String?): GeneralNames? {
if (sanString.isNullOrBlank()) return null
val names = sanString.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { parseToken(it) }
if (names.isEmpty()) return null
return GeneralNames(names.toTypedArray())
}
private fun parseToken(token: String): GeneralName {
val eqIndex = token.indexOf('=')
require(eqIndex > 0 && eqIndex != token.length - 1) {
"Malformed SAN token: \"$token\" (expected KEY=value)"
}
// Locale.ROOT keeps the comparison locale-independent: a Turkish-locale device
// would otherwise turn "ip" into "İP", which would not match the ASCII "IP" arm
// and would fail enrollment.
val key = token.substring(0, eqIndex).trim().uppercase(Locale.ROOT)
val value = token.substring(eqIndex + 1).trim()
require(value.isNotEmpty()) { "Malformed SAN token: \"$token\" (empty value)" }
return when (key) {
"DNS" -> GeneralName(GeneralName.dNSName, DERIA5String(value))
"EMAIL" -> GeneralName(GeneralName.rfc822Name, DERIA5String(value))
"URI" -> GeneralName(GeneralName.uniformResourceIdentifier, DERIA5String(value))
"IP" -> {
// BouncyCastle's IPAddress.isValid is literal-only (no DNS) and rejects
// bracketed forms, zone IDs, and anything outside dotted-quad / colon-hex.
// GeneralName(iPAddress, String) then encodes the literal to the raw 4-
// or 16-byte octet string the SAN extension requires.
require(IPAddress.isValid(value)) {
"Unparseable IP address: \"$value\" (expected IPv4 dotted-quad or IPv6 colon-hex)"
}
GeneralName(GeneralName.iPAddress, value)
}
"UPN" -> GeneralName(GeneralName.otherName, encodeUpn(value))
else -> throw IllegalArgumentException("Unknown SAN KEY: \"$key\" (supported: DNS, EMAIL, URI, IP, UPN)")
}
}
private fun encodeUpn(value: String): DERSequence = DERSequence(
arrayOf(
ASN1ObjectIdentifier(MICROSOFT_UPN_OID),
DERTaggedObject(true, 0, DERUTF8String(value)),
),
)
}
@@ -7,6 +7,7 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -165,4 +166,20 @@ class CertificateEnrollmentHandlerTest {
assertEquals(2048, mockScepClient.capturedConfig?.keyLength)
assertEquals("SHA256withRSA", mockScepClient.capturedConfig?.signatureAlgorithm)
}
@Test
fun `handler forwards subjectAlternativeName to SCEP client`() = runTest {
val san = "DNS=host.example.com, UPN=marko@corp.example.com"
val template = TestCertificateTemplateFactory.create(subjectAlternativeName = san)
handler.handleEnrollment(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
assertEquals(san, mockScepClient.capturedConfig?.subjectAlternativeName)
}
@Test
fun `handler forwards null subjectAlternativeName when absent`() = runTest {
val template = TestCertificateTemplateFactory.create()
handler.handleEnrollment(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
assertNotNull(mockScepClient.capturedConfig)
assertNull(mockScepClient.capturedConfig?.subjectAlternativeName)
}
}
@@ -1,10 +1,22 @@
package com.fleetdm.agent.scep
import com.fleetdm.agent.testutil.TestCertificateTemplateFactory
import org.bouncycastle.asn1.DERIA5String
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.Extension
import org.bouncycastle.asn1.x509.Extensions
import org.bouncycastle.asn1.x509.GeneralName
import org.bouncycastle.asn1.x509.GeneralNames
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import java.security.KeyPairGenerator
import kotlinx.coroutines.test.runTest
/**
@@ -60,6 +72,59 @@ class ScepClientImplTest {
}
}
// Note: Testing successful enrollment requires a mock SCEP server or extensive mocking
// of jScep's Client class. Integration tests should be used for this scenario.
@Test
fun `buildCsr omits SAN extension when SAN string is null or blank`() {
listOf(null, "", " ").forEach { input ->
val csr = buildTestCsr(subjectAlternativeName = input)
assertNull(
"Expected no SAN extension for input \"$input\"",
extractSanExtension(csr),
)
}
}
@Test
fun `buildCsr emits a non-critical SAN extension when SAN string is present`() {
// Per-KEY encoding correctness is covered by SubjectAlternativeNameParserTest.
val csr = buildTestCsr(subjectAlternativeName = "DNS=example.com")
val ext = extractSanExtension(csr)
assertNotNull(ext)
assertFalse("SAN extension must be non-critical", ext!!.isCritical)
val names = GeneralNames.getInstance(ext.parsedValue).names
assertEquals(1, names.size)
assertEquals(GeneralName.dNSName, names[0].tagNo)
assertEquals("example.com", (names[0].name as DERIA5String).string)
}
@Test
fun `buildCsr wraps parser exceptions as ScepCsrException`() {
try {
buildTestCsr(subjectAlternativeName = "FOO=bar")
fail("Expected ScepCsrException")
} catch (e: ScepCsrException) {
assertTrue(
"Expected wrapper message to mention SAN; got: ${e.message}",
e.message!!.contains("subject alternative name"),
)
}
}
private fun buildTestCsr(subjectAlternativeName: String?) = scepClient.buildCsr(
entity = X500Name("CN=Test,O=FleetDM"),
keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.genKeyPair(),
challenge = "test-challenge",
signatureAlgorithm = "SHA256withRSA",
subjectAlternativeName = subjectAlternativeName,
)
/**
* Pulls the subjectAltName Extension out of a PKCS#10 CSR's extensionRequest attribute.
* Returns null if there is no extensionRequest attribute or no SAN extension inside it.
*/
private fun extractSanExtension(csr: org.bouncycastle.pkcs.PKCS10CertificationRequest): Extension? {
val attributes = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest)
if (attributes.isEmpty()) return null
val extensions = Extensions.getInstance(attributes[0].attrValues.getObjectAt(0))
return extensions.getExtension(Extension.subjectAlternativeName)
}
}
@@ -4,6 +4,12 @@ import com.fleetdm.agent.GetCertificateTemplateResponse
import com.fleetdm.agent.IntegrationTest
import com.fleetdm.agent.IntegrationTestRule
import com.fleetdm.agent.testutil.TestCertificateTemplateFactory
import org.bouncycastle.asn1.ASN1OctetString
import org.bouncycastle.asn1.DERIA5String
import org.bouncycastle.asn1.DEROctetString
import org.bouncycastle.asn1.x509.Extension
import org.bouncycastle.asn1.x509.GeneralName
import org.bouncycastle.asn1.x509.GeneralNames
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
@@ -125,6 +131,75 @@ class ScepIntegrationTest {
assertTrue("Enrollment should complete within 30 seconds (took ${duration}ms)", duration < 30000)
}
@IntegrationTest
@Test
fun `SAN entries on the certificate template appear on the issued certificate`() = runTest {
// End-to-end check that the SAN extension we put on the CSR is accepted by the
// SCEP CA and copied verbatim to the issued certificate.
//
// UPN (otherName) is intentionally not asserted here: the reference CA used in
// CI (micromdm/scep) only copies DNS/Email/IP/URI from the CSR to the issued
// cert via the typed fields on Go's x509.Certificate, and drops otherName.
val uniqueId = System.currentTimeMillis()
val expectedDns = listOf("a-$uniqueId.example.com", "b-$uniqueId.example.com")
val expectedEmail = listOf("a-$uniqueId@example.com", "b-$uniqueId@example.com")
val expectedUri = listOf(
"spiffe://example.org/a-$uniqueId",
"spiffe://example.org/b-$uniqueId",
)
val expectedIpStrings = listOf("10.0.0.1", "10.0.0.2")
val expectedIpOctets = listOf(byteArrayOf(10, 0, 0, 1), byteArrayOf(10, 0, 0, 2))
val sanString = listOf(
"DNS=${expectedDns[0]}",
"DNS=${expectedDns[1]}",
"EMAIL=${expectedEmail[0]}",
"EMAIL=${expectedEmail[1]}",
"URI=${expectedUri[0]}",
"URI=${expectedUri[1]}",
"IP=${expectedIpStrings[0]}",
"IP=${expectedIpStrings[1]}",
).joinToString(", ")
val template = testTemplate.copy(
name = "san-test-cert-$uniqueId",
subjectName = "CN=SanIntegrationTest-$uniqueId,O=FleetDM,C=US",
subjectAlternativeName = sanString,
)
val result = scepClient.enroll(template, testScepUrl)
val leafCert = result.certificateChain[0] as java.security.cert.X509Certificate
// Pull the SAN extension off the issued cert and parse via BouncyCastle. The
// cert's getExtensionValue returns the OCTET STRING wrapper, not the SAN
// contents directly, so we unwrap once before handing to GeneralNames.
val sanBytes = leafCert.getExtensionValue(Extension.subjectAlternativeName.id)
assertNotNull("Issued certificate has no SAN extension", sanBytes)
val sanContents = ASN1OctetString.getInstance(sanBytes).octets
val sanNames = GeneralNames.getInstance(sanContents).names
fun ia5ValuesForTag(tag: Int): List<String> = sanNames
.filter { it.tagNo == tag }
.map { (it.name as DERIA5String).string }
fun ipOctetsHex(): List<String> = sanNames
.filter { it.tagNo == GeneralName.iPAddress }
.map { (it.name as DEROctetString).octets.joinToString("") { b -> "%02x".format(b) } }
// Order is not pinned: some CAs canonicalize SAN ordering during signing.
// Sort each side and compare to verify both entries of each type round-trip.
assertEquals(expectedDns.sorted(), ia5ValuesForTag(GeneralName.dNSName).sorted())
assertEquals(expectedEmail.sorted(), ia5ValuesForTag(GeneralName.rfc822Name).sorted())
assertEquals(
expectedUri.sorted(),
ia5ValuesForTag(GeneralName.uniformResourceIdentifier).sorted(),
)
assertEquals(
expectedIpOctets.map { it.joinToString("") { b -> "%02x".format(b) } }.sorted(),
ipOctetsHex().sorted(),
)
}
@Test
fun `enrollment with unreachable server fails quickly`() = runTest {
val unreachableUrl = "https://unreachable-scep-server.invalid/scep"
@@ -0,0 +1,261 @@
package com.fleetdm.agent.scep
import org.bouncycastle.asn1.ASN1ObjectIdentifier
import org.bouncycastle.asn1.ASN1Sequence
import org.bouncycastle.asn1.ASN1TaggedObject
import org.bouncycastle.asn1.DERIA5String
import org.bouncycastle.asn1.DEROctetString
import org.bouncycastle.asn1.DERUTF8String
import org.bouncycastle.asn1.x509.GeneralName
import org.bouncycastle.asn1.x509.GeneralNames
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.Assert.fail
import org.junit.Test
import java.util.Locale
class SubjectAlternativeNameParserTest {
private val originalLocale = Locale.getDefault()
@After
fun restoreLocale() {
Locale.setDefault(originalLocale)
}
// -- helpers ---------------------------------------------------------
private fun parseOrFail(input: String): GeneralNames {
val result = SubjectAlternativeNameParser.parse(input)
assertNotNull("Expected non-null GeneralNames for input: \"$input\"", result)
return result!!
}
private fun parseSingle(input: String): GeneralName {
val names = parseOrFail(input).names
assertEquals("Expected exactly one entry for input: \"$input\"", 1, names.size)
return names[0]
}
private fun assertRejects(input: String, expectedSubstring: String) {
try {
SubjectAlternativeNameParser.parse(input)
fail("Expected IllegalArgumentException for input: \"$input\"")
} catch (e: IllegalArgumentException) {
val msg = e.message ?: ""
assertTrue(
"Expected message to contain \"$expectedSubstring\" for input \"$input\", got: $msg",
msg.contains(expectedSubstring),
)
}
}
private fun upnUtf8Value(name: GeneralName): String {
assertEquals(GeneralName.otherName, name.tagNo)
val seq = name.name as ASN1Sequence
val tagged = seq.getObjectAt(1) as ASN1TaggedObject
// getExplicitBaseObject throws if the tag is implicit, so the multi-entry tests
// that call this helper would catch a regression that emits implicit-tagged UPN
// OtherName values without needing a separate isExplicit assertion per call.
return (tagged.getExplicitBaseObject() as DERUTF8String).string
}
// -- null and blank inputs -------------------------------------------
@Test
fun `null and blank inputs return null`() {
listOf(null, "", " ", " \t\n ").forEach { input ->
assertNull(
"Expected null for input \"$input\"",
SubjectAlternativeNameParser.parse(input),
)
}
}
@Test
fun `non-blank input that contains only empty tokens returns null`() {
// Distinct from blank input: this string is non-blank, but every comma-separated
// token is empty after trim, so no GeneralNames entries are produced.
assertNull(SubjectAlternativeNameParser.parse(", , , "))
}
// -- per-KEY positive encoding ---------------------------------------
@Test
fun `each IA5String KEY encodes its value verbatim with the right tag`() {
data class Case(val input: String, val expectedTag: Int, val expectedValue: String)
listOf(
Case("DNS=example.com", GeneralName.dNSName, "example.com"),
Case("EMAIL=user@example.com", GeneralName.rfc822Name, "user@example.com"),
Case(
"URI=spiffe://example.org/workload",
GeneralName.uniformResourceIdentifier,
"spiffe://example.org/workload",
),
).forEach { case ->
val name = parseSingle(case.input)
assertEquals("Wrong tag for \"${case.input}\"", case.expectedTag, name.tagNo)
assertEquals(
"Wrong value for \"${case.input}\"",
case.expectedValue,
(name.name as DERIA5String).string,
)
}
}
@Test
fun `URI value with percent-encoded comma passes through verbatim`() {
val name = parseSingle("URI=https://example.com/a%2Cb?x=1")
assertEquals(GeneralName.uniformResourceIdentifier, name.tagNo)
assertEquals("https://example.com/a%2Cb?x=1", (name.name as DERIA5String).string)
}
@Test
fun `IP addresses encode to canonical raw octets`() {
// Two cases pin the BC integration: we route IP through BouncyCastle and BC
// produces the RFC 5280 §4.2.1.6 raw octet form (4 bytes for IPv4, 16 for IPv6).
// Exhaustive enumeration of IPv6 forms is BouncyCastle's responsibility, not ours.
data class Case(val input: String, val expected: ByteArray)
listOf(
Case("192.168.1.100", byteArrayOf(192.toByte(), 168.toByte(), 1, 100)),
Case(
"2001:db8::1",
byteArrayOf(
0x20, 0x01, 0x0d, 0xb8.toByte(),
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01,
),
),
).forEach { case ->
val name = parseSingle("IP=${case.input}")
assertEquals("Wrong tag for \"${case.input}\"", GeneralName.iPAddress, name.tagNo)
assertArrayEquals(
"Wrong octets for \"${case.input}\"",
case.expected,
(name.name as DEROctetString).octets,
)
}
}
@Test
fun `UPN encodes as Microsoft otherName with EXPLICIT-tagged UTF8 value`() {
val upn = "marko@corp.example.com"
val name = parseSingle("UPN=$upn")
assertEquals(GeneralName.otherName, name.tagNo)
// OtherName ::= SEQUENCE { type-id OID, value [0] EXPLICIT ANY DEFINED BY type-id }
val otherName = name.name as ASN1Sequence
assertEquals(2, otherName.size())
assertEquals(
"1.3.6.1.4.1.311.20.2.3",
(otherName.getObjectAt(0) as ASN1ObjectIdentifier).id,
)
val tagged = otherName.getObjectAt(1) as ASN1TaggedObject
assertEquals(0, tagged.tagNo)
assertTrue("UPN value must be [0] EXPLICIT", tagged.isExplicit)
assertEquals(upn, (tagged.baseObject as DERUTF8String).string)
}
// -- multi-entry positive tests --------------------------------------
@Test
fun `mixed entries preserve type, value, and document order`() {
val san = "DNS=host.example.com, EMAIL=u@example.com, URI=spiffe://x/y, " +
"IP=10.0.0.1, UPN=marko@corp.example.com"
val names = parseOrFail(san).names
assertEquals(5, names.size)
assertEquals(GeneralName.dNSName, names[0].tagNo)
assertEquals("host.example.com", (names[0].name as DERIA5String).string)
assertEquals(GeneralName.rfc822Name, names[1].tagNo)
assertEquals("u@example.com", (names[1].name as DERIA5String).string)
assertEquals(GeneralName.uniformResourceIdentifier, names[2].tagNo)
assertEquals("spiffe://x/y", (names[2].name as DERIA5String).string)
assertEquals(GeneralName.iPAddress, names[3].tagNo)
assertArrayEquals(byteArrayOf(10, 0, 0, 1), (names[3].name as DEROctetString).octets)
assertEquals("marko@corp.example.com", upnUtf8Value(names[4]))
}
@Test
fun `repeated keys produce repeated entries in document order with distinct values`() {
val san = "DNS=a.example.com, DNS=b.example.com, EMAIL=u1@x, EMAIL=u2@x, " +
"UPN=u1@corp, UPN=u2@corp, IP=10.0.0.1, IP=10.0.0.2, " +
"URI=spiffe://x/1, URI=spiffe://x/2"
val names = parseOrFail(san).names
assertEquals(10, names.size)
assertEquals("a.example.com", (names[0].name as DERIA5String).string)
assertEquals("b.example.com", (names[1].name as DERIA5String).string)
assertEquals("u1@x", (names[2].name as DERIA5String).string)
assertEquals("u2@x", (names[3].name as DERIA5String).string)
assertEquals("u1@corp", upnUtf8Value(names[4]))
assertEquals("u2@corp", upnUtf8Value(names[5]))
assertArrayEquals(byteArrayOf(10, 0, 0, 1), (names[6].name as DEROctetString).octets)
assertArrayEquals(byteArrayOf(10, 0, 0, 2), (names[7].name as DEROctetString).octets)
assertEquals("spiffe://x/1", (names[8].name as DERIA5String).string)
assertEquals("spiffe://x/2", (names[9].name as DERIA5String).string)
}
// -- behavioral / format tolerance -----------------------------------
@Test
fun `KEY matching is case-insensitive`() {
val names = parseOrFail("dns=example.com, Email=u@x, uPn=marko@corp").names
assertEquals(3, names.size)
assertEquals(GeneralName.dNSName, names[0].tagNo)
assertEquals(GeneralName.rfc822Name, names[1].tagNo)
assertEquals(GeneralName.otherName, names[2].tagNo)
}
@Test
fun `KEY matching is locale-insensitive (Turkish dotless-i regression)`() {
// Turkish uppercase rules turn "i" into "İ" under Locale.getDefault();
// the parser must use Locale.ROOT so "ip" still maps to "IP".
Locale.setDefault(Locale.forLanguageTag("tr-TR"))
val names = parseOrFail("ip=10.0.0.1, dns=host.example.com").names
assertEquals(2, names.size)
assertEquals(GeneralName.iPAddress, names[0].tagNo)
assertEquals(GeneralName.dNSName, names[1].tagNo)
}
@Test
fun `whitespace around tokens and around equals is tolerated`() {
val names = parseOrFail(" DNS = example.com , EMAIL =u@x ").names
assertEquals(2, names.size)
assertEquals(GeneralName.dNSName, names[0].tagNo)
assertEquals("example.com", (names[0].name as DERIA5String).string)
assertEquals(GeneralName.rfc822Name, names[1].tagNo)
assertEquals("u@x", (names[1].name as DERIA5String).string)
}
@Test
fun `trailing and embedded empty tokens are skipped`() {
val names = parseOrFail("DNS=example.com, , ,").names
assertEquals(1, names.size)
assertEquals("example.com", (names[0].name as DERIA5String).string)
}
// -- rejection cases (one table; one assertion shape) ----------------
@Test
fun `parser rejects malformed inputs`() {
// Exhaustively testing what BouncyCastle rejects belongs in BC's own tests; we
// keep one representative bad IPv4 and one bad IPv6 to confirm we route IP
// through IPAddress.isValid and surface the right error message.
listOf(
// KEY allow-list violations.
"FOO=bar" to "FOO",
"RFC822=user@example.com" to "RFC822",
// Token shape violations.
"DNS=ok, OOPS" to "OOPS",
"DNS=" to "DNS=",
"=value" to "=value",
// IP value violations: representative bad IPv4 and bad IPv6.
"IP=999.0.0.1" to "999",
"IP=fe80::1%eth0" to "fe80::1%eth0",
).forEach { (input, substring) -> assertRejects(input, substring) }
}
}
@@ -17,6 +17,7 @@ object TestCertificateTemplateFactory {
certificateAuthorityName: String = "Test CA",
createdAt: String = "2024-01-01T00:00:00Z",
subjectName: String = "CN=Test,O=FleetDM",
subjectAlternativeName: String? = null,
certificateAuthorityType: String = "SCEP",
status: String = "active",
scepChallenge: String = "test-challenge",
@@ -30,6 +31,7 @@ object TestCertificateTemplateFactory {
certificateAuthorityName = certificateAuthorityName,
createdAt = createdAt,
subjectName = subjectName,
subjectAlternativeName = subjectAlternativeName,
certificateAuthorityType = certificateAuthorityType,
status = status,
scepChallenge = scepChallenge,
@@ -0,0 +1 @@
- Added subject alternative name (SAN) support to SCEP enrollment on Android. When a certificate template carries a `subject_alternative_name` value, the agent now includes a non-critical SAN extension on the PKCS#10 CSR, supporting `DNS`, `EMAIL`, `URI`, `IP`, and `UPN` (Microsoft otherName, OID 1.3.6.1.4.1.311.20.2.3) attribute types.