Open Madrid: clean-room Kotlin implementation of Apple's madrid (iMessage) protocol

Engine modules: :codec (binary+XML plists), :courier (APNs push client),
:ids (identity lookup), :crypto (the encryption envelope), :registration
(GSA login, albert activation, renewal), :engine (integration + live
drift probes). 344 offline tests; capture-fixture known-answer vectors
for the parsers; live unauthenticated probes validated against Apple.

The bundled FairPlay identity is a synthetic placeholder — Apple-derived
material is user-staged, never shipped. Apache-2.0.
This commit is contained in:
fenleon committed 2026-09-29 00:04:00 +02:00
commit bc00fa2c3d
117 files changed
+18717

No files matched your search

@@ -0,0 +1,433 @@
package dev.fenn.imessage.codec
/**
* Binary property list (bplist00) codec per imessage-protocol-spec §4
* ("Binary plist container format" / "Binary plist object encoding"), which
* restates Apple's publicly documented Core Foundation binary plist format.
*
* Integers: the 1/2/4-byte wire forms are unsigned (zero-extended on decode);
* the 8-byte form is two's-complement signed, and negative values encode only
* there — so [BpInt] carries a [Long]. A UID is a 1/2/4/8-byte unsigned
* integer (marker 0x8n, n = 0..3). Dates are seconds since
* 2001-01-01T00:00:00Z (the Apple epoch) as a double.
*/
class BplistFormatException(message: String) : PlistFormatException(message)
sealed interface BpValue
data object BpNull : BpValue
data class BpBool(val value: Boolean) : BpValue
/** Signed 64-bit integer. The 1/2/4-byte wire forms are unsigned
* (zero-extended on decode); negative values ride only the 8-byte
* two's-complement form, so the representable range is exactly [Long]. */
data class BpInt(val value: Long) : BpValue
data class BpReal(val value: Double) : BpValue
/** Seconds since 2001-01-01T00:00:00Z (Apple epoch). */
data class BpDate(val secondsSince2001: Double) : BpValue
class BpData(val bytes: ByteArray) : BpValue {
override fun equals(other: Any?) = other is BpData && bytes.contentEquals(other.bytes)
override fun hashCode() = bytes.contentHashCode()
override fun toString() = "BpData(${bytes.size} bytes)"
}
data class BpString(val value: String) : BpValue
data class BpArray(val items: List<BpValue>) : BpValue
/** Keys may be any object per spec; protocol plists use string keys. */
class BpDict(val entries: Map<BpValue, BpValue>) : BpValue {
override fun equals(other: Any?) = other is BpDict && entries == other.entries
override fun hashCode() = entries.hashCode()
override fun toString() = "BpDict($entries)"
}
/** 1/2/4/8-byte unsigned integer (marker 0x8n). */
data class BpUid(val value: ULong) : BpValue
object Bplist {
private val MAGIC = "bplist00".toByteArray(Charsets.US_ASCII)
private const val TRAILER_SIZE = 32
fun encode(root: BpValue): ByteArray {
val objects = ArrayList<BpValue>()
collect(root, objects)
val index = java.util.IdentityHashMap<BpValue, Int>(objects.size * 2)
objects.forEachIndexed { i, v -> index[v] = i }
val count = objects.size
val refWidth = widthFor(count)
val payloads = objects.map { encodeObject(it, index, refWidth) }
var offsetTableOffset = MAGIC.size
val offsets = IntArray(count)
for (i in 0 until count) {
offsets[i] = offsetTableOffset
offsetTableOffset += payloads[i].size
}
val offWidth = widthFor(offsetTableOffset)
val out = ByteArray(offsetTableOffset + count * offWidth + TRAILER_SIZE)
MAGIC.copyInto(out, 0)
var pos = MAGIC.size
for (p in payloads) {
p.copyInto(out, pos)
pos += p.size
}
for (o in offsets) {
writeBE(out, pos, offWidth, o.toLong())
pos += offWidth
}
// Trailer: 5 unused, 1 sort version, 1 offset width, 1 ref width,
// then 8-byte big-endian count / top index / offset-table offset.
out[pos + 6] = offWidth.toByte()
out[pos + 7] = refWidth.toByte()
writeBE(out, pos + 8, 8, count.toLong())
writeBE(out, pos + 16, 8, 0L) // top object is always index 0 here
writeBE(out, pos + 24, 8, offsetTableOffset.toLong())
return out
}
private fun collect(v: BpValue, out: MutableList<BpValue>) {
out.add(v)
when (v) {
is BpArray -> v.items.forEach { collect(it, out) }
is BpDict -> {
v.entries.keys.forEach { collect(it, out) }
v.entries.values.forEach { collect(it, out) }
}
else -> {}
}
}
/** Minimal 1/2/4/8-byte width whose unsigned range holds [value]. */
private fun widthFor(value: Int): Int {
var w = 1
while (value > (1L shl (8 * w)) - 1) w *= 2
return w
}
private fun writeBE(out: ByteArray, pos: Int, width: Int, value: Long) {
for (i in 0 until width) {
out[pos + i] = (value ushr (8 * (width - 1 - i))).toByte()
}
}
private fun nibbleFor(width: Int) = when (width) {
1 -> 0; 2 -> 1; 4 -> 2; else -> 3
}
private fun inlineIntBytes(value: Int): ByteArray {
val w = widthFor(value)
val b = ByteArray(1 + w)
b[0] = (0x10 or nibbleFor(w)).toByte()
writeBE(b, 1, w, value.toLong())
return b
}
/** Low nibble < 15 carries the count; else 0xF + inline 0x1n integer. */
private fun writeCount(count: Int, markerHigh: Int): ByteArray {
return if (count < 15) {
byteArrayOf(((markerHigh shl 4) or count).toByte())
} else {
byteArrayOf(((markerHigh shl 4) or 0x0F).toByte()) + inlineIntBytes(count)
}
}
private fun encodeObject(v: BpValue, index: MutableMap<BpValue, Int>, refWidth: Int): ByteArray {
return when (v) {
is BpNull -> byteArrayOf(0x00)
is BpBool -> byteArrayOf(if (v.value) 0x09 else 0x08)
is BpInt -> {
val w = if (v.value < 0) 8 else intWidth(v.value)
val b = ByteArray(1 + w)
b[0] = (0x10 or nibbleFor(w)).toByte()
for (i in 0 until w) {
b[1 + i] = (v.value shr (8 * (w - 1 - i))).toByte()
}
b
}
is BpReal -> {
val b = ByteArray(9)
b[0] = 0x23
putDouble(b, 1, v.value)
b
}
is BpDate -> {
val b = ByteArray(9)
b[0] = 0x33
putDouble(b, 1, v.secondsSince2001)
b
}
is BpData -> writeCount(v.bytes.size, 0x4) + v.bytes
is BpString -> {
if (v.value.all { it.code < 0x80 }) {
val ascii = v.value.toByteArray(Charsets.US_ASCII)
writeCount(ascii.size, 0x5) + ascii
} else {
val utf16 = v.value.toByteArray(Charsets.UTF_16BE)
writeCount(utf16.size / 2, 0x6) + utf16 // length in code units, not bytes
}
}
is BpUid -> {
val w = ulongWidth(v.value)
if (w > 8) throw BplistFormatException("UID too large: $v")
val b = ByteArray(1 + w)
b[0] = (0x80 or nibbleFor(w)).toByte()
for (i in 0 until w) {
b[1 + i] = (v.value shr (8 * (w - 1 - i))).toByte()
}
b
}
is BpArray -> {
val head = writeCount(v.items.size, 0xA)
val refs = ByteArray(v.items.size * refWidth)
var pos = 0
for (item in v.items) {
writeBE(refs, pos, refWidth, index[item]!!.toLong())
pos += refWidth
}
head + refs
}
is BpDict -> {
val head = writeCount(v.entries.size, 0xD)
val refs = ByteArray(v.entries.size * 2 * refWidth)
var pos = 0
for (k in v.entries.keys) {
writeBE(refs, pos, refWidth, index[k]!!.toLong())
pos += refWidth
}
for ((_, value) in v.entries) {
writeBE(refs, pos, refWidth, index[value]!!.toLong())
pos += refWidth
}
head + refs
}
}
}
private fun ulongWidth(value: ULong): Int {
var w = 1
while (w < 8 && value > ((1UL shl (8 * w)) - 1UL)) w *= 2
return w
}
/** Minimal 1/2/4/8-byte width for a non-negative integer. */
private fun intWidth(value: Long): Int {
var w = 1
while (w < 8 && value > ((1L shl (8 * w)) - 1)) w *= 2
return w
}
private fun putDouble(b: ByteArray, pos: Int, v: Double) {
val bits = java.lang.Double.doubleToLongBits(v)
for (i in 0 until 8) {
b[pos + i] = (bits ushr (8 * (7 - i))).toByte()
}
}
fun decode(bytes: ByteArray): BpValue {
if (bytes.size < MAGIC.size + TRAILER_SIZE) {
throw BplistFormatException("input too short (${bytes.size} bytes)")
}
for (i in MAGIC.indices) {
if (bytes[i] != MAGIC[i]) throw BplistFormatException("bad magic — not bplist00")
}
val base = bytes.size - TRAILER_SIZE
val offWidth = bytes[base + 6].toInt() and 0xFF
val refWidth = bytes[base + 7].toInt() and 0xFF
if (offWidth !in intArrayOf(1, 2, 4, 8) || refWidth !in intArrayOf(1, 2, 4, 8)) {
throw BplistFormatException("invalid offset/ref width ($offWidth/$refWidth)")
}
val count = readU64(bytes, base + 8)
val top = readU64(bytes, base + 16)
val tableOffset = readU64(bytes, base + 24)
if (count == 0L || top >= count) {
throw BplistFormatException("top object index $top out of range ($count objects)")
}
if (tableOffset > base.toLong() || count > (base - tableOffset) / offWidth) {
throw BplistFormatException("offset table overruns the file")
}
val offsets = LongArray(count.toInt())
var pos = tableOffset
for (i in offsets.indices) {
offsets[i] = readUnsigned(bytes, pos.toInt(), offWidth)
pos += offWidth
}
return Decoder(bytes, offsets, tableOffset, refWidth).parseAt(top.toInt())
}
private class Decoder(
val bytes: ByteArray,
val offsets: LongArray,
val tableOffset: Long,
val refWidth: Int,
) {
private val memo = HashMap<Int, BpValue>()
fun parseAt(index: Int): BpValue {
memo[index]?.let { return it }
if (index < 0 || index >= offsets.size) {
throw BplistFormatException("object reference $index out of range (${offsets.size} objects)")
}
val offset = offsets[index]
if (offset >= tableOffset) {
throw BplistFormatException("object $index offset $offset outside the object area")
}
val v = parseMarker(offset.toInt())
memo[index] = v
return v
}
private fun parseMarker(pos: Int): BpValue {
if (pos < 0 || pos >= tableOffset) throw BplistFormatException("truncated object at $pos")
val marker = bytes[pos].toInt() and 0xFF
val high = marker ushr 4
val low = marker and 0x0F
return when (high) {
0x0 -> when (marker) {
0x00 -> BpNull
0x08 -> BpBool(false)
0x09 -> BpBool(true)
else -> throw BplistFormatException("unknown simple value marker 0x%02X".format(marker))
}
0x1 -> {
if (low > 3) throw BplistFormatException("invalid integer width 2^$low")
val w = 1 shl low
// 1/2/4-byte forms are unsigned (zero-extended); the
// 8-byte form is two's-complement signed.
BpInt(if (w == 8) readLongBE(pos + 1) else readUnsigned(pos + 1, w))
}
0x2 -> when (low) {
2 -> BpReal(
java.lang.Float.intBitsToFloat(readUnsigned(pos + 1, 4).toInt()).toDouble()
)
3 -> BpReal(readDouble(pos + 1))
else -> throw BplistFormatException("invalid real width 2^$low")
}
0x3 -> {
if (marker != 0x33) throw BplistFormatException("unknown marker 0x%02X".format(marker))
BpDate(readDouble(pos + 1))
}
0x4 -> {
val (len, body) = readCount(pos, low)
BpData(readBytes(body, len))
}
0x5 -> {
val (len, body) = readCount(pos, low)
val b = readBytes(body, len)
if (b.any { it.toInt() and 0x80 != 0 }) {
throw BplistFormatException("non-ASCII byte in 0x5 string")
}
BpString(String(b, Charsets.US_ASCII))
}
0x6 -> {
val (units, body) = readCount(pos, low) // length in code units, not bytes
BpString(String(readBytes(body, units * 2), Charsets.UTF_16BE))
}
0x8 -> {
if (low > 3) throw BplistFormatException("invalid UID width 2^$low")
BpUid(readUnsignedLong(pos + 1, 1 shl low))
}
0xA -> {
val (n, body) = readCount(pos, low)
val items = ArrayList<BpValue>(n)
var p = body
repeat(n) {
items.add(parseRef(p))
p += refWidth
}
BpArray(items)
}
0xD -> {
val (n, body) = readCount(pos, low)
var p = body
val keys = ArrayList<BpValue>(n)
repeat(n) {
keys.add(parseRef(p))
p += refWidth
}
val values = ArrayList<BpValue>(n)
repeat(n) {
values.add(parseRef(p))
p += refWidth
}
BpDict(keys.zip(values).toMap(LinkedHashMap()))
}
else -> throw BplistFormatException("unknown marker 0x%02X".format(marker))
}
}
/** Returns (count, body start). Count < 15 rides the low nibble; 15+ is an inline 0x1n integer. */
private fun readCount(pos: Int, low: Int): Pair<Int, Int> {
return if (low < 15) {
low to pos + 1
} else {
val m = bytes.getOrNull(pos + 1)?.toInt()?.and(0xFF)
?: throw BplistFormatException("truncated inline count at $pos")
if (m ushr 4 != 0x1) throw BplistFormatException("inline count is not an integer marker")
val n = m and 0x0F
if (n > 3) throw BplistFormatException("invalid inline integer width 2^$n")
val w = 1 shl n
readUnsigned(pos + 2, w).toInt() to pos + 2 + w
}
}
private fun parseRef(pos: Int): BpValue = parseAt(readUnsigned(pos, refWidth).toInt())
private fun readBytes(pos: Int, len: Int): ByteArray {
if (pos < 0 || len < 0 || pos.toLong() + len > tableOffset) {
throw BplistFormatException("read of $len bytes at $pos overruns the object area")
}
return bytes.copyOfRange(pos, pos + len)
}
private fun readUnsignedLong(pos: Int, width: Int): ULong {
if (pos < 0 || pos + width > bytes.size) {
throw BplistFormatException("read of $width bytes at $pos overruns the file")
}
var v = 0UL
for (i in 0 until width) {
v = (v shl 8) or (bytes[pos + i].toULong() and 0xFFUL)
}
return v
}
private fun readUnsigned(pos: Int, width: Int) = readUnsignedLong(pos, width).toLong()
private fun readLongBE(pos: Int): Long {
var v = 0L
for (i in 0 until 8) {
v = (v shl 8) or (bytes[pos + i].toLong() and 0xFF)
}
return v
}
private fun readDouble(pos: Int): Double {
var bits = 0L
for (i in 0 until 8) {
bits = (bits shl 8) or (bytes[pos + i].toLong() and 0xFF)
}
return java.lang.Double.longBitsToDouble(bits)
}
}
private fun readU64(bytes: ByteArray, pos: Int): Long {
var v = 0L
for (i in 0 until 8) {
v = (v shl 8) or (bytes[pos + i].toLong() and 0xFF)
}
return v
}
private fun readUnsigned(bytes: ByteArray, pos: Int, width: Int): Long {
var v = 0L
for (i in 0 until width) {
v = (v shl 8) or (bytes[pos + i].toLong() and 0xFF)
}
return v
}
}
@@ -0,0 +1,85 @@
package dev.fenn.imessage.codec
/**
* Bridge between the binary-plist [BpValue] tree and plain Kotlin values (Long, String,
* Boolean, Double, ByteArray, List, Map) — the shape the plist consumers in this repo work
* with. Binary plists only: this codec does not parse XML plists.
*/
class NativePlistException(message: String, cause: Throwable? = null) : Exception(message, cause)
object NativePlist {
/** Encodes a tree of plain Kotlin values as a binary plist. */
fun encode(dict: Map<String, Any?>): ByteArray = Bplist.encode(fromNative(dict))
/** Decodes a binary plist into plain Kotlin values. */
fun decode(bytes: ByteArray): Any? = toNative(Bplist.decode(bytes))
/** Converts a [BpValue] tree into plain Kotlin values (see [decode]). */
fun toNative(value: BpValue): Any? = when (value) {
is BpNull -> null
is BpBool -> value.value
is BpInt -> value.value
is BpReal -> value.value
is BpDate -> PlistDate(
(PlistDate.APPLE_EPOCH_MILLIS + value.secondsSince2001 * 1000).let { Math.round(it) },
)
is BpData -> value.bytes
is BpString -> value.value
is BpUid -> value.value
is BpArray -> value.items.map { toNative(it) }
is BpDict -> LinkedHashMap<Any?, Any?>(value.entries.size).apply {
value.entries.forEach { (k, v) -> put(toNative(k), toNative(v)) }
}
}
/** Converts plain Kotlin values into the [BpValue] tree. */
fun fromNative(value: Any?): BpValue = when (value) {
null -> BpNull
is BpValue -> value
is PlistDate -> BpDate((value.epochMillis - PlistDate.APPLE_EPOCH_MILLIS) / 1000.0)
is String -> BpString(value)
is Boolean -> BpBool(value)
is Int -> BpInt(value.toLong())
is Long -> BpInt(value)
is Double -> BpReal(value)
is Float -> BpReal(value.toDouble())
is ByteArray -> BpData(value)
is List<*> -> BpArray(value.map { fromNative(it) })
is Map<*, *> -> BpDict(LinkedHashMap<BpValue, BpValue>(value.size).apply {
value.forEach { (k, v) -> put(fromNative(k), fromNative(v)) }
})
else -> throw NativePlistException("no plist representation for ${value::class.java.name}")
}
}
/** The [value] as a string-keyed dict, or null when it is not one. */
fun stringKeyedDictOrNull(value: Any?): Map<String, Any?>? =
(value as? Map<*, *>)?.takeIf { map -> map.keys.all { it is String } }
?.mapKeys { it.key as String }
/** A decoded plist integer — the codecs decode to Long, hand-built dicts may hold Int. */
fun integerOrNull(value: Any?): Int? = when (value) {
is Long -> value.toInt()
is Int -> value
else -> null
}
fun longOrNull(value: Any?): Long? = when (value) {
is Long -> value
is Int -> value.toLong()
else -> null
}
/** A human-readable type name for parse-error messages ("a string", "a dict", …). */
fun typeName(value: Any?): String = when (value) {
null -> "null"
is String -> "a string"
is Boolean -> "a boolean"
is Int, is Long -> "an integer"
is Double, is Float -> "a real"
is ByteArray -> "data"
is List<*> -> "an array"
is Map<*, *> -> "a dict"
else -> value::class.java.simpleName
}
@@ -0,0 +1,68 @@
package dev.fenn.imessage.codec
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* The one catchable error for a plist that will not decode, whichever codec threw it.
* [BplistFormatException] (the binary codec) extends this, so callers that only need
* "it was not a plist" can catch this type across both encodings. Open so the binary codec
* can keep its own nested subclass.
*/
open class PlistFormatException(message: String) : IllegalArgumentException(message)
/**
* A decoded plist date, carried as epoch milliseconds (the exchange format of the XML
* plist's ISO-8601 `<date>` and of the binary codec's Apple-epoch double).
*/
data class PlistDate(val epochMillis: Long) {
companion object {
/** Unix epoch millis of 2001-01-01T00:00:00Z (the Apple epoch). */
const val APPLE_EPOCH_MILLIS = 978_307_200_000L
}
}
/**
* Format dispatch between the two plist encodings per spec §1.2/§4.1: a `bplist00`
* magic means the binary container, an XML declaration or `<plist>` root means the XML
* form. Which encoding each IDS endpoint uses is not recorded (TODO(capture) C47), so
* both codecs stay reachable behind this one entry point.
*/
object Plist {
private val BINARY_MAGIC = "bplist00".toByteArray(Charsets.US_ASCII)
private val WHITESPACE = byteArrayOf(' '.code.toByte(), '\t'.code.toByte(), '\n'.code.toByte(), '\r'.code.toByte())
fun parse(bytes: ByteArray): Any? = when {
startsWithBinaryMagic(bytes) -> NativePlist.decode(bytes)
startsWithXml(bytes) -> XmlPlist.decode(bytes)
else -> throw PlistFormatException(
"not a plist: neither bplist00 nor an XML plist (starts with ${preview(bytes)})",
)
}
private fun startsWithBinaryMagic(b: ByteArray): Boolean =
b.size >= BINARY_MAGIC.size && BINARY_MAGIC.indices.all { b[it] == BINARY_MAGIC[it] }
/** Optional UTF-8 BOM, then optional whitespace, then `<?xml` or `<plist`. */
private fun startsWithXml(b: ByteArray): Boolean {
var i = 0
if (b.size >= 3 && b[0] == 0xEF.toByte() && b[1] == 0xBB.toByte() && b[2] == 0xBF.toByte()) i = 3
while (i < b.size && WHITESPACE.any { it == b[i] }) i++
val head = String(b, i, minOf(6, b.size - i), Charsets.US_ASCII)
return head.startsWith("<?xml") || head.startsWith("<plist")
}
private fun preview(b: ByteArray): String {
if (b.isEmpty()) return "0 bytes"
val head = b.copyOfRange(0, minOf(8, b.size))
return head.joinToString(" ") { "0x%02x".format(it) }
}
}
/** XML-plist date form: ISO-8601 instant (`2024-01-02T03:04:05Z`). */
internal fun PlistDate.toIso8601(): String =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)
.format(Instant.ofEpochMilli(epochMillis))
@@ -0,0 +1,244 @@
package dev.fenn.imessage.codec
import java.io.ByteArrayInputStream
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.Base64
import javax.xml.parsers.DocumentBuilder
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
import org.xml.sax.ErrorHandler
import org.xml.sax.InputSource
import org.xml.sax.SAXParseException
/**
* XML plist reader.
*
* The bag responses and their embedded payloads are XML plists (§1.2 [CAP-BAGCT]), as are the
* register/authenticate/activation bodies and responses (§1.4, §1.2). [Plist.parse] picks
* between this codec and the binary codec ([Bplist]) by content.
*
* Elements map to the same Kotlin values as the binary codec's native bridge:
* `dict` → LinkedHashMap<String, Any?>, `array` → List<Any?>, `string`/`key` → String,
* `integer` → Long, `real` → Double, `true`/`false` → Boolean, `data` → ByteArray
* (base64), `date` → [PlistDate].
*/
object XmlPlist {
fun decode(bytes: ByteArray): Any? {
val document = try {
builder().parse(ByteArrayInputStream(bytes))
} catch (e: SAXParseException) {
throw PlistFormatException("malformed XML plist at line ${e.lineNumber}: ${e.message}")
} catch (e: Exception) {
throw PlistFormatException("malformed XML plist: ${e.message}")
}
val root = document.documentElement ?: throw PlistFormatException("empty XML document")
if (root.tagName != "plist") throw PlistFormatException("<${root.tagName}> is not a <plist>")
val values = elementChildren(root)
if (values.size != 1) {
throw PlistFormatException("<plist> holds ${values.size} value elements, expected exactly 1")
}
return value(values[0])
}
/**
* A parser that never resolves an external entity or an external DTD: it parses
* untrusted network input, so an entity reference must not become a fetch. The
* DOCTYPE itself stays tolerated (Apple's XML plists carry the property-list DTD
* declaration) and is simply inert — every external reference is refused below.
*/
private fun builder(): DocumentBuilder {
val factory = DocumentBuilderFactory.newInstance()
factory.isNamespaceAware = false
factory.setExpandEntityReferences(false)
feature(factory, "http://xml.org/sax/features/external-general-entities", false)
feature(factory, "http://xml.org/sax/features/external-parameter-entities", false)
feature(factory, "http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
try {
factory.isXIncludeAware = false
} catch (e: UnsupportedOperationException) {
// parser has no XInclude support — nothing to turn off
}
val builder = factory.newDocumentBuilder()
builder.setEntityResolver { _, _ -> InputSource(java.io.StringReader("")) }
builder.setErrorHandler(object : ErrorHandler {
override fun warning(e: SAXParseException) {}
override fun error(e: SAXParseException) = throw e
override fun fatalError(e: SAXParseException) = throw e
})
return builder
}
/** Best-effort: parsers differ in which of these features they know. */
private fun feature(factory: DocumentBuilderFactory, name: String, value: Boolean) {
try {
factory.setFeature(name, value)
} catch (e: Exception) {
// feature not supported by this parser — the entity resolver still guards the fetch
}
}
private fun value(e: Element): Any? = when (e.tagName) {
"dict" -> dict(e)
"array" -> elementChildren(e).map { value(it) }
"string" -> e.textContent
"integer" -> e.textContent.trim().toLongOrNull()
?: throw PlistFormatException("<integer> is not an integer: '${e.textContent.trim()}'")
"real" -> e.textContent.trim().toDoubleOrNull()
?: throw PlistFormatException("<real> is not a number: '${e.textContent.trim()}'")
"true" -> true
"false" -> false
"data" -> data(e)
"date" -> date(e.textContent)
"key" -> throw PlistFormatException("<key> outside a <dict>")
else -> throw PlistFormatException("unknown plist element <${e.tagName}>")
}
private fun dict(e: Element): Map<String, Any?> {
val children = elementChildren(e)
if (children.size % 2 != 0) {
throw PlistFormatException("<dict> holds ${children.size} elements — key/value pairs expected")
}
val out = LinkedHashMap<String, Any?>(children.size / 2)
var i = 0
while (i < children.size) {
val key = children[i]
if (key.tagName != "key") {
throw PlistFormatException("<${key.tagName}> where <key> expected in <dict>")
}
out[key.textContent] = value(children[i + 1])
i += 2
}
return out
}
/** `<data>` content is base64; Apple's writer wraps long payloads with newlines and tabs. */
private fun data(e: Element): ByteArray {
val text = e.textContent.filterNot { it.isWhitespace() }
return try {
Base64.getDecoder().decode(text)
} catch (e: IllegalArgumentException) {
throw PlistFormatException("<data> is not valid base64: ${e.message}")
}
}
/** Apple writes `<date>` as an ISO-8601 instant (`2024-01-02T03:04:05Z`). */
private fun date(text: String): PlistDate {
val trimmed = text.trim()
val instant = try {
OffsetDateTime.parse(trimmed).toInstant()
} catch (e: DateTimeParseException) {
try {
Instant.parse(trimmed)
} catch (e2: DateTimeParseException) {
try {
LocalDateTime.parse(trimmed).toInstant(ZoneOffset.UTC)
} catch (e3: DateTimeParseException) {
throw PlistFormatException("<date> is not an ISO-8601 instant: '$trimmed'")
}
}
}
return PlistDate(instant.toEpochMilli())
}
/**
* The Albert activation response (§1.2): an XML document whose outer `<Protocol>` block
* wraps a plist document. Parses the plist inside the Protocol element.
*/
fun decodeProtocol(bytes: ByteArray): Any? {
val document = try {
builder().parse(ByteArrayInputStream(bytes))
} catch (e: SAXParseException) {
throw PlistFormatException("malformed XML at line ${e.lineNumber}: ${e.message}")
} catch (e: Exception) {
throw PlistFormatException("malformed XML: ${e.message}")
}
val root = document.documentElement ?: throw PlistFormatException("empty XML document")
if (root.tagName != "Protocol") throw PlistFormatException("<${root.tagName}> is not a <Protocol>")
val plists = elementChildren(root)
if (plists.size != 1 || plists[0].tagName != "plist") {
throw PlistFormatException("<Protocol> must hold exactly one <plist>")
}
val values = elementChildren(plists[0])
if (values.size != 1) {
throw PlistFormatException("<plist> holds ${values.size} value elements, expected exactly 1")
}
return value(values[0])
}
/**
* XML plist writer — the mirror of [decode]'s type mapping (spec §1.4: the register body
* is serialized as an XML plist). UID has no XML-plist representation and `null` has none
* at all; both throw rather than emit something the other side cannot read.
*/
fun encode(value: Any?): ByteArray {
val out = StringBuilder(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" " +
"\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n" +
"<plist version=\"1.0\">\n",
)
write(value, out, 1)
out.append("</plist>\n")
return out.toString().toByteArray(Charsets.UTF_8)
}
private fun write(value: Any?, out: StringBuilder, depth: Int) {
val pad = " ".repeat(depth)
when (value) {
is Map<*, *> -> {
out.append(pad).append("<dict>\n")
for ((key, v) in value) {
out.append(pad).append(" <key>").append(escape(key as? String
?: throw PlistFormatException("dict key is not a string: $key")))
.append("</key>\n")
write(v, out, depth + 1)
}
out.append(pad).append("</dict>\n")
}
is List<*> -> {
out.append(pad).append("<array>\n")
for (v in value) write(v, out, depth + 1)
out.append(pad).append("</array>\n")
}
is String -> out.append(pad).append("<string>").append(escape(value)).append("</string>\n")
is Long -> out.append(pad).append("<integer>").append(value).append("</integer>\n")
is Int -> out.append(pad).append("<integer>").append(value).append("</integer>\n")
is Double -> out.append(pad).append("<real>").append(value).append("</real>\n")
is Boolean -> out.append(pad).append(if (value) "<true/>\n" else "<false/>\n")
is ByteArray -> out.append(pad).append("<data>")
.append(Base64.getEncoder().encodeToString(value)).append("</data>\n")
is PlistDate -> out.append(pad).append("<date>").append(value.toIso8601()).append("</date>\n")
is BpUid, null ->
throw PlistFormatException("XML plist cannot represent $value")
else -> throw PlistFormatException("no XML plist representation for ${value::class.java}")
}
}
private fun escape(text: String): String = buildString(text.length) {
for (c in text) when (c) {
'&' -> append("&amp;")
'<' -> append("&lt;")
'>' -> append("&gt;")
'"' -> append("&quot;")
'\'' -> append("&apos;")
else -> append(c)
}
}
/** Element children only — text, comments and processing instructions are ignored. */
private fun elementChildren(e: Element): List<Element> {
val children = e.childNodes
val out = ArrayList<Element>()
for (i in 0 until children.length) {
val node = children.item(i)
if (node is Element) out.add(node)
}
return out
}
}
@@ -0,0 +1,226 @@
package dev.fenn.imessage.codec
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class BplistTest {
private val magic = "bplist00".toByteArray(Charsets.US_ASCII)
private fun be(width: Int, value: Long) = ByteArray(width) { i ->
(value ushr (8 * (width - 1 - i))).toByte()
}
private fun trailer(offWidth: Int, refWidth: Int, count: Long, top: Long, tableOffset: Long) =
ByteArray(5) + byteArrayOf(0, offWidth.toByte(), refWidth.toByte()) +
be(8, count) + be(8, top) + be(8, tableOffset)
// -- known answer ------------------------------------------------------
@Test
fun knownAnswerSmallDict() {
// {"hi": true} — hand-computed: obj0 dict@8 (D1 01 02), obj1 "hi"@11
// (51 68 69), obj2 true@14 (09); offsets 08 0B 0E; table@15, file=50.
val out = Bplist.encode(BpDict(mapOf(BpString("hi") to BpBool(true))))
val expected = magic +
byteArrayOf(0xD1.toByte(), 0x01, 0x02) +
byteArrayOf(0x52.toByte(), 0x68, 0x69) +
byteArrayOf(0x09) +
byteArrayOf(0x08, 0x0B, 0x0E) +
trailer(1, 1, 3, 0, 15)
assertTrue(out.contentEquals(expected), "got ${out.toHex()}")
}
@Test
fun knownAnswerDateMarker() {
val out = Bplist.encode(BpDate(0.0))
val expected = magic +
byteArrayOf(0x33) + be(8, 0) +
byteArrayOf(0x08) +
trailer(1, 1, 1, 0, 17)
assertTrue(out.contentEquals(expected), "got ${out.toHex()}")
}
// -- round trips -------------------------------------------------------
@Test
fun roundTripSimpleValues() {
for (v in listOf(BpNull, BpBool(false), BpBool(true))) {
assertEquals(v, Bplist.decode(Bplist.encode(v)))
}
}
@Test
fun roundTripIntWidths() {
for (i in listOf(
0L, 0xFFL, 0x100L, 0xFFFFL, 0x1_0000L, 0xFFFF_FFFFL,
0x1_0000_0000L, Long.MAX_VALUE,
)) {
assertEquals(BpInt(i), Bplist.decode(Bplist.encode(BpInt(i))))
}
// widths: 1/2/4/8 bytes across the boundary values above
assertEquals(0x10, Bplist.encode(BpInt(0xFFL))[8].toInt() and 0xFF)
assertEquals(0x11, Bplist.encode(BpInt(0x100L))[8].toInt() and 0xFF)
assertEquals(0x12, Bplist.encode(BpInt(0x1_0000L))[8].toInt() and 0xFF)
assertEquals(0x13, Bplist.encode(BpInt(0x1_0000_0000L))[8].toInt() and 0xFF)
}
@Test
fun negativeIntsAre8ByteTwosComplement() {
for (i in listOf(-1L, -255L, -0x1_0000_0000L, Long.MIN_VALUE)) {
assertEquals(BpInt(i), Bplist.decode(Bplist.encode(BpInt(i))))
}
val out = Bplist.encode(BpInt(-1L))
assertEquals(0x13, out[8].toInt() and 0xFF)
assertTrue(out.slice(9..16).all { it == 0xFF.toByte() }, out.toHex())
}
@Test
fun roundTripRealDateDataStrings() {
for (v in listOf(
BpReal(3.14159),
BpReal(0.0),
BpDate(712025642.123),
BpData(byteArrayOf()),
BpData(byteArrayOf(0, 1, 2, 0xFF.toByte(), 0x7F)),
BpString(""),
BpString("hello"),
BpString("café ☕ 𐍈"),
)) {
assertEquals(v, Bplist.decode(Bplist.encode(v)))
}
}
@Test
fun roundTripUidWidths() {
for (u in listOf(
0uL, 0xFFuL, 0x100uL, 0xFFFFuL, 0x1_0000uL, 0xFFFF_FFFFuL,
0x1_0000_0000uL, ULong.MAX_VALUE,
)) {
assertEquals(BpUid(u), Bplist.decode(Bplist.encode(BpUid(u))))
}
// 1/2/4/8-byte payload per marker nibble 0..3
assertEquals(0x80, Bplist.encode(BpUid(0x12uL))[8].toInt() and 0xFF)
assertEquals(0x81, Bplist.encode(BpUid(0x1234uL))[8].toInt() and 0xFF)
assertEquals(0x82, Bplist.encode(BpUid(0x1_2345uL))[8].toInt() and 0xFF)
assertEquals(0x83, Bplist.encode(BpUid(0x1_2345_6789uL))[8].toInt() and 0xFF)
}
@Test
fun roundTripNestedCollections() {
val v = BpDict(
linkedMapOf(
BpString("array") to BpArray(
listOf(BpInt(1L), BpString("two"), BpData(byteArrayOf(3, 4)),
BpArray(listOf(BpBool(true))), BpNull)
),
BpString("dict") to BpDict(
linkedMapOf(
BpString("date") to BpDate(100.5),
BpString("uid") to BpUid(0x4242uL),
BpInt(9L) to BpString("non-string key"),
BpInt(-7L) to BpArray(listOf(BpInt(-1L))),
)
),
BpString("nested") to BpArray(
listOf(BpDict(linkedMapOf(BpString("x") to BpArray(listOf(BpInt(7L))))))
),
)
)
assertEquals(v, Bplist.decode(Bplist.encode(v)))
}
// -- extended lengths (>14 elements / inline-count path) ----------------
@Test
fun extendedLengths() {
val bigArray = BpArray((1..20).map { BpInt(it.toLong()) })
assertEquals(bigArray, Bplist.decode(Bplist.encode(bigArray)))
val longString = BpString("x".repeat(100))
assertEquals(longString, Bplist.decode(Bplist.encode(longString)))
val longData = BpData(ByteArray(300) { (it and 0xFF).toByte() })
assertEquals(longData, Bplist.decode(Bplist.encode(longData)))
val bigDict = BpDict((1..17).associate { BpString("k$it") to BpInt(it.toLong()) })
assertEquals(bigDict, Bplist.decode(Bplist.encode(bigDict)))
val huge = BpArray(listOf(bigArray, bigDict, BpString("y".repeat(20))))
assertEquals(huge, Bplist.decode(Bplist.encode(huge)))
}
@Test
fun utf16LengthIsCodeUnitsNotBytes() {
// "a𐐷b": 4 UTF-16 code units but 8 bytes — marker 0x6n carries 4.
val s = BpString("a𐐷b")
assertEquals(4, s.value.length) // surrogate pair counts as 2 chars
val out = Bplist.encode(s)
assertEquals(0x64, out[8].toInt() and 0xFF) // 0x6n marker, count 4
assertEquals(s, Bplist.decode(out))
}
@Test
fun asciiStringUsesMarker5() {
val out = Bplist.encode(BpString("abc"))
assertEquals(0x53, out[8].toInt() and 0xFF)
assertEquals(BpString("abc"), Bplist.decode(out))
}
// -- malformed input ----------------------------------------------------
@Test
fun rejectsBadMagic() {
val bad = byteArrayOf(0x62, 0x70, 0x6C, 0x69, 0x73, 0x74, 0x30, 0x31) + ByteArray(32)
assertFailsWith<BplistFormatException> { Bplist.decode(bad) }
}
@Test
fun rejectsTruncatedTrailer() {
val full = Bplist.encode(BpBool(true))
for (cut in intArrayOf(0, 10, full.size - 1)) {
assertFailsWith<BplistFormatException> { Bplist.decode(full.copyOfRange(0, cut)) }
}
}
@Test
fun rejectsOutOfRangeRef() {
// handcrafted: array of 1 element whose ref (5) exceeds the object count (1)
val bytes = magic +
byteArrayOf(0xA1.toByte(), 0x05) +
byteArrayOf(0x08) +
trailer(1, 1, 1, 0, 10)
assertFailsWith<BplistFormatException> { Bplist.decode(bytes) }
}
@Test
fun rejectsTopIndexOutOfRange() {
val bytes = magic +
byteArrayOf(0x09) +
byteArrayOf(0x08) +
trailer(1, 1, 1, 7, 9)
assertFailsWith<BplistFormatException> { Bplist.decode(bytes) }
}
@Test
fun rejectsUnknownMarker() {
val bytes = magic +
byteArrayOf(0x70) +
byteArrayOf(0x08) +
trailer(1, 1, 1, 0, 9)
assertFailsWith<BplistFormatException> { Bplist.decode(bytes) }
}
@Test
fun rejectsNonAscii0x5String() {
val bytes = magic +
byteArrayOf(0x51.toByte(), 0xC3.toByte(), 0xA9.toByte()) + // é in UTF-8
byteArrayOf(0x08) +
trailer(1, 1, 1, 0, 11)
assertFailsWith<BplistFormatException> { Bplist.decode(bytes) }
}
private fun ByteArray.toHex() = joinToString(" ") { "%02X".format(it) }
}
@@ -0,0 +1,149 @@
package dev.fenn.imessage.codec
import java.io.ByteArrayInputStream
import java.security.cert.CertificateFactory
import javax.security.auth.x500.X500Principal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* Known-answer vectors from REAL Apple wire bytes (own mitmproxy captures, 2026-09-10).
* Provenance is recorded per test by capture filename. All embedded fixtures are public
* static Apple files (the IDS bag, the APNs bag, the CDN validation-certificate plist) —
* scanned for personal data before committing: no handles, tokens, device identifiers or
* account material (grep hits for `tel:`/`mailto:`/`X-Apple-*`/UDID-like strings are 0).
* Private capture material (validation-request.bin and friends) is NOT committed — its
* structure lives in :registration's ValidationRequestStructureTest as digest + layout only.
*/
class CaptureKnownAnswerTest {
private fun resource(name: String): ByteArray =
javaClass.getResourceAsStream("/imessage/$name")!!.readBytes()
// -- ids-bag-2026-09-10.xml: GET init.ess.apple.com getBag?ix=3 response ----------------
@Test
fun `real ids bag decodes with the recorded top-level shape`() {
val outer = XmlPlist.decode(resource("ids-bag-2026-09-10.xml")) as Map<*, *>
// [CAP-BAG]: the response is a plist with `signature`, `certs` and `bag` keys.
assertEquals(setOf("signature", "certs", "bag"), outer.keys)
assertIs<ByteArray>(outer["signature"])
assertTrue((outer["signature"] as ByteArray).size >= 200, "an RSA signature blob")
val certs = assertIs<List<*>>(outer["certs"])
assertTrue(certs.isNotEmpty() && certs.all { it is ByteArray })
assertIs<ByteArray>(outer["bag"])
}
@Test
fun `real ids bag embedded dictionary has 405 entries with the recorded endpoint urls`() {
val outer = XmlPlist.decode(resource("ids-bag-2026-09-10.xml")) as Map<*, *>
val bag = XmlPlist.decode(outer["bag"] as ByteArray) as Map<*, *>
// [CAP-BAG]: "the inner bag a flat dictionary of 405 entries".
assertEquals(405, bag.size)
val expected = mapOf(
"id-register" to "https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register",
"id-query" to "https://query.ess.apple.com/WebObjects/QueryService.woa/wa/query",
"id-authenticate-ds-id" to
"https://profile.ess.apple.com/WebObjects/VCProfileService.woa/wa/authenticateDS",
"id-validation-cert" to "http://static.ess.apple.com/identity/validation/cert-1.0.plist",
"id-initialize-validation" to
"https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/initializeValidation",
"id-get-handles" to "https://profile.ess.apple.com/WebObjects/VCProfileService.woa/wa/idsGetHandles",
"id-authenticate-phone-number" to
"https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/authenticatePhoneNumber",
)
expected.forEach { (key, url) -> assertEquals(url, bag[key], key) }
// The bag is not string-valued throughout — typed scalars ride along (finding, see
// the class KDoc): ints, bools, reals and lists of strings.
assertEquals(28800L, bag["id-query-refresh-queued-query-interval"]) // §1.2's recorded 8 h scalar
assertEquals(false, bag["vc-disaster-mode"])
assertEquals(1789652036030L, bag["bag-expiry-timestamp"])
assertEquals(0.4, bag["vc-adaptive-learning-A"])
assertEquals(listOf("[email protected]", "[email protected]"), bag["ds-vetting-email-from"])
}
@Test
fun `real ids bag inner dictionary round-trips through the xml writer value-exactly`() {
val outer = XmlPlist.decode(resource("ids-bag-2026-09-10.xml")) as Map<*, *>
val bag = XmlPlist.decode(outer["bag"] as ByteArray) as Map<*, *>
// Apple's indentation differs from ours, so the comparison is on decoded values,
// not bytes: encode → decode must be the identity on all 405 entries.
val roundTripped = XmlPlist.decode(XmlPlist.encode(bag)) as Map<*, *>
assertEquals(bag, roundTripped)
assertEquals(405, roundTripped.size)
}
// -- apns-bag-2026-09-10.xml: GET init-p01st.push.apple.com/bag response -----------------
@Test
fun `real apns bag decodes with the recorded courier endpoint values`() {
val outer = XmlPlist.decode(resource("apns-bag-2026-09-10.xml")) as Map<*, *>
assertEquals(setOf("signature", "certs", "bag"), outer.keys)
val bag = XmlPlist.decode(outer["bag"] as ByteArray) as Map<*, *>
// §1.2/§3.1 record exactly two keys; the capture shows a 65-entry typed config (finding).
assertEquals(65, bag.size)
assertEquals("courier.push.apple.com", bag["APNSCourierHostname"])
assertEquals(50L, bag["APNSCourierHostcount"])
}
// -- validation-cert-1.0.plist: CDN static object, the id-validation-cert GET body --------
/**
* §1.2's recorded container: a `cert` blob of 2385 bytes = `01 02` + two
* (4-byte big-endian length, DER certificate) segments — the step-1 cert container
* shape. Both segments are public Apple CA certificates (validated below by subject).
*/
@Test
fun `validation cert blob carries the 01 02 two-segment der container`() {
val outer = XmlPlist.decode(resource("validation-cert-1.0.plist")) as Map<*, *>
assertEquals(setOf("cert"), outer.keys)
val blob = assertIs<ByteArray>(outer["cert"])
assertEquals(2385, blob.size)
assertEquals(0x01, blob[0].toInt())
assertEquals(0x02, blob[1].toInt())
val l1 = ((blob[2].toInt() and 0xFF) shl 24) or ((blob[3].toInt() and 0xFF) shl 16) or
((blob[4].toInt() and 0xFF) shl 8) or (blob[5].toInt() and 0xFF)
assertEquals(1046, l1)
val seg1 = blob.copyOfRange(6, 6 + l1)
val off = 6 + l1
val l2 = ((blob[off].toInt() and 0xFF) shl 24) or ((blob[off + 1].toInt() and 0xFF) shl 16) or
((blob[off + 2].toInt() and 0xFF) shl 8) or (blob[off + 3].toInt() and 0xFF)
assertEquals(1329, l2)
assertEquals(2385, off + 4 + l2, "the two segments account for the whole blob")
val seg2 = blob.copyOfRange(off + 4, off + 4 + l2)
val factory = CertificateFactory.getInstance("X.509")
val c1 = factory.generateCertificate(ByteArrayInputStream(seg1)) as java.security.cert.X509Certificate
val c2 = factory.generateCertificate(ByteArrayInputStream(seg2)) as java.security.cert.X509Certificate
val cn = { c: java.security.cert.X509Certificate ->
c.subjectX500Principal.getName(X500Principal.CANONICAL)
.substringAfter("cn=").substringBefore(",")
}
// Both are public Apple CAs — the archived CDN object's known intermediates.
assertEquals("apple system integration certification authority", cn(c1))
assertEquals("drm technologies a01", cn(c2))
}
@Test
fun `binary codec rejects the xml captures by magic`() {
// The sweep of all 2026-09-10 capture files found zero bplist00 bodies — every
// captured plist is XML. The binary decoder must not accept these files.
for (name in listOf("ids-bag-2026-09-10.xml", "apns-bag-2026-09-10.xml")) {
assertFailsWith<BplistFormatException> { Bplist.decode(resource(name)) }
}
}
@Test
fun `plist format dispatch routes the real captures to the xml codec`() {
// Plist.parse is the entry point the IDS bag fetcher uses; the real bags must
// reach the inner dictionary through it (they carry a DOCTYPE + XML declaration).
val outer = Plist.parse(resource("ids-bag-2026-09-10.xml")) as Map<*, *>
assertEquals(setOf("signature", "certs", "bag"), outer.keys)
val apns = Plist.parse(resource("apns-bag-2026-09-10.xml")) as Map<*, *>
assertEquals(setOf("signature", "certs", "bag"), apns.keys)
}
}
@@ -0,0 +1,58 @@
package dev.fenn.imessage.codec
import dev.fenn.imessage.codec.Bplist
import dev.fenn.imessage.codec.NativePlist
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class PlistFormatTest {
private val xml = "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict><key>a</key><integer>1</integer></dict></plist>"
@Test
fun binaryMagicDispatchesToTheBinaryCodec() {
val binary = NativePlist.encode(mapOf("a" to 1L))
assertEquals(mapOf("a" to 1L), Plist.parse(binary))
}
@Test
fun xmlDeclarationDispatchesToTheXmlCodec() {
assertEquals(mapOf("a" to 1L), Plist.parse(xml.toByteArray()))
}
@Test
fun xmlWithoutDeclarationDispatchesOnThePlistRoot() {
assertEquals(
mapOf("a" to 1L),
Plist.parse("<plist version=\"1.0\"><dict><key>a</key><integer>1</integer></dict></plist>".toByteArray()),
)
}
@Test
fun bomAndLeadingWhitespaceAreSkippedBeforeDispatching() {
val bom = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
assertEquals(mapOf("a" to 1L), Plist.parse(bom + xml.toByteArray()))
// Whitespace may precede a <plist> root (a declaration must stay first, as XML requires).
val noDeclaration = "<plist version=\"1.0\"><dict><key>a</key><integer>1</integer></dict></plist>"
assertEquals(mapOf("a" to 1L), Plist.parse("\n\t $noDeclaration".toByteArray()))
}
@Test
fun garbageThrows() {
assertFailsWith<PlistFormatException> { Plist.parse(ByteArray(0)) }
assertFailsWith<PlistFormatException> { Plist.parse("not a plist at all.......".toByteArray()) }
}
// The binary codec's own exception type stays catchable as the shared top-level one.
@Test
fun binaryFormatExceptionIsTheSharedType() {
assertFailsWith<PlistFormatException> { Bplist.decode("not a plist at all.......".toByteArray()) }
assertFailsWith<BplistFormatException> { Bplist.decode(ByteArray(0)) }
}
@Test
fun xmlBodyThatIsNotAPlistThrowsTheXmlCodecError() {
assertFailsWith<PlistFormatException> { Plist.parse("<?xml version=\"1.0\"?><html/>".toByteArray()) }
}
}
@@ -0,0 +1,208 @@
package dev.fenn.imessage.codec
import dev.fenn.imessage.codec.PlistDate
import dev.fenn.imessage.codec.BpUid
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class XmlPlistTest {
// One document exercising every element the reader must know. The declaration, DOCTYPE,
// comment and processing instruction are decoration the reader has to skip.
private val sample = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- a comment -->
<?some-processing-instruction value?>
<plist version="1.0">
<dict>
<key>name</key>
<string>light phone</string>
<key>count</key>
<integer>42</integer>
<key>ratio</key>
<real>1.5</real>
<key>yes</key>
<true/>
<key>no</key>
<false/>
<key>blob</key>
<data>AQIDBA==</data>
<key>wrapped</key>
<data>
BQYH
CA==
</data>
<key>when</key>
<date>2023-11-14T22:13:20Z</date>
<key>list</key>
<array>
<string>a</string>
<integer>7</integer>
<array><string>inner</string></array>
</array>
<key>nested</key>
<dict>
<key>inner</key>
<string>deep</string>
</dict>
</dict>
</plist>
""".trimIndent().toByteArray()
@Suppress("UNCHECKED_CAST")
private fun decodeSample(): Map<String, Any?> = XmlPlist.decode(sample) as Map<String, Any?>
@Test
fun decodesEveryElementTypeToTheBinaryPlistMapping() {
val v = decodeSample()
assertEquals("light phone", v["name"])
assertEquals(42L, v["count"])
assertEquals(1.5, v["ratio"])
assertEquals(true, v["yes"])
assertEquals(false, v["no"])
assertContentEquals(byteArrayOf(1, 2, 3, 4), v["blob"] as ByteArray)
assertEquals(PlistDate(1_700_000_000_000L), v["when"])
assertEquals(mapOf("inner" to "deep"), v["nested"])
}
@Test
fun skipsWhitespaceInsideData() {
assertContentEquals(byteArrayOf(5, 6, 7, 8), decodeSample()["wrapped"] as ByteArray)
}
@Test
fun arrayHoldsNestedValues() {
val list = decodeSample()["list"] as List<*>
assertEquals(3, list.size)
assertEquals("a", list[0])
assertEquals(7L, list[1])
assertEquals(listOf("inner"), list[2])
}
@Test
fun dictKeepsDocumentOrder() {
assertEquals(
listOf(
"name", "count", "ratio", "yes", "no", "blob", "wrapped", "when", "list", "nested",
),
decodeSample().keys.toList(),
)
assertEquals(listOf("inner"), (decodeSample()["nested"] as Map<*, *>).keys.toList())
}
@Test
fun bareTopLevelValueWithoutDict() {
assertEquals("solo", XmlPlist.decode("<plist version=\"1.0\"><string>solo</string></plist>".toByteArray()))
assertEquals(11L, XmlPlist.decode("<plist><integer>11</integer></plist>".toByteArray()))
}
@Test
fun doctypeAndEntityReferencesNeverReachTheFilesystem() {
val xml = """
<?xml version="1.0"?>
<!DOCTYPE plist [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<plist version="1.0"><dict><key>leak</key><string>&xxe;</string></dict></plist>
""".trimIndent()
@Suppress("UNCHECKED_CAST")
val v = XmlPlist.decode(xml.toByteArray()) as Map<String, Any?>
assertFalse("${v["leak"]}".contains("root:"), "external entity was resolved")
}
@Test
fun malformedInputFails() {
val failures = listOf(
ByteArray(0),
"<plist>".toByteArray(),
"<notplist><string>x</string></notplist>".toByteArray(),
"<plist><string>a</string><string>b</string></plist>".toByteArray(),
"<plist><dict><key>a</key></dict></plist>".toByteArray(), // odd child count
"<plist><dict><string>a</string><string>b</string></dict></plist>".toByteArray(), // not a <key>
"<plist><dict><key>a</key><widget/></dict></plist>".toByteArray(),
"<plist><dict><key>a</key><integer>not a number</integer></dict></plist>".toByteArray(),
"<plist><dict><key>a</key><real>nope</real></dict></plist>".toByteArray(),
"<plist><dict><key>a</key><data>!!!</data></dict></plist>".toByteArray(),
"<plist><dict><key>a</key><date>yesterday</date></dict></plist>".toByteArray(),
"<plist><key>a</key></plist>".toByteArray(),
)
failures.forEach { xml ->
assertFailsWith<PlistFormatException>("expected a failure for: $xml") {
XmlPlist.decode(xml)
}
}
}
@Test
fun nonZonedDateIsReadAsUtc() {
assertEquals(
PlistDate(1_700_000_000_000L),
XmlPlist.decode("<plist><date>2023-11-14T22:13:20</date></plist>".toByteArray()),
)
}
@Test
fun thrownTypeIsTheSharedFormatException() {
val e = assertFailsWith<PlistFormatException> { XmlPlist.decode("<plist><widget/></plist>".toByteArray()) }
assertTrue(e.message!!.contains("widget"))
}
// --- writer ---
@Test
fun encodeDecodesBackThroughEveryElementType() {
val value: Map<String, Any?> = mapOf(
"string" to "light phone",
"integer" to 42L,
"int" to 7,
"real" to 1.5,
"true" to true,
"false" to false,
"data" to byteArrayOf(1, 2, 3, 4),
"date" to PlistDate(1_700_000_000_000L),
"list" to listOf("a", 7L, listOf("inner")),
"nested" to mapOf("inner" to "deep"),
)
val decoded = XmlPlist.decode(XmlPlist.encode(value)) as Map<*, *>
assertEquals(value["string"], decoded["string"])
assertEquals(42L, decoded["integer"])
assertEquals(7L, decoded["int"])
assertEquals(1.5, decoded["real"])
assertEquals(true, decoded["true"])
assertEquals(false, decoded["false"])
assertContentEquals(byteArrayOf(1, 2, 3, 4), decoded["data"] as ByteArray)
assertEquals(PlistDate(1_700_000_000_000L), decoded["date"])
assertEquals(value["list"], decoded["list"])
assertEquals(mapOf("inner" to "deep"), decoded["nested"])
}
@Test
fun encodeMatchesTheHandWrittenShape() {
val expected = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>a</key>
<integer>1</integer>
</dict>
</plist>
""".trimIndent()
assertEquals(expected, XmlPlist.encode(mapOf("a" to 1L)).toString(Charsets.UTF_8).trimEnd())
}
@Test
fun encodeEscapesSpecialCharacters() {
val decoded = XmlPlist.decode(XmlPlist.encode(mapOf("k" to "<&\"'>"))) as Map<*, *>
assertEquals("<&\"'>", decoded["k"])
}
@Test
fun encodeRejectsUnrepresentableValues() {
assertFailsWith<PlistFormatException> { XmlPlist.encode(mapOf("a" to BpUid(1uL))) }
assertFailsWith<PlistFormatException> { XmlPlist.encode(mapOf<String, Any?>("a" to null)) }
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>signature</key>
<data>j1Mm9aOqt2dpaAcWICf9naqod2C7/G7q3vEGehyjLJnu9SschcctiYYb13HMtHhTFMcTLJ6gWUmqMGbXoeq/wMnmTeGwkYEONKswD3xCjV3QeneI1aAaWLvF1QjXvdcTB7LZEFKOQGtnkKK9P4845zU8Mrs3J01/pqAJmqyxPBesycbEeXZBm5c+BSewFOE4faYaekrD38cc69V3JAm3IVBctILs0Bn6PIQbx0aNawWo440QpjNgfRHiuSwOd6ZZ2ovkns92Qb1LR/Yed0o33KBsZnavnCXhnAr/fOwmBnTEneSO2QxSUz6cxhekPL8ldTAZmA9tR2+iknIqelKkcQ==</data>
<key>certs</key>
<array>
<data>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</data>
<data>MIIEkDCCA3igAwIBAgIQCuSPIwEwZEGSWeHCmumNGDANBgkqhkiG9w0BAQsFADB7MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTE5MDYxOTAwMDAwMFoXDTI4MTIwNjIzNTk1OVowZDErMCkGA1UEAxMiQXBwbGUgUHVibGljIFNlcnZlciBSU0EgQ0EgMTIgLSBHMTETMBEGA1UEChMKQXBwbGUgSW5jLjETMBEGA1UECBMKQ2FsaWZvcm5pYTELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9vhI3XrifeRWYC+x6U5sPCaYAPncBLBBsyQdt7ztiyROEG6fEKZG1tToZMES3jtgqV+7RcBV9xX2jVeU+EwAnHkIpwoDWcte0gLfALL3hkVcwXJoJaojVF8mMHlJtQYTr3FYkZ6FqALt/LFUxeis3/Zgtug7EoqBsZeF5g79lSZtZqcNLm+3NuvZUdAHBGIzD++wVlOhy9IxahD/Z7eIkpuJTedYVpLmnX/fHq92gYoNHezlNN8vdILlsPV4kIdrwTKso7II25Kha0/l3hx2xEBtUIQLMGxKF+fD9AjcYhSMqhTM5/2tyud4QJxIy409Dj4OhNooHAxurBDHwV8ZlAgMBAAGjggElMIIBITAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAdBgNVHQ4EFgQUHlwXkQVXAvx3XONwQ+xr/d3S2GkwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAhBgNVHSAEGjAYMAwGCiqGSIb3Y2QFCwQwCAYGZ4EMAQICMBIGA1UdEwEB/wQIMAYBAf8CAQAwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEBAGa7XPTd+1lgnT/dDf2agLAIIXpbTn6XtEGMq9jovXbK076zHlQSWapd6Zd/p/uWydwuTbd+cobkUTFZhKGMkY0e8gXgI2s09kQ0EfotFUOdQ7qf1U+UM7JIEZmDgsTn9vBLNwbtwH9t6cvYxH//VhWyL07pPkfTFBVqvAprGZ9MgraZD2S9qYu9usfSvuH4lr4b3NuZ7mh3zOs4aD3Omgx30DU8DV82LikGN4/MF+uDOGgHtPv0ozlvPrFc8bsE+lTnjP199w5X+kPtpzrkjYPNrGxTg4nhugo6y+GPOCl00S+T/794oTq/HFlj6IoOvOR7UaKo39qnYwA6Fs0F0to=</data>
</array>
<key>bag</key>
<data>PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9InllcyI/Pgo8IURPQ1RZUEUgcGxpc3QgUFVCTElDICItLy9BcHBsZS8vRFREIFBMSVNUIDEuMC8vRU4iICJodHRwOi8vd3d3LmFwcGxlLmNvbS9EVERzL1Byb3BlcnR5TGlzdC0xLjAuZHRkIj4KPHBsaXN0IHZlcnNpb249IjEuMCI+Cgk8ZGljdD4KCQk8a2V5PkFQTlNDb3VyaWVySG9zdG5hbWU8L2tleT4KCQk8c3RyaW5nPmNvdXJpZXIucHVzaC5hcHBsZS5jb208L3N0cmluZz4KCgkJPGtleT5BUE5TVmVyaWZpZWRDb3VyaWVySG9zdG5hbWU8L2tleT4KCQk8c3RyaW5nPmNvdXJpZXIucHVzaC5hcHBsZS5jb208L3N0cmluZz4KCgkJPGtleT5BUE5TQ291cmllckhvc3Rjb3VudDwva2V5PgoJCTxpbnRlZ2VyPjUwPC9pbnRlZ2VyPgoKCQk8a2V5PkNsaWVudENvbm5lY3Rpb25SZXRyeUF0dGVtcHRzPC9rZXk+CgkJPGludGVnZXI+MTAwPC9pbnRlZ2VyPgoKCQk8a2V5PkFQTlNDb3VyaWVyU3RhdHVzPC9rZXk+CgkJPHRydWUvPgoKCQk8a2V5Pm1pbkNvbnNlY3V0aXZlS2VlcEFsaXZlc01haW50YWluaW5nV2lGaUNvbm5lY3Rpb248L2tleT4KCQk8aW50ZWdlcj4xMDwvaW50ZWdlcj4KCgkJPGtleT5taW51dGVzRGlzYWJsZVN3aXRjaGluZ1RvV2lGaUZyb21DZWxsdWxhcjwva2V5PgoJCTxpbnRlZ2VyPjE1PC9pbnRlZ2VyPgoKCQk8a2V5PkFQTlNOdW1iZXJPZkNyaXRpY2FsTWVzc2FnZUtlZXBBbGl2ZXNCZWZvcmVEaXNjb25uZWN0aW5nPC9rZXk+CgkJPGludGVnZXI+MzwvaW50ZWdlcj4KCgkJPGtleT5BUE5TQ3JpdGljYWxNZXNzYWdlS2VlcEFsaXZlVGltZXJEdXJhdGlvbjwva2V5PgoJCTxyZWFsPjEwLjA8L3JlYWw+CgoJCTxrZXk+QVBOU0NyaXRpY2FsTWVzc2FnZVRpbWVvdXQ8L2tleT4KCQk8cmVhbD4xMC4wPC9yZWFsPgoKCQk8a2V5PkFQTlNXV0FOVHJhY2tlZExpbmtRdWFsaXR5VGltZUludGVydmFsPC9rZXk+CgkJPHJlYWw+NjAwLjA8L3JlYWw+CgoJCTxrZXk+QVBOU1dXQU5UcmFja2VkTGlua1F1YWxpdHlPZmZUcmFuc2l0aW9uczwva2V5PgoJCTxpbnRlZ2VyPjI8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0FXRFNsb3dSZWNlaXZlVGhyZXNob2xkPC9rZXk+CgkJPHJlYWw+NjAuMDwvcmVhbD4KCgkJPGtleT5BUE5TTG93UHJpb3JpdHlNZXNzYWdlQmF0Y2hTaXplPC9rZXk+CgkJPGludGVnZXI+NTA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0FjdGl2ZUludGVydmFsPC9rZXk+CgkJPGludGVnZXI+NTwvaW50ZWdlcj4KCgkJPGtleT5BUE5TRm9yY2VkU2hvcnRUaW1lb3V0SW50ZXJ2YWw8L2tleT4KCQk8cmVhbD4yLjA8L3JlYWw+CgoJCTxrZXk+QVBOU0Nvc3REcml2ZW5EdWFsQ2hhbm5lbEF0dGVtcHRzPC9rZXk+CgkJPGludGVnZXI+MTAwPC9pbnRlZ2VyPgoKCQk8a2V5PkFQTlNQaWdneWJhY2tEdWFsQ2hhbm5lbEF0dGVtcHRzPC9rZXk+CgkJPGludGVnZXI+NTA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU01heGltdW1Mb3dQcmlvcml0eUJhdGNoZXNQZXJIb3VyPC9rZXk+CgkJPGludGVnZXI+MzwvaW50ZWdlcj4KCgkJPGtleT5BUE5TRGlzYWJsZUNvc3REcml2ZW5EdWFsQ2hhbm5lbDwva2V5PgoJCTxmYWxzZS8+CgoJCTxrZXk+QVBOU0xvd1ByaW9yaXR5QnVyc3RXaW5kb3c8L2tleT4KCQk8cmVhbD4zMC4wPC9yZWFsPgoKCQk8a2V5PkFQTlNMb3dQcmlvcml0eUJ1cnN0RGVsYXk8L2tleT4KCQk8cmVhbD4xMjAwLjA8L3JlYWw+CgoJCTxrZXk+QVBOU0xvd1ByaW9yaXR5QnVyc3RTZW5kUHJvYmFiaWxpdHk8L2tleT4KCQk8cmVhbD4wLjg8L3JlYWw+CgoJCTxrZXk+S2VlcEFsaXZlVjJUaW1lRHJpZnRNYXhpbXVtPC9rZXk+CgkJPGludGVnZXI+MDwvaW50ZWdlcj4KCgkJPGtleT5LZWVwQWxpdmVWMlRpbWVEcmlmdE1heEFsbG93ZWQ8L2tleT4KCQk8aW50ZWdlcj4zMDwvaW50ZWdlcj4KCgkJPGtleT5BUE5TSVBDYWNoaW5nVFRMTWludXRlczwva2V5PgoJCTxpbnRlZ2VyPjE0NDA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0lQQ2FjaGluZ1BlcmNlbnRhZ2U8L2tleT4KCQk8aW50ZWdlcj4wPC9pbnRlZ2VyPgoKCQk8a2V5PkVudmlyb25tZW50PC9rZXk+CgkJPHN0cmluZz5Qcm9kdWN0aW9uPC9zdHJpbmc+CgoJCTxrZXk+QVBOU05hZ2xlRW5hYmxlZDwva2V5PgoJCTxmYWxzZS8+CgoJCTxrZXk+QVBOU01pbmltdW1JbnRlcnZhbEZhbGxiYWNrRW5hYmxlZDwva2V5PgoJCTx0cnVlLz4KCgkJPGtleT5BUE5TSVBDYWNoaW5nVFRMTWludXRlc1YyPC9rZXk+CgkJPGludGVnZXI+MTQ0MDwvaW50ZWdlcj4KCgkJPGtleT5BUE5TV2lGaUtlZXBBbGl2ZUVhcmx5RmlyZUNvbnN0YW50SW50ZXJ2YWw8L2tleT4KCQk8aW50ZWdlcj4xMjA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0NvdXJpZXJIb3N0c1ByaW1hcnlJUHY0PC9rZXk+CgkJPGFycmF5PgoJCTwvYXJyYXk+CgoJCTxrZXk+QVBOU0NvdXJpZXJIb3N0c1ByaW1hcnlJUHY2PC9rZXk+CgkJPGFycmF5PgoJCTwvYXJyYXk+CgoJCTxrZXk+QVBOU0NvdXJpZXJIb3N0c1NlY29uZGFyeUlQdjQ8L2tleT4KCQk8YXJyYXk+CgkJPC9hcnJheT4KCgkJPGtleT5BUE5TQ291cmllckhvc3RzU2Vjb25kYXJ5SVB2Njwva2V5PgoJCTxhcnJheT4KCQk8L2FycmF5PgoKCQk8a2V5PkFQTlNDb3VyaWVySG9zdHNEZWZhdWx0SVB2NDwva2V5PgoJCTxhcnJheT4KCQk8L2FycmF5PgoKCQk8a2V5PkFQTlNDb3VyaWVySG9zdHNEZWZhdWx0SVB2Njwva2V5PgoJCTxhcnJheT4KCQk8L2FycmF5PgoKCQk8a2V5PkFQTlNCYWdFeHBpcnk8L2tleT4KCQk8aW50ZWdlcj4xMDgwMDwvaW50ZWdlcj4KCgkJPGtleT5BUE5TRGVmZXJyZWRIb3N0VGltZW91dDwva2V5PgoJCTxpbnRlZ2VyPjM2MDA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0ZpcnN0VW5sb2NrRGVsaXZlcnlTdGF0dXNQcm9iYWJpbGl0eTwva2V5PgoJCTxpbnRlZ2VyPjE8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0VuYWJsZUFsZXJ0RG93bmdyYWRlPC9rZXk+CgkJPHRydWUvPgoKCQk8a2V5PkFQTlNEZWxheWVkUmVjb25uZWN0TWluSW50ZXJ2YWxUcmlnZ2VyPC9rZXk+CgkJPGludGVnZXI+MzA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0RlbGF5ZWRSZWNvbm5lY3RJbnRlcnZhbEFsbDwva2V5PgoJCTxpbnRlZ2VyPjYwPC9pbnRlZ2VyPgoKCQk8a2V5PkFQTlNEZWxheWVkUmVjb25uZWN0TWF4SW50ZXJ2YWxUcmlnZ2VyPC9rZXk+CgkJPGludGVnZXI+NTA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0RlbGF5ZWRSZWNvbm5lY3RNYXhJbnRlcnZhbDwva2V5PgoJCTxpbnRlZ2VyPjkwPC9pbnRlZ2VyPgoKCQk8a2V5PkFQTlNEZWxheWVkUmVjb25uZWN0VExTSW50ZXJ2YWxUcmlnZ2VyPC9rZXk+CgkJPGludGVnZXI+NTA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0RlbGF5ZWRSZWNvbm5lY3RUTFNJbnRlcnZhbDwva2V5PgoJCTxpbnRlZ2VyPjEyMDwvaW50ZWdlcj4KCgkJPGtleT5BUE5TQWxsb3dUTFMxXzM8L2tleT4KCQk8aW50ZWdlcj4xPC9pbnRlZ2VyPgoKCQk8a2V5PkFQTlNDbG91ZENoYW5uZWxSZXRyeUNvdW50PC9rZXk+CgkJPGludGVnZXI+MzwvaW50ZWdlcj4KCgkJPGtleT5BUE5TQ2xvdWRDaGFubmVsUmVxdWVzdFRpbWVvdXRTZWNvbmRzPC9rZXk+CgkJPGludGVnZXI+MzA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU1dlYlB1c2hFbmRwb2ludFBhdGg8L2tleT4KCQk8c3RyaW5nPmh0dHBzOi8vd2ViLnB1c2guYXBwbGUuY29tLzwvc3RyaW5nPgoKCQk8a2V5PkFQTlNFbmFibGVTaW11bGF0b3JDb25uZWN0aW9uPC9rZXkLine truncated
</dict>
</plist>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>signature</key><data>ut4qTrXXC/KOPvNDUylU9CfCUOVoLwiQaX8CH1LI0kKTweBkDMoaMHkP++zSuigw8ieKvQJqdmxNgdMm5GvKbN7ppyKb1c4ZUX3+iyRHU36H1HY/XVQxhvHKbpGaTiRm2ECRhYk8iu4/iaRUU9/54V4N/0JHD+CNf9/wZkbAqFNbnS9NUVj2118o1pIvHVeNW+UqouIkGwEQ5b1QsY52u3s6oMONNGAxzCos+gMHRbVXAqo9/cKyJ+JErdD/6gipv+z/FG/wTXFv9t/aJ7mNP82ZJpb1YwwHRkxj1Z23dHEIJlylQbxQKSnHdrVqzwtWFVtTMo3Ugh8cdPKScxmLmw==</data>
<key>certs</key>
<array>
<data>MIIEzTCCA7WgAwIBAgIQeIV8UxL7W+totaWqsuqowjANBgkqhkiG9w0BAQsFADBtMScwJQYDVQQDDB5BcHBsZSBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0ExIDAeBgNVBAsMF0NlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yNjA2MjMxNjU5MzdaFw0yNjEyMTcxOTM2NTRaMFQxGzAZBgNVBAMMEmluaXQuZXNzLmFwcGxlLmNvbTETMBEGA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDJmFOkwJmuTZfHmUMzHq06YcC4AqFFEXUWSH1wNQK/S9WSelcCKIpNW7Pl0YxPQ8p5jMi7GD6ARHDcHvjvnJLhSpVWJmzqY7NoZPloi+nAVNzCdXo6fENSkYHriyp3Vdlz9tTy62beEoNLGDnU9fsQk05GZt3deg6/r9tN1lyRC6Y90MroWiAncza630FDgGCVr8ZnhnO1jT7OyPGu6AGI0aIFBw2PJUZEFnIsxQSkyX7zK4UZY6AJKN3WNGM0ewNh/aZrFwfmReth6exCJ4hX0oLUhJneeUn7F4uJ/ruZpgVuhkd5nsVl5L1oBMQysIQUcst/aCHQkHkNVJ05b2QrAgMBAAGjggGAMIIBfDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFCzFbVLdMe+M7AiB7d/cykMARQHQMIGFBggrBgEFBQcBAQR5MHcwOQYIKwYBBQUHMAKGLWh0dHA6Ly9jZXJ0cy5hcHBsZS5jb20vYXBwbGVzZXJ2ZXJhdXRoY2ExLmRlcjA6BggrBgEFBQcwAYYuaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHBsZXNlcnZlcmF1dGgyMjAdBgNVHREEFjAUghJpbml0LmVzcy5hcHBsZS5jb20wEwYDVR0lBAwwCgYIKwYBBQUHAwEwPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL2NybC5hcHBsZS5jb20vYXBwbGVzZXJ2ZXJhdXRoY2ExLmNybDAdBgNVHQ4EFgQUBen3KAJgLjbMGLIn1ME84axwNeIwDgYDVR0PAQH/BAQDAgWgMBEGCyqGSIb3Y2QGGwQCBAIFADAPBgkqhkiG92NkBlYEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQCmMrrbHO6enTE78Wo7VowVXgzMFTM9OMUOw7lF9pUAI1A0BatSmPql7MaLVS49eSFWWspkvcfN3kyERlaYE1B3ixqDFAmZ9XIkt6H5oiaIVQeBf4D732J3oi4aoTmdrgk5j/1g5erGp+rUwgzjMVqqU4+sxLj4Nyr+t1sZZ1wpCHyz+q4EZw7gmBc9c3j0FRtOUCMqKAuGPUomrhCAOh8xaHpvoUTYmYBQQ0dsnFgbwU2GV6wotBUX2BFTPk8vZPSYFIF33ZeVQLELd2AQAP6SeVAPEDJ5V8FrcXxDoAvMei3yI3xJLSkH5bx9Uuu2itcoxcF2h7B/ktc+SYJkuugQ</data>
<data>MIID+DCCAuCgAwIBAgIII2l0BK3LgxQwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsTHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBSb290IENBMB4XDTE0MDMwODAxNTMwNFoXDTI5MDMwODAxNTMwNFowbTEnMCUGA1UEAwweQXBwbGUgU2VydmVyIEF1dGhlbnRpY2F0aW9uIENBMSAwHgYDVQQLDBdDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5Jhawy4ercRWSjt+qPuGA11O6pGDMfIVy9zB8CU9XDUr/4V7JS1ATAmSxvTk10dcEUcEY+iL6rt+YGNa/Tk1DEPoliJ/TQIV25SKBtlRFc5qL45xIGoZ6w1Hi2pX4pH3bMN5sDsTF9WyY56b6VyAdGXN6Ds1jD7cniC7hmmiCuEBsYxYkZivnsuJUfeeIOaIbgT4C0znYl3dKMgzWCgqzBJvxcm9jqBUebDfoD9tTkNYpXLxqV5tGeAo+JOqaP6HYP/XbbqhsgrXdmTjsklaUpsVzJtGuCLLGUueOdkuJuFQPbuDZQtsqZYdGFLuWuFe7UeaEE/cNobaJrHzRIXSrAgMBAAGjgaYwgaMwHQYDVR0OBBYEFCzFbVLdMe+M7AiB7d/cykMARQHQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wLgYDVR0fBCcwJTAjoCGgH4YdaHR0cDovL2NybC5hcHBsZS5jb20vcm9vdC5jcmwwDgYDVR0PAQH/BAQDAgEGMBAGCiqGSIb3Y2QGAgwEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQAj8QZ+UEGBol7TcKRJka/YzGeMoSV9xJqTOS/YafsbQVtE19lryzslCRry9OPHnOiwW/Df3SIlERWTuUle2gxmel7Xb/Bj1GWMxHpUfVZPZZr92sSyyLC4oct94EeoQBW4FhntW2GO36rQzdI6wH46nyJO39/0ThrNk//Q8EVVZDM+1OXaaKATinYwJ9S/+B529vnDAO+xg+pTbVw1xw0HAbr4Ybn+xZprQ2GBA+u6X3Cd6G+UJEvczpKoLqI1PONJ4BZ3otxruY0YQrk2lkMyxst2mTU22FbGmF3Db6V+lcLVegoCIGZ4kvJnpCMN6Am9zCExEKC9vrXdTN1GA5mZ</data>
</array>
<key>bag</key><data>PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBwbGlzdCBQVUJMSUMgIi0vL0FwcGxlIENvbXB1dGVyLy9EVEQgUExJU1QgMS4wLy9FTiIgImh0dHA6Ly93d3cuYXBwbGUuY29tL0RURHMvUHJvcGVydHlMaXN0LTEuMC5kdGQiPgo8cGxpc3QgdmVyc2lvbj0iMS4wIj4KPGRpY3Q+CiAgPGtleT5iYWctdGVzdC1rZXk8L2tleT48c3RyaW5nPm5vcm1hbDwvc3RyaW5nPgogIDxrZXk+dmMtYnVpbGQtdmVyc2lvbjwva2V5PjxzdHJpbmc+MjYzMkItMTE2PC9zdHJpbmc+CiAgPGtleT52Yy1idWlsZC1yZXZpc2lvbjwva2V5PjxzdHJpbmc+dW5kZWZpbmVkPC9zdHJpbmc+CiAgPGtleT52Yy1kaXNhc3Rlci1tb2RlPC9rZXk+PGZhbHNlLz4KICA8a2V5PnZjLWRpc2FzdGVyLXNlbmRlci1tYXgtcmV0cmllczwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy1kaXNhc3Rlci1yZWNlaXZlci1yZXRyeS1pbnRlcnZhbDwva2V5PjxpbnRlZ2VyPjYwMDwvaW50ZWdlcj4KICA8a2V5PnZjLWRpc2FzdGVyLXNlbmRlci1zZWxmLXRva2Vuczwva2V5PjxmYWxzZS8+CiAgPGtleT52Yy1lbmFibGUtaGV2Yzwva2V5PjxmYWxzZS8+CiAgPGtleT52Yy1lbmFibGUtaGV2Yy12Mjwva2V5Pjx0cnVlLz4KICA8a2V5PnZjLWVuYWJsZS1ldnMtYXVkaW8tY29kZWM8L2tleT48dHJ1ZS8+CiAgPGtleT52Yy1lbmFibGUtcmVkLWF1ZGlvPC9rZXk+PHRydWUvPgogIDxrZXk+YXBucy1pZHMtcXVlcnktcGVyY2VudGFnZTwva2V5PjxpbnRlZ2VyPjEwMDwvaW50ZWdlcj4KICA8a2V5PmFwbnMtaWRzLXF1ZXJ5LW1pbi12ZXJzaW9uPC9rZXk+PGludGVnZXI+MjwvaW50ZWdlcj4KICA8a2V5PmlkLXZhbGlkYXRpb24tY2VydDwva2V5PjxzdHJpbmc+aHR0cDovL3N0YXRpYy5lc3MuYXBwbGUuY29tL2lkZW50aXR5L3ZhbGlkYXRpb24vY2VydC0xLjAucGxpc3Q8L3N0cmluZz4KICA8a2V5PmlkLXZhbGlkYXRpb24tY2VydC1odHRwczwva2V5PjxzdHJpbmc+aHR0cHM6Ly9zdGF0aWMuZXNzLmFwcGxlLmNvbS9pZGVudGl0eS92YWxpZGF0aW9uL2NlcnQtMS4wLnBsaXN0PC9zdHJpbmc+CiAgPGtleT5pZC1yZWNvdmVyLXNpZ25hdHVyZTwva2V5PjxzdHJpbmc+aHR0cHM6Ly9pZGVudGl0eS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVERJZGVudGl0eVNlcnZpY2Uud29hL3dhL3JlY292ZXJTaWduYXR1cmU8L3N0cmluZz4KICA8a2V5PmlkLWluaXRpYWxpemUtdmFsaWRhdGlvbjwva2V5PjxzdHJpbmc+aHR0cHM6Ly9pZGVudGl0eS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVERJZGVudGl0eVNlcnZpY2Uud29hL3dhL2luaXRpYWxpemVWYWxpZGF0aW9uPC9zdHJpbmc+CiAgPGtleT5pZC1wcm92aXNpb24tcGhvbmUtbnVtYmVyPC9rZXk+PHN0cmluZz5odHRwczovL2lkZW50aXR5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9URElkZW50aXR5U2VydmljZS53b2Evd2EvYXV0aGVudGljYXRlUGhvbmVOdW1iZXI8L3N0cmluZz4KICA8a2V5PmlkLXByZWZsaWdodDwva2V5PjxzdHJpbmc+aHR0cHM6Ly9pZGVudGl0eS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVERJZGVudGl0eVNlcnZpY2Uud29hL3dhL3ByZWZsaWdodDwvc3RyaW5nPgogIDxrZXk+aWQtZ2V0LWVzYXQ8L2tleT48c3RyaW5nPmh0dHBzOi8vaWRlbnRpdHkuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1RESWRlbnRpdHlTZXJ2aWNlLndvYS93YS9nZXRFc2ltQXV0aFRva2VuPC9zdHJpbmc+CiAgPGtleT5pZC1wcm92aXNpb24tZHMtaWQ8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd2EvYXV0aGVudGljYXRlRFM8L3N0cmluZz4KICA8a2V5PmlkLXJlZ2lzdGVyPC9rZXk+PHN0cmluZz5odHRwczovL2lkZW50aXR5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9URElkZW50aXR5U2VydmljZS53b2Evd2EvcmVnaXN0ZXI8L3N0cmluZz4KICA8a2V5PmlkLWRlcmVnaXN0ZXI8L2tleT48c3RyaW5nPmh0dHBzOi8vaWRlbnRpdHkuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1RESWRlbnRpdHlTZXJ2aWNlLndvYS93YS9kZXJlZ2lzdGVyPC9zdHJpbmc+CiAgPGtleT5pZC1jYW5vbmljYWxpemU8L2tleT48c3RyaW5nPmh0dHBzOi8vaWRlbnRpdHkuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1RESWRlbnRpdHlTZXJ2aWNlLndvYS93YS9jYW5vbmljYWxpemU8L3N0cmluZz4KICA8a2V5PmlkLXF1ZXJ5PC9rZXk+PHN0cmluZz5odHRwczovL3F1ZXJ5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9RdWVyeVNlcnZpY2Uud29hL3dhL3F1ZXJ5PC9zdHJpbmc+CiAgPGtleT5pZC1jaGVjay11bmtub3duPC9rZXk+PHN0cmluZz5odHRwczovL3F1ZXJ5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9RdWVyeVNlcnZpY2Uud29hL3dhL2NoZWNrVW5rbm93bjwvc3RyaW5nPgogIDxrZXk+aWQtcmVwb3J0LXNwYW08L2tleT48c3RyaW5nPmh0dHBzOi8vaWRlbnRpdHkuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1RESWRlbnRpdHlTZXJ2aWNlLndvYS93YS9yZXBvcnRTcGFtPC9zdHJpbmc+CiAgPGtleT5pZC1yZXBvcnQtdW5rbm93bjwva2V5PjxzdHJpbmc+aHR0cHM6Ly9pZGVudGl0eS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVERJZGVudGl0eVNlcnZpY2Uud29hL3dhL3JlcG9ydFVua25vd248L3N0cmluZz4KICA8a2V5PmlkLXZhbGlkYXRlLWNyZWRlbnRpYWxzPC9rZXk+PHN0cmluZz5odHRwczovL2lkZW50aXR5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9URElkZW50aXR5U2VydmljZS53b2Evd2EvdmFsaWRhdGVDcmVkZW50aWFsczwvc3RyaW5nPgogIDxrZXk+aWQtcHJvdmlzaW9uLXRlbXBvcmFyeTwva2V5PjxzdHJpbmc+aHR0cHM6Ly9pZGVudGl0eS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVERJZGVudGl0eVNlcnZpY2Uud29hL3dhL3Byb3Zpc2lvbkhvbWVOdW1iZXI8L3N0cmluZz4KICA8a2V5PmlkLXByb3Zpc2lvbi1hbGlhczwva2V5PjxzdHJpbmc+aHR0cHM6Ly9pZGVudGl0eS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVERJZGVudGl0eVNlcnZpY2Uud29hL3dhL3Byb3Zpc2lvbkFsaWFzPC9zdHJpbmc+CiAgPGtleT5pZC1wcm92aXNpb24tZGV2aWNlLWlkZW50aXR5PC9rZXk+PHN0cmluZz5odHRwczovL2lkZW50aXR5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9URElkZW50aXR5U2VydmljZS53b2Evd2EvcHJvdmlzaW9uRGV2aWNlSWRlbnRpdHk8L3N0cmluZz4KICA8a2V5PmlkLXJlcG9ydC13ZWItYXBwcm92YWwtc3RhdHVzPC9rZXk+PHN0cmluZz5odHRwczovL2lkZW50aXR5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9URElkZW50aXR5U2VydmljZS53b2Evd2EvcmVwb3J0V2ViQXBwcm92YWxTdGF0dXM8L3N0cmluZz4KICA8a2V5PmlkLWt0LW9wdC1pbi1vdXQ8L2tleT48c3RyaW5nPmh0dHBzOi8vaWRlbnRpdHkuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1RESWRlbnRpdHlTZXJ2aWNlLndvYS93YS9rdE9wdEluT3V0PC9zdHJpbmc+CiAgPGtleT5pZC1nZXQtdXNlci1mbGFnPC9rZXk+PHN0cmluZz5odHRwczovL2lkZW50aXR5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9URElkZW50aXR5U2VydmljZS53b2Evd2EvZ2V0VXNlckZsYWc8L3N0cmluZz4KICA8a2V5PmlkLXNldC11c2VyLWZsYWc8L2tleT48c3RyaW5nPmh0dHBzOi8vaWRlbnRpdHkuZLine truncated
</dict>
</plist>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- certificate-production.cer md5=f6e148c97bfbc9867e19c2eeadbab534 -->
<key>cert</key>
<data>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</data>
</dict>
</plist>