mirror of
https://github.com/fenleon/open-madrid.git
synced 2026-10-22 16:12:35 +00:00
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:
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("&")
|
||||
'<' -> append("<")
|
||||
'>' -> append(">")
|
||||
'"' -> append(""")
|
||||
'\'' -> append("'")
|
||||
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>
|
||||
Reference in new issue
Block a user