commit bc00fa2c3d3a24288355626791cb31dada140c7a Author: fenleon Date: Tue Sep 29 00:04:00 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e19836e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +.gradle/ +.kotlin/ +local.properties diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8bff93e --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# Open Madrid + +**An open implementation of Apple's madrid protocol — better known as iMessage.** + +"Madrid" is Apple's internal codename for iMessage. This repository is a clean-room, plain-old- +open-source implementation of the protocol in Kotlin: the binary and XML property-list codecs, +the APNs "courier" push client, the IDS identity service, the encryption envelope, and the full +registration/activation chain. It is not an app; it is the engine an app can be built on. + +The name follows the [open-absinthe](https://github.com/JJTech0130/open-absinthe) lineage — +"open-" plus Apple's internal codename. Unlike open-absinthe, whose public repository contains a +mock placeholder and no actual functionality, **everything here is real, readable, and licensed**: +the validation and registration layers that other projects keep closed or ship as binary blobs +are implemented in the open, from protocol facts. + +## Why another project? + +Almost every existing route to iMessage is closed in some way: + +| Project | What it is | The catch | +|---|---|---| +| rustpush | The maintained engine (Rust) | SSPL license — reading its code can contaminate yours | +| pypush | The original Python PoC | SSPL, and activation stopped working after Apple's Dec 2023 fix | +| imessagego | Go implementation | AGPL (the only ordinary open-source license in the space) | +| BlueBubbles | A working client | Needs a Mac running 24/7 to relay and decrypt | +| open-absinthe | The validation-data layer | Public repository, closed source — a mock, no functionality | +| OpenBubbles hosted | A hosted service | Someone else's server sees your messages | + +**Open Madrid's aim:** a permissively licensed (Apache-2.0), dependency-light, fully readable +implementation of the protocol — the first one where the registration and validation layers are +open too. Facts about the protocol live in a spec ([docs/imessage-protocol-spec.md](docs/imessage-protocol-spec.md)); +every fact cites its source. Code is written from the spec, never from the other projects. + +## What is validated + +- **The parsers handle real Apple bytes.** The actual 405-entry IDS bag and 65-entry APNs bag, + captured from a registered device, are committed as test fixtures and decoded with exact-value + assertions — 344 offline tests, all green. +- **Two live, unauthenticated probes against Apple succeeded:** both bag endpoints return valid + plists this stack parses, and the certificate pinning validates Apple's real TLS chains. A + recurring drift check ships with the repo (`:engine:runProbe`). +- **The courier's auth model is settled:** the TLS handshake succeeds, then the server demands + the Albert client certificate (a TLS 1.3 post-handshake `certificate_required` alert) — the + client-certificate wiring point is confirmed correct. +- **The registration chain runs end-to-end against scripted Apple responses** (a full dry-run + test). +- **The protocol around it is proven live** by this project's private sibling implementation: + a working device-identity chain (Apple's validation endpoint, key establishment, the 645-byte + validation blob), SRP login with Apple's GSA endpoints, and the decrypted response parsing — + on real accounts. Those findings are folded into the spec here. + +## What is not validated (yet) + +- **Nothing has registered a real device or sent a real message.** First live activation is the + next milestone. +- The crypto envelopes have round-trip tests but no known-answer vectors captured from a live + device. +- A few wire details remain capture-bound guesses, tracked in the spec's open-questions checklist. +- **PQ3** (Apple's newer post-quantum encryption) is unsupported — the same older fallback every + third-party iMessage client uses today. + +## Keeping the license clean (read this before contributing) + +This is a **clean-room**: contributors never read the source of rustpush, pypush, imessagego, or +their forks. Two roles, kept strictly apart: + +1. **Readers** study the existing projects. Their only output is the **spec** — plain-language + protocol notes with cited sources. Facts can't be copyrighted; code can. +2. **Writers** work only from the spec, Apple's own documentation, and recordings of their own + device's traffic. Don't open the other projects' source, don't read their diffs or issues when + debugging, and don't paste their identifiers into commits or issues. + +Why: it keeps this code fully ours — free of viral licenses, acceptable to F-Droid and other +distribution channels, and safe to embed anywhere. + +## The Apple-material rule + +No Apple-derived code or binary ships in this repository — not the IMDAppleServices framework, +not Apple's FairPlay identity. The bundled identity under +`registration/src/main/resources/imessage/fairplay/` is a **synthetic placeholder** (generated, +self-signed, for tests only). Live activation requires Apple's real FairPlay identity and +registration framework, which each user stages themselves — exactly like the Mac identity the +protocol requires anyway. + +## Building + +Standalone — no sibling checkouts required: + +```bash +./gradlew build # all modules + the full test suite +./gradlew :engine:runProbe # the live drift check (unauthenticated bag/cert probes) +``` + +## License + +[Apache-2.0](LICENSE). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4a993c8 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.12.3" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} diff --git a/codec/build.gradle.kts b/codec/build.gradle.kts new file mode 100644 index 0000000..8649397 --- /dev/null +++ b/codec/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + kotlin("jvm") // version inherited from the root classpath (kotlin-gradle-plugin 2.3.20) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + testImplementation(kotlin("test")) +} diff --git a/codec/src/main/kotlin/dev/fenn/imessage/codec/Bplist.kt b/codec/src/main/kotlin/dev/fenn/imessage/codec/Bplist.kt new file mode 100644 index 0000000..d54ca20 --- /dev/null +++ b/codec/src/main/kotlin/dev/fenn/imessage/codec/Bplist.kt @@ -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 + +/** Keys may be any object per spec; protocol plists use string keys. */ +class BpDict(val entries: Map) : 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() + collect(root, objects) + val index = java.util.IdentityHashMap(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) { + 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, 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() + + 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(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(n) + repeat(n) { + keys.add(parseRef(p)) + p += refWidth + } + val values = ArrayList(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 { + 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 + } +} diff --git a/codec/src/main/kotlin/dev/fenn/imessage/codec/NativePlist.kt b/codec/src/main/kotlin/dev/fenn/imessage/codec/NativePlist.kt new file mode 100644 index 0000000..ac3e2fb --- /dev/null +++ b/codec/src/main/kotlin/dev/fenn/imessage/codec/NativePlist.kt @@ -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): 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(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(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? = + (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 +} diff --git a/codec/src/main/kotlin/dev/fenn/imessage/codec/Plist.kt b/codec/src/main/kotlin/dev/fenn/imessage/codec/Plist.kt new file mode 100644 index 0000000..bfa7bf5 --- /dev/null +++ b/codec/src/main/kotlin/dev/fenn/imessage/codec/Plist.kt @@ -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 `` 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 `` 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 `= 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(", `array` → List, `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 ") + val values = elementChildren(root) + if (values.size != 1) { + throw PlistFormatException(" 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(" is not an integer: '${e.textContent.trim()}'") + "real" -> e.textContent.trim().toDoubleOrNull() + ?: throw PlistFormatException(" is not a number: '${e.textContent.trim()}'") + "true" -> true + "false" -> false + "data" -> data(e) + "date" -> date(e.textContent) + "key" -> throw PlistFormatException(" outside a ") + else -> throw PlistFormatException("unknown plist element <${e.tagName}>") + } + + private fun dict(e: Element): Map { + val children = elementChildren(e) + if (children.size % 2 != 0) { + throw PlistFormatException(" holds ${children.size} elements — key/value pairs expected") + } + val out = LinkedHashMap(children.size / 2) + var i = 0 + while (i < children.size) { + val key = children[i] + if (key.tagName != "key") { + throw PlistFormatException("<${key.tagName}> where expected in ") + } + out[key.textContent] = value(children[i + 1]) + i += 2 + } + return out + } + + /** `` 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(" is not valid base64: ${e.message}") + } + } + + /** Apple writes `` 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(" is not an ISO-8601 instant: '$trimmed'") + } + } + } + return PlistDate(instant.toEpochMilli()) + } + + /** + * The Albert activation response (§1.2): an XML document whose outer `` 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 ") + val plists = elementChildren(root) + if (plists.size != 1 || plists[0].tagName != "plist") { + throw PlistFormatException(" must hold exactly one ") + } + val values = elementChildren(plists[0]) + if (values.size != 1) { + throw PlistFormatException(" 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( + "\n" + + "\n" + + "\n", + ) + write(value, out, 1) + out.append("\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("\n") + for ((key, v) in value) { + out.append(pad).append(" ").append(escape(key as? String + ?: throw PlistFormatException("dict key is not a string: $key"))) + .append("\n") + write(v, out, depth + 1) + } + out.append(pad).append("\n") + } + is List<*> -> { + out.append(pad).append("\n") + for (v in value) write(v, out, depth + 1) + out.append(pad).append("\n") + } + is String -> out.append(pad).append("").append(escape(value)).append("\n") + is Long -> out.append(pad).append("").append(value).append("\n") + is Int -> out.append(pad).append("").append(value).append("\n") + is Double -> out.append(pad).append("").append(value).append("\n") + is Boolean -> out.append(pad).append(if (value) "\n" else "\n") + is ByteArray -> out.append(pad).append("") + .append(Base64.getEncoder().encodeToString(value)).append("\n") + is PlistDate -> out.append(pad).append("").append(value.toIso8601()).append("\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 { + val children = e.childNodes + val out = ArrayList() + for (i in 0 until children.length) { + val node = children.item(i) + if (node is Element) out.add(node) + } + return out + } +} diff --git a/codec/src/test/kotlin/dev/fenn/imessage/codec/BplistTest.kt b/codec/src/test/kotlin/dev/fenn/imessage/codec/BplistTest.kt new file mode 100644 index 0000000..f3760f2 --- /dev/null +++ b/codec/src/test/kotlin/dev/fenn/imessage/codec/BplistTest.kt @@ -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 { Bplist.decode(bad) } + } + + @Test + fun rejectsTruncatedTrailer() { + val full = Bplist.encode(BpBool(true)) + for (cut in intArrayOf(0, 10, full.size - 1)) { + assertFailsWith { 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 { Bplist.decode(bytes) } + } + + @Test + fun rejectsTopIndexOutOfRange() { + val bytes = magic + + byteArrayOf(0x09) + + byteArrayOf(0x08) + + trailer(1, 1, 1, 7, 9) + assertFailsWith { Bplist.decode(bytes) } + } + + @Test + fun rejectsUnknownMarker() { + val bytes = magic + + byteArrayOf(0x70) + + byteArrayOf(0x08) + + trailer(1, 1, 1, 0, 9) + assertFailsWith { 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 { Bplist.decode(bytes) } + } + + private fun ByteArray.toHex() = joinToString(" ") { "%02X".format(it) } +} diff --git a/codec/src/test/kotlin/dev/fenn/imessage/codec/CaptureKnownAnswerTest.kt b/codec/src/test/kotlin/dev/fenn/imessage/codec/CaptureKnownAnswerTest.kt new file mode 100644 index 0000000..f5a24a1 --- /dev/null +++ b/codec/src/test/kotlin/dev/fenn/imessage/codec/CaptureKnownAnswerTest.kt @@ -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(outer["signature"]) + assertTrue((outer["signature"] as ByteArray).size >= 200, "an RSA signature blob") + val certs = assertIs>(outer["certs"]) + assertTrue(certs.isNotEmpty() && certs.all { it is ByteArray }) + assertIs(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("appleid@id.apple.com", "appleid@apple.com"), 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(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 { 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) + } +} diff --git a/codec/src/test/kotlin/dev/fenn/imessage/codec/PlistFormatTest.kt b/codec/src/test/kotlin/dev/fenn/imessage/codec/PlistFormatTest.kt new file mode 100644 index 0000000..a7c6a73 --- /dev/null +++ b/codec/src/test/kotlin/dev/fenn/imessage/codec/PlistFormatTest.kt @@ -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 = "a1" + + @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("a1".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 root (a declaration must stay first, as XML requires). + val noDeclaration = "a1" + assertEquals(mapOf("a" to 1L), Plist.parse("\n\t $noDeclaration".toByteArray())) + } + + @Test + fun garbageThrows() { + assertFailsWith { Plist.parse(ByteArray(0)) } + assertFailsWith { 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 { Bplist.decode("not a plist at all.......".toByteArray()) } + assertFailsWith { Bplist.decode(ByteArray(0)) } + } + + @Test + fun xmlBodyThatIsNotAPlistThrowsTheXmlCodecError() { + assertFailsWith { Plist.parse("".toByteArray()) } + } +} diff --git a/codec/src/test/kotlin/dev/fenn/imessage/codec/XmlPlistTest.kt b/codec/src/test/kotlin/dev/fenn/imessage/codec/XmlPlistTest.kt new file mode 100644 index 0000000..c6ff893 --- /dev/null +++ b/codec/src/test/kotlin/dev/fenn/imessage/codec/XmlPlistTest.kt @@ -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 = """ + + + + + + + name + light phone + count + 42 + ratio + 1.5 + yes + + no + + blob + AQIDBA== + wrapped + + BQYH + CA== + + when + 2023-11-14T22:13:20Z + list + + a + 7 + inner + + nested + + inner + deep + + + + """.trimIndent().toByteArray() + + @Suppress("UNCHECKED_CAST") + private fun decodeSample(): Map = XmlPlist.decode(sample) as Map + + @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("solo".toByteArray())) + assertEquals(11L, XmlPlist.decode("11".toByteArray())) + } + + @Test + fun doctypeAndEntityReferencesNeverReachTheFilesystem() { + val xml = """ + + ]> + leak&xxe; + """.trimIndent() + @Suppress("UNCHECKED_CAST") + val v = XmlPlist.decode(xml.toByteArray()) as Map + assertFalse("${v["leak"]}".contains("root:"), "external entity was resolved") + } + + @Test + fun malformedInputFails() { + val failures = listOf( + ByteArray(0), + "".toByteArray(), + "x".toByteArray(), + "ab".toByteArray(), + "a".toByteArray(), // odd child count + "ab".toByteArray(), // not a + "a".toByteArray(), + "anot a number".toByteArray(), + "anope".toByteArray(), + "a!!!".toByteArray(), + "ayesterday".toByteArray(), + "a".toByteArray(), + ) + failures.forEach { xml -> + assertFailsWith("expected a failure for: $xml") { + XmlPlist.decode(xml) + } + } + } + + @Test + fun nonZonedDateIsReadAsUtc() { + assertEquals( + PlistDate(1_700_000_000_000L), + XmlPlist.decode("2023-11-14T22:13:20".toByteArray()), + ) + } + + @Test + fun thrownTypeIsTheSharedFormatException() { + val e = assertFailsWith { XmlPlist.decode("".toByteArray()) } + assertTrue(e.message!!.contains("widget")) + } + + // --- writer --- + + @Test + fun encodeDecodesBackThroughEveryElementType() { + val value: Map = 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 = """ + + + + + a + 1 + + + """.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 { XmlPlist.encode(mapOf("a" to BpUid(1uL))) } + assertFailsWith { XmlPlist.encode(mapOf("a" to null)) } + } +} diff --git a/codec/src/test/resources/imessage/apns-bag-2026-09-10.xml b/codec/src/test/resources/imessage/apns-bag-2026-09-10.xml new file mode 100644 index 0000000..268fdad --- /dev/null +++ b/codec/src/test/resources/imessage/apns-bag-2026-09-10.xml @@ -0,0 +1,18 @@ + + + + + signature + j1Mm9aOqt2dpaAcWICf9naqod2C7/G7q3vEGehyjLJnu9SschcctiYYb13HMtHhTFMcTLJ6gWUmqMGbXoeq/wMnmTeGwkYEONKswD3xCjV3QeneI1aAaWLvF1QjXvdcTB7LZEFKOQGtnkKK9P4845zU8Mrs3J01/pqAJmqyxPBesycbEeXZBm5c+BSewFOE4faYaekrD38cc69V3JAm3IVBctILs0Bn6PIQbx0aNawWo440QpjNgfRHiuSwOd6ZZ2ovkns92Qb1LR/Yed0o33KBsZnavnCXhnAr/fOwmBnTEneSO2QxSUz6cxhekPL8ldTAZmA9tR2+iknIqelKkcQ== + + certs + + MIIHHDCCBgSgAwIBAgIQJFWOiyBZmYVgWtupSa9mJTANBgkqhkiG9w0BAQsFADBkMSswKQYDVQQDEyJBcHBsZSBQdWJsaWMgU2VydmVyIFJTQSBDQSAxMiAtIEcxMRMwEQYDVQQKEwpBcHBsZSBJbmMuMRMwEQYDVQQIEwpDYWxpZm9ybmlhMQswCQYDVQQGEwJVUzAeFw0yNjA4MDcxOTAxNDJaFw0yNjExMDMyMDEyNTZaMG8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRIwEAYDVQQHDAlDdXBlcnRpbm8xEzARBgNVBAoMCkFwcGxlIEluYy4xIjAgBgNVBAMMGWluaXQtcDAxc3QucHVzaC5hcHBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3cOcG2E2VLxzn52j4/bxrH4CfMFLkaxoVLElrF6HXgIhciqyQRlIhZbqSxvA+8HOBRXQpIIGsH5Kfk4tn6a8JBJ4PwAnyP7tryMAfPr5Jgc1F5wxv2Lzsg19r78zbP/Q/jqUpHIkzxd1KuBbvI2mvowfr57kIeyYLlMR+F0PxQVKGczvr78o8e0RZq+Sz9MdIoGiSSpj5uFUqRZ54rMTKabpLFctcn3F95n7SdDEk7wf+Hb/8OZJWSogVRHlzYqiN5+oYS7rC1D0EE6PhD5Hd0Z2AJgMOvadO5a1IF/0juB2HbDnWWPKF9wsA3crhRWPxSnLvx4wKHvDCV25NuUObAgMBAAGjggO9MIIDuTAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFB5cF5EFVwL8d1zjcEPsa/3d0thpMHgGCCsGAQUFBwEBBGwwajAxBggrBgEFBQcwAoYlaHR0cDovL2NlcnRzLmFwcGxlLmNvbS9hcHNyc2ExMmcxLmRlcjA1BggrBgEFBQcwAYYpaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHNyc2ExMmcxMDEwJAYDVR0RBB0wG4IZaW5pdC1wMDFzdC5wdXNoLmFwcGxlLmNvbTBiBgNVHSAEWzBZMEkGBmeBDAECAjA/MD0GCCsGAQUFBwIBFjFodHRwczovL3d3dy5hcHBsZS5jb20vY2VydGlmaWNhdGVhdXRob3JpdHkvcHVibGljMAwGCiqGSIb3Y2QFCwQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwNAYDVR0fBC0wKzApoCegJYYjaHR0cDovL2NybC5hcHBsZS5jb20vYXBzcnNhMTJnMS5jcmwwHQYDVR0OBBYEFMAUekdK+/bGtU8feJnruCnIrCTVMA4GA1UdDwEB/wQEAwIFoDAPBgkqhkiG92NkBlYEAgUAMIIB9wYKKwYBBAHWeQIEAgSCAecEggHjAeEAdgDIo8R/x7OtuTVrAT9qehJt4zpOQ6XGRvmXrTl1mR3PmgAAAZ/do29TAAAEAwBHMEUCIGA9ZG+ic6nHWiDNV4uCcnUq8jz/6JlBQPJh4O8uUcyRAiEA7YUVqcHiSA1SMJlv0ndlTTCtXZtzLSNw8RuiPt3dM34AdQDXbX0Q0af1d8LH6V/XAL/5gskzWmXh0LMBcxfAyMVpdwAAAZ/do260AAAEAwBGMEQCIAUxSup+yP3BeKI4WvTT7n5JvrtytaYYnHj8L3YdwfLzAiBVMDJdsVpqg78CRg39LAHMyZwV2icY69taQxPW4Ek3/QB3AK9niDtXsE7dj6bZfvYuqOuBCsdxYPAkXlXWDC/nhYc6AAABn92jcA4AAAQDAEgwRgIhALRYBCtjD6P3XGx5BiEpVDFhYnjwoG33CrnwzvTtCwo0AiEA9RIugiyZSqzqBDS2Sv04QF3uDDnRJ34oddoneYZyL8YAdwDLOPcViXyEoURfW8Hd+8lu8ppZzUcKaQWFsMsUwxRY5wAAAZ/do26qAAAEAwBIMEYCIQCcmQi5WD/Br6PmiIrLRQ2U9ekOPbxbUzlGRWFiCJ+S8AIhAPhR0CjBF02JI84AoG+0VWWEazwI4wqbmHxnZVKDTL6wMA0GCSqGSIb3DQEBCwUAA4IBAQCgiT3BkBHetGBUiR/9Zu8ibkf0QjqpUZr/3n5fD6+qJJNjKCDIfZuasrbnY0/heXJwtjRGnGj+R9QqyKnW9wcXm9/AZA2K1oy8g53UL+ShXzOUj/A7N0vdqH+UC8EKXa5SXFani3RpJuonpfX5n/YRh2fEFSCtSY8t+19UWpNjL4zmJoLQIyqpo43PzL4+kJznH9omLXYnf5YAOTIapp9Yb7nB4aKqhPxF6yvT5d+TQsAXNbH/K59UFhXE3GEKp4J1TL0xpxK3GL2ngTttvzgp7pTouJKfb/tVvIb8/0Vm/aLqreoKiveBqtXibLyGLk7V9/AJsoTQSfzbR1kyUb7V + 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= + + + bag + 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+QVBOU1dlYlB1c2hFbmRwb2ludFBhdGg8L2tleT4KCQk8c3RyaW5nPmh0dHBzOi8vd2ViLnB1c2guYXBwbGUuY29tLzwvc3RyaW5nPgoKCQk8a2V5PkFQTlNFbmFibGVTaW11bGF0b3JDb25uZWN0aW9uPC9rZXk+CgkJPHRydWUvPgoKCQk8a2V5PkFQTlNNaW5QcmVzZW5jZVNhbHRSb3RhdGlvbkludGVydmFsTWludXRlczwva2V5PgoJCTxpbnRlZ2VyPjM2MDwvaW50ZWdlcj4KCgkJPGtleT5BUE5TRmlsdGVyT3B0aW1pemF0aW9uRW5hYmxlZFYxPC9rZXk+CgkJPHRydWUvPgoKCQk8a2V5PkFQTlNFbmFibGVCQUFBdXRoZW50aWNhdGlvbkZvckRldmljZXM8L2tleT4KCQk8dHJ1ZS8+CgoJCTxrZXk+QVBOU0VuYWJsZUJBQUF1dGhlbnRpY2F0aW9uRm9yQVZQPC9rZXk+CgkJPHRydWUvPgoKCQk8a2V5PkFQTlNFbmFibGVCQUFGYWxsYmFjazwva2V5PgoJCTx0cnVlLz4KCgkJPGtleT5BUE5TQkFBUmV0cnlDb3VudDwva2V5PgoJCTxpbnRlZ2VyPjU8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0JBQUVuYWJsZVVubGltaXRlZFJldHJpZXM8L2tleT4KCQk8dHJ1ZS8+CgoJCTxrZXk+QVBOU1VzZUJBQUF1dGhlbnRpY2F0aW9uRm9yRGV2aWNlc1BlcmNlbnRhZ2U8L2tleT4KCQk8aW50ZWdlcj4xMDA8L2ludGVnZXI+CgoJCTxrZXk+QVBTUFNLQ2FjaGVTaXplPC9rZXk+CgkJPGludGVnZXI+MjwvaW50ZWdlcj4KCgkJPGtleT5BUFNBT1BTdXBwb3J0ZWQ8L2tleT4KCQk8dHJ1ZS8+CgoJCTxrZXk+QVBTUFNLRmFpbHVyZUZldGNoRGVsYXlJblNlYzwva2V5PgoJCTxpbnRlZ2VyPjcyMDA8L2ludGVnZXI+CgoJCTxrZXk+QVBOU0VuYWJsZUNvbm5lY3Rpb25Qcm9iaW5nPC9rZXk+CgkJPHRydWUvPgoKCTwvZGljdD4KPC9wbGlzdD4K + + + \ No newline at end of file diff --git a/codec/src/test/resources/imessage/ids-bag-2026-09-10.xml b/codec/src/test/resources/imessage/ids-bag-2026-09-10.xml new file mode 100644 index 0000000..6b8db83 --- /dev/null +++ b/codec/src/test/resources/imessage/ids-bag-2026-09-10.xml @@ -0,0 +1,13 @@ + + + + + signatureut4qTrXXC/KOPvNDUylU9CfCUOVoLwiQaX8CH1LI0kKTweBkDMoaMHkP++zSuigw8ieKvQJqdmxNgdMm5GvKbN7ppyKb1c4ZUX3+iyRHU36H1HY/XVQxhvHKbpGaTiRm2ECRhYk8iu4/iaRUU9/54V4N/0JHD+CNf9/wZkbAqFNbnS9NUVj2118o1pIvHVeNW+UqouIkGwEQ5b1QsY52u3s6oMONNGAxzCos+gMHRbVXAqo9/cKyJ+JErdD/6gipv+z/FG/wTXFv9t/aJ7mNP82ZJpb1YwwHRkxj1Z23dHEIJlylQbxQKSnHdrVqzwtWFVtTMo3Ugh8cdPKScxmLmw== + certs + + MIIEzTCCA7WgAwIBAgIQeIV8UxL7W+totaWqsuqowjANBgkqhkiG9w0BAQsFADBtMScwJQYDVQQDDB5BcHBsZSBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0ExIDAeBgNVBAsMF0NlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yNjA2MjMxNjU5MzdaFw0yNjEyMTcxOTM2NTRaMFQxGzAZBgNVBAMMEmluaXQuZXNzLmFwcGxlLmNvbTETMBEGA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDJmFOkwJmuTZfHmUMzHq06YcC4AqFFEXUWSH1wNQK/S9WSelcCKIpNW7Pl0YxPQ8p5jMi7GD6ARHDcHvjvnJLhSpVWJmzqY7NoZPloi+nAVNzCdXo6fENSkYHriyp3Vdlz9tTy62beEoNLGDnU9fsQk05GZt3deg6/r9tN1lyRC6Y90MroWiAncza630FDgGCVr8ZnhnO1jT7OyPGu6AGI0aIFBw2PJUZEFnIsxQSkyX7zK4UZY6AJKN3WNGM0ewNh/aZrFwfmReth6exCJ4hX0oLUhJneeUn7F4uJ/ruZpgVuhkd5nsVl5L1oBMQysIQUcst/aCHQkHkNVJ05b2QrAgMBAAGjggGAMIIBfDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFCzFbVLdMe+M7AiB7d/cykMARQHQMIGFBggrBgEFBQcBAQR5MHcwOQYIKwYBBQUHMAKGLWh0dHA6Ly9jZXJ0cy5hcHBsZS5jb20vYXBwbGVzZXJ2ZXJhdXRoY2ExLmRlcjA6BggrBgEFBQcwAYYuaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHBsZXNlcnZlcmF1dGgyMjAdBgNVHREEFjAUghJpbml0LmVzcy5hcHBsZS5jb20wEwYDVR0lBAwwCgYIKwYBBQUHAwEwPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL2NybC5hcHBsZS5jb20vYXBwbGVzZXJ2ZXJhdXRoY2ExLmNybDAdBgNVHQ4EFgQUBen3KAJgLjbMGLIn1ME84axwNeIwDgYDVR0PAQH/BAQDAgWgMBEGCyqGSIb3Y2QGGwQCBAIFADAPBgkqhkiG92NkBlYEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQCmMrrbHO6enTE78Wo7VowVXgzMFTM9OMUOw7lF9pUAI1A0BatSmPql7MaLVS49eSFWWspkvcfN3kyERlaYE1B3ixqDFAmZ9XIkt6H5oiaIVQeBf4D732J3oi4aoTmdrgk5j/1g5erGp+rUwgzjMVqqU4+sxLj4Nyr+t1sZZ1wpCHyz+q4EZw7gmBc9c3j0FRtOUCMqKAuGPUomrhCAOh8xaHpvoUTYmYBQQ0dsnFgbwU2GV6wotBUX2BFTPk8vZPSYFIF33ZeVQLELd2AQAP6SeVAPEDJ5V8FrcXxDoAvMei3yI3xJLSkH5bx9Uuu2itcoxcF2h7B/ktc+SYJkuugQ + 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 + + bagPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+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+PHN0cmluZz5odHRwczovL2lkZW50aXR5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9URElkZW50aXR5U2VydmljZS53b2Evd2EvZ2V0VXNlckZsYWc8L3N0cmluZz4KICA8a2V5PmlkLXNldC11c2VyLWZsYWc8L2tleT48c3RyaW5nPmh0dHBzOi8vaWRlbnRpdHkuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1RESWRlbnRpdHlTZXJ2aWNlLndvYS93YS9zZXRVc2VyRmxhZzwvc3RyaW5nPgogIDxrZXk+aWQtY2xpZW50LXJlcG9ydDwva2V5PjxzdHJpbmc+aHR0cHM6Ly9pZGVudGl0eS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVERJZGVudGl0eVNlcnZpY2Uud29hL3dhL2NsaWVudFJlcG9ydDwvc3RyaW5nPgogIDxrZXk+aWQtcXVlcnktYnktc2VydmljZTwva2V5PjxzdHJpbmc+aHR0cHM6Ly9xdWVyeS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvUXVlcnlTZXJ2aWNlLndvYS93YS9xdWVyeUJ5U2VydmljZTwvc3RyaW5nPgogIDxrZXk+aWQtZ2V0LXBhaXJpbmctdG9rZW48L2tleT48c3RyaW5nPmh0dHBzOi8vcXVlcnkuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1F1ZXJ5U2VydmljZS53b2Evd2EvZ2V0UGFpcmluZ1Rva2VuPC9zdHJpbmc+CiAgPGtleT5pZC1nZXQtc2VydmljZS11c2VyLWlkPC9rZXk+PHN0cmluZz5odHRwczovL3F1ZXJ5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9RdWVyeVNlcnZpY2Uud29hL3dhL2dldFNlcnZpY2VVc2VySWQ8L3N0cmluZz4KICA8a2V5PmlkLWdldC1hZG1pbi10b2tlbjwva2V5PjxzdHJpbmc+aHR0cHM6Ly9xdWVyeS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvUXVlcnlTZXJ2aWNlLndvYS93YS9nZXRBZG1pblRva2VuPC9zdHJpbmc+CiAgPGtleT5pZC1nZXQtY29uc2VudC10b2tlbjwva2V5PjxzdHJpbmc+aHR0cHM6Ly9xdWVyeS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvUXVlcnlTZXJ2aWNlLndvYS93YS9nZXRDb25zZW50VG9rZW48L3N0cmluZz4KICA8a2V5PmlkLWdldC11c2VyLXRva2VuPC9rZXk+PHN0cmluZz5odHRwczovL3F1ZXJ5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9RdWVyeVNlcnZpY2Uud29hL3dhL2dldFVzZXJUb2tlbjwvc3RyaW5nPgogIDxrZXk+aWQtZ2V0LWRlcGVuZGVudC1yZWdpc3RyYXRpb25zPC9rZXk+PHN0cmluZz5odHRwczovL2lkZW50aXR5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9URElkZW50aXR5U2VydmljZS53b2Evd2EvZ2V0RGVwZW5kZW50UmVnaXN0cmF0aW9uczwvc3RyaW5nPgogIDxrZXk+aWQtYXV0aGVudGljYXRlLW11bHRpcGxlLXVzZXJzPC9rZXk+PHN0cmluZz5odHRwczovL2lkZW50aXR5LmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9URElkZW50aXR5U2VydmljZS53b2Evd2EvYXV0aGVudGljYXRlVXNlcnM8L3N0cmluZz4KICA8a2V5PmlkLWF1dGhlbnRpY2F0ZS1waG9uZS1udW1iZXI8L2tleT48c3RyaW5nPmh0dHBzOi8vaWRlbnRpdHkuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1RESWRlbnRpdHlTZXJ2aWNlLndvYS93YS9hdXRoZW50aWNhdGVQaG9uZU51bWJlcjwvc3RyaW5nPgogIDxrZXk+aWQtYXV0aGVudGljYXRlLWRzLWlkPC9rZXk+PHN0cmluZz5odHRwczovL3Byb2ZpbGUuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDUHJvZmlsZVNlcnZpY2Uud29hL3dhL2F1dGhlbnRpY2F0ZURTPC9zdHJpbmc+CiAgPGtleT5pZC1hdXRoZW50aWNhdGUtaWNsb3VkPC9rZXk+PHN0cmluZz5odHRwczovL3Byb2ZpbGUuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDUHJvZmlsZVNlcnZpY2Uud29hL3dhL2F1dGhlbnRpY2F0ZUlDbG91ZDwvc3RyaW5nPgogIDxrZXk+aWQtZ2V0LWhhbmRsZXM8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd2EvaWRzR2V0SGFuZGxlczwvc3RyaW5nPgogIDxrZXk+aWQtc2ltLWRlYWN0aXZhdGlvbjwva2V5PjxzdHJpbmc+aHR0cHM6Ly9waG9uZWF1dGguZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1Bob25lQXV0aFNlcnZpY2Uud29hL3dhL3NpbURlYWN0aXZhdGlvbjwvc3RyaW5nPgogIDxrZXk+bWF4LXNwYW0tbWVzc2FnZS1zaXplPC9rZXk+PGludGVnZXI+MTAyNDwvaW50ZWdlcj4KICA8a2V5Pm1heC1zcGFtLW1lc3NhZ2VzLXBlci1yZXBvcnQ8L2tleT48aW50ZWdlcj4zMDwvaW50ZWdlcj4KICA8a2V5PnZjLXJlZ2lzdHJhdGlvbi1oYmk8L2tleT48aW50ZWdlcj4yNDYwNzM2PC9pbnRlZ2VyPgogIDxrZXk+Z2staW52aXRhdGlvbi1pbml0aWF0ZTwva2V5PjxzdHJpbmc+aHR0cHM6Ly9pbnZpdGF0aW9uLmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9WQ0ludml0YXRpb25TZXJ2aWNlLndvYS93YS9pbml0aWF0ZTwvc3RyaW5nPgogIDxrZXk+Z2staW52aXRhdGlvbi1yZWluaXRpYXRlPC9rZXk+PHN0cmluZz5odHRwczovL2ludml0YXRpb24uZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDSW52aXRhdGlvblNlcnZpY2Uud29hL3dhL3JlaW5pdGlhdGU8L3N0cmluZz4KICA8a2V5PmdrLWludml0YXRpb24tYWNjZXB0PC9rZXk+PHN0cmluZz5odHRwczovL2ludml0YXRpb24uZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDSW52aXRhdGlvblNlcnZpY2Uud29hL3dhL2FjY2VwdDwvc3RyaW5nPgogIDxrZXk+Z2staW52aXRhdGlvbi1yZWplY3Q8L2tleT48c3RyaW5nPmh0dHBzOi8vaW52aXRhdGlvbi5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNJbnZpdGF0aW9uU2VydmljZS53b2Evd2EvcmVqZWN0PC9zdHJpbmc+CiAgPGtleT5nay1pbnZpdGF0aW9uLWNhbmNlbDwva2V5PjxzdHJpbmc+aHR0cHM6Ly9pbnZpdGF0aW9uLmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9WQ0ludml0YXRpb25TZXJ2aWNlLndvYS93YS9jYW5jZWw8L3N0cmluZz4KICA8a2V5PmdrLWludml0YXRpb24tcmVsYXktaW5pdGlhdGU8L2tleT48c3RyaW5nPmh0dHBzOi8vaW52aXRhdGlvbi5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNJbnZpdGF0aW9uU2VydmljZS53b2Evd2EvcmVsYXlJbml0aWF0ZTwvc3RyaW5nPgogIDxrZXk+Z2staW52aXRhdGlvbi1yZWxheS11cGRhdGU8L2tleT48c3RyaW5nPmh0dHBzOi8vaW52aXRhdGlvbi5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNJbnZpdGF0aW9uU2VydmljZS53b2Evd2EvcmVsYXlVcGRhdGU8L3N0cmluZz4KICA8a2V5PmdrLWludml0YXRpb24tcmVsYXktY2FuY2VsPC9rZXk+PHN0cmluZz5odHRwczovL2ludml0YXRpb24uZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDSW52aXRhdGlvblNlcnZpY2Uud29hL3dhL3JlbGF5Q2FuY2VsPC9zdHJpbmc+CiAgPGtleT5nay1pbnZpdGF0aW9uLXNlbmQ8L2tleT48c3RyaW5nPmh0dHBzOi8vaW52aXRhdGlvbi5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNJbnZpdGF0aW9uU2VydmljZS53b2Evd2Evc2VuZDwvc3RyaW5nPgogIDxrZXk+Z2staW52aXRhdGlvbi1icmVhay1iZWZvcmUtbWFrZS10aW1lb3V0PC9rZXk+PHJlYWw+NS4wPC9yZWFsPgogIDxrZXk+dmMtcHJvZmlsZS1hdXRoZW50aWNhdGU8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd2EvYXV0aGVudGljYXRlVXNlcjwvc3RyaW5nPgogIDxrZXk+dmMtcHJvZmlsZS1nZXQtaGFuZGxlczwva2V5PjxzdHJpbmc+aHR0cHM6Ly9wcm9maWxlLmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9WQ1Byb2ZpbGVTZXJ2aWNlLndvYS93YS9nZXRIYW5kbGVzPC9zdHJpbmc+CiAgPGtleT52Yy1wcm9maWxlLWdldC1lbWFpbHM8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd2EvZ2V0RW1haWxzPC9zdHJpbmc+CiAgPGtleT52Yy1wcm9maWxlLXZhbGlkYXRlLWVtYWlsPC9rZXk+PHN0cmluZz5odHRwczovL3Byb2ZpbGUuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDUHJvZmlsZVNlcnZpY2Uud29hL3dhL3ZhbGlkYXRlSGFuZGxlPC9zdHJpbmc+CiAgPGtleT52Yy1wcm9maWxlLXZhbGlkYXRlLWhhbmRsZTwva2V5PjxzdHJpbmc+aHR0cHM6Ly9wcm9maWxlLmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9WQ1Byb2ZpbGVTZXJ2aWNlLndvYS93YS92YWxpZGF0ZUhhbmRsZTwvc3RyaW5nPgogIDxrZXk+dmMtcHJvZmlsZS1saW5rLWhhbmRsZTwva2V5PjxzdHJpbmc+aHR0cHM6Ly9wcm9maWxlLmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9WQ1Byb2ZpbGVTZXJ2aWNlLndvYS93YS9saW5rSGFuZGxlPC9zdHJpbmc+CiAgPGtleT52Yy1wcm9maWxlLXVubGluay1oYW5kbGU8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd2EvdW5saW5rSGFuZGxlPC9zdHJpbmc+CiAgPGtleT52Yy1wcm9maWxlLWNvbmZpcm0tZW1haWw8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd2EvbGlua0hhbmRsZTwvc3RyaW5nPgogIDxrZXk+dmMtcHJvZmlsZS1wcm92aXNpb248L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd2EvcHJvdmlzaW9uRW1haWxzPC9zdHJpbmc+CiAgPGtleT52Yy1wcm9maWxlLXNldHVwLXBhZ2U8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd3AvYWNjb3VudFNldHVwPC9zdHJpbmc+CiAgPGtleT52Yy1wcm9maWxlLWVkaXQtcGFnZTwva2V5PjxzdHJpbmc+aHR0cHM6Ly9wcm9maWxlLmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9WQ1Byb2ZpbGVTZXJ2aWNlLndvYS93cC9hY2NvdW50RWRpdDwvc3RyaW5nPgogIDxrZXk+dmMtcHJvZmlsZS1hdXRob3JpemU8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd3AvYWNjb3VudEVkaXQ8L3N0cmluZz4KICA8a2V5PnZjLXByb2ZpbGUtcGFzc3dvcmQtY2hhbmdlPC9rZXk+PHN0cmluZz5odHRwczovL3Byb2ZpbGUuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDUHJvZmlsZVNlcnZpY2Uud29hL3dwL3Bhc3N3b3JkQ2hhbmdlPC9zdHJpbmc+CiAgPGtleT52Yy1wcm9maWxlLWdldC1yZWdpb24tbWV0YWRhdGE8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd2EvcmVnaW9uTWV0YWRhdGE8L3N0cmluZz4KICA8a2V5PnZjLXByb2ZpbGUtZ2V0LWRlZmF1bHQtaW52aXRhdGlvbi1jb250ZXh0PC9rZXk+PHN0cmluZz5odHRwczovL3Byb2ZpbGUuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDUHJvZmlsZVNlcnZpY2Uud29hL3dhL2dldERlZmF1bHRJbnZpdGF0aW9uQ29udGV4dDwvc3RyaW5nPgogIDxrZXk+dmMtcHJvZmlsZS12YWxpZGF0ZS1pbnZpdGF0aW9uLWNvbnRleHQ8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd2EvdmFsaWRhdGVJbnZpdGF0aW9uQ29udGV4dDwvc3RyaW5nPgogIDxrZXk+bWQtcHJvZmlsZS1zZXR1cC1wYWdlPC9rZXk+PHN0cmluZz5odHRwczovL3Byb2ZpbGUuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDUHJvZmlsZVNlcnZpY2Uud29hL3dwL2FjY291bnRTZXR1cDwvc3RyaW5nPgogIDxrZXk+bWQtcHJvZmlsZS1lZGl0LXBhZ2U8L2tleT48c3RyaW5nPmh0dHBzOi8vcHJvZmlsZS5lc3MuYXBwbGUuY29tL1dlYk9iamVjdHMvVkNQcm9maWxlU2VydmljZS53b2Evd3AvYWNjb3VudEVkaXQ8L3N0cmluZz4KICA8a2V5Pm1kLXByb2ZpbGUtYXV0aG9yaXplPC9rZXk+PHN0cmluZz5odHRwczovL3Byb2ZpbGUuZXNzLmFwcGxlLmNvbS9XZWJPYmplY3RzL1ZDUHJvZmlsZVNlcnZpY2Uud29hL3dwL2FjY291bnRFZGl0PC9zdHJpbmc+CiAgPGtleT5tZC1wcm9maWxlLXBhc3N3b3JkLWNoYW5nZTwva2V5PjxzdHJpbmc+aHR0cHM6Ly9wcm9maWxlLmVzcy5hcHBsZS5jb20vV2ViT2JqZWN0cy9WQ1Byb2ZpbGVTZXJ2aWNlLndvYS93cC9wYXNzd29yZENoYW5nZTwvc3RyaW5nPgogIDxrZXk+cXItaW52aXRhdGlvbnMtZW5hYmxlZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT5xci1tYXgtcGFydGljaXBhbnRzPC9rZXk+PGludGVnZXI+MzI8L2ludGVnZXI+CiAgPGtleT5pZHMtcXVpYy1mb3ItcXItZW5hYmxlZDwva2V5Pjx0cnVlLz4KICA8a2V5PnZjLXJhdGUtY29udHJvbGxlci10eXBlPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PmRzLWlmb3Jnb3QtdXJsPC9rZXk+PHN0cmluZz5odHRwczovL2lmb3Jnb3QuYXBwbGUuY29tLzwvc3RyaW5nPgogIDxrZXk+ZHMtdmV0dGluZy1lbWFpbC1mcm9tPC9rZXk+CiAgPGFycmF5PgogICAgPHN0cmluZz5hcHBsZWlkQGlkLmFwcGxlLmNvbTwvc3RyaW5nPgogICAgPHN0cmluZz5hcHBsZWlkQGFwcGxlLmNvbTwvc3RyaW5nPgogIDwvYXJyYXk+CiAgPGtleT5nay1jZHg8L2tleT48c3RyaW5nPjE3LjE1NS41LjIzMjozNDc5PC9zdHJpbmc+CiAgPGtleT5nay1jb21tbmF0LW1haW4wPC9rZXk+PHN0cmluZz4xNy4xODguMTcxLjEzNTozNDgwPC9zdHJpbmc+CiAgPGtleT5nay1jb21tbmF0LW1haW4xPC9rZXk+PHN0cmluZz4xNy4xODguMTcxLjEzNTozNDgxPC9zdHJpbmc+CiAgPGtleT5nay1jb21tbmF0LWNvaG9ydDwva2V5PjxzdHJpbmc+MTcuMTg4LjE3MS4xNjM6MzQ4Mjwvc3RyaW5nPgogIDxrZXk+Z2stY2R4LW5hbWU8L2tleT48c3RyaW5nPmNkeC5lc3MuYXBwbGUuY29tOjM0Nzk8L3N0cmluZz4KICA8a2V5PmdrLWNvbW1uYXQtbWFpbjAtbmFtZTwva2V5PjxzdHJpbmc+Y29tbS1tYWluLmVzcy5hcHBsZS5jb206MzQ4MDwvc3RyaW5nPgogIDxrZXk+Z2stY29tbW5hdC1tYWluMS1uYW1lPC9rZXk+PHN0cmluZz5jb21tLW1haW4uZXNzLmFwcGxlLmNvbTozNDgxPC9zdHJpbmc+CiAgPGtleT5nay1jb21tbmF0LWNvaG9ydC1uYW1lPC9rZXk+PHN0cmluZz5jb21tLWNvaG9ydC5lc3MuYXBwbGUuY29tOjM0ODI8L3N0cmluZz4KICA8a2V5PmdrLXAycC10Y3AtY2hlY2stdXJsPC9rZXk+PHN0cmluZz5odHRwOi8vc3RhdGljLmVzcy5hcHBsZS5jb206ODAvY29ubmVjdGl2aXR5LnR4dDwvc3RyaW5nPgogIDxrZXk+Z2stcDJwLXNzbC1jaGVjay11cmw8L2tleT48c3RyaW5nPmh0dHBzOi8vaW52aXRhdGlvbi5lc3MuYXBwbGUuY29tOjQ0My9XZWJPYmplY3RzL1ZDSW52aXRhdGlvblNlcnZpY2Uud29hL3dhL2hlYWx0aENoZWNrPC9zdHJpbmc+CiAgPGtleT5nay1wMnAtYmxvYi1zaXplLW1heDwva2V5PjxpbnRlZ2VyPjEzMDwvaW50ZWdlcj4KICA8a2V5PmdrLXAycC1uYXQtdHlwZS10aW1lb3V0PC9rZXk+PHJlYWw+NS4wPC9yZWFsPgogIDxrZXk+Z2stcDJwLWljZS10aW1lb3V0PC9rZXk+PHJlYWw+MzUuMDwvcmVhbD4KICA8a2V5PmdrLXAycC1wcmUtcmVsYXktdGltZW91dDwva2V5PjxyZWFsPjYuMDwvcmVhbD4KICA8a2V5PmdrLXAycC1yZWxheS1iaXRyYXRlLW1heDwva2V5PjxpbnRlZ2VyPjIwMDA8L2ludGVnZXI+CiAgPGtleT5nay1wMnAtdGNwLXJlbGF5LWJpdHJhdGUtbWF4PC9rZXk+PGludGVnZXI+MjAwMDwvaW50ZWdlcj4KICA8a2V5PmdrLXAycC1yZW1vdGUtcGFja2V0cy10aW1lb3V0PC9rZXk+PHJlYWw+MzAuMDwvcmVhbD4KICA8a2V5PmdrLXAycC1uZXR3b3JrLWNvbmRpdGlvbnMtdGltZW91dDwva2V5PjxyZWFsPjYwLjA8L3JlYWw+CiAgPGtleT5nay1wMnAtcG9zdC1jb25uZWN0aXZpdHktY2hlY2s8L2tleT48dHJ1ZS8+CiAgPGtleT5nay1wMnAtc3RhcnR1cC10aW1lb3V0PC9rZXk+PHJlYWw+MzAuMDwvcmVhbD4KICA8a2V5PmdrLXAycC12aWRlby10aHJvdHRsaW5nLXRpbWVvdXQ8L2tleT48cmVhbD4zMC4wPC9yZWFsPgogIDxrZXk+Z2stcDJwLWVuYWJsZWQtM2c8L2tleT48dHJ1ZS8+CiAgPGtleT5nay1wMnAtYml0cmF0ZS1tYXgtMmc8L2tleT48aW50ZWdlcj4xMDA8L2ludGVnZXI+CiAgPGtleT5nay1wMnAtYml0cmF0ZS1tYXgtM2c8L2tleT48aW50ZWdlcj4yMjg8L2ludGVnZXI+CiAgPGtleT5nay1wMnAtYml0cmF0ZS1tYXgtbHRlPC9rZXk+PGludGVnZXI+MjI4PC9pbnRlZ2VyPgogIDxrZXk+Z2stcDJwLXZpZGVvLW5lYXItZGVncmFkZWQtdGhyZXNob2xkPC9rZXk+PHJlYWw+My4wPC9yZWFsPgogIDxrZXk+cnRjLWFjLWJpdHJhdGUtbWF4LTJnPC9rZXk+PGludGVnZXI+NDA8L2ludGVnZXI+CiAgPGtleT5ydGMtYWMtYml0cmF0ZS1tYXgtM2c8L2tleT48aW50ZWdlcj40MDwvaW50ZWdlcj4KICA8a2V5PnJ0Yy1hYy1iaXRyYXRlLW1heC1sdGU8L2tleT48aW50ZWdlcj40MDwvaW50ZWdlcj4KICA8a2V5PnJ0Yy1hYy1yZWxheS1iaXRyYXRlLW1heDwva2V5PjxpbnRlZ2VyPjQwPC9pbnRlZ2VyPgogIDxrZXk+cnRjLWFjLXRjcC1yZWxheS1iaXRyYXRlLW1heDwva2V5PjxpbnRlZ2VyPjQwPC9pbnRlZ2VyPgogIDxrZXk+cnRjLXNzLWJpdHJhdGUtbWF4LTJnPC9rZXk+PGludGVnZXI+NTEwPC9pbnRlZ2VyPgogIDxrZXk+cnRjLXNzLWJpdHJhdGUtbWF4LTNnPC9rZXk+PGludGVnZXI+NTEwPC9pbnRlZ2VyPgogIDxrZXk+cnRjLXNzLWJpdHJhdGUtbWF4LWx0ZTwva2V5PjxpbnRlZ2VyPjUxMDwvaW50ZWdlcj4KICA8a2V5PnZjLW1heC1iaXRyYXRlLTJnLXVwbGluazwva2V5PjxpbnRlZ2VyPjEwMDwvaW50ZWdlcj4KICA8a2V5PnZjLW1heC1iaXRyYXRlLTNnLXVwbGluazwva2V5PjxpbnRlZ2VyPjIyODwvaW50ZWdlcj4KICA8a2V5PnZjLW1heC1iaXRyYXRlLWx0ZS11cGxpbms8L2tleT48aW50ZWdlcj41MDI8L2ludGVnZXI+CiAgPGtleT52Yy1tYXgtYml0cmF0ZS13aWZpLXVwbGluazwva2V5PjxpbnRlZ2VyPjYzNjU8L2ludGVnZXI+CiAgPGtleT52Yy1tYXgtYml0cmF0ZS1ub25leHBlbnNpdmUtdXBsaW5rPC9rZXk+PGludGVnZXI+MjAwMDwvaW50ZWdlcj4KICA8a2V5PnZjLW1heC1iaXRyYXRlLTJnLWRvd25saW5rPC9rZXk+PGludGVnZXI+MTAwPC9pbnRlZ2VyPgogIDxrZXk+dmMtbWF4LWJpdHJhdGUtM2ctZG93bmxpbms8L2tleT48aW50ZWdlcj41MDI8L2ludGVnZXI+CiAgPGtleT52Yy1tYXgtYml0cmF0ZS1sdGUtZG93bmxpbms8L2tleT48aW50ZWdlcj4xMDc4PC9pbnRlZ2VyPgogIDxrZXk+dmMtbWF4LWJpdHJhdGUtd2lmaS1kb3dubGluazwva2V5PjxpbnRlZ2VyPjYzNjU8L2ludGVnZXI+CiAgPGtleT52Yy1tYXgtYml0cmF0ZS1ub25leHBlbnNpdmUtZG93bmxpbms8L2tleT48aW50ZWdlcj4yMDAwPC9pbnRlZ2VyPgogIDxrZXk+dmMtbm8tcmVtb3RlLWR1cGxpY2F0aW9uLXRocmVzaG9sZC1mYXN0PC9rZXk+PHJlYWw+NC4wPC9yZWFsPgogIDxrZXk+cGhvbmUtcmVnaXN0cmF0aW9uLXJldHJ5LWludGVydmFsLXNlY29uZHM8L2tleT48aW50ZWdlcj44NjQwMDwvaW50ZWdlcj4KICA8a2V5Pm1heC11cmktbXVsdGktcXVlcnk8L2tleT48aW50ZWdlcj4zNTwvaW50ZWdlcj4KICA8a2V5PmRvLWh0dHAtcGlwZWxpbmluZzwva2V5Pjx0cnVlLz4KICA8a2V5PmRvLWh0dHAta2VlcC1hbGl2ZTwva2V5Pjx0cnVlLz4KICA8a2V5Pmh0dHAta2VlcC1hbGl2ZS1pZGxlLXRpbWVvdXQtd2lmaS1taWxsaXM8L2tleT48aW50ZWdlcj4zMDAwMDwvaW50ZWdlcj4KICA8a2V5Pmh0dHAta2VlcC1hbGl2ZS1pZGxlLXRpbWVvdXQtY2VsbC1taWxsaXM8L2tleT48aW50ZWdlcj4zMDAwMDwvaW50ZWdlcj4KICA8a2V5Pm1heC1jb25jdXJyZW50LWNvbm5lY3Rpb25zPC9rZXk+PGludGVnZXI+NDwvaW50ZWdlcj4KICA8a2V5Pmlkcy1kYXRhY2hhbm5lbHMtbWF4Yml0cmF0ZS1vdmVyLXF1aWNrLXJlbGF5PC9rZXk+PGludGVnZXI+MTAwMDAwPC9pbnRlZ2VyPgogIDxrZXk+Z2RyLW1heC1hdXRoLXJldHJpZXM8L2tleT48aW50ZWdlcj4xMDwvaW50ZWdlcj4KICA8a2V5Pmdkci1hdXRoLXJldHJ5LWludGVydmFsLXNlY29uZHM8L2tleT48cmVhbD4xNS4wPC9yZWFsPgogIDxrZXk+c21zLW1heC1yZXRyaWVzPC9rZXk+PGludGVnZXI+NTwvaW50ZWdlcj4KICA8a2V5PnNtcy1tYXgtZGVsaXZlcnktcmV0cmllczwva2V5PjxpbnRlZ2VyPjU8L2ludGVnZXI+CiAgPGtleT5iYWctZXhwaXJ5LXRpbWVzdGFtcDwva2V5PjxpbnRlZ2VyPjE3ODk2NTIwMzYwMzA8L2ludGVnZXI+CiAgPGtleT52Yy1lbmFibGUtaXJhdC1yZWNvbW1lbmRhdGlvbjwva2V5Pjx0cnVlLz4KICA8a2V5Pm1hZHJpZC1oZWFsdGhjaGVjazwva2V5Pjx0cnVlLz4KICA8a2V5PnByZWZsaWdodC1lbmFibGVkPC9rZXk+PHRydWUvPgogIDxrZXk+cHJlZmxpZ2h0LW1lY2hhbmlzbS1kZWxheTwva2V5PjxpbnRlZ2VyPjM2MDwvaW50ZWdlcj4KICA8a2V5PnByZWZsaWdodC1yZXF1ZXN0LWxpbWl0PC9rZXk+PGludGVnZXI+NTwvaW50ZWdlcj4KICA8a2V5PnZjLWFkYXB0aXZlLWxlYXJuaW5nLWVuYWJsZWQ8L2tleT48dHJ1ZS8+CiAgPGtleT52Yy1zaG9ydC10ZXJtLWhpc3RvcnktbGVuZ3RoPC9rZXk+PGludGVnZXI+NTwvaW50ZWdlcj4KICA8a2V5PnZjLWxvbmctdGVybS1oaXN0b3J5LWxlbmd0aDwva2V5PjxpbnRlZ2VyPjMwPC9pbnRlZ2VyPgogIDxrZXk+dmMtYWRhcHRpdmUtbGVhcm5pbmctQTwva2V5PjxyZWFsPjAuNDwvcmVhbD4KICA8a2V5PnZjLWFkanVzdG1lbnQtZmFjdG9yLUE8L2tleT48cmVhbD4wLjI1PC9yZWFsPgogIDxrZXk+dmMtc2hvcnQtdGVybS12YWx1ZS13ZWlnaHQtQTwva2V5PjxyZWFsPjAuNDwvcmVhbD4KICA8a2V5PnZjLWxvbmctdGVybS12YWx1ZS13ZWlnaHQtQTwva2V5PjxyZWFsPjAuMTwvcmVhbD4KICA8a2V5PnZjLWFkYXB0aXZlLWxlYXJuaW5nLUI8L2tleT48cmVhbD4wLjQ8L3JlYWw+CiAgPGtleT52Yy1hZGp1c3RtZW50LWZhY3Rvci1CPC9rZXk+PHJlYWw+MC4yNTwvcmVhbD4KICA8a2V5PnZjLXNob3J0LXRlcm0tdmFsdWUtd2VpZ2h0LUI8L2tleT48cmVhbD4wLjU8L3JlYWw+CiAgPGtleT52Yy1sb25nLXRlcm0tdmFsdWUtd2VpZ2h0LUI8L2tleT48cmVhbD4wLjI8L3JlYWw+CiAgPGtleT52Yy1hZGp1c3RtZW50LWZhY3Rvci1DPC9rZXk+PHJlYWw+MC41PC9yZWFsPgogIDxrZXk+dmMtc2hvcnQtdGVybS12YWx1ZS13ZWlnaHQtQzwva2V5PjxyZWFsPjAuNDwvcmVhbD4KICA8a2V5PnZjLWxvbmctdGVybS12YWx1ZS13ZWlnaHQtQzwva2V5PjxyZWFsPjAuMTwvcmVhbD4KICA8a2V5PnZjLXUxLWF1dGgtdGFnLXNhbXBsZS1yYXRlPC9rZXk+PGludGVnZXI+MTAwPC9pbnRlZ2VyPgogIDxrZXk+dmMtZ2Z0LXRsZS1zYW1wbGUtcmF0ZTwva2V5PjxpbnRlZ2VyPjEwMDwvaW50ZWdlcj4KICA8a2V5Pm0tcmVwbHktZW5hYmxlZDwva2V5PjxmYWxzZS8+CiAgPGtleT52Yy1yZWQtc3RhdGUtdmFsdWUtQTwva2V5PjxpbnRlZ2VyPjI5PC9pbnRlZ2VyPgogIDxrZXk+ZHMtc2Vzc2lvbi10b2tlbi1ncmFjZS1wZXJpb2Q8L2tleT48aW50ZWdlcj42PC9pbnRlZ2VyPgogIDxrZXk+Y2stcmVtb3ZlLWZyb20tYmFja3VwLXZlcnNpb248L2tleT48aW50ZWdlcj4wPC9pbnRlZ2VyPgogIDxrZXk+YWJzaW50aGUtdjMtZW5hYmxlZDwva2V5Pjx0cnVlLz4KICA8a2V5PmFic2ludGhlLWJhYS1vcHRpb24tZW5hYmxlZDwva2V5Pjx0cnVlLz4KICA8a2V5PmFic2ludGhlLWJhYS1vcHRpb24tZW5hYmxlZC1pb3MxMzwva2V5Pjx0cnVlLz4KICA8a2V5PmFic2ludGhlLWJhYS1jZXJ0LXR0bC1taW51dGVzPC9rZXk+PGludGVnZXI+MTQ0MDAwPC9pbnRlZ2VyPgogIDxrZXk+dmMtdmlld3BvaW50LWNvcnJlY3Rpb24tZW5hYmxlZDwva2V5Pjx0cnVlLz4KICA8a2V5Pmlkcy1ydGVuY3J5cHRpb24tbWttLWV4cGlyZS1kdXJhdGlvbjwva2V5PjxpbnRlZ2VyPjEyMDA8L2ludGVnZXI+CiAgPGtleT5pZHMtcnRlbmNyeXB0aW9uLXByZWtleS1leHBpcmUtZHVyYXRpb248L2tleT48aW50ZWdlcj4zNjAwPC9pbnRlZ2VyPgogIDxrZXk+dmMtZGlzYWJsZS1mdC1tdzwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT5pZHMtbXVsdGl3YXktZnRtZXNzYWdlLXNlbmQtdGltZW91dDwva2V5PjxpbnRlZ2VyPjYwNTwvaW50ZWdlcj4KICA8a2V5PnZjLWRlY3J5cHRpb24tdGltZW91dC1pbnRlcnZhbDwva2V5PjxpbnRlZ2VyPjMwPC9pbnRlZ2VyPgogIDxrZXk+dmMtbmV3LXNlc3Npb24tam9pbmluZy1pbnRlcnZhbDwva2V5PjxpbnRlZ2VyPjEyMDwvaW50ZWdlcj4KICA8a2V5Pmt0LWRpc2FibGVkPC9rZXk+PHRydWUvPgogIDxrZXk+a3QtZGlzYWJsZWQtdjI8L2tleT48ZmFsc2UvPgogIDxrZXk+a3QtbWluLWlkdi1pbTwva2V5PjxpbnRlZ2VyPjE3MjA8L2ludGVnZXI+CiAgPGtleT5rdC1hY2NvdW50LWtleS1zaWduaW5nLXRpbWVvdXQ8L2tleT48aW50ZWdlcj4zMDwvaW50ZWdlcj4KICA8a2V5Pmt0LWdvc3NpcC1maXJzdC1nb3NzaXAtY2hhbmNlPC9rZXk+PGludGVnZXI+MjA8L2ludGVnZXI+CiAgPGtleT5rdC1nb3NzaXAtc3Vic2VxdWVudC1nb3NzaXAtY2hhbmNlPC9rZXk+PGludGVnZXI+MjwvaW50ZWdlcj4KICA8a2V5Pmt0LWdvc3NpcC1tYXgtcmVjaXBpZW50LW1lbW9yeTwva2V5PjxpbnRlZ2VyPjEwMDwvaW50ZWdlcj4KICA8a2V5Pmt0LXJlZ2lzdGVyLXVwZGF0ZS1saW1pdDwva2V5PjxpbnRlZ2VyPjU8L2ludGVnZXI+CiAgPGtleT5rdC1yZWdpc3Rlci11cGRhdGUtbGltaXQtdGltZTwva2V5PjxpbnRlZ2VyPjM2MDA8L2ludGVnZXI+CiAgPGtleT5lbmFibGUtcHNldWRvbnltLWFsaWFzZXM8L2tleT48dHJ1ZS8+CiAgPGtleT5kZWJ1Zy1jb250ZXh0LXZlcnNpb248L2tleT48aW50ZWdlcj4xPC9pbnRlZ2VyPgogIDxrZXk+cXItbWVtYmVyLWJsb2NrLXRpbWVvdXQ8L2tleT48aW50ZWdlcj4zMDwvaW50ZWdlcj4KICA8a2V5PmVuYWJsZS1yZXBvcnQtd2ViLWFwcHJvdmFsLXN0YXR1czwva2V5Pjx0cnVlLz4KICA8a2V5PnFyLXNlc3Npb24tZnQtdXBncmFkZS1lbmFibGVkPC9rZXk+PHRydWUvPgogIDxrZXk+Z2Z0LWxlZ2FjeS11cGdyYWRlLWVuYWJsZWQ8L2tleT48dHJ1ZS8+CiAgPGtleT5pbmFjdGl2ZS1saW5rLWNhY2hlLWxpbWl0PC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PmluYWN0aXZlLWxpbmstcHNldWRvbnltLWV4cGlyeTwva2V5PjxpbnRlZ2VyPjI1OTIwMDA8L2ludGVnZXI+CiAgPGtleT52Yy1wcmVmZXItcmVsYXktb3Zlci1wMnAtdGhyZXNob2xkPC9rZXk+PHJlYWw+MC4wPC9yZWFsPgogIDxrZXk+dmMtYWx3YXlzLW9uLWF1ZGlvLXJlZHVuZGFuY3ktZW5hYmxlLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy1oaWdoLWZlYy1lbmFibGUtdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MDwvaW50ZWdlcj4KICA8a2V5PnZjLWxvdy1mcHMtdmlkZW8tZW5hYmxlLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy12cGxyLWZlYy1lbmFibGUtdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PnZjLXJhbXAtZG93bi1idy1kcm9wLWVuYWJsZS10aHJlc2hvbGQ8L2tleT48aW50ZWdlcj4xPC9pbnRlZ2VyPgogIDxrZXk+dmMtZmFzdC1tZWRpYS1kdXBsaWNhdGlvbi1lbmFibGUtdGhyZXNob2xkPC9rZXk+PHJlYWw+MS4wPC9yZWFsPgogIDxrZXk+dmMtaVJBVC1ydHAtZW5hYmxlLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy1wcmUtd2FybS1jZWxsLWVuYWJsZS10aHJlc2hvbGQ8L2tleT48aW50ZWdlcj4xPC9pbnRlZ2VyPgogIDxrZXk+dmMtZHVwbGljYXRlLWltcG9ydGFudC1wa3RzLWVuYWJsZS10aHJlc2hvbGQ8L2tleT48aW50ZWdlcj4xPC9pbnRlZ2VyPgogIDxrZXk+dmMtYWxsb3ctYnVyc3R5LWxvc3MtcmFtcC1kb3duLWVuYWJsZS10aHJlc2hvbGQ8L2tleT48cmVhbD4xLjA8L3JlYWw+CiAgPGtleT52Yy1oZXZjLXdpZmktdGllcnMtZW5hYmxlLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy12aWV3cG9pbnQtY29ycmVjdGlvbi1lbmFibGUtdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PnZjLWhlaWYtaGV2Yy1saXZlLXBob3Rvcy1lbmFibGUtdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PnZjLXdpZmktbG93LXRpZXItcmVkdW5kYW5jeS1lbmFibGUtdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PnZjLWNlbGx1bGFyLWxvdy10aWVyLXJlZHVuZGFuY3ktZW5hYmxlLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy1pUkFULW1ldHJpY3MtZW5hYmxlLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy1mZWMtaGVhZGVyLXYxLWVuYWJsZS10aHJlc2hvbGQ8L2tleT48aW50ZWdlcj4xPC9pbnRlZ2VyPgogIDxrZXk+dmMtdXBkYXRlZC1BRlJDLWhlYWRlci1lbmFibGUtdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PnZjLXNlY3VyZS1tZXNzYWdpbmctZW5hYmxlLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy1kaXNhYmxlLVNJUC1lbmFibGUtdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PnZjLXdpZmktYXNzaXN0LWR1cGxpY2F0aW9uLWVuYWJsZS10aHJlc2hvbGQ8L2tleT48aW50ZWdlcj4xPC9pbnRlZ2VyPgogIDxrZXk+dmMtd2lmaS1hc3Npc3QtYnVkZ2V0LXN0YXR1cy1lbmFibGUtdGhyZXNob2xkPC9rZXk+PHJlYWw+MS4wPC9yZWFsPgogIDxrZXk+dmMtd2lmaS1hc3Npc3Qtc3RhdHVzLWJhci1lbmFibGUtdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PnZjLWxvdy1uZXR3b3JrLW1vZGUtZW5hYmxlLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy1yZW1vdGUtbWVkaWEtc3RhbGwtdGltZW91dDwva2V5PjxyZWFsPjE1LjA8L3JlYWw+CiAgPGtleT52Yy1pUkFULWR1cGxpY2F0aW9uLWVuYWJsZS10aHJlc2hvbGQ8L2tleT48cmVhbD4xLjA8L3JlYWw+CiAgPGtleT52Yy1hYi10ZXN0LXZpZGVvLWVuaGFuY2VtZW50LWZlYXR1cmUtY29tYmluYXRpb25zPC9rZXk+PHN0cmluZz4xNTwvc3RyaW5nPgogIDxrZXk+dmMtYWItdGVzdC1tZWRpYS1kdXBsaWNhdGlvbi1lbmhhbmNlbWVudC1mZWF0dXJlLWNvbWJpbmF0aW9uczwva2V5PjxzdHJpbmc+MTU8L3N0cmluZz4KICA8a2V5PnZjLWFiLXRlc3QtbWFzdGVyLWxvY2FsLXN3aXRjaGVzPC9rZXk+PGludGVnZXI+MjAxMzI2NTg2PC9pbnRlZ2VyPgogIDxrZXk+dmMtdmlkZW8tamItZW5hYmxlLXRocmVzaG9sZDwva2V5PjxyZWFsPjAuMDwvcmVhbD4KICA8a2V5PnZjLWFiLXRlc3QtYXVkaW8tZW5oYW5jZW1lbnQtZmVhdHVyZS1jb21iaW5hdGlvbnM8L2tleT48c3RyaW5nPjM8L3N0cmluZz4KICA8a2V5PnZjLWFiLXRlc3QtbmV0d29yay1lbmhhbmNlbWVudC1mZWF0dXJlLWNvbWJpbmF0aW9uczwva2V5PjxzdHJpbmc+MDwvc3RyaW5nPgogIDxrZXk+dmMtYWItdGVzdC1yYXRlLWNvbnRyb2wtZW5oYW5jZW1lbnQtZmVhdHVyZS1jb21iaW5hdGlvbnM8L2tleT48c3RyaW5nPjE8L3N0cmluZz4KICA8a2V5PnZjLWFiLXRlc3Rpbmctc3RhdGU8L2tleT48aW50ZWdlcj4wPC9pbnRlZ2VyPgogIDxrZXk+dmMtZHVwbGljYXRpb24tZW5oYW5jZW1lbnQtZW5hYmxlLXRocmVzaG9sZDwva2V5PjxyZWFsPjEuMDwvcmVhbD4KICA8a2V5PnJhcHBvcnQtZnJpZW5kLWtleXMtbWF4PC9rZXk+PGludGVnZXI+MzI8L2ludGVnZXI+CiAgPGtleT5yYXBwb3J0LWZyaWVuZC1rZXlzLWluaXRpYWwtZGVsYXk8L2tleT48aW50ZWdlcj42MDQ4MDA8L2ludGVnZXI+CiAgPGtleT5hcHBseS1pbnRlcm5hdGlvbmFsLXNwYW0tZmlsdGVyPC9rZXk+PHRydWUvPgogIDxrZXk+Y2FsbGhpc3RvcnktY2xvdWRraXQtZW5hYmxlZDwva2V5Pjx0cnVlLz4KICA8a2V5PnZjLWxpbmstcHJvYmluZy1jYXBhYmlsaXR5LXZlcnNpb248L2tleT48aW50ZWdlcj4yPC9pbnRlZ2VyPgogIDxrZXk+dmMtbGluay1wcm9iaW5nLWZlYXR1cmUtdGhyZXNob2xkPC9rZXk+PHJlYWw+MS4wPC9yZWFsPgogIDxrZXk+dmMtbGluay1wcm9iaW5nLWludGVydmFsPC9rZXk+PGludGVnZXI+MTAwPC9pbnRlZ2VyPgogIDxrZXk+dmMtbGluay1wcm9iaW5nLXRpbWVvdXQ8L2tleT48aW50ZWdlcj4yMDAwPC9pbnRlZ2VyPgogIDxrZXk+dmMtbGluay1wcm9iaW5nLXF1ZXJ5LXJlc3VsdHMtaW50ZXJ2YWw8L2tleT48aW50ZWdlcj4yPC9pbnRlZ2VyPgogIDxrZXk+dmMtbGluay1wcm9iaW5nLWV4cG9uZW50aWFsLW1vdmluZy1tZWFuLWZhY3Rvcjwva2V5PjxyZWFsPjAuNzwvcmVhbD4KICA8a2V5PnZjLWxpbmstcHJvYmluZy1lbnZlbG9wZS1hdHRhY2stZmFjdG9yPC9rZXk+PHJlYWw+MC43PC9yZWFsPgogIDxrZXk+dmMtbGluay1wcm9iaW5nLWVudmVsb3BlLWRlY2F5LWZhY3Rvcjwva2V5PjxyZWFsPjAuNTwvcmVhbD4KICA8a2V5PnZjLWxpbmstcHJvYmluZy1jb25uZWN0aW9uLWxvY2tkb3duLXBlcmlvZDwva2V5PjxyZWFsPjMuMDwvcmVhbD4KICA8a2V5PnZjLWxpbmstcHJvYmluZy1wbHItYnVja2V0czwva2V5PjxzdHJpbmc+Mi41LDEwLDIwLDMwLDEwMDwvc3RyaW5nPgogIDxrZXk+dmMtZmFjZXRpbWUtaml0dGVyLWJ1ZmZlci1tb2RlPC9rZXk+PGludGVnZXI+MDwvaW50ZWdlcj4KICA8a2V5PnZjLWZhY2V0aW1lLWpiLWhpZ2gtc3RhcnQ8L2tleT48aW50ZWdlcj4wPC9pbnRlZ2VyPgogIDxrZXk+c3BzLXByb3Zpc2lvbjwva2V5PjxzdHJpbmc+aHR0cHM6Ly9wcm92aXNpb24uZXNzLmFwcGxlLmNvbS9wcm92aXNpb248L3N0cmluZz4KICA8a2V5PnNwcy1wcm92LWRlYm91bmNlLXRpbWU8L2tleT48aW50ZWdlcj4yODgwMDwvaW50ZWdlcj4KICA8a2V5PnNwcy1wcm92LWRlYm91bmNlLXRpbWUtdjI8L2tleT48aW50ZWdlcj4xMjA8L2ludGVnZXI+CiAgPGtleT5jaGFubmVsLXJvbGwta2V5cy1rZXk8L2tleT48c3RyaW5nPlNsVk1NalpBZFVkNlJTMWhjMFpVVmc9PTwvc3RyaW5nPgogIDxrZXk+ZW4tcHVzaC1hbGxvdy1kb21haW5zPC9rZXk+CiAgPGFycmF5PgogICAgPHN0cmluZz5lbi5leHByZXNzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVuZXhwcmVzcy5hcHA8L3N0cmluZz4KICA8L2FycmF5PgogIDxrZXk+ZW4tcHVzaC1kaXNhYmxlZDwva2V5PjxmYWxzZS8+CiAgPGtleT5yYXBwb3J0LWZyaWVuZC1rZXlzLW1heC12Mjwva2V5PjxpbnRlZ2VyPjY0PC9pbnRlZ2VyPgogIDxrZXk+dmMtY29yZS1tb3Rpb24tZGV0ZWN0aW9uLWVuYWJsZWQ8L2tleT48aW50ZWdlcj4xPC9pbnRlZ2VyPgogIDxrZXk+dmMtdmNyYy1mYWNlVGltZS1leHBlcmltZW50LWV4cGVyaW1lbnRDb25maWc8L2tleT48c3RyaW5nPjU7dmMtdmNyYy1mYWNlVGltZS1iaWYtZW5hYmxlZCx2Yy12Y3JjLWZhY2VUaW1lLWNvbnRpbnVvdXMtdGltZUZhY3Rvck1lZGl1bSx2Yy12Y3JjLWZhY2VUaW1lLWNvbnRpbnVvdXMtdGltZUZhY3RvckhpZ2gsdmMtdmNyYy1mYWNlVGltZS1jb250aW51b3VzLXRpbWVGYWN0b3JFeGNlbGxlbnQsdmMtdmNyYy1mYWNlVGltZS1sNHNlY24tZWNuQml0cmF0ZUxvdyx2Yy12Y3JjLWZhY2VUaW1lLWw0c2Vjbi1lY25CaXRyYXRlTWVkaXVtLHZjLXZjcmMtZmFjZVRpbWUtbDRzZWNuLWVjbkJpdHJhdGVIaWdoLHZjLXZjcmMtZmFjZVRpbWUtbDRzZWNuLWVjbkNFRHVyYXRpb25Ub1JUVDswLjUsMC41OzFfMS41XzVfNV8yMjhfNjAwXzEyMDBfMywxXzEuNV81XzVfMjI4XzYwMF8xMjAwXzA8L3N0cmluZz4KICA8a2V5PnZjLXZjcmMtZmFjZVRpbWUtc21hcnRCcmFrZS1zdHJhdGVneU51bTwva2V5PjxpbnRlZ2VyPjA8L2ludGVnZXI+CiAgPGtleT5oaDJTb2Z0d2FyZVJlbGVhc2VkPC9rZXk+PHRydWUvPgogIDxrZXk+aGgyU29mdHdhcmVSZWxlYXNlZC1pbnRlcm5hbDwva2V5Pjx0cnVlLz4KICA8a2V5PmhoMlNvZnR3YXJlUmVsZWFzZWQtc2VlZDwva2V5Pjx0cnVlLz4KICA8a2V5PmhoMkF1dG9NaWdyYXRpb25FbmFibGVkPC9rZXk+PGZhbHNlLz4KICA8a2V5PmhoMkF1dG9NaWdyYXRpb25FbmFibGVkLWludGVybmFsPC9rZXk+PGZhbHNlLz4KICA8a2V5PmhoMkF1dG9NaWdyYXRpb25FbmFibGVkLXNlZWQ8L2tleT48ZmFsc2UvPgogIDxrZXk+aGgyTWFudWFsTWlncmF0aW9uRW5hYmxlZDwva2V5PjxmYWxzZS8+CiAgPGtleT5oaDJNYW51YWxNaWdyYXRpb25FbmFibGVkLWludGVybmFsPC9rZXk+PHRydWUvPgogIDxrZXk+aGgyTWFudWFsTWlncmF0aW9uRW5hYmxlZC1zZWVkPC9rZXk+PHRydWUvPgogIDxrZXk+aGgyTWFudWFsTWlncmF0aW9uRW5hYmxlZFYyPC9rZXk+PGZhbHNlLz4KICA8a2V5PmhoMk1hbnVhbE1pZ3JhdGlvbkVuYWJsZWRWMi1pbnRlcm5hbDwva2V5Pjx0cnVlLz4KICA8a2V5PmhoMk1hbnVhbE1pZ3JhdGlvbkVuYWJsZWRWMi1zZWVkPC9rZXk+PGZhbHNlLz4KICA8a2V5PmhoMkF1dG9NaWdyYXRpb25FbmFibGVkVjI8L2tleT48ZmFsc2UvPgogIDxrZXk+aGgyQXV0b01pZ3JhdGlvbkVuYWJsZWRWMi1pbnRlcm5hbDwva2V5PjxmYWxzZS8+CiAgPGtleT5oaDJBdXRvTWlncmF0aW9uRW5hYmxlZFYyLXNlZWQ8L2tleT48ZmFsc2UvPgogIDxrZXk+aG9tZS1ybWUtYzwva2V5Pjx0cnVlLz4KICA8a2V5PmhvbWUtcm1lLWMtaW50ZXJuYWw8L2tleT48dHJ1ZS8+CiAgPGtleT5ob21lLXJtZS1jLXNlZWQ8L2tleT48dHJ1ZS8+CiAgPGtleT5ob21lLXNrcnNlPC9rZXk+PHRydWUvPgogIDxrZXk+aG9tZS1za3JzZS1pbnRlcm5hbDwva2V5Pjx0cnVlLz4KICA8a2V5PmhvbWUtc2tyc2Utc2VlZDwva2V5Pjx0cnVlLz4KICA8a2V5Pm5vdGljZS1mYW5vdXQtdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MTAwPC9pbnRlZ2VyPgogIDxrZXk+ZGV2aWNlLXF1ZXJ5LXJlc2V0LWludGVydmFsLWhvdXJzLWNvbS5hcHBsZS5wcml2YXRlLmFsbG95LmFyY2FkZTwva2V5PjxpbnRlZ2VyPjU8L2ludGVnZXI+CiAgPGtleT5kZXZpY2UtcXVlcmllcy1wZXItaG91ci1jb20uYXBwbGUucHJpdmF0ZS5hbGxveS5hcmNhZGU8L2tleT48aW50ZWdlcj4xMDAwPC9pbnRlZ2VyPgogIDxrZXk+QjY5OGlQYWRTdXBwb3J0VW5hdmFpbGFibGU8L2tleT48aW50ZWdlcj4xPC9pbnRlZ2VyPgogIDxrZXk+aWRzLXF1aWMtZGlzY29ubmVjdGlvbi1kZWxheS1hZnRlci1pbmZvPC9rZXk+PHJlYWw+NS4wPC9yZWFsPgogIDxrZXk+YXBwbHktaW1lc3NhZ2UtanVuay1maWx0ZXI8L2tleT48dHJ1ZS8+CiAgPGtleT50ZWxlcGhvbnktaW1lc3NhZ2UtanVuay1maWx0ZXI8L2tleT4KICA8YXJyYXk+CiAgICA8c3RyaW5nPmFmPC9zdHJpbmc+CiAgICA8c3RyaW5nPmF4PC9zdHJpbmc+CiAgICA8c3RyaW5nPmFsPC9zdHJpbmc+CiAgICA8c3RyaW5nPmR6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmFzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFxPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmF3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmF1PC9zdHJpbmc+CiAgICA8c3RyaW5nPmF0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmF6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJoPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJiPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJqPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJxPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ2PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJuPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJmPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmN2PC9zdHJpbmc+CiAgICA8c3RyaW5nPmtoPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmt5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmNmPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNsPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNuPC9zdHJpbmc+CiAgICA8c3RyaW5nPmN4PC9zdHJpbmc+CiAgICA8c3RyaW5nPmNjPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmttPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNrPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmhyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmN1PC9zdHJpbmc+CiAgICA8c3RyaW5nPmN3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmN5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmN6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmRrPC9zdHJpbmc+CiAgICA8c3RyaW5nPmRqPC9zdHJpbmc+CiAgICA8c3RyaW5nPmRtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmRvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVjPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN2PC9zdHJpbmc+CiAgICA8c3RyaW5nPmdxPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVlPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmV0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmZrPC9zdHJpbmc+CiAgICA8c3RyaW5nPmZvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmZqPC9zdHJpbmc+CiAgICA8c3RyaW5nPmZpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmZyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdmPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBmPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRmPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmRlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdoPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdsPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdwPC9zdHJpbmc+CiAgICA8c3RyaW5nPmd1PC9zdHJpbmc+CiAgICA8c3RyaW5nPmd0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmdnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmduPC9zdHJpbmc+CiAgICA8c3RyaW5nPmd3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmd5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmh0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmhtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmhuPC9zdHJpbmc+CiAgICA8c3RyaW5nPmhrPC9zdHJpbmc+CiAgICA8c3RyaW5nPmh1PC9zdHJpbmc+CiAgICA8c3RyaW5nPmlzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmluPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlxPC9zdHJpbmc+CiAgICA8c3RyaW5nPmllPC9zdHJpbmc+CiAgICA8c3RyaW5nPmltPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlsPC9zdHJpbmc+CiAgICA8c3RyaW5nPml0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmptPC9zdHJpbmc+CiAgICA8c3RyaW5nPmpwPC9zdHJpbmc+CiAgICA8c3RyaW5nPmplPC9zdHJpbmc+CiAgICA8c3RyaW5nPmpvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmt6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmtlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmtpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmtwPC9zdHJpbmc+CiAgICA8c3RyaW5nPmtyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmt3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmtnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmx2PC9zdHJpbmc+CiAgICA8c3RyaW5nPmxiPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmx5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmxpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmx0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmx1PC9zdHJpbmc+CiAgICA8c3RyaW5nPm1vPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1nPC9zdHJpbmc+CiAgICA8c3RyaW5nPm13PC9zdHJpbmc+CiAgICA8c3RyaW5nPm15PC9zdHJpbmc+CiAgICA8c3RyaW5nPm12PC9zdHJpbmc+CiAgICA8c3RyaW5nPm1sPC9zdHJpbmc+CiAgICA8c3RyaW5nPm10PC9zdHJpbmc+CiAgICA8c3RyaW5nPm1oPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1xPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1yPC9zdHJpbmc+CiAgICA8c3RyaW5nPm11PC9zdHJpbmc+CiAgICA8c3RyaW5nPnl0PC9zdHJpbmc+CiAgICA8c3RyaW5nPm14PC9zdHJpbmc+CiAgICA8c3RyaW5nPmZtPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1kPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1jPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1uPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1lPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1zPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1hPC9zdHJpbmc+CiAgICA8c3RyaW5nPm16PC9zdHJpbmc+CiAgICA8c3RyaW5nPm1tPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5hPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5yPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5wPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5sPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5jPC9zdHJpbmc+CiAgICA8c3RyaW5nPm56PC9zdHJpbmc+CiAgICA8c3RyaW5nPm5pPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5lPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5nPC9zdHJpbmc+CiAgICA8c3RyaW5nPm51PC9zdHJpbmc+CiAgICA8c3RyaW5nPm5mPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1rPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1wPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5vPC9zdHJpbmc+CiAgICA8c3RyaW5nPm9tPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBrPC9zdHJpbmc+CiAgICA8c3RyaW5nPnB3PC9zdHJpbmc+CiAgICA8c3RyaW5nPnBzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBhPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnB5PC9zdHJpbmc+CiAgICA8c3RyaW5nPnBlPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBoPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBuPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBsPC9zdHJpbmc+CiAgICA8c3RyaW5nPnB0PC9zdHJpbmc+CiAgICA8c3RyaW5nPnByPC9zdHJpbmc+CiAgICA8c3RyaW5nPnFhPC9zdHJpbmc+CiAgICA8c3RyaW5nPnJlPC9zdHJpbmc+CiAgICA8c3RyaW5nPnJvPC9zdHJpbmc+CiAgICA8c3RyaW5nPnJ1PC9zdHJpbmc+CiAgICA8c3RyaW5nPnJ3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJsPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNoPC9zdHJpbmc+CiAgICA8c3RyaW5nPmtuPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxjPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1mPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZjPC9zdHJpbmc+CiAgICA8c3RyaW5nPndzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN0PC9zdHJpbmc+CiAgICA8c3RyaW5nPnNhPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNuPC9zdHJpbmc+CiAgICA8c3RyaW5nPnJzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNjPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNsPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN4PC9zdHJpbmc+CiAgICA8c3RyaW5nPnNrPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNpPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNiPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNvPC9zdHJpbmc+CiAgICA8c3RyaW5nPnphPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxrPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNkPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNyPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNqPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNoPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN5PC9zdHJpbmc+CiAgICA8c3RyaW5nPnR3PC9zdHJpbmc+CiAgICA8c3RyaW5nPnRqPC9zdHJpbmc+CiAgICA8c3RyaW5nPnR6PC9zdHJpbmc+CiAgICA8c3RyaW5nPnRoPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRsPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRrPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRvPC9zdHJpbmc+CiAgICA8c3RyaW5nPnR0PC9zdHJpbmc+CiAgICA8c3RyaW5nPnRuPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRyPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRjPC9zdHJpbmc+CiAgICA8c3RyaW5nPnR2PC9zdHJpbmc+CiAgICA8c3RyaW5nPnVnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnVhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdiPC9zdHJpbmc+CiAgICA8c3RyaW5nPnVtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnVzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnV5PC9zdHJpbmc+CiAgICA8c3RyaW5nPnV6PC9zdHJpbmc+CiAgICA8c3RyaW5nPnZ1PC9zdHJpbmc+CiAgICA8c3RyaW5nPnZlPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZuPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZpPC9zdHJpbmc+CiAgICA8c3RyaW5nPndmPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVoPC9zdHJpbmc+CiAgICA8c3RyaW5nPnllPC9zdHJpbmc+CiAgICA8c3RyaW5nPnptPC9zdHJpbmc+CiAgICA8c3RyaW5nPnp3PC9zdHJpbmc+CiAgPC9hcnJheT4KICA8a2V5PnRlbGVwaG9ueS1pbWVzc2FnZS1qdW5rLWZpbHRlci1pbnRlcm5hbDwva2V5PgogIDxhcnJheT4KICAgIDxzdHJpbmc+YWY8L3N0cmluZz4KICAgIDxzdHJpbmc+YXg8L3N0cmluZz4KICAgIDxzdHJpbmc+YWw8L3N0cmluZz4KICAgIDxzdHJpbmc+ZHo8L3N0cmluZz4KICAgIDxzdHJpbmc+YXM8L3N0cmluZz4KICAgIDxzdHJpbmc+YWQ8L3N0cmluZz4KICAgIDxzdHJpbmc+YW88L3N0cmluZz4KICAgIDxzdHJpbmc+YWk8L3N0cmluZz4KICAgIDxzdHJpbmc+YXE8L3N0cmluZz4KICAgIDxzdHJpbmc+YWc8L3N0cmluZz4KICAgIDxzdHJpbmc+YXI8L3N0cmluZz4KICAgIDxzdHJpbmc+YW08L3N0cmluZz4KICAgIDxzdHJpbmc+YXc8L3N0cmluZz4KICAgIDxzdHJpbmc+YXU8L3N0cmluZz4KICAgIDxzdHJpbmc+YXQ8L3N0cmluZz4KICAgIDxzdHJpbmc+YXo8L3N0cmluZz4KICAgIDxzdHJpbmc+YnM8L3N0cmluZz4KICAgIDxzdHJpbmc+Ymg8L3N0cmluZz4KICAgIDxzdHJpbmc+YmQ8L3N0cmluZz4KICAgIDxzdHJpbmc+YmI8L3N0cmluZz4KICAgIDxzdHJpbmc+Ynk8L3N0cmluZz4KICAgIDxzdHJpbmc+YmU8L3N0cmluZz4KICAgIDxzdHJpbmc+Yno8L3N0cmluZz4KICAgIDxzdHJpbmc+Ymo8L3N0cmluZz4KICAgIDxzdHJpbmc+Ym08L3N0cmluZz4KICAgIDxzdHJpbmc+YnQ8L3N0cmluZz4KICAgIDxzdHJpbmc+Ym88L3N0cmluZz4KICAgIDxzdHJpbmc+YnE8L3N0cmluZz4KICAgIDxzdHJpbmc+YmE8L3N0cmluZz4KICAgIDxzdHJpbmc+Ync8L3N0cmluZz4KICAgIDxzdHJpbmc+YnY8L3N0cmluZz4KICAgIDxzdHJpbmc+YnI8L3N0cmluZz4KICAgIDxzdHJpbmc+aW88L3N0cmluZz4KICAgIDxzdHJpbmc+Ym48L3N0cmluZz4KICAgIDxzdHJpbmc+Ymc8L3N0cmluZz4KICAgIDxzdHJpbmc+YmY8L3N0cmluZz4KICAgIDxzdHJpbmc+Ymk8L3N0cmluZz4KICAgIDxzdHJpbmc+Y3Y8L3N0cmluZz4KICAgIDxzdHJpbmc+a2g8L3N0cmluZz4KICAgIDxzdHJpbmc+Y208L3N0cmluZz4KICAgIDxzdHJpbmc+Y2E8L3N0cmluZz4KICAgIDxzdHJpbmc+a3k8L3N0cmluZz4KICAgIDxzdHJpbmc+Y2Y8L3N0cmluZz4KICAgIDxzdHJpbmc+dGQ8L3N0cmluZz4KICAgIDxzdHJpbmc+Y2w8L3N0cmluZz4KICAgIDxzdHJpbmc+Y248L3N0cmluZz4KICAgIDxzdHJpbmc+Y3g8L3N0cmluZz4KICAgIDxzdHJpbmc+Y2M8L3N0cmluZz4KICAgIDxzdHJpbmc+Y288L3N0cmluZz4KICAgIDxzdHJpbmc+a208L3N0cmluZz4KICAgIDxzdHJpbmc+Y2Q8L3N0cmluZz4KICAgIDxzdHJpbmc+Y2c8L3N0cmluZz4KICAgIDxzdHJpbmc+Y2s8L3N0cmluZz4KICAgIDxzdHJpbmc+Y3I8L3N0cmluZz4KICAgIDxzdHJpbmc+Y2k8L3N0cmluZz4KICAgIDxzdHJpbmc+aHI8L3N0cmluZz4KICAgIDxzdHJpbmc+Y3U8L3N0cmluZz4KICAgIDxzdHJpbmc+Y3c8L3N0cmluZz4KICAgIDxzdHJpbmc+Y3k8L3N0cmluZz4KICAgIDxzdHJpbmc+Y3o8L3N0cmluZz4KICAgIDxzdHJpbmc+ZGs8L3N0cmluZz4KICAgIDxzdHJpbmc+ZGo8L3N0cmluZz4KICAgIDxzdHJpbmc+ZG08L3N0cmluZz4KICAgIDxzdHJpbmc+ZG88L3N0cmluZz4KICAgIDxzdHJpbmc+ZWM8L3N0cmluZz4KICAgIDxzdHJpbmc+ZWc8L3N0cmluZz4KICAgIDxzdHJpbmc+c3Y8L3N0cmluZz4KICAgIDxzdHJpbmc+Z3E8L3N0cmluZz4KICAgIDxzdHJpbmc+ZXI8L3N0cmluZz4KICAgIDxzdHJpbmc+ZWU8L3N0cmluZz4KICAgIDxzdHJpbmc+c3o8L3N0cmluZz4KICAgIDxzdHJpbmc+ZXQ8L3N0cmluZz4KICAgIDxzdHJpbmc+Zms8L3N0cmluZz4KICAgIDxzdHJpbmc+Zm88L3N0cmluZz4KICAgIDxzdHJpbmc+Zmo8L3N0cmluZz4KICAgIDxzdHJpbmc+Zmk8L3N0cmluZz4KICAgIDxzdHJpbmc+ZnI8L3N0cmluZz4KICAgIDxzdHJpbmc+Z2Y8L3N0cmluZz4KICAgIDxzdHJpbmc+cGY8L3N0cmluZz4KICAgIDxzdHJpbmc+dGY8L3N0cmluZz4KICAgIDxzdHJpbmc+Z2E8L3N0cmluZz4KICAgIDxzdHJpbmc+Z208L3N0cmluZz4KICAgIDxzdHJpbmc+Z2U8L3N0cmluZz4KICAgIDxzdHJpbmc+ZGU8L3N0cmluZz4KICAgIDxzdHJpbmc+Z2g8L3N0cmluZz4KICAgIDxzdHJpbmc+Z2k8L3N0cmluZz4KICAgIDxzdHJpbmc+Z3I8L3N0cmluZz4KICAgIDxzdHJpbmc+Z2w8L3N0cmluZz4KICAgIDxzdHJpbmc+Z2Q8L3N0cmluZz4KICAgIDxzdHJpbmc+Z3A8L3N0cmluZz4KICAgIDxzdHJpbmc+Z3U8L3N0cmluZz4KICAgIDxzdHJpbmc+Z3Q8L3N0cmluZz4KICAgIDxzdHJpbmc+Z2c8L3N0cmluZz4KICAgIDxzdHJpbmc+Z248L3N0cmluZz4KICAgIDxzdHJpbmc+Z3c8L3N0cmluZz4KICAgIDxzdHJpbmc+Z3k8L3N0cmluZz4KICAgIDxzdHJpbmc+aHQ8L3N0cmluZz4KICAgIDxzdHJpbmc+aG08L3N0cmluZz4KICAgIDxzdHJpbmc+dmE8L3N0cmluZz4KICAgIDxzdHJpbmc+aG48L3N0cmluZz4KICAgIDxzdHJpbmc+aGs8L3N0cmluZz4KICAgIDxzdHJpbmc+aHU8L3N0cmluZz4KICAgIDxzdHJpbmc+aXM8L3N0cmluZz4KICAgIDxzdHJpbmc+aW48L3N0cmluZz4KICAgIDxzdHJpbmc+aWQ8L3N0cmluZz4KICAgIDxzdHJpbmc+aXI8L3N0cmluZz4KICAgIDxzdHJpbmc+aXE8L3N0cmluZz4KICAgIDxzdHJpbmc+aWU8L3N0cmluZz4KICAgIDxzdHJpbmc+aW08L3N0cmluZz4KICAgIDxzdHJpbmc+aWw8L3N0cmluZz4KICAgIDxzdHJpbmc+aXQ8L3N0cmluZz4KICAgIDxzdHJpbmc+am08L3N0cmluZz4KICAgIDxzdHJpbmc+anA8L3N0cmluZz4KICAgIDxzdHJpbmc+amU8L3N0cmluZz4KICAgIDxzdHJpbmc+am88L3N0cmluZz4KICAgIDxzdHJpbmc+a3o8L3N0cmluZz4KICAgIDxzdHJpbmc+a2U8L3N0cmluZz4KICAgIDxzdHJpbmc+a2k8L3N0cmluZz4KICAgIDxzdHJpbmc+a3A8L3N0cmluZz4KICAgIDxzdHJpbmc+a3I8L3N0cmluZz4KICAgIDxzdHJpbmc+a3c8L3N0cmluZz4KICAgIDxzdHJpbmc+a2c8L3N0cmluZz4KICAgIDxzdHJpbmc+bGE8L3N0cmluZz4KICAgIDxzdHJpbmc+bHY8L3N0cmluZz4KICAgIDxzdHJpbmc+bGI8L3N0cmluZz4KICAgIDxzdHJpbmc+bHM8L3N0cmluZz4KICAgIDxzdHJpbmc+bHI8L3N0cmluZz4KICAgIDxzdHJpbmc+bHk8L3N0cmluZz4KICAgIDxzdHJpbmc+bGk8L3N0cmluZz4KICAgIDxzdHJpbmc+bHQ8L3N0cmluZz4KICAgIDxzdHJpbmc+bHU8L3N0cmluZz4KICAgIDxzdHJpbmc+bW88L3N0cmluZz4KICAgIDxzdHJpbmc+bWc8L3N0cmluZz4KICAgIDxzdHJpbmc+bXc8L3N0cmluZz4KICAgIDxzdHJpbmc+bXk8L3N0cmluZz4KICAgIDxzdHJpbmc+bXY8L3N0cmluZz4KICAgIDxzdHJpbmc+bWw8L3N0cmluZz4KICAgIDxzdHJpbmc+bXQ8L3N0cmluZz4KICAgIDxzdHJpbmc+bWg8L3N0cmluZz4KICAgIDxzdHJpbmc+bXE8L3N0cmluZz4KICAgIDxzdHJpbmc+bXI8L3N0cmluZz4KICAgIDxzdHJpbmc+bXU8L3N0cmluZz4KICAgIDxzdHJpbmc+eXQ8L3N0cmluZz4KICAgIDxzdHJpbmc+bXg8L3N0cmluZz4KICAgIDxzdHJpbmc+Zm08L3N0cmluZz4KICAgIDxzdHJpbmc+bWQ8L3N0cmluZz4KICAgIDxzdHJpbmc+bWM8L3N0cmluZz4KICAgIDxzdHJpbmc+bW48L3N0cmluZz4KICAgIDxzdHJpbmc+bWU8L3N0cmluZz4KICAgIDxzdHJpbmc+bXM8L3N0cmluZz4KICAgIDxzdHJpbmc+bWE8L3N0cmluZz4KICAgIDxzdHJpbmc+bXo8L3N0cmluZz4KICAgIDxzdHJpbmc+bW08L3N0cmluZz4KICAgIDxzdHJpbmc+bmE8L3N0cmluZz4KICAgIDxzdHJpbmc+bnI8L3N0cmluZz4KICAgIDxzdHJpbmc+bnA8L3N0cmluZz4KICAgIDxzdHJpbmc+bmw8L3N0cmluZz4KICAgIDxzdHJpbmc+bmM8L3N0cmluZz4KICAgIDxzdHJpbmc+bno8L3N0cmluZz4KICAgIDxzdHJpbmc+bmk8L3N0cmluZz4KICAgIDxzdHJpbmc+bmU8L3N0cmluZz4KICAgIDxzdHJpbmc+bmc8L3N0cmluZz4KICAgIDxzdHJpbmc+bnU8L3N0cmluZz4KICAgIDxzdHJpbmc+bmY8L3N0cmluZz4KICAgIDxzdHJpbmc+bWs8L3N0cmluZz4KICAgIDxzdHJpbmc+bXA8L3N0cmluZz4KICAgIDxzdHJpbmc+bm88L3N0cmluZz4KICAgIDxzdHJpbmc+b208L3N0cmluZz4KICAgIDxzdHJpbmc+cGs8L3N0cmluZz4KICAgIDxzdHJpbmc+cHc8L3N0cmluZz4KICAgIDxzdHJpbmc+cHM8L3N0cmluZz4KICAgIDxzdHJpbmc+cGE8L3N0cmluZz4KICAgIDxzdHJpbmc+cGc8L3N0cmluZz4KICAgIDxzdHJpbmc+cHk8L3N0cmluZz4KICAgIDxzdHJpbmc+cGU8L3N0cmluZz4KICAgIDxzdHJpbmc+cGg8L3N0cmluZz4KICAgIDxzdHJpbmc+cG48L3N0cmluZz4KICAgIDxzdHJpbmc+cGw8L3N0cmluZz4KICAgIDxzdHJpbmc+cHQ8L3N0cmluZz4KICAgIDxzdHJpbmc+cHI8L3N0cmluZz4KICAgIDxzdHJpbmc+cWE8L3N0cmluZz4KICAgIDxzdHJpbmc+cmU8L3N0cmluZz4KICAgIDxzdHJpbmc+cm88L3N0cmluZz4KICAgIDxzdHJpbmc+cnU8L3N0cmluZz4KICAgIDxzdHJpbmc+cnc8L3N0cmluZz4KICAgIDxzdHJpbmc+Ymw8L3N0cmluZz4KICAgIDxzdHJpbmc+c2g8L3N0cmluZz4KICAgIDxzdHJpbmc+a248L3N0cmluZz4KICAgIDxzdHJpbmc+bGM8L3N0cmluZz4KICAgIDxzdHJpbmc+bWY8L3N0cmluZz4KICAgIDxzdHJpbmc+cG08L3N0cmluZz4KICAgIDxzdHJpbmc+dmM8L3N0cmluZz4KICAgIDxzdHJpbmc+d3M8L3N0cmluZz4KICAgIDxzdHJpbmc+c208L3N0cmluZz4KICAgIDxzdHJpbmc+c3Q8L3N0cmluZz4KICAgIDxzdHJpbmc+c2E8L3N0cmluZz4KICAgIDxzdHJpbmc+c248L3N0cmluZz4KICAgIDxzdHJpbmc+cnM8L3N0cmluZz4KICAgIDxzdHJpbmc+c2M8L3N0cmluZz4KICAgIDxzdHJpbmc+c2w8L3N0cmluZz4KICAgIDxzdHJpbmc+c2c8L3N0cmluZz4KICAgIDxzdHJpbmc+c3g8L3N0cmluZz4KICAgIDxzdHJpbmc+c2s8L3N0cmluZz4KICAgIDxzdHJpbmc+c2k8L3N0cmluZz4KICAgIDxzdHJpbmc+c2I8L3N0cmluZz4KICAgIDxzdHJpbmc+c288L3N0cmluZz4KICAgIDxzdHJpbmc+emE8L3N0cmluZz4KICAgIDxzdHJpbmc+Z3M8L3N0cmluZz4KICAgIDxzdHJpbmc+c3M8L3N0cmluZz4KICAgIDxzdHJpbmc+ZXM8L3N0cmluZz4KICAgIDxzdHJpbmc+bGs8L3N0cmluZz4KICAgIDxzdHJpbmc+c2Q8L3N0cmluZz4KICAgIDxzdHJpbmc+c3I8L3N0cmluZz4KICAgIDxzdHJpbmc+c2o8L3N0cmluZz4KICAgIDxzdHJpbmc+c2U8L3N0cmluZz4KICAgIDxzdHJpbmc+Y2g8L3N0cmluZz4KICAgIDxzdHJpbmc+c3k8L3N0cmluZz4KICAgIDxzdHJpbmc+dHc8L3N0cmluZz4KICAgIDxzdHJpbmc+dGo8L3N0cmluZz4KICAgIDxzdHJpbmc+dHo8L3N0cmluZz4KICAgIDxzdHJpbmc+dGg8L3N0cmluZz4KICAgIDxzdHJpbmc+dGw8L3N0cmluZz4KICAgIDxzdHJpbmc+dGc8L3N0cmluZz4KICAgIDxzdHJpbmc+dGs8L3N0cmluZz4KICAgIDxzdHJpbmc+dG88L3N0cmluZz4KICAgIDxzdHJpbmc+dHQ8L3N0cmluZz4KICAgIDxzdHJpbmc+dG48L3N0cmluZz4KICAgIDxzdHJpbmc+dHI8L3N0cmluZz4KICAgIDxzdHJpbmc+dG08L3N0cmluZz4KICAgIDxzdHJpbmc+dGM8L3N0cmluZz4KICAgIDxzdHJpbmc+dHY8L3N0cmluZz4KICAgIDxzdHJpbmc+dWc8L3N0cmluZz4KICAgIDxzdHJpbmc+dWE8L3N0cmluZz4KICAgIDxzdHJpbmc+YWU8L3N0cmluZz4KICAgIDxzdHJpbmc+Z2I8L3N0cmluZz4KICAgIDxzdHJpbmc+dW08L3N0cmluZz4KICAgIDxzdHJpbmc+dXM8L3N0cmluZz4KICAgIDxzdHJpbmc+dXk8L3N0cmluZz4KICAgIDxzdHJpbmc+dXo8L3N0cmluZz4KICAgIDxzdHJpbmc+dnU8L3N0cmluZz4KICAgIDxzdHJpbmc+dmU8L3N0cmluZz4KICAgIDxzdHJpbmc+dm48L3N0cmluZz4KICAgIDxzdHJpbmc+dmc8L3N0cmluZz4KICAgIDxzdHJpbmc+dmk8L3N0cmluZz4KICAgIDxzdHJpbmc+d2Y8L3N0cmluZz4KICAgIDxzdHJpbmc+ZWg8L3N0cmluZz4KICAgIDxzdHJpbmc+eWU8L3N0cmluZz4KICAgIDxzdHJpbmc+em08L3N0cmluZz4KICAgIDxzdHJpbmc+enc8L3N0cmluZz4KICA8L2FycmF5PgogIDxrZXk+dGVsZXBob255LWltZXNzYWdlLWp1bmstZmlsdGVyLXNlZWQ8L2tleT4KICA8YXJyYXk+CiAgICA8c3RyaW5nPmFmPC9zdHJpbmc+CiAgICA8c3RyaW5nPmF4PC9zdHJpbmc+CiAgICA8c3RyaW5nPmFsPC9zdHJpbmc+CiAgICA8c3RyaW5nPmR6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmFzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFxPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmF3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmF1PC9zdHJpbmc+CiAgICA8c3RyaW5nPmF0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmF6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJoPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJiPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJqPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJxPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJ2PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJuPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJmPC9zdHJpbmc+CiAgICA8c3RyaW5nPmJpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmN2PC9zdHJpbmc+CiAgICA8c3RyaW5nPmtoPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmt5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmNmPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNsPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNuPC9zdHJpbmc+CiAgICA8c3RyaW5nPmN4PC9zdHJpbmc+CiAgICA8c3RyaW5nPmNjPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmttPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNrPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmhyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmN1PC9zdHJpbmc+CiAgICA8c3RyaW5nPmN3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmN5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmN6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmRrPC9zdHJpbmc+CiAgICA8c3RyaW5nPmRqPC9zdHJpbmc+CiAgICA8c3RyaW5nPmRtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmRvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVjPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN2PC9zdHJpbmc+CiAgICA8c3RyaW5nPmdxPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVlPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmV0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmZrPC9zdHJpbmc+CiAgICA8c3RyaW5nPmZvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmZqPC9zdHJpbmc+CiAgICA8c3RyaW5nPmZpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmZyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdmPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBmPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRmPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdtPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmRlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdoPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdsPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdwPC9zdHJpbmc+CiAgICA8c3RyaW5nPmd1PC9zdHJpbmc+CiAgICA8c3RyaW5nPmd0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmdnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmduPC9zdHJpbmc+CiAgICA8c3RyaW5nPmd3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmd5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmh0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmhtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmhuPC9zdHJpbmc+CiAgICA8c3RyaW5nPmhrPC9zdHJpbmc+CiAgICA8c3RyaW5nPmh1PC9zdHJpbmc+CiAgICA8c3RyaW5nPmlzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmluPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlkPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlxPC9zdHJpbmc+CiAgICA8c3RyaW5nPmllPC9zdHJpbmc+CiAgICA8c3RyaW5nPmltPC9zdHJpbmc+CiAgICA8c3RyaW5nPmlsPC9zdHJpbmc+CiAgICA8c3RyaW5nPml0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmptPC9zdHJpbmc+CiAgICA8c3RyaW5nPmpwPC9zdHJpbmc+CiAgICA8c3RyaW5nPmplPC9zdHJpbmc+CiAgICA8c3RyaW5nPmpvPC9zdHJpbmc+CiAgICA8c3RyaW5nPmt6PC9zdHJpbmc+CiAgICA8c3RyaW5nPmtlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmtpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmtwPC9zdHJpbmc+CiAgICA8c3RyaW5nPmtyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmt3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmtnPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmx2PC9zdHJpbmc+CiAgICA8c3RyaW5nPmxiPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxyPC9zdHJpbmc+CiAgICA8c3RyaW5nPmx5PC9zdHJpbmc+CiAgICA8c3RyaW5nPmxpPC9zdHJpbmc+CiAgICA8c3RyaW5nPmx0PC9zdHJpbmc+CiAgICA8c3RyaW5nPmx1PC9zdHJpbmc+CiAgICA8c3RyaW5nPm1vPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1nPC9zdHJpbmc+CiAgICA8c3RyaW5nPm13PC9zdHJpbmc+CiAgICA8c3RyaW5nPm15PC9zdHJpbmc+CiAgICA8c3RyaW5nPm12PC9zdHJpbmc+CiAgICA8c3RyaW5nPm1sPC9zdHJpbmc+CiAgICA8c3RyaW5nPm10PC9zdHJpbmc+CiAgICA8c3RyaW5nPm1oPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1xPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1yPC9zdHJpbmc+CiAgICA8c3RyaW5nPm11PC9zdHJpbmc+CiAgICA8c3RyaW5nPnl0PC9zdHJpbmc+CiAgICA8c3RyaW5nPm14PC9zdHJpbmc+CiAgICA8c3RyaW5nPmZtPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1kPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1jPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1uPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1lPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1zPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1hPC9zdHJpbmc+CiAgICA8c3RyaW5nPm16PC9zdHJpbmc+CiAgICA8c3RyaW5nPm1tPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5hPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5yPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5wPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5sPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5jPC9zdHJpbmc+CiAgICA8c3RyaW5nPm56PC9zdHJpbmc+CiAgICA8c3RyaW5nPm5pPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5lPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5nPC9zdHJpbmc+CiAgICA8c3RyaW5nPm51PC9zdHJpbmc+CiAgICA8c3RyaW5nPm5mPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1rPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1wPC9zdHJpbmc+CiAgICA8c3RyaW5nPm5vPC9zdHJpbmc+CiAgICA8c3RyaW5nPm9tPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBrPC9zdHJpbmc+CiAgICA8c3RyaW5nPnB3PC9zdHJpbmc+CiAgICA8c3RyaW5nPnBzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBhPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnB5PC9zdHJpbmc+CiAgICA8c3RyaW5nPnBlPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBoPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBuPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBsPC9zdHJpbmc+CiAgICA8c3RyaW5nPnB0PC9zdHJpbmc+CiAgICA8c3RyaW5nPnByPC9zdHJpbmc+CiAgICA8c3RyaW5nPnFhPC9zdHJpbmc+CiAgICA8c3RyaW5nPnJlPC9zdHJpbmc+CiAgICA8c3RyaW5nPnJvPC9zdHJpbmc+CiAgICA8c3RyaW5nPnJ1PC9zdHJpbmc+CiAgICA8c3RyaW5nPnJ3PC9zdHJpbmc+CiAgICA8c3RyaW5nPmJsPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNoPC9zdHJpbmc+CiAgICA8c3RyaW5nPmtuPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxjPC9zdHJpbmc+CiAgICA8c3RyaW5nPm1mPC9zdHJpbmc+CiAgICA8c3RyaW5nPnBtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZjPC9zdHJpbmc+CiAgICA8c3RyaW5nPndzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN0PC9zdHJpbmc+CiAgICA8c3RyaW5nPnNhPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNuPC9zdHJpbmc+CiAgICA8c3RyaW5nPnJzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNjPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNsPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN4PC9zdHJpbmc+CiAgICA8c3RyaW5nPnNrPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNpPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNiPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNvPC9zdHJpbmc+CiAgICA8c3RyaW5nPnphPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVzPC9zdHJpbmc+CiAgICA8c3RyaW5nPmxrPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNkPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNyPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNqPC9zdHJpbmc+CiAgICA8c3RyaW5nPnNlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmNoPC9zdHJpbmc+CiAgICA8c3RyaW5nPnN5PC9zdHJpbmc+CiAgICA8c3RyaW5nPnR3PC9zdHJpbmc+CiAgICA8c3RyaW5nPnRqPC9zdHJpbmc+CiAgICA8c3RyaW5nPnR6PC9zdHJpbmc+CiAgICA8c3RyaW5nPnRoPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRsPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRrPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRvPC9zdHJpbmc+CiAgICA8c3RyaW5nPnR0PC9zdHJpbmc+CiAgICA8c3RyaW5nPnRuPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRyPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnRjPC9zdHJpbmc+CiAgICA8c3RyaW5nPnR2PC9zdHJpbmc+CiAgICA8c3RyaW5nPnVnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnVhPC9zdHJpbmc+CiAgICA8c3RyaW5nPmFlPC9zdHJpbmc+CiAgICA8c3RyaW5nPmdiPC9zdHJpbmc+CiAgICA8c3RyaW5nPnVtPC9zdHJpbmc+CiAgICA8c3RyaW5nPnVzPC9zdHJpbmc+CiAgICA8c3RyaW5nPnV5PC9zdHJpbmc+CiAgICA8c3RyaW5nPnV6PC9zdHJpbmc+CiAgICA8c3RyaW5nPnZ1PC9zdHJpbmc+CiAgICA8c3RyaW5nPnZlPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZuPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZnPC9zdHJpbmc+CiAgICA8c3RyaW5nPnZpPC9zdHJpbmc+CiAgICA8c3RyaW5nPndmPC9zdHJpbmc+CiAgICA8c3RyaW5nPmVoPC9zdHJpbmc+CiAgICA8c3RyaW5nPnllPC9zdHJpbmc+CiAgICA8c3RyaW5nPnptPC9zdHJpbmc+CiAgICA8c3RyaW5nPnp3PC9zdHJpbmc+CiAgPC9hcnJheT4KICA8a2V5PmFjY291bnRzLWltZXNzYWdlLWp1bmstZmlsdGVyPC9rZXk+CiAgPGFycmF5PgogICAgPHN0cmluZz5SOkFGPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QVg8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkRaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QVM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBRDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFPPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QUk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBUTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QVI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QVU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QlM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJEPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QkI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCWTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qlo8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCSjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QlQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJRPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QkE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCVzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJWPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QlI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qkc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJJPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q1Y8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpLSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0E8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpLWTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNGPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VEQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q1g8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDQzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNPPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S008L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDRDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0s8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNJPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SFI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q1k8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDWjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkRLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6REo8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpETTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkRPPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RUM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpFRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNWPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R1E8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpFUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkVFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U1o8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpFVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkZLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Rk88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpGSjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkZJPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RlI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBGPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VEY8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R0U8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpERTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdIPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R0k8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdMPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R0Q8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHUDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdVPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R1Q8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R1c8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHWTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkhUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SE08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpWQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkhOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SEs8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpIVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOklTPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SU48L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJRDwvc3RyaW5nPgogICAgPHN0cmluZz5SOklSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SVE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOklNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SUw8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkpNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SlA8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpKRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkpPPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S1o8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpLRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOktJPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S1A8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpLUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOktXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S0c8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxWPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TEI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TFk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TFU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1HPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNWTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1WPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TUw8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1IPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1VPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6WVQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNWDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkZNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TUQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNQzwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1OPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TUU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1BPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVo8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5BPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TlI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOUDwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5MPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TkM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOWjwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5JPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TkU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpORzwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5VPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TkY8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNSzwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1QPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Tk88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpPTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UFc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBBPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UEc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQWTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UEg8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQTjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBMPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UFQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlFBPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UkU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpSTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlJVPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Ulc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNIPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S048L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMQzwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1GPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UE08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpWQzwvc3RyaW5nPgogICAgPHN0cmluZz5SOldTPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U008L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNBPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U048L3N0cmluZz4KICAgIDxzdHJpbmc+UjpSUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNDPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U0w8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNYPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U0s8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNCPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U088L3N0cmluZz4KICAgIDxzdHJpbmc+UjpaQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdTPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U1M8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpFUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U0Q8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNKPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U0U8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNZPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VFc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUSjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VEg8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VEs8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VE48L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VEM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUVjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlVHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VUE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdCPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VU08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpVUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlVZPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VVo8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpWVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlZFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Vk48L3N0cmluZz4KICAgIDxzdHJpbmc+UjpWRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlZJPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6V0Y8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpFSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOllFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Wk08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpaVzwvc3RyaW5nPgogIDwvYXJyYXk+CiAgPGtleT5hY2NvdW50cy1pbWVzc2FnZS1qdW5rLWZpbHRlci1pbnRlcm5hbDwva2V5PgogIDxhcnJheT4KICAgIDxzdHJpbmc+UjpBRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFYPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QUw8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpEWjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFTPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QUQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFJPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QVE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QU08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBVzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFVPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QVQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBWjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJTPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qkg8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCRDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJCPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qlk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qko8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qk88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCUTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJBPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qlc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCVjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SU88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCTjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QkY8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNWPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S0g8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNBPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S1k8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlREPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0w8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDTjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNYPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0M8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOktNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0Q8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q1I8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkhSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q1U8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDVzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNZPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q1o8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpESzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkRKPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RE08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpETzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkVDPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RUc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTVjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdRPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RVI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpFRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RVQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpGSzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkZPPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Rko8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpGSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkZSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R0Y8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRGPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R0E8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6REU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdJPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R1I8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdEPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R1A8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R0c8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHTjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R1k8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpIVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkhNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VkE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpITjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkhLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SFU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOklOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SUQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOklRPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SUU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOklMPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SVQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpKTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkpQPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SkU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpKTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOktaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S0U8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpLSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOktQPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S1I8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpLVzwvc3RyaW5nPgogICAgPHN0cmluZz5SOktHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TEE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMVjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxCPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TFM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxZPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TEk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxVPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TU88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1XPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNVjwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1MPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1RPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOllUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVg8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpGTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1EPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TUM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNTjwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1FPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1aPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TU08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5SPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TlA8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5DPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Tlo8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5FPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Tkc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5GPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TUs8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNUDwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5PPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6T008L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQSzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UFM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UFk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBIPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UE48L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UFI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpRQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlJFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Uk88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpSVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlJXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qkw8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOktOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TEM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VkM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpXUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U1Q8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UlM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTQzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNMPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U0c8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTWDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U0k8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTQjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNPPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6WkE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNTPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RVM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMSzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNEPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U1I8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTSjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0g8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTWTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VEo8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUWjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRIPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VEw8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpURzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VE88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlROPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VFI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRDPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VFY8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpVRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlVBPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QUU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHQjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlVNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VVM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpVWTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlVaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VlU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpWRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlZOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Vkc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpWSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOldGPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RUg8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpZRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlpNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Wlc8L3N0cmluZz4KICA8L2FycmF5PgogIDxrZXk+YWNjb3VudHMtaW1lc3NhZ2UtanVuay1maWx0ZXItc2VlZDwva2V5PgogIDxhcnJheT4KICAgIDxzdHJpbmc+UjpBRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFYPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QUw8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpEWjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFTPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QUQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFJPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QVE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QU08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBVzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkFVPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QVQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpBWjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJTPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qkg8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCRDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJCPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qlk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qko8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qk88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCUTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJBPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qlc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCVjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SU88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCTjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkJHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QkY8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpCSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNWPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S0g8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNBPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S1k8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlREPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0w8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDTjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNYPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0M8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOktNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0Q8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q1I8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkhSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q1U8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpDVzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkNZPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q1o8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpESzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkRKPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RE08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpETzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkVDPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RUc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTVjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdRPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RVI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpFRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RVQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpGSzwvc3RyaW5nPgogICAgPHN0cmluZz5SOkZPPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Rko8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpGSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkZSPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R0Y8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRGPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R0E8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6REU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdJPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R1I8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdEPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R1A8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R0c8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHTjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkdXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6R1k8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpIVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkhNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VkE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpITjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkhLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SFU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOklOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SUQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOklRPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SUU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpJTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOklMPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SVQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpKTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOkpQPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6SkU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpKTzwvc3RyaW5nPgogICAgPHN0cmluZz5SOktaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S0U8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpLSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOktQPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6S1I8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpLVzwvc3RyaW5nPgogICAgPHN0cmluZz5SOktHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TEE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMVjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxCPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TFM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMUjwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxZPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TEk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOkxVPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TU88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1XPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNVjwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1MPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVQ8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1RPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOllUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVg8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpGTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1EPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TUM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNTjwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1FPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TVM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk1aPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TU08L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5SPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TlA8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5DPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Tlo8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5FPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Tkc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpOVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5GPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TUs8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNUDwvc3RyaW5nPgogICAgPHN0cmluZz5SOk5PPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6T008L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQSzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UFM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBHPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UFk8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBIPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UE48L3N0cmluZz4KICAgIDxzdHJpbmc+UjpQTDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBUPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UFI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpRQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlJFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Uk88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpSVTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlJXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Qkw8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTSDwvc3RyaW5nPgogICAgPHN0cmluZz5SOktOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6TEM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpNRjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlBNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VkM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpXUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U1Q8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTQTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6UlM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTQzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNMPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U0c8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTWDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U0k8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTQjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNPPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6WkE8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHUzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNTPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RVM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpMSzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNEPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6U1I8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTSjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlNFPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Q0g8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpTWTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRXPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VEo8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUWjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRIPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VEw8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpURzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRLPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VE88L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUVDwvc3RyaW5nPgogICAgPHN0cmluZz5SOlROPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VFI8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpUTTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlRDPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VFY8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpVRzwvc3RyaW5nPgogICAgPHN0cmluZz5SOlVBPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6QUU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpHQjwvc3RyaW5nPgogICAgPHN0cmluZz5SOlVNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VVM8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpVWTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlVaPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6VlU8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpWRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlZOPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Vkc8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpWSTwvc3RyaW5nPgogICAgPHN0cmluZz5SOldGPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6RUg8L3N0cmluZz4KICAgIDxzdHJpbmc+UjpZRTwvc3RyaW5nPgogICAgPHN0cmluZz5SOlpNPC9zdHJpbmc+CiAgICA8c3RyaW5nPlI6Wlc8L3N0cmluZz4KICA8L2FycmF5PgogIDxrZXk+aWRzLWZhbGxiYWNrLXRvLXRjcC1maXJzdDwva2V5Pjx0cnVlLz4KICA8a2V5PmhvbWUtY2ItaW50ZXJuYWw8L2tleT48dHJ1ZS8+CiAgPGtleT5ob21lLWNiLXNlZWQ8L2tleT48dHJ1ZS8+CiAgPGtleT5ob21lLWNiPC9rZXk+PHRydWUvPgogIDxrZXk+Y3Mtc2Vjb25kcy1mb3ItcmluZ2luZzwva2V5PjxpbnRlZ2VyPjE3PC9pbnRlZ2VyPgogIDxrZXk+Y3MtbWF4LXNlY29uZHM8L2tleT48aW50ZWdlcj4xMjA8L2ludGVnZXI+CiAgPGtleT52Yy1tb3Rpb24tYmFzZWQtZHVwbGljYXRpb24tZW5hYmxlZDwva2V5PjxyZWFsPjEuMDwvcmVhbD4KICA8a2V5PmhvbWUtcm12ZnN1bWJvbTwva2V5PjxzdHJpbmc+MTAuMy4xPC9zdHJpbmc+CiAgPGtleT5ob21lLXJtdmZvbWRtd29zdS1pbnRlcm5hbDwva2V5PjxzdHJpbmc+MTAuNjwvc3RyaW5nPgogIDxrZXk+aG9tZS1ybXZmb29kbXdvc3UtaW50ZXJuYWw8L2tleT48c3RyaW5nPjEwLjI8L3N0cmluZz4KICA8a2V5PmhvbWUtcm12Zm9yZG13b3N1LWludGVybmFsPC9rZXk+PHN0cmluZz4xMC41PC9zdHJpbmc+CiAgPGtleT5ob21lLXJhbXJkLWludGVybmFsPC9rZXk+PGludGVnZXI+MTAwPC9pbnRlZ2VyPgogIDxrZXk+aG9tZS1yYW1yci1pbnRlcm5hbDwva2V5PjxpbnRlZ2VyPjUwPC9pbnRlZ2VyPgogIDxrZXk+ZW5hYmxlLXNuYXAtdjItYmFjay1jb21wYXQ8L2tleT48dHJ1ZS8+CiAgPGtleT5hbGxvdy1jYWxscy11aS13aGlsZS1sb2NrZWQ8L2tleT48dHJ1ZS8+CiAgPGtleT5ob21lLXJtdmZvbWRtd29zdTwva2V5PjxzdHJpbmc+MTEuMTwvc3RyaW5nPgogIDxrZXk+aG9tZS1ybXZmb29kbXdvc3U8L2tleT48c3RyaW5nPjEwLjU8L3N0cmluZz4KICA8a2V5PmhvbWUtcm12Zm9yZG13b3N1PC9rZXk+PHN0cmluZz4xMS4xPC9zdHJpbmc+CiAgPGtleT5ob21lLXJtdmZvbWRtLWludGVybmFsPC9rZXk+PHN0cmluZz4xMC42PC9zdHJpbmc+CiAgPGtleT5ob21lLXJtdmZvb2RtLWludGVybmFsPC9rZXk+PHN0cmluZz4xMC4yPC9zdHJpbmc+CiAgPGtleT5ob21lLXJtdmZvcmRtLWludGVybmFsPC9rZXk+PHN0cmluZz4xMC41PC9zdHJpbmc+CiAgPGtleT5ob21lLXJtdmZzdW1ib2EtaW50ZXJuYWw8L2tleT48c3RyaW5nPjEwLjMuMTwvc3RyaW5nPgogIDxrZXk+aG9tZS1yYW1yZC1zZWVkPC9rZXk+PGludGVnZXI+MTAwPC9pbnRlZ2VyPgogIDxrZXk+aG9tZS1yYW1yci1zZWVkPC9rZXk+PGludGVnZXI+NTA8L2ludGVnZXI+CiAgPGtleT5ob21lLXJhbXJyPC9rZXk+PGludGVnZXI+MTAwPC9pbnRlZ2VyPgogIDxrZXk+aG9tZS1yYW1yZDwva2V5PjxpbnRlZ2VyPjEwMDwvaW50ZWdlcj4KICA8a2V5PmFpcmRyb3AtcHNldWRvbnltLXJldXNlLWR1cmF0aW9uLXNlY29uZHM8L2tleT48aW50ZWdlcj4yNTkyMDAwPC9pbnRlZ2VyPgogIDxrZXk+YWlyZHJvcC1wc2V1ZG9ueW0tZW5hYmxlZDwva2V5Pjx0cnVlLz4KICA8a2V5Pm5lYXJieS1ncm91cC1wc2V1ZG9ueW0tY29uZmlndXJhdGlvbnMtdHRsLXNlY29uZHM8L2tleT48aW50ZWdlcj4yNTkyMDAwPC9pbnRlZ2VyPgogIDxrZXk+Y29hbGVzY2Utc2VuZC1kZWxheS1ub3JtYWwtY29tLmFwcGxlLnByaXZhdGUuYWxsb3kuYnVsbGV0aW5ib2FyZDwva2V5PjxpbnRlZ2VyPjk2MDwvaW50ZWdlcj4KICA8a2V5PmNsZWFyTXV0ZUNhY2hlRGVsYXk8L2tleT48aW50ZWdlcj41PC9pbnRlZ2VyPgogIDxrZXk+dmMtcmVwb3J0aW5nLWF1ZGlvLWNvbm5lY3Rpb24tdGltZS1zeW1wdG9tLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjI3NzI8L2ludGVnZXI+CiAgPGtleT52Yy1yZXBvcnRpbmctYXVkaW8tZXJhc3VyZS1wZXJjZW50YWdlLXN5bXB0b20tdGhyZXNob2xkPC9rZXk+PGludGVnZXI+NzI0PC9pbnRlZ2VyPgogIDxrZXk+dmMtcmVwb3J0aW5nLXBvb3ItY29ubmVjdGlvbi1wZXJjZW50YWdlLXN5bXB0b20tdGhyZXNob2xkPC9rZXk+PGludGVnZXI+Mjg3PC9pbnRlZ2VyPgogIDxrZXk+dmMtcmVwb3J0aW5nLXZpZGVvLWNvbm5lY3Rpb24tdGltZS1zeW1wdG9tLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjE5MjQ8L2ludGVnZXI+CiAgPGtleT52Yy1yZXBvcnRpbmctdmlkZW8tc3RhbGwtcGVyY2VudGFnZS1zeW1wdG9tLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPjEyNTQ8L2ludGVnZXI+CiAgPGtleT5rdC1wZWVyLXVwZGF0ZS1sYXN0LXJlc29ydC1mZXRjaC1pbnRlcnZhbDwva2V5PjxpbnRlZ2VyPjI4ODAwPC9pbnRlZ2VyPgogIDxrZXk+aWRzLWRpc2FibGUtc2Vjb25kYXJ5LXJlZ2lzdHJhdGlvbi1wZXJjZW50YWdlPC9rZXk+PGludGVnZXI+MTU8L2ludGVnZXI+CiAgPGtleT5pZHMtZGlzYWJsZS1zZWNvbmRhcnktd2F2ZTItcmVnaXN0cmF0aW9uLXBlcmNlbnRhZ2U8L2tleT48aW50ZWdlcj4wPC9pbnRlZ2VyPgogIDxrZXk+dmMtYWItdGVzdC1lbmFibGUtbWF4LWNhbWVyYS1iaXRyYXRlLXZpZGVvLXF1YWxpdHktdjItdGhyZXNob2xkPC9rZXk+PHJlYWw+MC4wPC9yZWFsPgogIDxrZXk+cGhvbmUtbnVtYmVyLWFjdGl2YXRpb24tbGVhcm4tbW9yZV9pcGhvbmU8L2tleT48c3RyaW5nPmh0dHBzOi8vc3VwcG9ydC5hcHBsZS5jb20vaHQyMDE0MjI/Y2lkPW1jLW9scy1pcGhvbmUtYXJ0aWNsZV9odDIwMTQyMi1pb3NfdWktMTIxODIwMjM8L3N0cmluZz4KICA8a2V5PnBob25lLW51bWJlci1hY3RpdmF0aW9uLWxlYXJuLW1vcmVfaXBhZDwva2V5PjxzdHJpbmc+aHR0cHM6Ly9zdXBwb3J0LmFwcGxlLmNvbS9odDIwNDQwOD9jaWQ9bWMtb2xzLWlwYWQtYXJ0aWNsZV9odDIwNDQwOC1pcGFkb3NfdWktMTIxODIwMjM8L3N0cmluZz4KICA8a2V5PnZpc2lvbm9zLXNwYXRpYWwtcGVyc29uYS1hbGxvd2VkPC9rZXk+PHRydWUvPgogIDxrZXk+dmMtZW5oYW5jZWQtamItYWRhcHRhdGlvbnM8L2tleT48cmVhbD4wLjA8L3JlYWw+CiAgPGtleT51bnN1cHBvcnRlZC1hcHBzLWZvci1zaGFyZXBsYXk8L2tleT4KICA8YXJyYXk+CiAgICA8c3RyaW5nPmNvbS5hcHBsZS5mcmVlZm9ybTwvc3RyaW5nPgogICAgPHN0cmluZz5jb20uYXBwbGUuTVJRdWlja0xvb2s8L3N0cmluZz4KICAgIDxzdHJpbmc+Y29tLmFwcGxlLlBob3Rvczwvc3RyaW5nPgogIDwvYXJyYXk+CiAgPGtleT52Yy1uYWNrLWdlbmVyYXRvci1uYWNrLWdlbmVyYXRpb24tbWF4LXBscjwva2V5PjxyZWFsPjAuMjwvcmVhbD4KICA8a2V5PnZjLW5hY2stZ2VuZXJhdG9yLW5hY2stZ2VuZXJhdGlvbi1tYXgtcnR0PC9rZXk+PHJlYWw+MC4yNzU8L3JlYWw+CiAgPGtleT52Yy1uYWNrLWdlbmVyYXRvci1zdG9yZWJhZy1jb25maWctdmVyc2lvbjwva2V5PjxpbnRlZ2VyPjM8L2ludGVnZXI+CiAgPGtleT5kaXNhYmxlLWNhbGwtcmVjb3JkaW5nLWRpc2Nsb3N1cmUtY2hlY2tzPC9rZXk+PHRydWUvPgogIDxrZXk+dmMtYWItdGVzdGluZy1oaWdoZXItYXVkaW8tUkVELWN1dG92ZXI8L2tleT48cmVhbD4wLjU8L3JlYWw+CiAgPGtleT52Yy1hYi10ZXN0LWVuYWJsZS1tYXgtY2FtZXJhLWJpdHJhdGUtdmlkZW8tcXVhbGl0eS12My10aHJlc2hvbGQ8L2tleT48cmVhbD4wLjU8L3JlYWw+CiAgPGtleT5pZHMtdGhyZXNob2xkLXBlcmNlbnRhZ2UtZm9yLWNlbGx1bGFyLXNsaWNpbmc8L2tleT48aW50ZWdlcj4xMDA8L2ludGVnZXI+CiAgPGtleT5ja3YtcmVsaWFiaWxpdHktcmVwb3J0LWludGVydmFsPC9rZXk+PHN0cmluZz57MTgxMDozMCwxODMwOjcsOTk5OTo3fTwvc3RyaW5nPgogIDxrZXk+Y2t2LXJlbGlhYmlsaXR5LXNhbXBsZS1pbnRlcnZhbDwva2V5PjxzdHJpbmc+ezE4MTA6MSwxODMwOjEsOTk5OToxfTwvc3RyaW5nPgogIDxrZXk+Y2t2LXJlbGlhYmlsaXR5LXByZXNlbnQtcmF0ZTwva2V5PjxzdHJpbmc+ezE4MTA6MTAwLDE4MzA6MTAwLDk5OTk6MTAwfTwvc3RyaW5nPgogIDxrZXk+Y2t2LXJlbGlhYmlsaXR5LXNhbXBsZS1udW1iZXI8L2tleT48c3RyaW5nPnsxODEwOjEyMCwxODMwOjYwLDk5OTk6N308L3N0cmluZz4KICA8a2V5PmNrdi1yZWxpYWJpbGl0eS1wZWVydmVyaWZpY2F0aW9uLXByZXNlbnQtcmF0ZTwva2V5PjxzdHJpbmc+ezE4MTA6NTAsMTgzMDo1MCw5OTk5OjUwfTwvc3RyaW5nPgogIDxrZXk+Y2xpZW50LXJlcG9ydC1ja3YtcmVsaWFiaWxpdHktZW5hYmxlZDwva2V5Pjx0cnVlLz4KICA8a2V5PnZjLWFiLXRlc3QtdGhlcm1hbC1saWdodC1taXRpZ2F0aW9ucy1lbmFibGVkPC9rZXk+PHJlYWw+MS4wPC9yZWFsPgogIDxrZXk+aG9tZS1ybXZmc3VtYm9hPC9rZXk+PHN0cmluZz4xMC4zLjE8L3N0cmluZz4KICA8a2V5Pmt0LWZhaWx1cmUtcGVlci1tYXgtcGVlci1wZXItZGF5PC9rZXk+PGludGVnZXI+MjwvaW50ZWdlcj4KICA8a2V5Pmt0LWZhaWx1cmUtcGVlci1tYXgtZ2xvYmFsLXBlci1kYXk8L2tleT48aW50ZWdlcj4xMDwvaW50ZWdlcj4KICA8a2V5PnZjLWV4cGVyaW1lbnQtbmFjay1nZW5lcmF0aW9uPC9rZXk+PHJlYWw+MC41PC9yZWFsPgogIDxrZXk+dmMtZXhwZXJpbWVudC1mYXN0LWxxbS1yZXBvcnRpbmctdGhyZXNob2xkPC9rZXk+PHJlYWw+MC41PC9yZWFsPgogIDxrZXk+c2VuZGVyLWtleS1zZWxmLWNoZWNrLXJhdGUtbGltaXQtdGltZTwva2V5PjxpbnRlZ2VyPjI1OTIwMDA8L2ludGVnZXI+CiAgPGtleT5ya3MtZ2V0Q2xpZW50SWQ8L2tleT48c3RyaW5nPmh0dHBzOi8vcmtzLXJlZ2lzdHJhdGlvbi5lc3MuYXBwbGUuY29tL2dldENsaWVudElEPC9zdHJpbmc+CiAgPGtleT5ya3MtaXNzdWVDZXJ0PC9rZXk+PHN0cmluZz5odHRwczovL3Jrcy1yZWdpc3RyYXRpb24uZXNzLmFwcGxlLmNvbS9pc3N1ZUNlcnQ8L3N0cmluZz4KICA8a2V5PnJrcy1yZWdpc3Rlcjwva2V5PjxzdHJpbmc+aHR0cHM6Ly9ya3MtcmVnaXN0cmF0aW9uLmVzcy5hcHBsZS5jb20vcmVnaXN0ZXI8L3N0cmluZz4KICA8a2V5PnJrcy1xdWVyeTwva2V5PjxzdHJpbmc+aHR0cHM6Ly9ya3MtcHJveHkuZXNzLmFwcGxlLmNvbS9xdWVyeTwvc3RyaW5nPgogIDxrZXk+cmtzLWlzc3VlLWNlcnQtaW50ZXJ2YWw8L2tleT48cmVhbD4xMjk2MDAwLjA8L3JlYWw+CiAgPGtleT5ya3MtcmVmcmVzaC1rZXlzLWludGVydmFsPC9rZXk+PHJlYWw+MTI5NjAwMC4wPC9yZWFsPgogIDxrZXk+cmtzLWJyb2FkY2FzdC1jaGFubmVsLWlkPC9rZXk+PHN0cmluZz5IRkV5anFoK0VlOEFBTXFGZDlKbCtRPT08L3N0cmluZz4KICA8a2V5Pm1scy1lbmhhbmNlZC1zZWxmLWhlYWwtZGlzYWJsZWQ8L2tleT48aW50ZWdlcj4xPC9pbnRlZ2VyPgogIDxrZXk+aG9tZS1oaDItdXJyPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PmhvbWUtaGgyLXVyci1pbnRlcm5hbDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT5ob21lLWhoMi11cnItc2VlZDwva2V5PjxpbnRlZ2VyPjE8L2ludGVnZXI+CiAgPGtleT52Yy1hYi10ZXN0aW5nLWVuYWJsZS1sYXRlLWtleS1mcmFtZS1kZXRlY3Rpb248L2tleT48cmVhbD4wLjU8L3JlYWw+CiAgPGtleT52Yy1hYi10ZXN0LWVuYWJsZS1tYXgtY2FtZXJhLWJpdHJhdGUtdmlkZW8tcXVhbGl0eS12NC10aHJlc2hvbGQ8L2tleT48cmVhbD4wLjU8L3JlYWw+CiAgPGtleT52Yy1leHBlcmltZW50LWxvdy1iYW5kd2lkdGgtb3B0aW1pemF0aW9uPC9rZXk+PHJlYWw+MC41PC9yZWFsPgogIDxrZXk+aWQtcXVlcnktcmVmcmVzaC1xdWV1ZWQtcXVlcnktaW50ZXJ2YWw8L2tleT48aW50ZWdlcj4yODgwMDwvaW50ZWdlcj4KICA8a2V5PmFsbG93LXByb3RlY3RlZC1pZGVudGl0eS1leHBvcnQ8L2tleT48dHJ1ZS8+CiAgPGtleT5hbGxvdy1wcm90ZWN0ZWQtaWRlbnRpdHktZXhwb3J0LTIwMjY8L2tleT48dHJ1ZS8+CiAgPGtleT5kaXNhYmxlLXJlY2VwdGlvbmlzdC1kaXNjbG9zdXJlLWNoZWNrczwva2V5Pjx0cnVlLz4KICA8a2V5PmhvbWUtaGgyLXVycmlzLWludGVybmFsPC9rZXk+PGludGVnZXI+MTQ8L2ludGVnZXI+CiAgPGtleT5ob21lLWhoMi11cnJpcy1zZWVkPC9rZXk+PGludGVnZXI+MTQ8L2ludGVnZXI+CiAgPGtleT52Yy1ydGMtc2FtcGxpbmctdGhyZXNob2xkPC9rZXk+PGludGVnZXI+MTwvaW50ZWdlcj4KICA8a2V5PmhvbWUtaGgyLXVycmlzPC9rZXk+PGludGVnZXI+MTQ8L2ludGVnZXI+CiAgPGtleT5hbGxvdy1pbnRyb3MtdGlwPC9rZXk+PGludGVnZXI+MjwvaW50ZWdlcj4KICA8a2V5PnZjLWFiLXRlc3RpbmctZW5hYmxlLUFDQzI0LWZpeGVkLWJpdHJhdGUtbW9kZTwva2V5PjxyZWFsPjAuNTwvcmVhbD4KICA8a2V5PnZjLWFiLXRlc3RpbmctdXNlLWF1ZGlvLWNvZGVjLUFDQzI0LWZvci1VMTwva2V5PjxyZWFsPjAuNTwvcmVhbD4KICA8a2V5PnZjLWFiLXRlc3RpbmctdXNlLWF1ZGlvLWNvZGVjLUFDQzI0LWZvci1HRlQ8L2tleT48cmVhbD4wLjU8L3JlYWw+CiAgPGtleT52Yy1hYi10ZXN0aW5nLXVzZS1hdWRpby1jb2RlYy1BR1ZDLWZvci1VMTwva2V5PjxyZWFsPjAuNTwvcmVhbD4KICA8a2V5PnZjLWFiLXRlc3RpbmctZGV0ZWN0LWluYWN0aXZlLWF1ZGlvLWZyYW1lcy1BQ0MyNDwva2V5PjxyZWFsPjAuNTwvcmVhbD4KICA8a2V5Pmt0LW9ic2VydmFiaWxpdHktc2VsZWN0aW9uLXRocmVzaG9sZDwva2V5PjxpbnRlZ2VyPi0xPC9pbnRlZ2VyPgogIDxrZXk+bmFtZS1hbmQtcGhvdG8tZGlzYWxsb3dlZC1jb3VudHJ5LWNvZGVzPC9rZXk+CiAgPGFycmF5PgogICAgPHN0cmluZz5zZzwvc3RyaW5nPgogIDwvYXJyYXk+CiAgPGtleT5ncm91cC1uYW1lLWFuZC1waG90by1kaXNhbGxvd2VkLWNvdW50cnktY29kZXM8L2tleT4KICA8YXJyYXk+CiAgICA8c3RyaW5nPnNnPC9zdHJpbmc+CiAgPC9hcnJheT4KICA8a2V5PmhvbWUtaGVsaGt2LWludGVybmFsPC9rZXk+PHN0cmluZz4xMy4xPC9zdHJpbmc+CiAgPGtleT5ob21lLWhlbGVtdi1pbnRlcm5hbDwva2V5PjxzdHJpbmc+MjYuMTwvc3RyaW5nPgogIDxrZXk+aG9tZS1oZWxoa3Y8L2tleT48c3RyaW5nPjEzLjE8L3N0cmluZz4KICA8a2V5PmhvbWUtaGVsZW12PC9rZXk+PHN0cmluZz4yNi4xPC9zdHJpbmc+CiAgPGtleT5ja3YtZW5mb3JjZW1lbnQtbWFkcmlkLWVuYWJsZWQ8L2tleT48ZmFsc2UvPgogIDxrZXk+Y2t2LWVuZm9yY2VtZW50LW1hZHJpZC1wZXJjZW50YWdlPC9rZXk+PGludGVnZXI+MDwvaW50ZWdlcj4KICA8a2V5PmNrdi1lbmZvcmNlbWVudC1tYWRyaWQtYWNjb3VudC1wZXJjZW50YWdlPC9rZXk+PGludGVnZXI+MjA8L2ludGVnZXI+CiAgPGtleT5ja3YtZW5mb3JjZW1lbnQtbWFkcmlkLWFjY291bnQtcGVyY2VudGFnZS1zZWVkPC9rZXk+PGludGVnZXI+MTAwPC9pbnRlZ2VyPgogIDxrZXk+c3dpZnQtc2lnbmluZy1kaXNhYmxlZDwva2V5PjxmYWxzZS8+CiAgPGtleT52Yy1hYi10ZXN0aW5nLWVuYWJsZS1mdGEtcHJvYWN0aXZlLXJlZDwva2V5PjxyZWFsPjAuNTwvcmVhbD4KICA8a2V5PnZjLWV4cGVyaW1lbnQtcmVkdWNlLWtwaS12YXJpYXRpb24tdGllci1hLXRocmVzaG9sZDwva2V5PjxyZWFsPjAuNTwvcmVhbD4KICA8a2V5PnZjLWV4cGVyaW1lbnQtcmVkdWNlLWtwaS12YXJpYXRpb24tdGllci1iLXRocmVzaG9sZDwva2V5PjxyZWFsPjAuNTwvcmVhbD4KICA8a2V5PnZjLWtwaS12YXItZ3JhY2VmdWwtc3RvcC1pbi10aWVyLWI8L2tleT48aW50ZWdlcj4wPC9pbnRlZ2VyPgogIDxrZXk+dmMtZXhwZXJpbWVudC13aWZpLXJvYW0taGFuZG92ZXI8L2tleT48cmVhbD4wLjU8L3JlYWw+CjwvZGljdD4KPC9wbGlzdD4K + + diff --git a/codec/src/test/resources/imessage/validation-cert-1.0.plist b/codec/src/test/resources/imessage/validation-cert-1.0.plist new file mode 100644 index 0000000..26fd3b9 --- /dev/null +++ b/codec/src/test/resources/imessage/validation-cert-1.0.plist @@ -0,0 +1,9 @@ + + + + + + cert + AQIAAAQWMIIEEjCCAvqgAwIBAgIBHDANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMTEwMTI2MTkwMTM0WhcNMTkwMTI2MTkwMTM0WjCBhTELMAkGA1UEBhMCVVMxEzARBgNVBAoMCkFwcGxlIEluYy4xJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MTkwNwYDVQQDDDBBcHBsZSBTeXN0ZW0gSW50ZWdyYXRpb24gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDa4A+Yl8tYKYYqC7ieGVoxwy0OaixSAe4dA/uCQWnNUCY2ercMbw45A7jUGFajCLI8w/s2QeTXyGdgMgtOMn2H9/3NU7AauvwfbMlFB62COPOofMROwrFW2T6ybW0EQRrBmkfArBV8LXiRqweiZbF6g92YS3dA2O5Q68drWAgGl1dVfSf4Cua1Feenk/nxgOZCeT8W0zKdEXZBKQoxCe8PW/jzp6n3Ug27+C10rKZJHx/OewWnhT2+z6KnqiOFZv7FFhJ+W+Ixd5ECCd9+fuSK4OxBrBcsBOC8eaSJeEQGiztLoLyE4rCCtTK+BBwD7YI+dTcUz3WfgjFtzwkUhtEnAgMBAAGjga4wgaswDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFPAwc2Py7x2szOYJMsH6eXqxaVBoMB8GA1UdIwQYMBaAFCvQaUeUdgn+9GuNLkCm90dNfwheMDYGA1UdHwQvMC0wK6ApoCeGJWh0dHA6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2Evcm9vdC5jcmwwEAYKKoZIhvdjZAYCBAQCBQAwDQYJKoZIhvcNAQEFBQADggEBAD17j60fDCKKm0ujz/grsB9o4Qz3nCSDFgMt07Ko0EPorzyXJsit1SzETFVTAUnQ4rT75tty0Zi7/JvITrePzGWGf0S52icqTt/L39N930Fx+LPAHaIKM7nsK8Vzcvvhyl2OLzT0a8RPD8iKrA/7byVut66Ox+QCuCBOXVZMSZexJHR+yZOTNIyZ0afAHNPUwq5p65+fV+Jox8rVxSKCZEFY/njRysH5NmprRPezhnJ6ZEAXMZ28rHXw+jNR5b0Balg/8ACumVwKwsnpXhyHAuygCFVBKpuMZIWOUAPN4BGvznIZ61Lzr5Ktky6Undav/8Am8d6UkhzZvD02zFX6ONsAAAUxMIIFLTCCBBWgAwIBAgIISyyRSB2bfaAwDQYJKoZIhvcNAQEFBQAwgYUxCzAJBgNVBAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTE5MDcGA1UEAwwwQXBwbGUgU3lzdGVtIEludGVncmF0aW9uIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTExMDMyNTAxMTMzMloXDTE0MDMyNDAxMTMzMlowaTEdMBsGA1UEAwwURFJNIFRlY2hub2xvZ2llcyBBMDExJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALQGbX5lc5fhv0mx+poiLqfTcYEga0lBFcLbYHrGordNei+OwWMHHATMk9jgDci48lvObfpCyxBAwiQKp+QdJoKKPjCGXe0XOO6Hq73oSEpJd4Uut5GEmyl9QQWgefWtjMEL2J1p55yyqUbQS/4JGFAkillHKyJVR+1RIp1C6Z3ugcNHzeRvCio/TyvSBNC4jOhkmN/OYFObiBrP1MINdGW/84WHX0uHEKKHim0+QFUO+Z+ZzDKTg1GIybn4XskZXxfna5t8Ot3/aN/U0TRVdOz3S+gckHWF8vxD/6VEI1I/+/Uh44MWP74KdPk8dJlq/j/SWqFQ4y6LSA0iJjvVnkkCAwEAAaOCAbowggG2MB0GA1UdDgQWBBTSJCP76+iOj3GchO5icz3pXiQJLzAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFPAwc2Py7x2szOYJMsH6eXqxaVBoMIIBDgYDVR0gBIIBBTCCAQEwgf4GCSqGSIb3Y2QFATCB8DAoBggrBgEFBQcCARYcaHR0cDovL3d3dy5hcHBsZS5jb20vYXBwbGVjYTCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlzIGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2YgdGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9ucyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBwcmFjdGljZSBzdGF0ZW1lbnRzLjAvBgNVHR8EKDAmMCSgIqAghh5odHRwOi8vY3JsLmFwcGxlLmNvbS9hc2ljYS5jcmwwDgYDVR0PAQH/BAQDAgWgMBMGCiqGSIb3Y2QGDAEBAf8EAgUAMA0GCSqGSIb3DQEBBQUAA4IBAQB9eadjbkE7vsHOsYz6bTAguLpJMJI9HVXOucItS2LFykD2t7yx9tKm+q0Ba08czK7ORiD/wrPALHdP0BNETIfHYS0Px8xDLjc6N/2umJoStkmwqnfTU4GWgM2E23OqR6ggVjbC2aXpDDwiHXnv57BPCX1e+7Iio7b3IyUJg3moNFaE5kWtIqEcVZyiLx+2Ibn/2A/Jcwl28AMXGY/po/zmQstfZIaWjGg/wqBYQtSfdm2Vv8D32xR0/Fqogsem/FaKN7fIcpy8m0TRRuKNJNl/J3nxdLnFsrDC4SYG5P+vpQvZox6V20SRzOlLAjID5lL2pypaIzTQHRfy6+rCeQrp + + diff --git a/courier/build.gradle.kts b/courier/build.gradle.kts new file mode 100644 index 0000000..59fa43a --- /dev/null +++ b/courier/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + kotlin("jvm") // version inherited from the root classpath (kotlin-gradle-plugin 2.3.20) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":codec")) + implementation(libs.kotlinx.coroutines.core) + testImplementation(kotlin("test")) +} diff --git a/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierClient.kt b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierClient.kt new file mode 100644 index 0000000..bc26d8b --- /dev/null +++ b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierClient.kt @@ -0,0 +1,170 @@ +package dev.fenn.imessage.courier + +import java.io.Closeable +import java.io.IOException +import java.net.Socket +import java.security.SecureRandom +import java.util.concurrent.atomic.AtomicLong +import javax.net.ssl.SSLSocket +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * APNs courier transport per spec §3: TLS to "-" with SNI set + * to the bare hostname, legacy frame format, keepalive pacing, reconnect with backoff. + * + * Command construction lives in [CourierCommands] / [CourierConnect] (spec §3.3, C45 closed + * in rev 12). Keepalive/reconnect pacing are our client-policy constants (both source + * policies are recorded in spec §3.4; C21 open). + * + * TLS (§3.1 [CAP-COURIERTLS]): the courier chain anchors at the publicly-trusted + * `AAA Certificate Services` root — unlike the IDS endpoints, which anchor at Apple roots — + * so [tlsFactory] must pin that root (root only; the leaf rotates every ~90 days). Port + * note: the originating repo's embedded-root trust component was not ported into this + * module, so no default factory is provided — the caller supplies the pinned factory. + * Whether the handshake presents the Albert client certificate is C18-open — no client + * certificate is configured here. + */ +class CourierClient( + private val config: Config, + private val tlsFactory: (tcpSocket: Socket, sniHost: String) -> SSLSocket, +) : Closeable { + + data class Config( + /** Bag `APNSCourierHostname` (spec §3.1, [CAP-APNSBAG]: courier.push.apple.com). */ + val hostname: String, + /** Bag `APNSCourierHostcount` (spec §3.1, [CAP-APNSBAG]: 50). */ + val hostCount: Int = 50, + val port: Int = 5223, + val keepaliveSecs: Long = 60, + val pongTimeoutSecs: Long = 15, + val backoffCapSecs: Long = 30, + ) + + interface Handler { + fun onFrame(frame: CourierFrame.Frame) {} + fun onConnected() {} + } + + private val random = SecureRandom() + private val backoffAttempt = AtomicLong(0) + private var socket: SSLSocket? = null + private val writeLock = Any() + + /** Random message id for send messages: 1..2^31−1 (spec §3.3). */ + // The two-arg origin-bound nextInt is a desktop-JVM API ART lacks (NoSuchMethodError + // on-device, found by the service smoke) — use the classic bound form everywhere. + fun newMessageId(): Int = random.nextInt(Int.MAX_VALUE - 1) + 1 + + fun sendFrame(frame: ByteArray) { + val s = socket ?: throw IOException("not connected") + synchronized(writeLock) { + s.getOutputStream().apply { write(frame); flush() } + } + } + + /** + * Runs the connect/read/keepalive/reconnect loop until cancelled. Each connection + * calls [handler.onConnected], then every inbound frame goes to [handler.onFrame]. + */ + suspend fun run(handler: Handler) { + while (coroutineContext.isActive) { + val tcpHost = courierHost(config.hostname, config.hostCount, random) + try { + socket = withContext(Dispatchers.IO) { + val tcp = Socket() + tcp.connect(java.net.InetSocketAddress(tcpHost, config.port), CONNECT_TIMEOUT_MS) + val ssl = tlsFactory(tcp, config.hostname) + tcp.soTimeout = CONNECT_TIMEOUT_MS + try { + ssl.startHandshake() + } finally { + tcp.soTimeout = 0 // idle connections are healthy — only keepalive drops + } + ssl + } + backoffAttempt.set(0) + handler.onConnected() + val connection = socket!! + coroutineScope { + val reader = launch(Dispatchers.IO) { readLoop(connection, handler) } + launch(Dispatchers.IO) { keepaliveLoop(connection) } + reader.join() + closeSocket() // ends the keepalive ticker with the reader + } + } catch (e: CancellationException) { + closeSocket() + throw e + } catch (e: Exception) { + // reconnect loop: backoff applied below (spec §3.4, client policy) + } finally { + closeSocket() + } + val attempt = backoffAttempt.getAndIncrement() + delay(backoffDelayMs(attempt, config.backoffCapSecs * 1000)) + } + } + + private fun readLoop(connection: SSLSocket, handler: Handler) { + val input = connection.getInputStream() + while (true) { + val frame = CourierFrame.decode(input) ?: return // clean EOF + if (frame.command == CourierFrame.KEEPALIVE_ACK) lastPongMillis = System.currentTimeMillis() + handler.onFrame(frame) + } + } + + @Volatile + private var lastPongMillis: Long = 0 + + private fun keepaliveLoop(connection: SSLSocket) { + val intervalMs = config.keepaliveSecs * 1000 + val timeoutMs = config.pongTimeoutSecs * 1000 + lastPongMillis = System.currentTimeMillis() + while (!connection.isClosed) { + try { + sendFrame(CourierFrame.encode(CourierFrame.KEEPALIVE)) + } catch (e: IOException) { + return // reader will notice too; reconnect loop takes over + } + var slept = 0L + while (slept < intervalMs && !connection.isClosed) { + Thread.sleep(minOf(500L, intervalMs - slept)) + slept += 500L + } + if (System.currentTimeMillis() - lastPongMillis > intervalMs + timeoutMs) { + closeSocket() // silent past the pong window — drop and let the loop reconnect + return + } + } + } + + override fun close() = closeSocket() + + private fun closeSocket() { + try { + socket?.close() + } catch (e: IOException) { + // already closed / half-closed — nothing to do + } + socket = null + } + + companion object { + /** Connect to "-", SNI on the bare hostname (spec §3.1). */ + fun courierHost(hostname: String, hostCount: Int, random: SecureRandom): String = + "${random.nextInt(hostCount) + 1}-$hostname" + + /** Exponential backoff 1 s, 2 s, 4 s … capped (spec §3.4 client policy; C21 open). */ + fun backoffDelayMs(attempt: Long, capMs: Long): Long = + (1000L shl minOf(attempt, 20).toInt()).coerceAtMost(capMs) + + private const val CONNECT_TIMEOUT_MS = 10_000 + } +} diff --git a/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierCommands.kt b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierCommands.kt new file mode 100644 index 0000000..6aaf410 --- /dev/null +++ b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierCommands.kt @@ -0,0 +1,319 @@ +package dev.fenn.imessage.courier + +import dev.fenn.imessage.codec.BpDict +import dev.fenn.imessage.codec.BpString +import dev.fenn.imessage.codec.BpValue +import dev.fenn.imessage.codec.Bplist +import java.io.ByteArrayOutputStream +import java.security.MessageDigest + +/** + * Courier command builders over the §3.2 legacy frame: the per-command field-id tables of + * spec §3.3 (C45, closed in rev 12). Every table recorded there is implemented; where the + * two extracted sources disagree (C57, §7 item 21) the differing shape is an explicit + * parameter defaulting to what the section's prose states — never a silent choice. + * + * Note (rev 12): payload command 160 is a `c` value inside command-10 plists, NOT a frame + * command — the payload layer owns it; nothing here builds it. + */ +object CourierCommands { + + // --- connect (7) field ids, sent token/state/flags/cert/nonce/signature (§3.3) -------- + const val CONNECT_FIELD_TOKEN = 1 + const val CONNECT_FIELD_STATE = 2 + const val CONNECT_FIELD_FLAGS = 5 + const val CONNECT_FIELD_CERTIFICATE = 12 + const val CONNECT_FIELD_NONCE = 13 + const val CONNECT_FIELD_SIGNATURE = 14 + /** Rust-only extra: hardcoded version identifier, value 9 (§3.3, C57). */ + const val CONNECT_FIELD_RUST_VERSION = 0x10 + const val CONNECT_RUST_VERSION_VALUE = 9 + + /** Connect state byte (§3.3: "a state byte (1)"). */ + const val CONNECT_STATE = 1 + + /** + * Connection flags (§3.3, C57): both sources' base reads the same value `0b1000001` = + * 0x41; only Go adds the `0b100` bit marking a root connection. That bit is explicit in + * [CONNECT_FLAG_ROOT] — default off (the plain registered-device connection). + */ + const val CONNECT_FLAGS_BASE = 0x41 + const val CONNECT_FLAG_ROOT = 0x04 + + // --- send message (10) field ids (§3.3, outgoing agreed) ------------------------------ + const val SEND_FIELD_TOPIC = 1 + const val SEND_FIELD_TOKEN = 2 + const val SEND_FIELD_PAYLOAD = 3 + const val SEND_FIELD_MESSAGE_ID = 4 + + // --- send ack (11) field ids (§3.3): 1 token, 4 message id, 8 status ------------------ + const val ACK_FIELD_TOKEN = 1 + const val ACK_FIELD_MESSAGE_ID = 4 + const val ACK_FIELD_STATUS = 8 + + // --- set state (20) field ids (§3.3, both sources) ------------------------------------ + const val SET_STATE_FIELD_STATE = 1 + const val SET_STATE_FIELD_INTERVAL = 2 + /** §3.3: the interval field's recorded constant (both sources). */ + const val SET_STATE_INTERVAL = 0x7fffffff + + /** SHA-1 of a topic string — how topics appear on the wire (§3.3 filter, §2.3). */ + fun topicHash(topic: String): ByteArray = + MessageDigest.getInstance("SHA-1").digest(topic.toByteArray(Charsets.UTF_8)) + + /** + * Filter (9), sent after connect and on topic-set changes (§3.3). + * + * Field-set disagreement (C57): [FilterShape.PER_TOPIC_STATES] is the prose's reading — + * token (1) plus per-state fields enabled (2) / ignored (3) / opportunistic (4) / paused + * (5), each carrying the SHA-1 topic hashes in that state; [FilterShape.TOPIC_LIST] is + * the other client's shape — token (1) plus a single topic field (2) listing the + * subscribed hashes. + * + * TODO(capture): the multi-topic field encoding is not recorded — this concatenates the + * 20-byte digests per field; a real client's filter frame arbitrates. + */ + enum class FilterShape { PER_TOPIC_STATES, TOPIC_LIST } + + fun filterFrame( + token: ByteArray, + enabled: List = emptyList(), + ignored: List = emptyList(), + opportunistic: List = emptyList(), + paused: List = emptyList(), + shape: FilterShape = FilterShape.PER_TOPIC_STATES, + ): CourierFrame.Frame { + val fields = ArrayList() + fields.add(CourierFrame.Frame.Field(1, token)) + when (shape) { + FilterShape.PER_TOPIC_STATES -> { + fields.add(CourierFrame.Frame.Field(2, hashes(enabled))) + fields.add(CourierFrame.Frame.Field(3, hashes(ignored))) + fields.add(CourierFrame.Frame.Field(4, hashes(opportunistic))) + fields.add(CourierFrame.Frame.Field(5, hashes(paused))) + } + FilterShape.TOPIC_LIST -> + fields.add(CourierFrame.Frame.Field(2, hashes(enabled + ignored + opportunistic + paused))) + } + return CourierFrame.Frame(CourierFrame.FILTER, fields) + } + + /** + * Send message (10), outgoing fields agreed (§3.3): topic (1), connect token (2), + * payload plist (3), message id (4, u32 BE — random in 1..2^31−1, see + * [CourierClient.newMessageId]). [topic] is the wire form — the SHA-1 of the topic string. + */ + fun sendFrame(topic: ByteArray, token: ByteArray, payload: ByteArray, messageId: Int): CourierFrame.Frame = + CourierFrame.Frame( + CourierFrame.SEND, + listOf( + CourierFrame.Frame.Field(SEND_FIELD_TOPIC, topic), + CourierFrame.Frame.Field(SEND_FIELD_TOKEN, token), + CourierFrame.Frame.Field(SEND_FIELD_PAYLOAD, payload), + CourierFrame.Frame.Field(SEND_FIELD_MESSAGE_ID, u32(messageId)), + ), + ) + + /** + * Send ack (11) (§3.3): token (1), the acknowledged message id (4), status (8; 0 = + * accepted, 0x80 = sent-too-early, 0x03 = too large). + */ + fun ackFrame(token: ByteArray, messageId: Int, status: Int): CourierFrame.Frame = + CourierFrame.Frame( + CourierFrame.SEND_ACK, + listOf( + CourierFrame.Frame.Field(ACK_FIELD_TOKEN, token), + CourierFrame.Frame.Field(ACK_FIELD_MESSAGE_ID, u32(messageId)), + CourierFrame.Frame.Field(ACK_FIELD_STATUS, byteArrayOf(status.toByte())), + ), + ) + + /** Keepalive (12) / keepalive ack (13): no fields (§3.3). */ + fun keepaliveFrame(): ByteArray = CourierFrame.encode(CourierFrame.KEEPALIVE) + + fun keepaliveAckFrame(): ByteArray = CourierFrame.encode(CourierFrame.KEEPALIVE_ACK) + + /** + * Set state (20) (§3.3, both sources): state (1, u8) and interval (2) at the recorded + * constant [SET_STATE_INTERVAL]. + */ + fun setStateFrame(state: Int): CourierFrame.Frame = + CourierFrame.Frame( + CourierFrame.SET_STATE, + listOf( + CourierFrame.Frame.Field(SET_STATE_FIELD_STATE, byteArrayOf(state.toByte())), + CourierFrame.Frame.Field(SET_STATE_FIELD_INTERVAL, u32(SET_STATE_INTERVAL)), + ), + ) + + /** + * Subscribe-to-channels (29) (§3.3): recorded from one source only with fields 1 index, + * 2 message (protobuf), 3 token; its response carries a status field 4. + * TODO(capture) C24 — whether real clients use it; unwired until then. + */ + fun subscribeChannelsFrame(index: ByteArray, message: ByteArray, token: ByteArray): CourierFrame.Frame = + CourierFrame.Frame( + 29, + listOf( + CourierFrame.Frame.Field(1, index), + CourierFrame.Frame.Field(2, message), + CourierFrame.Frame.Field(3, token), + ), + ) + + // --- tunnel request (96) / tunnel response (97) (spec §2.1) --------------------------- + + /** The header dictionary key inside the tunnel payload (spec §2.1: "the lookup request + * and its headers travel inside a tunnel payload whose header dictionary is keyed `h`"). */ + const val TUNNEL_HEADERS_KEY = "h" + + /** §2.1: "a 16-byte request UUID" correlates the command-97 response with its request. */ + const val TUNNEL_REQUEST_UUID_BYTES = 16 + + /** + * Frame field ids of the tunnel commands. Spec §2.1 records the field SET — 96: URL, + * headers, content type, body, 16-byte request UUID; 97: the same request UUID, a status + * field, the body — but not their ids, so the mapping is caller-supplied and a stale + * guess fails loudly instead of silently riding along (TODO(capture) C12). + */ + data class TunnelFieldIds( + val requestUrl: Int, + val requestHeaders: Int, + val requestContentType: Int, + val requestBody: Int, + val requestUuid: Int, + val replyUuid: Int, + val replyStatus: Int, + val replyBody: Int, + ) + + /** + * The command-96 tunnel request (§2.1): one field per recorded item. The headers field + * carries the header dictionary keyed [TUNNEL_HEADERS_KEY] — a binary plist `{"h": {…}}`. + */ + fun tunnelRequestFrame( + url: String, + headers: Map, + contentType: String, + body: ByteArray, + requestUuid: ByteArray, + ids: TunnelFieldIds, + ): CourierFrame.Frame { + require(requestUuid.size == TUNNEL_REQUEST_UUID_BYTES) { + "tunnel request uuid must be $TUNNEL_REQUEST_UUID_BYTES bytes, got ${requestUuid.size}" + } + return CourierFrame.Frame( + CourierFrame.TUNNEL_REQUEST, + listOf( + CourierFrame.Frame.Field(ids.requestUrl, url.toByteArray(Charsets.UTF_8)), + CourierFrame.Frame.Field( + ids.requestHeaders, + // binary plist {"h": {header: value, …}} + Bplist.encode( + BpDict( + buildMap { + put( + BpString(TUNNEL_HEADERS_KEY), + BpDict( + LinkedHashMap().apply { + for ((k, v) in headers) put(BpString(k), BpString(v)) + } + ), + ) + } + ) + ), + ), + CourierFrame.Frame.Field(ids.requestContentType, contentType.toByteArray(Charsets.UTF_8)), + CourierFrame.Frame.Field(ids.requestBody, body), + CourierFrame.Frame.Field(ids.requestUuid, requestUuid), + ), + ) + } + + /** The parsed command-97 tunnel response (§2.1): same request UUID, a status field, the body. */ + data class TunnelResponse(val requestUuid: ByteArray, val status: Int, val body: ByteArray) + + fun parseTunnelResponse(frame: CourierFrame.Frame, ids: TunnelFieldIds): TunnelResponse { + require(frame.command == CourierFrame.TUNNEL_RESPONSE) { + "command ${frame.command} is not a tunnel response" + } + val uuid = frame.field(ids.replyUuid) + ?: throw IllegalArgumentException("tunnel response without a request-uuid field") + val status = frame.field(ids.replyStatus)?.let(::u32Value) + ?: throw IllegalArgumentException("tunnel response without a status field") + return TunnelResponse(uuid, status, frame.field(ids.replyBody) ?: ByteArray(0)) + } + + // --- connect response (8) ------------------------------------------------------------- + + /** Connect-response status values (§3.3): 0 ok; 2 invalid certificate. */ + const val CONNECT_STATUS_OK = 0 + const val CONNECT_STATUS_INVALID_CERT = 2 + + /** + * The connect response (§3.3): status (1) and the 32-byte connect token (3) — the token + * the client stores and uses in later messages. The other source's extra fields (4 max + * message size ~4 KiB, 5, 6 capabilities, 8 large-message size ~15 KiB, 10 server + * timestamp unix ms) are parsed when present; the shape of 5 and 6 is unrecorded, so + * they stay raw (C57). + */ + data class ConnectResponse( + val status: Int, + val token: ByteArray?, + val maxMessageSize: Int?, + val capabilities: ByteArray?, + val largeMessageSize: Int?, + val serverTimestampMs: Long?, + val raw: List, + ) { + fun ok(): Boolean = status == CONNECT_STATUS_OK + } + + fun parseConnectResponse(frame: CourierFrame.Frame): ConnectResponse { + require(frame.command == CourierFrame.CONNECT_RESPONSE) { + "command ${frame.command} is not a connect response" + } + val status = frame.field(1)?.let { if (it.isEmpty()) null else it[0].toInt() and 0xFF } + ?: throw IllegalArgumentException("connect response without a status field") + return ConnectResponse( + status = status, + token = frame.field(3), + maxMessageSize = frame.field(4)?.let(::u32Value), + capabilities = frame.field(6), + largeMessageSize = frame.field(8)?.let(::u32Value), + serverTimestampMs = frame.field(10)?.let(::u64Value), + raw = frame.fields, + ) + } + + private fun hashes(topics: List): ByteArray { + val out = ByteArrayOutputStream(topics.size * 20) + for (hash in topics) { + require(hash.size == 20) { "topic hashes are SHA-1 (20 B), got ${hash.size} B" } + out.write(hash) + } + return out.toByteArray() + } + + internal fun u32(v: Int): ByteArray = + byteArrayOf((v ushr 24).toByte(), (v ushr 16).toByte(), (v ushr 8).toByte(), v.toByte()) + + private fun u32Value(value: ByteArray): Int? = + when (value.size) { + 0 -> null + 1 -> value[0].toInt() and 0xFF + 2 -> ((value[0].toInt() and 0xFF) shl 8) or (value[1].toInt() and 0xFF) + 3 -> ((value[0].toInt() and 0xFF) shl 16) or ((value[1].toInt() and 0xFF) shl 8) or + (value[2].toInt() and 0xFF) + else -> ((value[0].toInt() and 0xFF) shl 24) or ((value[1].toInt() and 0xFF) shl 16) or + ((value[2].toInt() and 0xFF) shl 8) or (value[3].toInt() and 0xFF) + } + + private fun u64Value(value: ByteArray): Long? { + if (value.isEmpty()) return null + var v = 0L + for (b in value) v = (v shl 8) or (b.toLong() and 0xFF) + return v + } +} diff --git a/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierConnect.kt b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierConnect.kt new file mode 100644 index 0000000..4c02515 --- /dev/null +++ b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierConnect.kt @@ -0,0 +1,104 @@ +package dev.fenn.imessage.courier + +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.Signature + +/** + * The connect command's auth construction (spec §3.3): device/push token (1), state 1 (2), + * connection flags (5), the Albert device certificate (12), a nonce (13) and its RSA + * signature (14), sent in that order. + * + * Nonce (§3.3): the two sources agree it is the structured 17-byte nonce of §1.4 with the + * type byte 0x00 (APNs) — 0x00 ‖ big-endian unix-milliseconds ‖ 8 random bytes — signed + * *alone*: `0x01 0x01` + RSA-SHA1-PKCS1v1.5 over the raw nonce. The Go client instead sends + * a 20-byte random nonce with the first byte zeroed ([NonceShape.Random20FirstByteZeroed]); + * the spec does not arbitrate (C57), so the shape is an explicit parameter defaulting to the + * section's structured agreement. + * + * Port note: in the originating repo the structured nonce and the signature prefix lived in + * the signing component (not ported into this module); the spec-derived byte shapes it + * provides (§1.4/§3.3) are inlined below. The certificate comes from the stored activation + * state and the signature key is the push key the certificate was issued for. + */ +object CourierConnect { + + enum class NonceShape { + /** §3.3's recorded agreement: the 17-byte §1.4 nonce with the APNs type byte 0x00. */ + Structured17, + + /** Go-only variant (C57): 20 random bytes with the first byte zeroed. */ + Random20FirstByteZeroed, + } + + /** §1.4 nonce type byte for APNs use. */ + private const val NONCE_TYPE_APNS = 0x00 + + /** §1.4/§3.3: every signature is prefixed with the two bytes 0x01 0x01. */ + private val SIGNATURE_PREFIX = byteArrayOf(0x01, 0x01) + + fun nonce( + shape: NonceShape = NonceShape.Structured17, + nowMillis: Long = System.currentTimeMillis(), + random: SecureRandom = SecureRandom(), + ): ByteArray = when (shape) { + NonceShape.Structured17 -> structuredNonce(nowMillis, random) + NonceShape.Random20FirstByteZeroed -> ByteArray(20).also { random.nextBytes(it); it[0] = 0x00 } + } + + /** §1.4 structured nonce: type byte ‖ 8-byte BE unix-millis ‖ 8 random bytes. */ + private fun structuredNonce(nowMillis: Long, random: SecureRandom): ByteArray { + val tail = ByteArray(8).also(random::nextBytes) + val millis = ByteArray(8) + for (i in 0 until 8) millis[i] = (nowMillis ushr (8 * (7 - i))).toByte() + return byteArrayOf(NONCE_TYPE_APNS.toByte()) + millis + tail + } + + /** `0x01 0x01` + RSA-SHA1-PKCS1v1.5 over [nonce] alone (§3.3 — signed alone, no fields). */ + fun signature(pushKey: PrivateKey, nonce: ByteArray): ByteArray { + val signer = Signature.getInstance("SHA1withRSA") + signer.initSign(pushKey) + signer.update(nonce) + return SIGNATURE_PREFIX + signer.sign() + } + + /** + * The connect frame's fields in the recorded order (§3.3). [flags] starts from + * [CourierCommands.CONNECT_FLAGS_BASE]; the Go-only root bit ([CourierCommands] + * .CONNECT_FLAG_ROOT, C57) is added by the caller. [includeRustVersionField] appends the + * Rust-only field 0x10 = 9 (C57) — its position is unrecorded, so it goes last; fields + * are id-tagged, so order only matters for the recorded six. + */ + fun connectFrame( + deviceToken: ByteArray, + pushCertificateDer: ByteArray, + nonce: ByteArray, + signature: ByteArray, + flags: Int = CourierCommands.CONNECT_FLAGS_BASE, + includeRustVersionField: Boolean = false, + ): CourierFrame.Frame { + require(flags and CourierCommands.CONNECT_FLAGS_BASE == CourierCommands.CONNECT_FLAGS_BASE) { + "flags ${flags.toString(2)} lack the base 0x41 (spec §3.3)" + } + val fields = listOf( + CourierFrame.Frame.Field(CourierCommands.CONNECT_FIELD_TOKEN, deviceToken), + CourierFrame.Frame.Field( + CourierCommands.CONNECT_FIELD_STATE, + byteArrayOf(CourierCommands.CONNECT_STATE.toByte()), + ), + CourierFrame.Frame.Field(CourierCommands.CONNECT_FIELD_FLAGS, byteArrayOf(flags.toByte())), + CourierFrame.Frame.Field(CourierCommands.CONNECT_FIELD_CERTIFICATE, pushCertificateDer), + CourierFrame.Frame.Field(CourierCommands.CONNECT_FIELD_NONCE, nonce), + CourierFrame.Frame.Field(CourierCommands.CONNECT_FIELD_SIGNATURE, signature), + ) + val withExtra = if (includeRustVersionField) { + fields + CourierFrame.Frame.Field( + CourierCommands.CONNECT_FIELD_RUST_VERSION, + byteArrayOf(CourierCommands.CONNECT_RUST_VERSION_VALUE.toByte()), + ) + } else { + fields + } + return CourierFrame.Frame(CourierFrame.CONNECT, withExtra) + } +} diff --git a/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierFrame.kt b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierFrame.kt new file mode 100644 index 0000000..20bdb61 --- /dev/null +++ b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierFrame.kt @@ -0,0 +1,109 @@ +package dev.fenn.imessage.courier + +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.EOFException +import java.io.IOException +import java.io.InputStream + +/** + * APNs courier legacy frame codec per spec §3.2: 1-byte command, 4-byte big-endian total + * length covering the whole field block after the command byte, then fields (1-byte id, + * 2-byte big-endian length, value). Command numbers and the per-command field-id tables are + * wire facts (spec §3.3, C45 closed in rev 12) — builders live in [CourierCommands] / + * [CourierConnect]. Payload command 160 is a `c` value inside command-10 plists, not a frame + * command (the payload layer owns it; nothing here builds it). + */ +object CourierFrame { + + // Command numbers (spec §3.3) + const val CONNECT = 7 + const val CONNECT_RESPONSE = 8 + const val FILTER = 9 + const val SEND = 10 + const val SEND_ACK = 11 + const val KEEPALIVE = 12 + const val KEEPALIVE_ACK = 13 + const val SET_STATE = 20 + + /** + * Tunnel request/response (spec §2.1): IDS lookups ride the APNs tunnel — 96 carries the + * URL, headers, content type, body and a 16-byte request UUID; the 97 reply carries the + * same UUID, a status field and the body, on the same topic. Field-id tables live in + * [CourierCommands.TunnelFieldIds] (the spec records the field set, not the ids). + */ + const val TUNNEL_REQUEST = 96 + const val TUNNEL_RESPONSE = 97 + + /** Sanity cap on frame size — the spec's advertised message sizes are 4–15 KiB (§3.3). */ + const val MAX_FRAME_BYTES = 256 * 1024 + + class Frame(val command: Int, val fields: List) { + data class Field(val id: Int, val value: ByteArray) + + fun field(id: Int): ByteArray? = fields.firstOrNull { it.id == id }?.value + + override fun equals(other: Any?): Boolean = + other is Frame && other.command == command && other.fields.size == fields.size && + other.fields.zip(fields).all { (a, b) -> a.id == b.id && a.value.contentEquals(b.value) } + + override fun hashCode(): Int = command * 31 + fields.size + } + + fun encode(command: Int, fields: List> = emptyList()): ByteArray { + val body = ByteArrayOutputStream() + for ((id, value) in fields) { + if (value.size > 0xFFFF) throw IllegalArgumentException("field $id too large (${value.size} B)") + body.write(id) + body.write((value.size shr 8) and 0xFF) + body.write(value.size and 0xFF) + body.write(value) + } + val payload = body.toByteArray() + if (5 + payload.size > MAX_FRAME_BYTES) throw IllegalArgumentException("frame too large") + val out = ByteArrayOutputStream(5 + payload.size) + out.write(command) + writeU32(out, payload.size) + out.write(payload) + return out.toByteArray() + } + + /** Returns null on clean EOF (peer closed between frames); throws on malformed data. */ + fun decode(input: InputStream): Frame? { + val data = DataInputStream(input) + val command = try { + data.readUnsignedByte() + } catch (e: EOFException) { + return null + } + val length = readU32(data) + if (length > MAX_FRAME_BYTES) throw IOException("frame length $length exceeds cap") + var remaining = length + val fields = ArrayList() + while (remaining > 0) { + if (remaining < 3) throw IOException("truncated field header") + val id = data.readUnsignedByte() + val valueLen = data.readUnsignedShort() + if (valueLen + 3 > remaining) throw IOException("field overruns frame") + val value = ByteArray(valueLen) + data.readFully(value) + fields.add(Frame.Field(id, value)) + remaining -= 3 + valueLen + } + return Frame(command, fields) + } + + private fun writeU32(out: ByteArrayOutputStream, v: Int) { + out.write((v ushr 24) and 0xFF) + out.write((v ushr 16) and 0xFF) + out.write((v ushr 8) and 0xFF) + out.write(v and 0xFF) + } + + private fun readU32(data: DataInputStream): Int { + val b = ByteArray(4) + data.readFully(b) + return ((b[0].toInt() and 0xFF) shl 24) or ((b[1].toInt() and 0xFF) shl 16) or + ((b[2].toInt() and 0xFF) shl 8) or (b[3].toInt() and 0xFF) + } +} diff --git a/courier/src/test/kotlin/dev/fenn/imessage/courier/CourierTest.kt b/courier/src/test/kotlin/dev/fenn/imessage/courier/CourierTest.kt new file mode 100644 index 0000000..fbb12a9 --- /dev/null +++ b/courier/src/test/kotlin/dev/fenn/imessage/courier/CourierTest.kt @@ -0,0 +1,374 @@ +package dev.fenn.imessage.courier + +import java.io.ByteArrayInputStream +import java.io.IOException +import java.security.KeyPairGenerator +import java.security.Signature +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CourierFrameTest { + + private fun roundTrip(frame: CourierFrame.Frame): CourierFrame.Frame { + val bytes = CourierFrame.encode(frame.command, frame.fields.map { it.id to it.value }) + return CourierFrame.decode(ByteArrayInputStream(bytes))!! + } + + // Hand-computed: keepalive (12) has no fields (spec §3.3) → 0C 00000000 + @Test + fun knownAnswerEmptyFrame() { + assertContentEquals(byteArrayOf(0x0C, 0, 0, 0, 0), CourierFrame.encode(CourierFrame.KEEPALIVE)) + } + + @Test + fun knownAnswerSingleField() { + val f = byteArrayOf(0x10, 0, 1, 9) + val expected = byteArrayOf(20, 0, 0, 0, 4) + f + assertContentEquals(expected, CourierFrame.encode(20, listOf(0x10 to byteArrayOf(9)))) + assertEquals(CourierFrame.decode(ByteArrayInputStream(expected))!!.field(0x10)!!.single().toInt(), 9) + } + + @Test + fun roundTripMultiField() { + val frame = CourierFrame.Frame( + CourierFrame.SEND, + listOf( + CourierFrame.Frame.Field(1, ByteArray(300) { it.toByte() }), // 2-byte field length path + CourierFrame.Frame.Field(2, byteArrayOf(1)), + CourierFrame.Frame.Field(3, ByteArray(0)), + ), + ) + assertEquals(frame, roundTrip(frame)) + assertEquals(300, frame.fields[0].value.size) + } + + @Test + fun backToBackFramesDecode() { + val a = CourierFrame.encode(CourierFrame.KEEPALIVE) + val b = CourierFrame.encode(CourierFrame.SEND_ACK, listOf(1 to byteArrayOf(5, 6))) + val input = ByteArrayInputStream(a + b) + assertEquals(CourierFrame.KEEPALIVE, CourierFrame.decode(input)!!.command) + val ack = CourierFrame.decode(input)!! + assertEquals(CourierFrame.SEND_ACK, ack.command) + assertContentEquals(byteArrayOf(5, 6), ack.field(1)) + assertNull(CourierFrame.decode(input)) // EOF between frames is a clean null + } + + @Test + fun decodeRejectsMalformed() { + // length prefix beyond the cap + assertFailsWith { + CourierFrame.decode(ByteArrayInputStream(byteArrayOf(12, 0x10, 0, 0, 0))) + } + // field overruns the frame: length=6 but field claims 3+5 + assertFailsWith { + CourierFrame.decode(ByteArrayInputStream(byteArrayOf(12, 0, 0, 0, 6, 1, 0, 5, 9, 9))) + } + // truncated stream mid-frame + assertFailsWith { + CourierFrame.decode(ByteArrayInputStream(byteArrayOf(12, 0, 0))) + } + } +} + +class CourierClientHelpersTest { + + // No TLS in helper tests — the pinned-root factory is caller-supplied (port note). + private fun stubClient() = + CourierClient(CourierClient.Config("courier.push.apple.com")) { _, _ -> + error("no TLS in helper tests") + } + + @Test + fun courierHostIsShardPrefixed() { + val random = java.security.SecureRandom() + repeat(50) { + val host = CourierClient.courierHost("courier.push.apple.com", 50, random) + val shard = host.substringBefore('-').toInt() + assertTrue(shard in 1..50, "shard out of range: $host") + assertTrue(host.endsWith("-courier.push.apple.com")) + } + } + + @Test + fun backoffGrowsAndCaps() { + assertEquals(1000, CourierClient.backoffDelayMs(0, 30_000)) + assertEquals(2000, CourierClient.backoffDelayMs(1, 30_000)) + assertEquals(8000, CourierClient.backoffDelayMs(3, 30_000)) + assertEquals(30_000, CourierClient.backoffDelayMs(10, 30_000)) + assertEquals(30_000, CourierClient.backoffDelayMs(100, 30_000)) + } + + @Test + fun messageIdInRange() { + val client = stubClient() + repeat(100) { + val id = client.newMessageId() + assertTrue(id in 1..(Int.MAX_VALUE - 1), "message id out of range: $id") + } + } +} + +/** The §3.3 field-id tables (C45, closed in rev 12) — known-answer where bytes are recorded. */ +class CourierCommandsTest { + + private val token = ByteArray(32) { (0x10 + it).toByte() } + private val cert = ByteArray(24) { (0x20 + it).toByte() } + private val nonce = ByteArray(17) { (0x30 + it).toByte() } + private val signature = ByteArray(8) { (0x40 + it).toByte() } + + @Test + fun connectFrameFieldsInRecordedOrder() { + val frame = CourierConnect.connectFrame(token, cert, nonce, signature) + assertEquals(CourierFrame.CONNECT, frame.command) + // token/state/flags/cert/nonce/signature, ids 1/2/5/12/13/14 (spec §3.3) + assertContentEquals(listOf(1, 2, 5, 12, 13, 14), frame.fields.map { it.id }) + assertContentEquals(token, frame.field(1)) + assertEquals(1, frame.field(2)!![0].toInt()) // the state byte (1) + assertEquals(0x41, frame.field(5)!![0].toInt()) // base flags 0b1000001 + assertContentEquals(cert, frame.field(12)) + assertContentEquals(nonce, frame.field(13)) + assertContentEquals(signature, frame.field(14)) + } + + @Test + fun connectFrameKnownAnswerBytes() { + // Whole-frame layout for a 2-byte stand-in cert: 07 | len | 01.. fields + val smallCert = byteArrayOf(0x61, 0x62) + val bytes = CourierFrame.encode( + CourierFrame.CONNECT, + CourierConnect.connectFrame(token, smallCert, nonce, signature).fields + .map { it.id to it.value }, + ) + // command 7, 4-byte BE length = (32+3)+(1+3)+(1+3)+(2+3)+(17+3)+(8+3) = 79 + assertEquals(0x07, bytes[0].toInt()) + assertEquals(79, ((bytes[1].toInt() and 0xFF) shl 24) or ((bytes[2].toInt() and 0xFF) shl 16) or + ((bytes[3].toInt() and 0xFF) shl 8) or (bytes[4].toInt() and 0xFF)) + val parsed = CourierFrame.decode(ByteArrayInputStream(bytes))!! + assertEquals(frameIdsFor(parsed), listOf(1, 2, 5, 12, 13, 14)) + } + + private fun frameIdsFor(frame: CourierFrame.Frame) = frame.fields.map { it.id } + + @Test + fun connectRootFlagAddsTheGoRootBit() { + val plain = CourierConnect.connectFrame(token, cert, nonce, signature) + val root = CourierConnect.connectFrame( + token, cert, nonce, signature, + flags = CourierCommands.CONNECT_FLAGS_BASE or CourierCommands.CONNECT_FLAG_ROOT, + ) + assertEquals(0x41, plain.field(5)!![0].toInt()) + assertEquals(0x45, root.field(5)!![0].toInt()) + } + + @Test + fun connectRejectsFlagsWithoutTheBase() { + assertFailsWith { + CourierConnect.connectFrame(token, cert, nonce, signature, flags = 0x04) + } + } + + @Test + fun connectRustVersionFieldIsExplicitAndAppended() { + val plain = CourierConnect.connectFrame(token, cert, nonce, signature) + assertNull(plain.field(0x10)) // not sent by default (C57: Rust-only extra) + val withExtra = CourierConnect.connectFrame(token, cert, nonce, signature, includeRustVersionField = true) + assertEquals(9, withExtra.field(0x10)!![0].toInt()) + assertEquals(listOf(1, 2, 5, 12, 13, 14, 0x10), frameIdsFor(withExtra)) + } + + @Test + fun structuredNonceIsTheSpecShape() { + val nowMillis = 0x00_01_02_03_04_05_06_07 + val nonce = CourierConnect.nonce(CourierConnect.NonceShape.Structured17, nowMillis) + assertEquals(17, nonce.size) + assertEquals(0x00, nonce[0].toInt()) // APNs type byte + for (i in 0 until 8) { + assertEquals(((nowMillis ushr ((7 - i) * 8)) and 0xFF).toInt(), nonce[1 + i].toInt() and 0xFF) + } + } + + @Test + fun goVariantNonceIs20RandomBytesFirstZeroed() { + val nonce = CourierConnect.nonce(CourierConnect.NonceShape.Random20FirstByteZeroed) + assertEquals(20, nonce.size) + assertEquals(0, nonce[0].toInt()) // first byte zeroed (C57) + } + + @Test + fun connectSignatureVerifiesOverTheRawNonceAlone() { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val nonce = CourierConnect.nonce(nowMillis = 1_700_000_000_000) + val signature = CourierConnect.signature(keyPair.private, nonce) + assertEquals(0x01, signature[0].toInt()) + assertEquals(0x01, signature[1].toInt()) + val verifier = Signature.getInstance("SHA1withRSA") + verifier.initVerify(keyPair.public) + verifier.update(nonce) // signed *alone* — no length-prefixed fields (spec §3.3) + assertTrue(verifier.verify(signature.copyOfRange(2, signature.size))) + } + + @Test + fun filterDefaultShapeIsPerTopicStates() { + val madrid = CourierCommands.topicHash("com.apple.madrid") + val sms = CourierCommands.topicHash("com.apple.private.alloy.sms") + val frame = CourierCommands.filterFrame(token, enabled = listOf(madrid), ignored = listOf(sms)) + assertEquals(CourierFrame.FILTER, frame.command) + // Rust reading per the §3.3 prose: 1 token, 2 enabled, 3 ignored, 4 opportunistic, 5 paused + assertContentEquals(listOf(1, 2, 3, 4, 5), frame.fields.map { it.id }) + assertContentEquals(token, frame.field(1)) + assertContentEquals(madrid, frame.field(2)) + assertContentEquals(sms, frame.field(3)) + assertEquals(0, frame.field(4)!!.size) + assertEquals(0, frame.field(5)!!.size) + } + + @Test + fun filterTopicListShapeIsTheGoVariant() { + val madrid = CourierCommands.topicHash("com.apple.madrid") + val frame = CourierCommands.filterFrame(token, enabled = listOf(madrid), shape = CourierCommands.FilterShape.TOPIC_LIST) + assertContentEquals(listOf(1, 2), frame.fields.map { it.id }) + assertContentEquals(madrid, frame.field(2)) + } + + @Test + fun filterTopicsAreSha1HashesConcatenated() { + val topics = listOf("com.apple.madrid", "com.apple.private.alloy.sms") + val frame = CourierCommands.filterFrame(token, enabled = topics.map(CourierCommands::topicHash)) + assertContentEquals( + topics.map(CourierCommands::topicHash).reduce { a, b -> a + b }, + frame.field(2), + ) + } + + @Test + fun sendFrameFieldIdsAndBigEndianMessageId() { + val payload = byteArrayOf(0x0B, 0x0E, 0x0E, 0x0F) + val frame = CourierCommands.sendFrame( + CourierCommands.topicHash("com.apple.madrid"), token, payload, 0x01020304, + ) + assertEquals(CourierFrame.SEND, frame.command) + assertContentEquals(listOf(1, 2, 3, 4), frame.fields.map { it.id }) + assertContentEquals(byteArrayOf(1, 2, 3, 4), frame.field(4)) // u32 BE + assertContentEquals(payload, frame.field(3)) + } + + @Test + fun ackFrameFieldIds() { + val frame = CourierCommands.ackFrame(token, 0x01020304, 0) + assertEquals(CourierFrame.SEND_ACK, frame.command) + assertContentEquals(listOf(1, 4, 8), frame.fields.map { it.id }) + assertContentEquals(byteArrayOf(0), frame.field(8)) + assertContentEquals(byteArrayOf(1, 2, 3, 4), frame.field(4)) + } + + @Test + fun setStateCarriesTheRecordedIntervalConstant() { + val frame = CourierCommands.setStateFrame(1) + assertEquals(CourierFrame.SET_STATE, frame.command) + assertContentEquals(listOf(1, 2), frame.fields.map { it.id }) + assertEquals(1, frame.field(1)!![0].toInt()) + assertContentEquals(byteArrayOf(0x7F, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()), frame.field(2)) + } + + @Test + fun connectResponseParsesStatusTokenAndTheGoOnlyExtras() { + val frame = CourierFrame.Frame( + CourierFrame.CONNECT_RESPONSE, + listOf( + CourierFrame.Frame.Field(1, byteArrayOf(0)), + CourierFrame.Frame.Field(3, ByteArray(32) { 7 }), + CourierFrame.Frame.Field(4, byteArrayOf(0x10, 0x00)), // 4096 + CourierFrame.Frame.Field(6, byteArrayOf(1, 2, 3)), // capabilities stay raw + CourierFrame.Frame.Field(8, byteArrayOf(0x3C, 0x00)), // 15360 + CourierFrame.Frame.Field(10, byteArrayOf(0, 0, 0, 0, 0x00, 0x01, 0x02, 0x03)), + ), + ) + val response = CourierCommands.parseConnectResponse(frame) + assertTrue(response.ok()) + assertEquals(4096, response.maxMessageSize) + assertEquals(15360, response.largeMessageSize) + assertEquals(0x0000010203, response.serverTimestampMs) + assertContentEquals(ByteArray(32) { 7 }, response.token) + } + + @Test + fun connectResponseInvalidCertStatus() { + val frame = CourierFrame.Frame( + CourierFrame.CONNECT_RESPONSE, + listOf(CourierFrame.Frame.Field(1, byteArrayOf(2))), + ) + val response = CourierCommands.parseConnectResponse(frame) + assertEquals(CourierCommands.CONNECT_STATUS_INVALID_CERT, response.status) + assertTrue(!response.ok()) + assertNull(response.token) + } + + @Test + fun connectResponseWithoutStatusFailsLoudly() { + val frame = CourierFrame.Frame(CourierFrame.CONNECT_RESPONSE, emptyList()) + assertFailsWith { CourierCommands.parseConnectResponse(frame) } + } + + @Test + fun payloadCommand160IsNotAFrameCommand() { + // Rev-12 correction: 160 is a `c` value inside command-10 plists (§3.3/§5.1) — the + // frame layer gives it no special meaning; it decodes as an ordinary unknown command. + val bytes = CourierFrame.encode(160, listOf(1 to byteArrayOf(9))) + val frame = CourierFrame.decode(ByteArrayInputStream(bytes))!! + assertEquals(160, frame.command) + assertEquals(9, frame.field(1)!![0].toInt()) + } + + @Test + fun tunnelRequestCarriesTheHeadersPlist() { + val uuid = ByteArray(16) { it.toByte() } + val frame = CourierCommands.tunnelRequestFrame( + "https://query.ess.apple.com/query", + mapOf("x-protocol-version" to "1640"), + "text/x-xml-plist", + byteArrayOf(1, 2, 3), + uuid, + CourierCommands.TunnelFieldIds( + requestUrl = 1, requestHeaders = 2, requestContentType = 3, + requestBody = 4, requestUuid = 5, replyUuid = 6, replyStatus = 7, replyBody = 8, + ), + ) + assertEquals(CourierFrame.TUNNEL_REQUEST, frame.command) + // The headers field is a binary plist {"h": {...}} — decode with :codec. + val headers = dev.fenn.imessage.codec.Bplist.decode(frame.field(2)!!) + val expected = dev.fenn.imessage.codec.BpDict( + mapOf( + dev.fenn.imessage.codec.BpString("h") to dev.fenn.imessage.codec.BpDict( + mapOf(dev.fenn.imessage.codec.BpString("x-protocol-version") to dev.fenn.imessage.codec.BpString("1640")) + ) + ) + ) + assertEquals(expected, headers) + } + + @Test + fun tunnelResponseParsesUuidStatusBody() { + val uuid = ByteArray(16) { 9 } + val frame = CourierFrame.Frame( + CourierFrame.TUNNEL_RESPONSE, + listOf( + CourierFrame.Frame.Field(6, uuid), + CourierFrame.Frame.Field(7, byteArrayOf(0)), + CourierFrame.Frame.Field(8, byteArrayOf(4, 5)), + ), + ) + val ids = CourierCommands.TunnelFieldIds( + requestUrl = 1, requestHeaders = 2, requestContentType = 3, + requestBody = 4, requestUuid = 5, replyUuid = 6, replyStatus = 7, replyBody = 8, + ) + val response = CourierCommands.parseTunnelResponse(frame, ids) + assertContentEquals(uuid, response.requestUuid) + assertEquals(0, response.status) + assertContentEquals(byteArrayOf(4, 5), response.body) + } +} diff --git a/crypto/build.gradle.kts b/crypto/build.gradle.kts new file mode 100644 index 0000000..9ad6e89 --- /dev/null +++ b/crypto/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + kotlin("jvm") // version inherited from the root classpath (kotlin-gradle-plugin 2.3.20) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":codec")) + testImplementation(kotlin("test")) +} diff --git a/crypto/src/main/kotlin/dev/fenn/imessage/crypto/EcCrypto.kt b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/EcCrypto.kt new file mode 100644 index 0000000..0171826 --- /dev/null +++ b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/EcCrypto.kt @@ -0,0 +1,157 @@ +package dev.fenn.imessage.crypto + +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.security.KeyFactory +import java.security.PublicKey +import java.security.interfaces.ECPublicKey +import java.security.spec.ECFieldFp +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import java.security.spec.EllipticCurve +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * JCE-only helpers shared by the §4.2/§4.3 encryptors: P-256 point (de)compression, raw + * (r‖s) ECDSA signatures, and HKDF-SHA256. No third-party crypto dependency. + */ +object EcCrypto { + + private val P = BigInteger("ffffffff00000001000000000000000000000000ffffffffffffffffffffffff", 16) + private val THREE = BigInteger.valueOf(3) + private val B = BigInteger("5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b", 16) + private val N = BigInteger("ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551", 16) + private val G = ECPoint( + BigInteger("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", 16), + BigInteger("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5", 16), + ) + private val SQRT_EXPONENT = P.add(BigInteger.ONE).shiftRight(2) // p ≡ 3 (mod 4) + + /** (x, y) of the generator point as a JCE parameter spec (curve params are fixed for P-256). */ + private val P256_SPEC = ECParameterSpec( + EllipticCurve(ECFieldFp(P), THREE.negate().mod(P), B), // a = -3 mod p + G, + N, + 1, + ) + + /** 33-byte compressed SEC1 encoding (0x02/0x03 prefix) of an uncompressed EC public key. */ + fun compress(publicKey: PublicKey): ByteArray { + val w = (publicKey as ECPublicKey).w + val x = fixed32(w.affineX) + val y = fixed32(w.affineY) + val out = ByteArray(33) + out[0] = if (y[31].toInt() and 1 == 1) 0x03 else 0x02 + x.copyInto(out, 1) + return out + } + + /** Rebuilds an [ECPublicKey] from a 33-byte compressed SEC1 point (decompresses y). */ + fun decompress(compressed: ByteArray): ECPublicKey { + require(compressed.size == 33) { "compressed P-256 point must be 33 bytes, was ${compressed.size}" } + val prefix = compressed[0].toInt() and 0xFF + require(prefix == 0x02 || prefix == 0x03) { "bad compressed point prefix 0x%02x".format(prefix) } + val x = BigInteger(1, compressed.copyOfRange(1, 33)) + require(x.signum() >= 0 && x < P) { "compressed point x out of range" } + val rhs = x.modPow(THREE, P).subtract(THREE.multiply(x)).add(B).mod(P) + var y = rhs.modPow(SQRT_EXPONENT, P) + if (y.modPow(BigInteger.TWO, P) != rhs) { + throw IllegalArgumentException("compressed point is not on the P-256 curve") + } + if ((y.testBit(0) != (prefix == 0x03))) y = P.subtract(y) + val spec = KeyFactory.getInstance("EC") + return spec.generatePublic(ECPublicKeySpec(ECPoint(x, y), P256_SPEC)) as ECPublicKey + } + + /** + * Rebuilds an [ECPublicKey] from a 32-byte x-coordinate (the C57 pair-ec field-2 form, + * which drops the parity prefix): tries both parity prefixes since the wire carries + * neither. + */ + fun decompressXOnly(x: ByteArray): ECPublicKey { + require(x.size == 32) { "x-coordinate must be 32 bytes, was ${x.size}" } + runCatching { return decompress(byteArrayOf(0x02) + x) } + return decompress(byteArrayOf(0x03) + x) + } + + private fun fixed32(v: BigInteger): ByteArray { + val raw = v.toByteArray() + val out = ByteArray(32) + raw.copyInto(out, maxOf(0, 32 - raw.size), maxOf(0, raw.size - 32)) + return out + } + + /** DER (Java's `SHA256withECDSA` output) → raw 64-byte r‖s. */ + fun derToRaw(der: ByteArray): ByteArray { + var i = 0 + fun u8(): Int = der[i++].toInt() and 0xFF + require(u8() == 0x30) { "not a DER ECDSA signature" } + readDerLength(der, i).let { (len, next) -> i = next + len } + i = 2 + val r = readDerInteger(der, i) + i = r.second + val s = readDerInteger(der, i).first + return fixed32(r.first) + fixed32(s) + } + + /** Raw 64-byte r‖s → DER (what Java's `SHA256withECDSA` verify expects). */ + fun rawToDer(raw: ByteArray): ByteArray { + require(raw.size == 64) { "raw P-256 signature must be 64 bytes, was ${raw.size}" } + val r = BigInteger(1, raw.copyOfRange(0, 32)) + val s = BigInteger(1, raw.copyOfRange(32, 64)) + val rBytes = derIntegerBody(r) + val sBytes = derIntegerBody(s) + return byteArrayOf(0x30, (rBytes.size + sBytes.size).toByte()) + rBytes + sBytes + } + + private fun readDerInteger(der: ByteArray, at: Int): Pair { + require(der[at].toInt() and 0xFF == 0x02) { "bad DER integer at $at" } + val (len, body) = readDerLength(der, at + 1) + return BigInteger(1, der.copyOfRange(body, body + len)) to body + len + } + + private fun derIntegerBody(v: BigInteger): ByteArray { + var body = v.toByteArray() + if (body.size > 1 && body[0] == 0.toByte() && body[1].toInt() >= 0) body = body.copyOfRange(1, body.size) + return byteArrayOf(0x02, body.size.toByte()) + body + } + + private fun readDerLength(der: ByteArray, at: Int): Pair { + val first = der[at].toInt() and 0xFF + return if (first < 0x80) first to at + 1 + else { + val n = first and 0x7F + var len = 0 + for (i in 0 until n) len = (len shl 8) or (der[at + 1 + i].toInt() and 0xFF) + len to at + 1 + n + } + } +} + +/** HKDF-SHA256 (RFC 5869) via JCE HMAC — the §4.3 key-derivation primitive. */ +object Hkdf { + + fun extract(salt: ByteArray?, ikm: ByteArray): ByteArray = + hmac(if (salt == null || salt.isEmpty()) ByteArray(32) else salt, ikm) + + fun expand(prk: ByteArray, info: ByteArray, length: Int): ByteArray { + require(length <= 255 * 32) { "HKDF output too long" } + val out = ByteArrayOutputStream(length) + var t = ByteArray(0) + var counter = 1 + while (out.size() < length) { + t = hmac(prk, t + info + byteArrayOf(counter.toByte())) + out.write(t) + counter++ + } + return out.toByteArray().copyOf(length) + } + + fun hmac(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(data) + } +} diff --git a/crypto/src/main/kotlin/dev/fenn/imessage/crypto/MessageBody.kt b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/MessageBody.kt new file mode 100644 index 0000000..0602f56 --- /dev/null +++ b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/MessageBody.kt @@ -0,0 +1,121 @@ +package dev.fenn.imessage.crypto + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.NativePlistException +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName + +/** + * The §5.2 normal message body — the decrypted inner plaintext of command + * [PayloadCommands.PAYLOAD] (100), a string-keyed plist encoded with the `:codec` binary-plist bridge ([NativePlist]) and + * optionally gzipped (§4.1) before encryption. + * + * Plist value types per body key are not recorded for most keys (§5.2; the value-type half + * of C50 stays capture-bound, §7 item 17); the assumptions are stated on each property and + * follow the spec's own quoting (versions quoted as strings: `gv` "8", `v` "1"). All fields + * are optional: which subset a given message carries depends on the + * message kind (plain text vs rich body vs balloon vs reaction target), and decode of a body + * the spec does not fully describe must not fail on absent keys. + */ +data class MessageBody( + /** `t` — plain text, the concatenation of parts. */ + val text: String? = null, + /** `s` — subject. */ + val subject: String? = null, + /** `p` — participants, full list including self, as URIs. */ + val participants: List? = null, + /** `x` — rich XML body (present when multipart, mentions, or styling). */ + val richBody: String? = null, + /** `gid` — group GUID. */ + val groupGuid: String? = null, + /** `gv` — group version, "8". */ + val groupVersion: String? = null, + /** `v` — protocol version, "1". */ + val protocolVersion: String? = null, + /** `pv` — properties version (0 for DMs, 1 for groups per one source). */ + val propertiesVersion: Long? = null, + /** `tg` — reply/thread reference, format "r::::". */ + val threadRef: String? = null, + /** `iid` — screen-effect id ("com.apple.messages.effect.*" / "com.apple.MobileSMS.expressivesend.*"). */ + val screenEffect: String? = null, + /** `n` — chat display name. */ + val displayName: String? = null, + /** `a` — audio flag (modeled as a boolean; type not captured, C50). */ + val audio: Boolean? = null, + /** `e` — expiring-audio flag (modeled as a boolean; type not captured, C50). */ + val expiringAudio: Boolean? = null, + /** `bid` — balloon id. */ + val balloonId: String? = null, + /** `bp` — balloon payload (optionally gzipped; raw keyed-archiver plist or a wrapper). */ + val balloonPayload: ByteArray? = null, + /** `bpdi` — balloon MMCS download info (key, size, owner, URL, signature). */ + val balloonDownloadInfo: Map? = null, + /** `msi` — message-summary info (content-type number `amc`, source text `ams`, balloon type `amb`). */ + val messageSummaryInfo: Map? = null, + /** `r` — previous-message UUID (modeled as 16 UUID bytes; type not captured, C50). */ + val previousMessageUuid: ByteArray? = null, +) { + + fun encode(): ByteArray = NativePlist.encode(toMap()) + + fun toMap(): Map { + val m = LinkedHashMap() + text?.let { m["t"] = it } + subject?.let { m["s"] = it } + participants?.let { m["p"] = it } + richBody?.let { m["x"] = it } + groupGuid?.let { m["gid"] = it } + groupVersion?.let { m["gv"] = it } + protocolVersion?.let { m["v"] = it } + propertiesVersion?.let { m["pv"] = it } + threadRef?.let { m["tg"] = it } + screenEffect?.let { m["iid"] = it } + displayName?.let { m["n"] = it } + audio?.let { m["a"] = it } + expiringAudio?.let { m["e"] = it } + balloonId?.let { m["bid"] = it } + balloonPayload?.let { m["bp"] = it } + balloonDownloadInfo?.let { m["bpdi"] = it } + messageSummaryInfo?.let { m["msi"] = it } + previousMessageUuid?.let { m["r"] = it } + return m + } + + companion object { + + /** Decodes a command-100 body; recorded-but-absent keys stay null. */ + fun decode(bytes: ByteArray): MessageBody { + val dict = NativePlist.decode(bytes) + val m = stringKeyedDictOrNull(dict) + ?: throw NativePlistException("message body is not a string-keyed dict (${typeName(dict)})") + fun str(key: String): String? = m[key] as? String + fun long(key: String): Long? = (m[key] as? Int)?.toLong() ?: m[key] as? Long + fun bool(key: String): Boolean? = m[key] as? Boolean + fun data(key: String): ByteArray? = m[key] as? ByteArray + fun dict(key: String): Map? = stringKeyedDictOrNull(m[key]) + fun strList(key: String): List? = (m[key] as? List<*>)?.map { + it as? String ?: throw NativePlistException("'$key' element is not a string (${typeName(it)})") + } + return MessageBody( + text = str("t"), + subject = str("s"), + participants = strList("p"), + richBody = str("x"), + groupGuid = str("gid"), + groupVersion = str("gv"), + protocolVersion = str("v"), + propertiesVersion = long("pv"), + threadRef = str("tg"), + screenEffect = str("iid"), + displayName = str("n"), + audio = bool("a"), + expiringAudio = bool("e"), + balloonId = str("bid"), + balloonPayload = data("bp"), + balloonDownloadInfo = dict("bpdi"), + messageSummaryInfo = dict("msi"), + previousMessageUuid = data("r"), + ) + } + } +} diff --git a/crypto/src/main/kotlin/dev/fenn/imessage/crypto/PairEcEnvelope.kt b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/PairEcEnvelope.kt new file mode 100644 index 0000000..7dea074 --- /dev/null +++ b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/PairEcEnvelope.kt @@ -0,0 +1,359 @@ +package dev.fenn.imessage.crypto + +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.PublicKey +import java.security.SecureRandom +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import javax.crypto.KeyAgreement + +/** + * The §4.3 "pair-ec" encryption envelope — pure JCE, no third-party crypto, and a + * hand-rolled minimal protobuf for the outer/inner messages (only varint and + * length-delimited fields; no protobuf dependency). + * + * Wire form (§4.3): an outer protobuf with the ciphertext (field 1), the ephemeral P-256 + * public key (field 2), a 64-byte raw ECDSA-P256-SHA256 signature (field 3), and the + * 7-byte key validator (field 99). The inner message carries the gzipped-plist plaintext + * (field 1), a per-sender-pair counter (field 2), and optional key-transparency gossip + * (field 3) / debug (field 99) fields. + * + * Envelope *selection* between this and [PairEnvelope] is unresolved (spec §4.5, §7 item 1, + * C25) — this codec builds the scheme and invents no selection rule. The sender-side + * counter *policy* (keying, persistence) is C27-open — rev 12 records both sources + * persisting from 0 — but the codec takes the counter as a parameter and persists nothing. + * + * Not recorded in the spec and therefore explicit (TODO(capture), §7 item 21 / C57): + * - the ephemeral key's wire form: §4.3's prose records a 33-byte compressed point, the + * field-2 extraction records 32 bytes — [EphemeralKeyForm] parameter, defaulting to the + * §4.3 prose; a capture arbitrates. + * - where the Rust side applies the inner-body gzip (C57) — the caller hands this codec + * already-gzipped bytes (§4.1 records Go gzipping the plist before encryption). + */ +object PairEcEnvelope { + + /** Validator final version byte — agreed at 0x0c (spec §4.3, second-extraction correction). */ + const val VALIDATOR_VERSION: Byte = 0x0c + + const val VALIDATOR_BYTES = 7 // 2-byte prefix × 3 keys + the version byte + + /** HKDF-SHA256 SALT (§4.3, rev-12 correction) — an interop-required protocol constant; info is EMPTY. */ + const val HKDF_SALT = "LastPawn-MessageKeys" + + const val PREKEY_SIGNATURE_LABEL = "NGMPrekeySignature" + + /** Protobuf field numbers (§4.3, C52 closed by facts-only extraction — recorded, not configurable). */ + object FieldNumbers { + const val OUTER_PAYLOAD = 1 + const val OUTER_EPHEMERAL_KEY = 2 + const val OUTER_SIGNATURE = 3 + const val OUTER_VALIDATOR = 99 + const val INNER_MESSAGE = 1 + const val INNER_COUNTER = 2 + const val INNER_GOSSIP = 3 + const val INNER_DEBUG = 99 + } + + /** + * The field-2 ephemeral key's wire form. §4.3's prose records a 33-byte compressed + * point; the field-2 extraction records 32 bytes (C57, §7 item 21 — capture-bound). + * [COMPRESSED_33] is the default per the §4.3 wording; the 32-byte form is the bare + * x-coordinate (the receiver recovers the point by trying both parity prefixes). + */ + enum class EphemeralKeyForm(val wireBytes: Int) { + COMPRESSED_33(33), + X_ONLY_32(32), + } + + /** + * Encrypts [plaintext] (the gzipped plist) to [recipientPrekey] (33-byte compressed). + * The inner message (plaintext ‖ counter ‖ optional gossip/debug) is what gets padded + * and encrypted — §4.3's "the inner message carries the plaintext …, a counter, and + * optional key-transparency gossip". + * + * The signature covers (shared secret, recipient prekey bytes, sender ephemeral key + * bytes — the field-2 wire form chosen by [ephemeralKeyForm], recipient device-key + * bytes, ciphertext); the marshalled key forms are the caller's (§4.3 records the + * signature items, not their encodings). + */ + fun encrypt( + plaintext: ByteArray, + recipientPrekey: ByteArray, + recipientPrekeyMarshalled: ByteArray, + senderDeviceKeyMarshalled: ByteArray, + recipientDeviceKeyMarshalled: ByteArray, + counter: Long, + senderDeviceSigningKey: PrivateKey, + random: SecureRandom, + ephemeralKeyForm: EphemeralKeyForm = EphemeralKeyForm.COMPRESSED_33, + gossip: ByteArray? = null, + debug: ByteArray? = null, + ): ByteArray { + val ephemeral = generateKeyPair(random) + val sharedSecret = ecdh(ephemeral.private, EcCrypto.decompress(recipientPrekey)) + val okm = Hkdf.expand(Hkdf.extract(HKDF_SALT.toByteArray(Charsets.US_ASCII), sharedSecret), ByteArray(0), 48) + val key = okm.copyOfRange(0, 32) + val iv = okm.copyOfRange(32, 48) + val innerFields = ArrayList>(4) + innerFields.add(FieldNumbers.INNER_MESSAGE to plaintext) + innerFields.add(FieldNumbers.INNER_COUNTER to counter) + if (gossip != null) innerFields.add(FieldNumbers.INNER_GOSSIP to gossip) + if (debug != null) innerFields.add(FieldNumbers.INNER_DEBUG to debug) + val padded = pad(MiniProto.encode(innerFields), random) + val ciphertext = PairEnvelope.aesCtr(padded, key, iv) + val ephemeralWire = ephemeralWireBytes(ephemeral.public, ephemeralKeyForm) + val signature = signRaw( + senderDeviceSigningKey, + sharedSecret + recipientPrekeyMarshalled + ephemeralWire + + recipientDeviceKeyMarshalled + ciphertext, + ) + return MiniProto.encode( + listOf( + FieldNumbers.OUTER_PAYLOAD to ciphertext, + FieldNumbers.OUTER_EPHEMERAL_KEY to ephemeralWire, + FieldNumbers.OUTER_SIGNATURE to signature, + FieldNumbers.OUTER_VALIDATOR to validator( + senderDeviceKeyMarshalled, recipientDeviceKeyMarshalled, recipientPrekeyMarshalled, + ), + ), + ) + } + + /** + * Decrypts an outer message. Validator and signature verification are skipped unless the + * corresponding keys are supplied (§4.3: a prekey-signature failure only warns; one + * source verifies only the validator's first six bytes). The received counter is + * returned but NOT enforced — receiver-side enforcement is C27-open. + */ + fun decrypt( + outer: ByteArray, + recipientPrekeyPrivate: PrivateKey, + recipientPrekeyMarshalled: ByteArray, + recipientDeviceKeyMarshalled: ByteArray, + senderDeviceKeyMarshalled: ByteArray? = null, + senderDeviceSigningPublicKey: PublicKey? = null, + ): Pair { + val parsed = MiniProto.decode(outer) + fun bytes(field: Int): ByteArray = parsed.firstOrNull { it.first == field }?.second as? ByteArray + ?: throw IllegalArgumentException("outer message without field $field") + val ciphertext = bytes(FieldNumbers.OUTER_PAYLOAD) + val ephemeralWire = bytes(FieldNumbers.OUTER_EPHEMERAL_KEY) + val signature = bytes(FieldNumbers.OUTER_SIGNATURE) + senderDeviceKeyMarshalled?.let { sender -> + if (parsed.any { p -> p.first == FieldNumbers.OUTER_VALIDATOR }) { + val received = bytes(FieldNumbers.OUTER_VALIDATOR) + val expected = validator(sender, recipientDeviceKeyMarshalled, recipientPrekeyMarshalled) + if (!expected.contentEquals(received)) { + throw PairIntegrityException("key validator mismatch: ${received.toHex()} vs ${expected.toHex()}") + } + } + } + val ephemeralPublic = ephemeralPublicKey(ephemeralWire) + val sharedSecret = ecdh(recipientPrekeyPrivate, ephemeralPublic) + val okm = Hkdf.expand(Hkdf.extract(HKDF_SALT.toByteArray(Charsets.US_ASCII), sharedSecret), ByteArray(0), 48) + val padded = PairEnvelope.aesCtr( + ciphertext, + okm.copyOfRange(0, 32), + okm.copyOfRange(32, 48), + ) + senderDeviceSigningPublicKey?.let { + val verified = runCatching { + verifyRaw( + it, + sharedSecret + recipientPrekeyMarshalled + ephemeralWire + + recipientDeviceKeyMarshalled + ciphertext, + signature, + ) + }.getOrDefault(false) + if (!verified) throw PairIntegrityException("signature verification failed") + } + val inner = MiniProto.decode(unpad(padded)) + val plaintext = inner.firstOrNull { it.first == FieldNumbers.INNER_MESSAGE }?.second as? ByteArray + ?: throw IllegalArgumentException("inner message without field ${FieldNumbers.INNER_MESSAGE}") + val counter = inner.firstOrNull { it.first == FieldNumbers.INNER_COUNTER }?.second as? Long ?: 0L + return plaintext to counter + } + + /** The field-2 wire bytes for [publicKey] in [form] (§4.3 prose vs C57's 32-byte reading). */ + fun ephemeralWireBytes(publicKey: PublicKey, form: EphemeralKeyForm): ByteArray { + val compressed = EcCrypto.compress(publicKey) + return when (form) { + EphemeralKeyForm.COMPRESSED_33 -> compressed + EphemeralKeyForm.X_ONLY_32 -> compressed.copyOfRange(1, 33) + } + } + + /** Rebuilds the ephemeral public key from either recorded field-2 wire form. */ + fun ephemeralPublicKey(wire: ByteArray): java.security.interfaces.ECPublicKey = when (wire.size) { + EphemeralKeyForm.COMPRESSED_33.wireBytes -> EcCrypto.decompress(wire) + EphemeralKeyForm.X_ONLY_32.wireBytes -> EcCrypto.decompressXOnly(wire) + else -> throw IllegalArgumentException("ephemeral key is ${wire.size} bytes; neither recorded form") + } + + /** The 7-byte validator: 2-byte prefixes of (sender device, recipient device, recipient prekey) + 0x0c. */ + fun validator( + senderDeviceKeyMarshalled: ByteArray, + recipientDeviceKeyMarshalled: ByteArray, + recipientPrekeyMarshalled: ByteArray, + ): ByteArray = senderDeviceKeyMarshalled.copyOfRange(0, 2) + + recipientDeviceKeyMarshalled.copyOfRange(0, 2) + + recipientPrekeyMarshalled.copyOfRange(0, 2) + + byteArrayOf(VALIDATOR_VERSION) + + /** The signed prekey record's signature input (§4.3): SHA-256 over label ‖ prekey ‖ LE timestamp. */ + fun prekeySignatureBytes(compressedPrekey: ByteArray, timestampSeconds: Double): ByteArray { + val ts = ByteBuffer.allocate(8).putLong(java.lang.Double.doubleToLongBits(timestampSeconds)).array() + return MessageDigest.getInstance("SHA-256") + .digest(PREKEY_SIGNATURE_LABEL.toByteArray(Charsets.US_ASCII) + compressedPrekey + ts.reversedArray()) + } + + /** Signs the prekey record (§4.3) with the device key; raw 64-byte output. */ + fun signPrekey(deviceKey: PrivateKey, compressedPrekey: ByteArray, timestampSeconds: Double): ByteArray = + signRaw(deviceKey, prekeySignatureBytes(compressedPrekey, timestampSeconds)) + + fun verifyPrekey( + deviceKey: PublicKey, + compressedPrekey: ByteArray, + timestampSeconds: Double, + signature: ByteArray, + ): Boolean = runCatching { + verifyRaw(deviceKey, prekeySignatureBytes(compressedPrekey, timestampSeconds), signature) + }.getOrDefault(false) + + /** Plaintext ‖ random pad to a 16-byte multiple ‖ 4-byte LE pad length (§4.3). */ + internal fun pad(plaintext: ByteArray, random: SecureRandom): ByteArray { + val padLength = (16 - plaintext.size % 16) % 16 + val pad = ByteArray(padLength).also { random.nextBytes(it) } + return plaintext + pad + ByteBuffer.allocate(4).putInt(padLength).array() + } + + internal fun unpad(padded: ByteArray): ByteArray { + if (padded.size < 4) throw IllegalArgumentException("padded plaintext too short") + // plaintext ‖ pad ‖ trailer: body+pad is the 16-byte multiple, the trailer sits outside it. + if ((padded.size - 4) % 16 != 0) { + throw IllegalArgumentException("encrypted length ${padded.size} inconsistent with pad-to-16 + 4-byte trailer") + } + val padLength = ByteBuffer.wrap(padded.copyOfRange(padded.size - 4, padded.size)).int + val bodyLength = padded.size - 4 - padLength + if (bodyLength < 0) throw IllegalArgumentException("padding length $padLength inconsistent with ${padded.size} B input") + return padded.copyOfRange(0, bodyLength) + } + + private fun generateKeyPair(random: SecureRandom): KeyPair = + KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1"), random) + }.generateKeyPair() + + private fun ecdh(privateKey: PrivateKey, publicKey: ECPublicKey): ByteArray { + val agreement = KeyAgreement.getInstance("ECDH") + agreement.init(privateKey) + agreement.doPhase(publicKey, true) + return agreement.generateSecret() + } + + private fun signRaw(key: PrivateKey, data: ByteArray): ByteArray { + val signer = Signature.getInstance("SHA256withECDSA") + signer.initSign(key) + signer.update(data) + return EcCrypto.derToRaw(signer.sign()) + } + + private fun verifyRaw(key: PublicKey, data: ByteArray, rawSignature: ByteArray): Boolean { + val verifier = Signature.getInstance("SHA256withECDSA") + verifier.initVerify(key) + verifier.update(data) + return verifier.verify(EcCrypto.rawToDer(rawSignature)) + } + + class PairIntegrityException(message: String) : IllegalStateException(message) + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} + +/** + * Minimal protobuf wire codec — varint (wire type 0) and length-delimited (wire type 2) + * fields only, which is all the §4.3 messages use. Hand-rolled per the task's no-protobuf- + * library constraint; groups, fixed-width scalars and packed repeated fields do not decode. + */ +internal object MiniProto { + + fun encode(fields: List>): ByteArray { + val out = ByteArrayOutputStream() + for ((field, value) in fields) { + when (value) { + is Long -> { + writeVarint(out, ((field shl 3) or 0).toLong()) + writeVarint(out, value) + } + is ByteArray -> { + writeVarint(out, ((field shl 3) or 2).toLong()) + writeVarint(out, value.size.toLong()) + out.write(value) + } + else -> throw IllegalArgumentException("unsupported protobuf value: ${value::class.java.name}") + } + } + return out.toByteArray() + } + + /** Returns the fields in wire order; varints as [Long], length-delimited as [ByteArray]. */ + fun decode(bytes: ByteArray): List> { + val out = ArrayList>() + var i = 0 + while (i < bytes.size) { + val (tag, next) = readVarint(bytes, i) + i = next + val field = (tag ushr 3).toInt() + require(field > 0) { "protobuf field number 0" } + when (val wire = (tag and 0x7).toInt()) { + 0 -> { + val (v, next2) = readVarint(bytes, i) + i = next2 + out.add(field to v) + } + 2 -> { + val (len, next2) = readVarint(bytes, i) + i = next2 + if (len > bytes.size - i) throw IllegalArgumentException("length-delimited field overruns input") + out.add(field to bytes.copyOfRange(i, i + len.toInt())) + i += len.toInt() + } + else -> throw IllegalArgumentException("unsupported protobuf wire type $wire") + } + } + return out + } + + private fun writeVarint(out: ByteArrayOutputStream, v: Long) { + var value = v + while (true) { + if (value and 0x7FL.inv() == 0L) { + out.write(value.toInt()) + return + } + out.write(((value and 0x7F) or 0x80).toInt()) + value = value ushr 7 + } + } + + private fun readVarint(bytes: ByteArray, at: Int): Pair { + var value = 0L + var shift = 0 + var i = at + while (true) { + if (i >= bytes.size) throw IllegalArgumentException("truncated varint") + if (shift >= 64) throw IllegalArgumentException("varint too long") + value = value or ((bytes[i].toLong() and 0x7F) shl shift) + if (bytes[i].toInt() and 0x80 == 0) return value to i + 1 + shift += 7 + i++ + } + } +} diff --git a/crypto/src/main/kotlin/dev/fenn/imessage/crypto/PairEnvelope.kt b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/PairEnvelope.kt new file mode 100644 index 0000000..145cbb7 --- /dev/null +++ b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/PairEnvelope.kt @@ -0,0 +1,170 @@ +package dev.fenn.imessage.crypto + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.PublicKey +import java.security.SecureRandom +import java.security.Signature +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * The §4.2 legacy "pair" encryption envelope — pure JCE, no third-party crypto. + * + * Wire form (§4.2): a 0x02 tag byte, a 2-byte big-endian body length, the body, a 1-byte + * signature length, then the signature. The body is the 160-byte RSA-OAEP block (AES-128 + * message key ‖ first min(100, len) ciphertext bytes — a shorter ciphertext transports + * only what exists) followed by the remaining ciphertext in the clear. + * + * Recorded constructions (rev 12, C51 closed — §4.2): + * - the 40-bit integrity value is HMAC-SHA-256 **keyed with the 11 random bytes + * themselves**, over plaintext ‖ 0x02 ‖ sender-identity-hash ‖ recipient-identity-hash; + * only its first 5 bytes are used, appended to the 11-byte seed to form the AES-128 key, + * and the HMAC is never placed in the envelope; + * - an identity hash is SHA-256 over the concatenated marshalled signing + encryption key + * blobs (the caller's published-identity sub-blobs: the ASN.1-style blob's signing point + * and RSA-1280 key, each with a 2-byte type prefix); + * - the RSA-OAEP block is SHA-1 (MGF1-SHA1, empty label) under the recipient's RSA + * encryption key; + * - the signature is SHA1withECDSA (P-256) over the exact body bytes, DER/ASN.1-encoded. + * + * Envelope *selection* between this and [PairEcEnvelope] is unresolved (spec §4.5, §7 item + * 1, C25) — this codec builds both schemes and invents no selection rule. + */ +object PairEnvelope { + + /** Envelope tag byte (§4.2). */ + const val TAG: Byte = 0x02 + + /** RSA block size: 1280-bit key → 160 bytes; a decryptable body is at least this long. */ + const val RSA_BLOCK_BYTES = 160 + + /** Maximum bytes of ciphertext carried inside the RSA block after the 16-byte message key. */ + const val KEY_TRANSPORT_CIPHERTEXT_BYTES = 100 + + const val MESSAGE_KEY_BYTES = 16 // 11 random + 5 HMAC-derived (§4.2) + + fun identityHash(firstMarshalledKey: ByteArray, secondMarshalledKey: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(firstMarshalledKey + secondMarshalledKey) + + /** The full 32-byte HMAC, keyed with the 11 random bytes (§4.2); the envelope uses its first 5 bytes. */ + fun integrityHmac( + randomKeyBytes: ByteArray, + plaintext: ByteArray, + senderIdentityHash: ByteArray, + recipientIdentityHash: ByteArray, + ): ByteArray = Hkdf.hmac(randomKeyBytes, plaintext + byteArrayOf(TAG) + senderIdentityHash + recipientIdentityHash) + + fun messageKey(randomPart: ByteArray, integrityPart: ByteArray): ByteArray { + require(randomPart.size == MESSAGE_KEY_BYTES - 5) { "random part must be 11 bytes" } + require(integrityPart.size == 5) { "integrity part must be 5 bytes" } + return randomPart + integrityPart + } + + /** Fixed IV (§4.2): 15 zero bytes followed by 0x01. */ + fun iv(): ByteArray = ByteArray(15) + byteArrayOf(0x01) + + fun encrypt( + plaintext: ByteArray, + senderSigningKey: PrivateKey, + recipientEncryptionKey: PublicKey, + senderIdentityHash: ByteArray, + recipientIdentityHash: ByteArray, + random: SecureRandom, + ): ByteArray { + val randomPart = ByteArray(MESSAGE_KEY_BYTES - 5).also { random.nextBytes(it) } + val integrity = integrityHmac(randomPart, plaintext, senderIdentityHash, recipientIdentityHash) + .copyOfRange(0, 5) + val key = messageKey(randomPart, integrity) + val ciphertext = aesCtr(plaintext, key, iv()) + // §4.2: the RSA block transports the key plus the first min(100, len) ciphertext bytes. + val transported = ciphertext.copyOfRange(0, minOf(KEY_TRANSPORT_CIPHERTEXT_BYTES, ciphertext.size)) + + val rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding") + rsa.init(Cipher.ENCRYPT_MODE, recipientEncryptionKey, random) + val rsaBlock = rsa.doFinal(key + transported) + if (rsaBlock.size != RSA_BLOCK_BYTES) { + throw IllegalStateException("RSA block is ${rsaBlock.size} bytes, expected $RSA_BLOCK_BYTES (1280-bit key?)") + } + val body = rsaBlock + ciphertext.copyOfRange(transported.size, ciphertext.size) + val signature = sign(senderSigningKey, body) + if (signature.size > 0xFF) throw IllegalStateException("signature longer than the 1-byte length") + return ByteBuffer.allocate(1 + 2 + body.size + 1 + signature.size) + .put(TAG) + .putShort(body.size.toShort()) + .put(body) + .put(signature.size.toByte()) + .put(signature) + .array() + } + + /** + * Decrypts and integrity-checks. Verifies the sender's signature only when + * [senderSigningPublicKey] is given (§4.2: third-party clients may skip verification). + * Throws [IllegalArgumentException] on malformed envelopes and [PairIntegrityException] + * on a 40-bit integrity mismatch or a failed signature check. + */ + fun decrypt( + envelope: ByteArray, + recipientDecryptionKey: PrivateKey, + senderSigningPublicKey: PublicKey?, + senderIdentityHash: ByteArray, + recipientIdentityHash: ByteArray, + ): ByteArray { + if (envelope.size < 1 + 2 + 1) throw IllegalArgumentException("envelope too short (${envelope.size} B)") + if (envelope[0] != TAG) throw IllegalArgumentException("bad tag 0x%02x, expected 0x02".format(envelope[0])) + val bodyLength = ((envelope[1].toInt() and 0xFF) shl 8) or (envelope[2].toInt() and 0xFF) + val signatureLength = envelope[3 + bodyLength].toInt() and 0xFF + if (1 + 2 + bodyLength + 1 + signatureLength != envelope.size) { + throw IllegalArgumentException("envelope length mismatch (${envelope.size} B vs $bodyLength B body)") + } + if (bodyLength < RSA_BLOCK_BYTES) { + throw IllegalArgumentException("body of $bodyLength B is shorter than the $RSA_BLOCK_BYTES-byte RSA block") + } + val body = envelope.copyOfRange(3, 3 + bodyLength) + val signature = envelope.copyOfRange(4 + bodyLength, envelope.size) + senderSigningPublicKey?.let { + val verifier = Signature.getInstance("SHA1withECDSA") + verifier.initVerify(it) + verifier.update(body) + if (!verifier.verify(signature)) throw PairIntegrityException("signature verification failed") + } + + val rsaBlock = body.copyOfRange(0, RSA_BLOCK_BYTES) + val clearTail = body.copyOfRange(RSA_BLOCK_BYTES, body.size) + val rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding") + rsa.init(Cipher.DECRYPT_MODE, recipientDecryptionKey) + val transported = rsa.doFinal(rsaBlock) + if (transported.size < MESSAGE_KEY_BYTES) { + throw IllegalArgumentException("RSA block decrypted to ${transported.size} B; no room for the message key") + } + val key = transported.copyOfRange(0, MESSAGE_KEY_BYTES) + // min(100, len) transport (§4.2): the decrypted block's size tells how much rode inside it. + val ciphertext = transported.copyOfRange(MESSAGE_KEY_BYTES, transported.size) + clearTail + val plaintext = aesCtr(ciphertext, key, iv()) + val expected = integrityHmac(key.copyOfRange(0, MESSAGE_KEY_BYTES - 5), plaintext, senderIdentityHash, recipientIdentityHash) + .copyOfRange(0, 5) + if (!expected.contentEquals(key.copyOfRange(MESSAGE_KEY_BYTES - 5, MESSAGE_KEY_BYTES))) { + throw PairIntegrityException("40-bit integrity value mismatch") + } + return plaintext + } + + class PairIntegrityException(message: String) : IllegalStateException(message) + + private fun sign(key: PrivateKey, body: ByteArray): ByteArray { + val signer = Signature.getInstance("SHA1withECDSA") + signer.initSign(key) + signer.update(body) + return signer.sign() + } + + internal fun aesCtr(data: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(data) + } +} diff --git a/crypto/src/main/kotlin/dev/fenn/imessage/crypto/PayloadCommands.kt b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/PayloadCommands.kt new file mode 100644 index 0000000..7b2e7aa --- /dev/null +++ b/crypto/src/main/kotlin/dev/fenn/imessage/crypto/PayloadCommands.kt @@ -0,0 +1,61 @@ +package dev.fenn.imessage.crypto + +/** + * Payload command numbers — the `c` field of the outer envelope (spec §4.1) — on the + * madrid / SMS-alloy topics (spec §5.1). Wire facts; these are courier-message commands, + * not the APNs framing commands of [CourierFrame]. + * + * Only the numbers both sources agree on are constants here. The spec additionally records + * single-source numbers (113, 120, 122, 131, 138, 160, 103, 196, 104, 142, 146, 149 — + * TODO(capture) C36); none of those are wired until a capture confirms them. + */ +object PayloadCommands { + + /** iMessage payload (text, reactions, typing, effects) — spec §5.1/§5.2. */ + const val PAYLOAD = 100 + + const val DELIVERED = 101 + + const val READ = 102 + + const val CERTIFIED_DELIVERY_RECEIPT = 109 + + const val MARK_UNREAD = 111 + + /** Edit and unsend (spec §5.4). */ + const val EDIT = 118 + + /** Peer cache invalidate / new-device notice. */ + const val PEER_CACHE_INVALIDATE = 130 + + /** Incoming SMS / MMS forwarded from a real phone. */ + const val SMS_INCOMING = 140 + const val MMS_INCOMING = 141 + + /** SMS / MMS sent by this client (reflected). */ + const val SMS_REFLECTED = 143 + const val MMS_REFLECTED = 144 + + /** SMS relay enable / toggle confirm. */ + const val SMS_RELAY_CONFIRM = 145 + + /** Read-on-device — the SMS variant of [READ]. */ + const val SMS_READ_ON_DEVICE = 147 + + /** Profile / settings changes. */ + const val PROFILE = 180 + + /** Chat delete (recoverable metadata rides under 182). */ + const val CHAT_DELETE = 181 + const val CHAT_DELETE_METADATA = 182 + + /** Group events (spec §5.7). */ + const val GROUP_EVENT = 190 + + /** Send-time ack: echoes the message UUID with a per-device status (spec §5.6). */ + const val SEND_ACK = 255 + + /** APNs-tunnel HTTP request / response (spec §5.1, both sources). */ + const val TUNNEL_REQUEST = 96 + const val TUNNEL_RESPONSE = 97 +} diff --git a/crypto/src/test/kotlin/dev/fenn/imessage/crypto/MessageBodyTest.kt b/crypto/src/test/kotlin/dev/fenn/imessage/crypto/MessageBodyTest.kt new file mode 100644 index 0000000..af9a4fc --- /dev/null +++ b/crypto/src/test/kotlin/dev/fenn/imessage/crypto/MessageBodyTest.kt @@ -0,0 +1,107 @@ +package dev.fenn.imessage.crypto + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.NativePlistException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class MessageBodyTest { + + private fun fullBody() = MessageBody( + text = "hello there", + subject = "subj", + participants = listOf("mailto:self@example.com", "mailto:peer@example.com"), + richBody = "", + groupGuid = "guid-1", + groupVersion = "8", + protocolVersion = "1", + propertiesVersion = 1L, + threadRef = "r:0:0:5:uuid", + screenEffect = "com.apple.messages.effect.confetti", + displayName = "The Group", + audio = true, + expiringAudio = false, + balloonId = "com.apple.messages.URLBalloonProvider", + balloonPayload = byteArrayOf(1, 2, 3), + balloonDownloadInfo = mapOf( + "key" to "0x00deadbeef", + "size" to 3L, + "owner" to "owner", + "URL" to "https://content.example.com/a", + "signature" to "abc123", + ), + messageSummaryInfo = mapOf("amc" to 4L, "ams" to "source", "amb" to "balloon"), + previousMessageUuid = ByteArray(16) { it.toByte() }, + ) + + @Test + fun versionsStayStringsPerSpec() { + val raw = NativePlist.decode(fullBody().encode()) as Map<*, *> + assertEquals("8", raw["gv"], "gv is the string \"8\" (spec §5.2)") + assertEquals("1", raw["v"], "v is the string \"1\" (spec §5.2)") + } + + @Test + fun encodeEmitsExactlyTheRecordedKeys() { + val raw = NativePlist.decode(fullBody().encode()) as Map<*, *> + assertEquals( + setOf("t", "s", "p", "x", "gid", "gv", "v", "pv", "tg", "iid", "n", "a", "e", "bid", "bp", "bpdi", "msi", "r"), + raw.keys, + ) + } + + @Test + fun roundTripPreservesAllRecordedFields() { + val back = MessageBody.decode(fullBody().encode()) + assertEquals("hello there", back.text) + assertEquals("subj", back.subject) + assertEquals(listOf("mailto:self@example.com", "mailto:peer@example.com"), back.participants) + assertEquals("", back.richBody) + assertEquals("guid-1", back.groupGuid) + assertEquals("8", back.groupVersion) + assertEquals("1", back.protocolVersion) + assertEquals(1L, back.propertiesVersion) + assertEquals("r:0:0:5:uuid", back.threadRef) + assertEquals("com.apple.messages.effect.confetti", back.screenEffect) + assertEquals("The Group", back.displayName) + assertEquals(true, back.audio) + assertEquals(false, back.expiringAudio) + assertEquals("com.apple.messages.URLBalloonProvider", back.balloonId) + assertContentEquals(byteArrayOf(1, 2, 3), back.balloonPayload) + assertEquals(5, back.balloonDownloadInfo?.size) + assertEquals(3L, back.balloonDownloadInfo?.get("size")) + assertEquals(3, back.messageSummaryInfo?.size) + assertEquals("source", back.messageSummaryInfo?.get("ams")) + assertContentEquals(ByteArray(16) { it.toByte() }, back.previousMessageUuid) + } + + @Test + fun sparseBodiesRoundTrip() { + val back = MessageBody.decode(MessageBody(text = "typing", protocolVersion = "1").encode()) + assertEquals("typing", back.text) + assertEquals("1", back.protocolVersion) + assertEquals(null, back.participants) + } + + @Test + fun emptyBodyDecodesToAllNulls() { + val back = MessageBody.decode(NativePlist.encode(emptyMap())) + assertEquals(MessageBody(), back.copy(previousMessageUuid = null, balloonPayload = null)) + assertEquals(null, back.text) + } + + @Test + fun nonStringParticipantFailsLoudly() { + val bytes = NativePlist.encode(mapOf("p" to listOf(1L))) + assertFailsWith { MessageBody.decode(bytes) } + } + + @Test + fun plainTextEncodesAsBinaryPlist() { + val encoded = MessageBody(text = "plain").encode() + assertTrue(encoded.size > 8 && encoded.copyOfRange(0, 8).decodeToString() == "bplist00", "inner plaintext is a binary plist (§4.1)") + } +} diff --git a/crypto/src/test/kotlin/dev/fenn/imessage/crypto/PairEcEnvelopeTest.kt b/crypto/src/test/kotlin/dev/fenn/imessage/crypto/PairEcEnvelopeTest.kt new file mode 100644 index 0000000..c090138 --- /dev/null +++ b/crypto/src/test/kotlin/dev/fenn/imessage/crypto/PairEcEnvelopeTest.kt @@ -0,0 +1,336 @@ +package dev.fenn.imessage.crypto + +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.spec.ECGenParameterSpec +import javax.crypto.KeyAgreement +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PairEcEnvelopeTest { + + private val plaintext = MessageBody(text = "pair-ec envelope test", protocolVersion = "1").encode() + + private val senderDeviceMarshalled = ByteArray(65) { (it + 1).toByte() } + private val recipientDeviceMarshalled = ByteArray(65) { (it + 2).toByte() } + private val recipientPrekeyMarshalled = ByteArray(65) { (it + 3).toByte() } + private val compressedPrekey = EcCrypto.compress(PREKEY.public) + + /** Seeded RNG so the ephemeral key, pad bytes and OAEP/ECDSA nonces are reproducible. */ + private fun fixedRandom(seed: Byte = 1) = object : SecureRandom() { + private var next = seed + override fun nextBytes(bytes: ByteArray) { + for (i in bytes.indices) bytes[i] = (next + i).toByte() + next = (next + bytes.size).toByte() + } + } + + private fun encrypt( + counter: Long = 1L, + random: SecureRandom = fixedRandom(), + gossip: ByteArray? = null, + debug: ByteArray? = null, + ephemeralKeyForm: PairEcEnvelope.EphemeralKeyForm = PairEcEnvelope.EphemeralKeyForm.COMPRESSED_33, + ): ByteArray = PairEcEnvelope.encrypt( + plaintext = plaintext, + recipientPrekey = compressedPrekey, + recipientPrekeyMarshalled = recipientPrekeyMarshalled, + senderDeviceKeyMarshalled = senderDeviceMarshalled, + recipientDeviceKeyMarshalled = recipientDeviceMarshalled, + counter = counter, + senderDeviceSigningKey = DEVICE_KEY.private, + random = random, + ephemeralKeyForm = ephemeralKeyForm, + gossip = gossip, + debug = debug, + ) + + private fun decrypt( + outer: ByteArray, + senderDeviceKeyMarshalled: ByteArray? = senderDeviceMarshalled, + senderSigningPublicKey: java.security.PublicKey? = DEVICE_KEY.public, + ): Pair = PairEcEnvelope.decrypt( + outer = outer, + recipientPrekeyPrivate = PREKEY.private, + recipientPrekeyMarshalled = recipientPrekeyMarshalled, + recipientDeviceKeyMarshalled = recipientDeviceMarshalled, + senderDeviceKeyMarshalled = senderDeviceKeyMarshalled, + senderDeviceSigningPublicKey = senderSigningPublicKey, + ) + + @Test + fun roundTripPreservesPlaintextAndCounter() { + val (back, counter) = decrypt(encrypt(counter = 7L)) + assertContentEquals(plaintext, back) + assertEquals(7L, counter) + } + + // Known answer (§4.3, C52): the outer protobuf's field numbers are payload 1, key 2, + // signature 3, validator 99; the field-2 key is the 33-byte compressed point. + @Test + fun outerMessageCarriesTheRecordedShape() { + val parsed = MiniProto.decode(encrypt()) + assertEquals( + setOf(1, 2, 3, 99), + parsed.map { it.first }.toSet(), + "outer field numbers must be payload 1, key 2, signature 3, validator 99 (§4.3)", + ) + assertEquals(33, (parsed.first { it.first == 2 }.second as ByteArray).size) + assertEquals(64, (parsed.first { it.first == 3 }.second as ByteArray).size) + assertEquals(7, (parsed.first { it.first == 99 }.second as ByteArray).size) + // 33-byte ephemeral keys are SEC1-compressed: 0x02/0x03 prefix. + val prefix = (parsed.first { it.first == 2 }.second as ByteArray)[0] + assertTrue(prefix == 0x02.toByte() || prefix == 0x03.toByte()) + } + + // Known answer (§4.3, C50's protobuf half): the inner message's field numbers are + // message 1, counter 2, gossip 3, debug 99 — verified by hand-decrypting the ciphertext. + @Test + fun innerMessageCarriesTheRecordedFieldNumbers() { + val withExtras = encrypt(gossip = ByteArray(8), debug = ByteArray(4)) + val innerFields = handDecryptedInnerFields(withExtras, gossip = true, debug = true) + assertEquals(listOf(1, 2, 3, 99), innerFields) + assertEquals(listOf(1, 2), handDecryptedInnerFields(encrypt())) + } + + @Test + fun validatorIsTwoBytePrefixesPlusTheZeroZeroCVersionByte() { + val expected = byteArrayOf( + senderDeviceMarshalled[0], senderDeviceMarshalled[1], + recipientDeviceMarshalled[0], recipientDeviceMarshalled[1], + recipientPrekeyMarshalled[0], recipientPrekeyMarshalled[1], + 0x0c, + ) + assertContentEquals( + expected, + PairEcEnvelope.validator(senderDeviceMarshalled, recipientDeviceMarshalled, recipientPrekeyMarshalled), + ) + val received = (MiniProto.decode(encrypt()).first { it.first == 99 }.second as ByteArray) + assertContentEquals(expected, received) + } + + @Test + fun ciphertextIsPaddedToAMultipleOfSixteenPlusTheFourByteTrailer() { + val ciphertext = MiniProto.decode(encrypt()).first { it.first == 1 }.second as ByteArray + assertTrue(ciphertext.size >= 4) + assertEquals(0, (ciphertext.size - 4) % 16, "pad-to-16 then 4-byte LE trailer (§4.3)") + } + + @Test + fun signatureCoversSharedSecretPrekeysEphemeralAndCiphertext() { + // Structural: decryption recomputes the same signature input, so the correct key + // verifies and any other key fails — the coverage test is in the failure paths. + decrypt(encrypt()) + assertFailsWith { + decrypt(encrypt(), senderSigningPublicKey = OTHER_DEVICE_KEY.public) + } + } + + @Test + fun validatorMismatchFailsLoudlyWhenSenderKeySupplied() { + assertFailsWith { + decrypt(encrypt(), senderDeviceKeyMarshalled = ByteArray(65) { 0x55 }) + } + } + + @Test + fun verificationSkippedWhenKeysAbsent() { + val (back, _) = decrypt(encrypt(), senderDeviceKeyMarshalled = null, senderSigningPublicKey = null) + assertContentEquals(plaintext, back) + } + + @Test + fun gossipAndDebugFieldsRideTheInnerMessage() { + val plain = encrypt() + val withExtras = encrypt(gossip = ByteArray(64), debug = byteArrayOf(3)) + fun ciphertextLength(outer: ByteArray): Int = + (MiniProto.decode(outer).first { it.first == 1 }.second as ByteArray).size + assertTrue(ciphertextLength(withExtras) > ciphertextLength(plain), "extras grow the encrypted inner message") + val (back, _) = decrypt(withExtras, senderDeviceKeyMarshalled = null, senderSigningPublicKey = null) + assertContentEquals(plaintext, back, "the inner parse must tolerate the optional fields") + } + + // --- ephemeral key wire form (§4.3 prose vs C57's 32-byte reading, §7 item 21) ------- + + @Test + fun defaultEphemeralKeyIsTheThirtyThreeByteCompressedPoint() { + val parsed = MiniProto.decode(encrypt()) + val key = parsed.first { it.first == 2 }.second as ByteArray + assertEquals(33, key.size) + val (back, _) = decrypt(encrypt(), senderDeviceKeyMarshalled = null, senderSigningPublicKey = null) + assertContentEquals(plaintext, back) + } + + @Test + fun xOnlyEphemeralKeyFormEmitsBareCoordinatesAndStillDecrypts() { + val parsed = MiniProto.decode(encrypt(ephemeralKeyForm = PairEcEnvelope.EphemeralKeyForm.X_ONLY_32)) + val key = parsed.first { it.first == 2 }.second as ByteArray + assertEquals(32, key.size, "the C57 32-byte form is the bare x-coordinate (no parity prefix)") + assertTrue(key[0] != 0x02.toByte() && key[0] != 0x03.toByte()) + val (back, _) = decrypt( + encrypt(ephemeralKeyForm = PairEcEnvelope.EphemeralKeyForm.X_ONLY_32), + senderDeviceKeyMarshalled = null, + senderSigningPublicKey = null, + ) + assertContentEquals(plaintext, back, "the receiver recovers the point by trying both parities") + } + + @Test + fun neitherRecordedEphemeralFormIsRejectedLoudly() { + val parsed = MiniProto.decode(encrypt()) + val mangled = (parsed.first { it.first == 2 }.second as ByteArray).copyOfRange(0, 20) + assertFailsWith { + PairEcEnvelope.decrypt( + MiniProto.encode(listOf(1 to ByteArray(4), 2 to mangled)), + PREKEY.private, + recipientPrekeyMarshalled, + recipientDeviceMarshalled, + ) + } + } + + // --- HKDF derivation (§4.3, rev-12 correction: "LastPawn-MessageKeys" is the SALT, info empty) + + @Test + fun hkdfUsesTheLastPawnSaltWithEmptyInfo() { + // Known answer computed independently of this code (HKDF-SHA256, RFC 5869): + // salt = "LastPawn-MessageKeys", info = empty, ikm = 00..1f. + val ikm = ByteArray(32) { it.toByte() } + val prk = Hkdf.extract(HkdfSaltVector.salt, ikm) + assertContentEquals(HkdfSaltVector.expectedPrk, prk) + assertContentEquals(HkdfSaltVector.expectedOkm, Hkdf.expand(prk, ByteArray(0), 48)) + } + + @Test + fun decryptRejectsAWrongSaltDerivation() { + // The salt is a fixed constant, not caller configuration — a peer deriving with the + // superseded info-string reading must not decrypt. + val outer = encrypt() + val parsed = MiniProto.decode(outer) + val ciphertext = parsed.first { it.first == 1 }.second as ByteArray + val ephemeral = PairEcEnvelope.ephemeralPublicKey(parsed.first { it.first == 2 }.second as ByteArray) + val shared = KeyAgreement.getInstance("ECDH").apply { + init(PREKEY.private) + doPhase(ephemeral, true) + }.generateSecret() + val wrong = Hkdf.expand(Hkdf.extract(ByteArray(32), shared), "LastPawn-MessageKeys".toByteArray(), 48) + val wrongPlaintext = PairEnvelope.aesCtr(ciphertext, wrong.copyOfRange(0, 32), wrong.copyOfRange(32, 48)) + assertTrue( + runCatching { PairEcEnvelope.unpad(wrongPlaintext) }.isFailure, + "the superseded info-string derivation must not produce a parseable inner message", + ) + } + + // --- prekey record signature (§4.3) -------------------------------------- + + @Test + fun prekeySignatureInputIsLabelPlusPrekeyPlusLittleEndianTimestamp() { + val label = "NGMPrekeySignature".toByteArray(Charsets.US_ASCII) + // ts = 0.0 → eight zero timestamp bytes (endian-invariant, but still appended). + assertContentEquals( + MessageDigest.getInstance("SHA-256").digest(label + compressedPrekey + ByteArray(8)), + PairEcEnvelope.prekeySignatureBytes(compressedPrekey, 0.0), + ) + // ts = 1.0 → IEEE-754 bits 0x3FF0000000000000, little-endian: 00 00 00 00 00 00 F0 3F. + assertContentEquals( + MessageDigest.getInstance("SHA-256").digest( + label + compressedPrekey + byteArrayOf(0, 0, 0, 0, 0, 0, 0xF0.toByte(), 0x3F), + ), + PairEcEnvelope.prekeySignatureBytes(compressedPrekey, 1.0), + ) + } + + @Test + fun prekeySignatureRoundTripAndTamper() { + val signature = PairEcEnvelope.signPrekey(DEVICE_KEY.private, compressedPrekey, 1_700_000_000.0) + assertEquals(64, signature.size, "raw 64-byte r‖s (§4.3)") + assertTrue(PairEcEnvelope.verifyPrekey(DEVICE_KEY.public, compressedPrekey, 1_700_000_000.0, signature)) + assertTrue( + !PairEcEnvelope.verifyPrekey(OTHER_DEVICE_KEY.public, compressedPrekey, 1_700_000_000.0, signature), + "a different device key must not verify (§4.3: receivers verify against the sender's device key)", + ) + val tampered = signature.copyOf().also { it[10] = (it[10].toInt() xor 1).toByte() } + assertTrue(!PairEcEnvelope.verifyPrekey(DEVICE_KEY.public, compressedPrekey, 1_700_000_000.0, tampered)) + } + + // --- minimal protobuf ------------------------------------------------------ + + @Test + fun protobufVarintKnownAnswers() { + assertContentEquals(byteArrayOf(0x08, 0x96.toByte(), 0x01), MiniProto.encode(listOf(1 to 150L))) + assertContentEquals(byteArrayOf(0x08, 0xAC.toByte(), 0x02), MiniProto.encode(listOf(1 to 300L))) + assertContentEquals( + byteArrayOf(0x12, 0x02, 0x68, 0x69), + MiniProto.encode(listOf(2 to "hi".toByteArray())), + ) + assertContentEquals(listOf>(1 to 150L), MiniProto.decode(byteArrayOf(0x08, 0x96.toByte(), 0x01))) + // Round-trip a mix of varints and length-delimited fields across a field number > 15. + val mixed = listOf(99 to "validator".toByteArray(), 3 to 1_000_000L, 10 to byteArrayOf(1, 2)) + MiniProto.decode(MiniProto.encode(mixed)).forEachIndexed { i, (field, value) -> + assertEquals(mixed[i].first, field) + when (value) { + is ByteArray -> assertContentEquals(mixed[i].second as ByteArray, value) + else -> assertEquals(mixed[i].second, value) + } + } + } + + /** ECDH + HKDF + AES-CTR + unpad + decode, using only the recorded wire facts. */ + private fun handDecryptedInnerFields(outer: ByteArray, gossip: Boolean = false, debug: Boolean = false): List { + val parsed = MiniProto.decode(outer) + val ciphertext = parsed.first { it.first == 1 }.second as ByteArray + val ephemeral = PairEcEnvelope.ephemeralPublicKey(parsed.first { it.first == 2 }.second as ByteArray) + val shared = KeyAgreement.getInstance("ECDH").apply { + init(PREKEY.private) + doPhase(ephemeral, true) + }.generateSecret() + val okm = Hkdf.expand( + Hkdf.extract("LastPawn-MessageKeys".toByteArray(Charsets.US_ASCII), shared), + ByteArray(0), + 48, + ) + val padded = PairEnvelope.aesCtr(ciphertext, okm.copyOfRange(0, 32), okm.copyOfRange(32, 48)) + val inner = MiniProto.decode(PairEcEnvelope.unpad(padded)) + return inner.map { it.first }.also { fields -> + val expectedFields = buildSet { + add(1) + add(2) + if (gossip) add(3) + if (debug) add(99) + } + assertEquals(expectedFields, fields.toSet()) + } + } + + private object HkdfSaltVector { + val salt = "LastPawn-MessageKeys".toByteArray(Charsets.US_ASCII) + val expectedPrk = hexToBytes("d3d43516b7e4540c6f8c188aa959a7a538851856a604e4cbfcf432b02912de84") + val expectedOkm = hexToBytes( + "c2d243f6ae6fb64ea3a0691dda66415678d78add225a7ebc5be826ee4cff88cf" + + "e365c7f1c4483fa427d9eb6f250b13c9", + ) + + fun hexToBytes(hex: String): ByteArray = ByteArray(hex.length / 2) { + ((Character.digit(hex[it * 2], 16) shl 4) + Character.digit(hex[it * 2 + 1], 16)).toByte() + } + } + + private companion object { + val DEVICE_KEY: KeyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + + val OTHER_DEVICE_KEY: KeyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + + val PREKEY: KeyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + } +} diff --git a/crypto/src/test/kotlin/dev/fenn/imessage/crypto/PairEnvelopeTest.kt b/crypto/src/test/kotlin/dev/fenn/imessage/crypto/PairEnvelopeTest.kt new file mode 100644 index 0000000..310ed58 --- /dev/null +++ b/crypto/src/test/kotlin/dev/fenn/imessage/crypto/PairEnvelopeTest.kt @@ -0,0 +1,215 @@ +package dev.fenn.imessage.crypto + +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.SecureRandom +import java.security.spec.ECGenParameterSpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PairEnvelopeTest { + + private val plaintext = MessageBody( + text = "pair envelope test with enough bytes that the ciphertext clears the " + + "hundred-byte RSA transport window comfortably", + protocolVersion = "1", + ).encode() + private val senderIdentityHash = ByteArray(32) { (it + 1).toByte() } + private val recipientIdentityHash = ByteArray(32) { (it + 9).toByte() } + + /** Seeded RNG: the 11 random message-key bytes are deterministic (1, 2, 3, …). */ + private fun fixedRandom(seed: Byte = 1) = object : SecureRandom() { + private var next = seed + override fun nextBytes(bytes: ByteArray) { + for (i in bytes.indices) bytes[i] = (next + i).toByte() + next = (next + bytes.size).toByte() + } + } + + private fun encryptEnvelope(random: SecureRandom = fixedRandom()): ByteArray = PairEnvelope.encrypt( + plaintext = plaintext, + senderSigningKey = EC_KEY.private, + recipientEncryptionKey = RSA_KEY.public, + senderIdentityHash = senderIdentityHash, + recipientIdentityHash = recipientIdentityHash, + random = random, + ) + + // Known answer: identityHash is SHA-256 over the plain concatenation of the two marshalled + // keys — with an empty second key it must equal SHA-256 of the first ("abc" vector). + @Test + fun identityHashIsSha256OverThePlainConcatenation() { + val expected = hexToBytes("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + assertContentEquals(expected, PairEnvelope.identityHash("abc".toByteArray(), ByteArray(0))) + } + + // RFC 4231 test case 2 for the HMAC primitive. + @Test + fun hmacPrimitiveMatchesRfc4231() { + val mac = Hkdf.hmac("Jefe".toByteArray(), "what do ya want for nothing?".toByteArray()) + assertContentEquals(hexToBytes("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"), mac) + } + + // §4.2 (C51): the 40-bit integrity HMAC is keyed with the 11 random bytes themselves and + // computed over plaintext ‖ 0x02 ‖ sender-identity-hash ‖ recipient-identity-hash. + @Test + fun integrityHmacIsKeyedByTheRandomBytesAndCoversPlaintextTagAndBothIdentityHashes() { + val randomPart = ByteArray(11) { (it + 3).toByte() } + val expected = Hkdf.hmac( + randomPart, + plaintext + byteArrayOf(PairEnvelope.TAG) + senderIdentityHash + recipientIdentityHash, + ) + assertContentEquals( + expected, + PairEnvelope.integrityHmac(randomPart, plaintext, senderIdentityHash, recipientIdentityHash), + ) + assertTrue( + !expected.contentEquals( + Hkdf.hmac(ByteArray(11) { 9 }, plaintext + byteArrayOf(PairEnvelope.TAG) + senderIdentityHash + recipientIdentityHash), + ), + "a different 11-byte key must produce a different integrity value", + ) + } + + @Test + fun messageKeyIsElevenRandomBytesThenTheFiveIntegrityBytes() { + val key = PairEnvelope.messageKey(ByteArray(11) { it.toByte() }, byteArrayOf(9, 8, 7, 6, 5)) + assertContentEquals(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 7, 6, 5), key) + assertFailsWith { PairEnvelope.messageKey(ByteArray(10), byteArrayOf(1, 2, 3, 4, 5)) } + assertFailsWith { PairEnvelope.messageKey(ByteArray(11), ByteArray(6)) } + } + + @Test + fun ivIsFifteenZeroBytesThenOne() { + assertContentEquals(ByteArray(15) + byteArrayOf(0x01), PairEnvelope.iv()) + } + + @Test + fun envelopeStructure() { + val envelope = encryptEnvelope() + assertEquals(PairEnvelope.TAG, envelope[0]) + val bodyLength = ((envelope[1].toInt() and 0xFF) shl 8) or (envelope[2].toInt() and 0xFF) + val signatureLength = envelope[3 + bodyLength].toInt() and 0xFF + assertEquals(envelope.size, 3 + bodyLength + 1 + signatureLength) + assertTrue(bodyLength >= PairEnvelope.RSA_BLOCK_BYTES, "a decryptable body is at least the RSA block") + // CTR preserves length: body = 160-byte RSA block + the ciphertext past its first 100 bytes. + assertEquals( + PairEnvelope.RSA_BLOCK_BYTES - PairEnvelope.KEY_TRANSPORT_CIPHERTEXT_BYTES + plaintext.size, + bodyLength, + ) + } + + @Test + fun roundTripWithSignatureVerification() { + val decrypted = PairEnvelope.decrypt( + envelope = encryptEnvelope(), + recipientDecryptionKey = RSA_KEY.private, + senderSigningPublicKey = EC_KEY.public, + senderIdentityHash = senderIdentityHash, + recipientIdentityHash = recipientIdentityHash, + ) + assertContentEquals(plaintext, decrypted) + } + + @Test + fun decryptSkipsSignatureVerificationWhenKeyAbsent() { + val decrypted = PairEnvelope.decrypt( + encryptEnvelope(), + RSA_KEY.private, + senderSigningPublicKey = null, + senderIdentityHash, + recipientIdentityHash, + ) + assertContentEquals(plaintext, decrypted) + } + + @Test + fun wrongSigningKeyFailsVerification() { + assertFailsWith { + PairEnvelope.decrypt( + encryptEnvelope(), + RSA_KEY.private, + OTHER_EC_KEY.public, + senderIdentityHash, + recipientIdentityHash, + ) + } + } + + @Test + fun tamperedCiphertextFailsTheFortyBitIntegrityCheck() { + val envelope = encryptEnvelope().copyOf() + // Flip a byte in the clear tail (first byte after the RSA block) — the plaintext changes, + // so the HMAC-derived 40 bits must stop matching. + val clearTailStart = 3 + PairEnvelope.RSA_BLOCK_BYTES + envelope[clearTailStart] = (envelope[clearTailStart].toInt() xor 0x55).toByte() + assertFailsWith { + PairEnvelope.decrypt(envelope, RSA_KEY.private, null, senderIdentityHash, recipientIdentityHash) + } + } + + @Test + fun tamperedRandomKeyBytesFailLoudly() { + // The integrity HMAC is keyed by the transported 11 random bytes (§4.2, C51): flipping + // one inside the RSA block must never yield a clean decrypt (OAEP may reject the + // mangled block outright, or the 40-bit check fires). + val envelope = encryptEnvelope().copyOf() + envelope[3] = (envelope[3].toInt() xor 0x01).toByte() + val outcome = runCatching { + PairEnvelope.decrypt(envelope, RSA_KEY.private, null, senderIdentityHash, recipientIdentityHash) + } + assertTrue(outcome.isFailure, "mangling the transported random key bytes must fail loudly") + } + + @Test + fun malformedEnvelopesRejected() { + val good = encryptEnvelope() + assertFailsWith { PairEnvelope.decrypt(ByteArray(3), RSA_KEY.private, null, senderIdentityHash, recipientIdentityHash) } + assertFailsWith { + val badTag = good.copyOf().also { it[0] = 0x03 } + PairEnvelope.decrypt(badTag, RSA_KEY.private, null, senderIdentityHash, recipientIdentityHash) + } + assertFailsWith { + PairEnvelope.decrypt(good.copyOfRange(0, good.size - 5), RSA_KEY.private, null, senderIdentityHash, recipientIdentityHash) + } + } + + @Test + fun shortPlaintextTransportsOnlyWhatExists() { + // §4.2 (C51): the RSA block carries the key plus the first min(100, len) ciphertext + // bytes — a shorter ciphertext fills only what exists and nothing rides in the clear. + val tiny = "tiny".toByteArray() + val envelope = PairEnvelope.encrypt( + plaintext = tiny, + senderSigningKey = EC_KEY.private, + recipientEncryptionKey = RSA_KEY.public, + senderIdentityHash = senderIdentityHash, + recipientIdentityHash = recipientIdentityHash, + random = fixedRandom(), + ) + val bodyLength = ((envelope[1].toInt() and 0xFF) shl 8) or (envelope[2].toInt() and 0xFF) + assertEquals(PairEnvelope.RSA_BLOCK_BYTES, bodyLength, "a sub-100-byte ciphertext leaves no clear tail") + val decrypted = PairEnvelope.decrypt(envelope, RSA_KEY.private, EC_KEY.public, senderIdentityHash, recipientIdentityHash) + assertContentEquals(tiny, decrypted) + } + + private fun hexToBytes(hex: String): ByteArray = ByteArray(hex.length / 2) { + ((Character.digit(hex[it * 2], 16) shl 4) + Character.digit(hex[it * 2 + 1], 16)).toByte() + } + + private companion object { + val EC_KEY: KeyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + + val OTHER_EC_KEY: KeyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + + /** 1280-bit RSA encryption key — the §4.2 identity encryption key size (160-byte block). */ + val RSA_KEY: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1280) }.generateKeyPair() + } +} diff --git a/docs/imessage-protocol-spec.md b/docs/imessage-protocol-spec.md new file mode 100644 index 0000000..a2326f0 --- /dev/null +++ b/docs/imessage-protocol-spec.md @@ -0,0 +1,328 @@ +# iMessage protocol spec (clean-room) + +**Status:** draft — revision 24. Rev 24 (2026-09-28, first live probes of this implementation — two unauthenticated bag GETs + a no-client-cert courier TLS handshake, runner `:engine:runProbe`; nothing else touched): (1) C18 SETTLED — the courier demands the Albert client certificate via a fatal TLS 1.3 `certificate_required` alert after a successful handshake, on both ports (§3.1); (2) the bags are TYPED CONFIG DICTIONARIES, not URL maps — the live IDS bag carries ints/bools/floats/string-lists alongside 400+ URLs and a `bag-expiry-timestamp` (~17-day observed TTL; 403 entries live vs 405 on 2026-09-10, two new push-debounce keys in, four A/B-test keys out); the APNs bag is 65 stable entries incl. BAA-authentication flags (`APNSEnableBAAAuthenticationForDevices` etc. — C17/C20-relevant); (3) AppleTrust's embedded-root pinning validates the live chains of `init.ess.apple.com` and `courier.push.apple.com` (no root served — chain anchored at the embedded root); (4) rev-23's container-fact follow-ups: sort-version 0 and the ref-width rule are now capture/probe-consistent. Rev 23 (2026-09-27, writer-pass follow-up, [CF] public material + host-decoder empirical cross-check only — no restricted source): three §4 container-format gaps found by the first writer session are closed — integer signedness (1/2/4-byte forms unsigned; the 8-byte form is two's-complement signed; non-negative values take the minimal width, negative values always the 8-byte form), the object-reference width rule (minimal width whose 2^(8w) ≥ object count, so every reference 0..count−1 is representable), and the sort-version byte (written as 0). Rev 22 (2026-09-12, offline byte-audit of the saved live step-2 request — no network call, nothing committed): the CLIENT-SIDE ENCODING class is closed. The exact 3898 bytes the live runner sent (§7 item 24) parse as an XML plist with the single `session-info-request` `` key, and its 2729-byte payload walks the invented TLV v1 to the last byte with ZERO slack, 17 distinct tags, no duplicates and no trailing bytes — our bytes are exactly what the code intends, so no writer bug hides there (the framing *intent* stays invented and unverified). By-product fact: the step-1 `cert` data field is NOT a bare DER cert — it is Apple's own container, `01 02` followed by two `(4-byte BE length, DER cert)` pairs (1046 and 1329 bytes; 2+4+1046+4+1329 = 2385 exactly), carried through verbatim by this implementation (§1.1) and cross-checking against the archived genuine step-1 response size. Apple's length convention there is 4-BYTE big-endian where our TLV v1 uses 2 — the only Apple framing ground truth in hand, and it weakens TLV v1 further. Rev 21 (2026-09-11, step-2 refusal re-read + offline cross-checks — no new live calls): one correction and five facts. CORRECTION — the rev-20 "BODY-INDEPENDENT" conclusion is withdrawn as unproven: a 20-byte non-plist body and our 3898-byte reconstruction returning the same generic `status` 5003 establishes only that 5003 is this endpoint's generic request-level failure; it cannot separate "the body is never parsed" from "both bodies are parsed and both rejected identically", because the reconstruction is itself unverified — the `session-info-request` blob is minted by the closed-source library (§1.1 [OA]). Blob FRAMING is therefore live again beside the envelope. FACTS — (1) 5003 is named in the IDS status space: `IDSStatusBadRequest` [IG:imessage/direct/ids/types/idserrors.gen.go], a table auto-generated from web FaceTime's JavaScript, so it is the IDS family's space and not proven for this WebObjects service; that same table shows what a body-level rejection in this family looks like (`ParseFailure` 1000, `InvalidField` 1001, `DecodeFailureBase64`/`Hex` 1004/1005, `MissingRequiredKey` 5008) and the endpoint returned none of them. (2) `reference/rustpush` IS the OpenBubbles fork (`origin` = github.com/OpenBubbles/rustpush, i.e. the genuine client the capture plan names); its tip re-fetched 2026-09-11 equals the staged snapshot, and its step-2 call is still `REQWEST.post(bag_url).body(xml_plist)` with no content-type and one default header `Accept-Language: en-US,en;q=0.9` — a current model client's envelope is body-only, which makes the header half the weakest suspect and the blob the strongest. (3) The route is confirmed against a GENUINE device: the archived 2026-09-10 capture's `id-initialize-validation` bag value is byte-identical to the URL the live run posted to. (4) That genuine bag enables attestation on this endpoint family — `absinthe-baa-option-enabled`/`-ios13` and `absinthe-v3-enabled` all true, `absinthe-baa-cert-ttl-minutes` 144000 — the one mechanism that could still put the envelope back in front. (5) Both SED traces are now recorded as a grammar: `SED:3:VEN-PROD:::::<64-hex>:EM`; between the two runs every uuid and the epoch differ (the epochs 28 s apart, matching the calls' spacing) and the 64-hex differs, so it is per-request, not a digest of any recorded input (checked against the body, the empty string and the step-2 URL). Next lever unchanged: the genuine-client capture — valued now for the BLOB it reveals, not the header set. Rev 20 (2026-09-11, live step-2 control experiment): the step-2 refusal is BODY-INDEPENDENT — a 20-byte body that is not a plist at all and the full reconstructed 3898-byte request are answered identically (HTTP 200, the same 498-byte `status` 5003 plus a nine-token SED trace; only its per-request epoch and uuids differ between the two runs). The invented TLV framing, the wrapper encoding, and the step-2 content-type are therefore ALL retired as causes at this endpoint: nothing in the body is examined before the refusal. The rev-18 "byte-identical" reading is corrected — the trace carries a live per-request epoch (two runs 28 s apart) and three per-request uuids, so the responses were shape-identical, and the rev-19 inference chain from that reading is superseded by this direct experiment. Surviving suspect class: the request ENVELOPE (the header set a genuine client carries, or an identity the endpoint resolves before parsing); the live step-2 URL is now recorded (§1.1, §7 item 24). Rev 19 (2026-09-11, step-2 self-check round): the step-2 request reconstruction was audited against the extracted client's own request construction (§7 item 24) — the XML-plist wrapper is confirmed, while our `text/x-xml-plist` default turned out to be an unsupported inference: the recorded client sets NO content-type on the step-2 POST (§1.1), so all three content-types of the rev-18 probe matrix were non-faithful (the constant is now absent in the code); the rev-18 "invariant under every flipped variable" claim is corrected to a *body-content* independence (the five attempts did NOT send identical bodies — fresh key material each time), which retires the step-2 crypto variables and leaves the invented TLV framing as the prime suspect; and an archived genuine-device capture shows the validation endpoint family reached with the full anisette/BAA header set while step 1 succeeds header-less because that URL is a CDN static object (§1.1) — next levers: one faithful (no content-type) step-2 POST beside a deliberate control POST, then a genuine-client capture. Rev 18 (2026-09-11, first live step-1 attempt + probe matrix): the §1.1 validation exchange ran against the live Apple endpoints for the first time — bag + step-1 cert GET OK (pinned TLS, HTTP 200); FIVE step-2 POST variants (default C60 set, X25519, HMAC, three content-types) all answered HTTP 200 with the same 498-byte error plist (`status` 5003, SED trace message) and no `session-info` (§1.1, §7 item 24) — the refusal is invariant under every flipped variable, so the C60 crypto hypotheses remain open and the next lever is a genuine-client wire capture; the SED status table is a new TODO(capture). Rev 17 (2026-09-11, FairPlay material metadata extraction): the provenance half of the FairPlay identity question is closed (§1.2, §7 item 21) — all known embedded copies across the reference repos are ONE 1024-bit RSA identity (leaf cert expired 2024, yet accepted), descending from a 2023 iCloud-bypass community release; rustpush's "ten pairs" are ten copies; no own-Mac extraction path is documented anywhere (open research item); BYO-identity is recorded as the sane architecture. Rev 16 (2026-09-11, activation implementation round): the three remaining GSA header literals are pinned (§1.5 — browser UA, non-akd `X-MMe-Client-Info`, the full-wrapper `X-Apple-I-CFU-State` plist, the bracketed sign-in client-info); the validation-data exchange is implemented — steps 1–2 concrete, step 3 as an explicit contract reconstruction with six named hypotheses (key agreement, KDF, signature, session-info interpretation, blob framing, step-2 content-type) collected as new **C60** (§7 item 24) and arbitrable only by one live activation; the Mac-Hardware-Info export parser and the C59 constants are wired into code. Rev 15 (2026-09-11, second activation extraction round — C59 + C58 closed): the GSA-login residue is recorded (§1.5 closure paragraph: RFC 5054 2048-bit SRP group with g=2, the full 2FA browser header set with literal values, per-flavor cpd values, akd UA/`X-MMe-Client-Info` forms, `spd` `t`-map inner keys + top-level `GsIdmsToken` + `fn`/`ln`, the `gsas.apple.com` postdata/teardown host split, the sign-in UA/timezone forms); C58 is closed — the Mac-Hardware-Info export-code format is fully recorded in §1.1 (`OABS` magic + flag byte + protobuf field map; plain-base64 or OpenSSL-`Salted__` AES-256-CBC delivery; sharing mechanics server-side; upstream repo unlicensed — facts only). Citation legend gains [MHG]. Rev 14 (2026-09-11, activation extraction round — facts-only over the anisette/GSA crates and the Mac client, read-only agents, main-session merge): C6's value-format and provisioning halves are closed (§1.1 — per-request anisette value forms, the ADI provisioning flow with `spim`/`cpim`/`ptm`/`tk`, the 60-byte Apple-issued `mid`, the 16-byte local `keychain_identifier`, machine-state persistence); new §1.5 records the full GSA login chain end-to-end (SRP-6a 2048-bit with the GSA empty-username identity-hash quirk, `s2k`/`s2k_fo`, `spd` decryption, both 2FA variants + the Circle PAKE, PET semantics, delegate logins yielding the IDS `authToken`/`profileId`, the `X-Mme-Nas-Qualify` sign-in POST); the five obfuscation labels are mapped to their fields (§1.1); C56 step-3 and the OTP derivation are CONFIRMED closed at source (upstream ships stubs — contracts only). Rev 13 (2026-09-11, implementer round — rev 12's closed fact sets wired into code by four sequential firewall-clean implementer components; no new protocol facts): courier command builders + connect/auth + the engine's courier frame handler are concrete (§3.3, C45); lookups ride the APNs tunnel with the C47/C48 wire names, plain-string `uris`, and C55 top-level-status routing (§2.1); register/authenticate bodies and the Albert activation wire half are concrete (C53/C54/C8, §1.2/§1.4); envelope fixes landed (HKDF salt `"LastPawn-MessageKeys"` with empty info, pair HMAC keying, recorded pair-ec field numbers, dtl array-of-dicts regression-tested, §4). Nothing closed was re-opened. C57 (§7 item 21) gains six writer-pass gaps: filter multi-topic encoding, connect field-0x10 position, connect-response 5/6 shapes, tunnel 96/97 field ids + payload framing/encoding + status table, phone-flow authentication-data value encodings, Albert request/response value types. Rev 12 (2026-09-10, facts-only extraction round over both source repos plus the OpenAbsinthe-Stub mock): closed C45 (courier field-id tables + length-prefix scope, §3.2–3.3), C47/C48 (lookup headers/keys, §2.1), C49/C51/C52 and C50's protobuf half (§4), C53 (register wire names, §1.4), C54 (authenticate exchange, §1.2), C8's wire half (Albert activation, §1.2), and C56's steps 1–2 with the GSA login + full anisette set (§1.1). Corrected: the HKDF "info string" is the salt (§4.3); `dtl` is an array of dicts; payload command 160 is not a frame command (§3.3, §5.1). Narrowed: C55 — both clients read lookup error statuses from the top-level `status` only (§2.1, §7 item 19). New first-class architecture fact: IDS lookups ride the APNs tunnel, never plain HTTPS (§2.1). New **C57** (§7 item 21) collects extraction disagreements and closed-source residue. Legend gains [OA]. Rev 11 (2026-09-10, OpenBubbles-flow research — C5/C6 model corrected): the Mac's role in activation is **identifier export only** — the shipped OpenBubbles helper (`Mac-Hardware-Info` 1.2.0, notarized) reads serial/board-ID/model via public IORegistry/sysctl APIs on any macOS ≥ High Sierra *including 26.x*, and never calls an Apple private framework (§1.1, §6.1). The prior "minting requires a Mac on macOS ≤ 14.3" conclusion bound only framework-calling helpers (beeper's archived `mac-registration-provider`; rustpush running *on* a Mac); the remaining C5/C6 work is client-side software — the generation procedure, recorded as new **C56** (§7 item 20). Legend gains [OB-MHI]. Rev 10 (2026-09-10, Phase 2 lookup-orchestration component): added C55 (§2.1) — the lookup response's error-status locations: whether 5206 ("response too large"), 6005, and the send-side 5040/5041 session-token codes appear as a top-level response status, per-handle result statuses, or both; the lookup flow reads per-handle result statuses only, the one numeric-status location the fact set records. Rev 9 (2026-09-10, Phase 2 component 5): registered the registration wire-name gaps the component's writer pass hit — C53 (§1.4: register request/response key names beyond the recorded set, and the `x-auth-user-id-` value form) and C54 (§1.2: the authenticate request/response pair — body wire keys, CSR encoding, CN encoding, self-signature algorithm, response keys) — plus §7 item 18. The activation code takes every unrecorded name as caller-supplied configuration and keeps every unrecorded response field in a raw map. Rev 8 (2026-09-10, Phase 2 component 4): [CAP-IDSTLS] (§1.2) fixed the IDS TLS anchor set — `Apple Root CA - G3` and `Apple Root CA`, roots not served, system store rejects the unanchored chain — and [CAP-BAGCT] (§1.2, §7 item 16) closed C47's encoding half: both bag responses and the embedded bag payload are XML plists on the wire, content-type labels notwithstanding. The component-4 writer pass added TODO(capture) C49–C52 and §7 item 17 (unrecorded plist/body value types, the §4.2 HMAC key + ECDSA wire encoding, §4.3 protobuf field numbers beyond validator=99 and the HKDF salt). Rev 7 (2026-09-10, Phase 2 component 3): recorded the IDS lookup/bag wire details the phase-1 fact set never captured — endpoint plist encodings and the unrecorded lookup header/body/response key names (C47/C48, §1.2, §2.1, §7 item 16). The writer phase guesses none of them: they are configuration in the code until a capture closes them. Rev 6 (2026-09-10, Phase 2 component 2): added [CAP-COURIERTLS] chain ground truth to §3.1 and the C45 framing-gaps TODO (§3.2, §7 item 15). Rev 5 (2026-09-10, Phase-2 start): added the publicly documented binary-plist container format to §4.1 ([CF]) so the clean-room codec can be written from the spec alone. Revision 2 merged three phase-1 extractions plus first capture ground truth (own mitmproxy captures of a registered iPhone 14,4 running iOS 26.1, 2026-09-10 — still the only post-PQ3 evidence here). Revision 3 (2026-09-10) is a second targeted extraction round over both source repos on the three highest-value gaps (lifetimes C41/C10, envelope selection C25, bag/registration C1/C3); it corrects two rev-2 misreadings (the HTTP signature construction is agreed, not disagreed, §1.4; the pair-ec validator final byte is agreed 0x0c, not "0xc0-ish", §4.3) and re-reads the ~45-day certificate figure as a client-side cap (§6.1). Revision 4 (2026-09-10) is the phase-1 review-gate pass: removed one leaked source identifier (`registered_at`, rewritten as prose), paraphrased the only reproduced source fragment, added citation-form legend entries and the protocol-constant provenance notes; no copied expression, code, or structural transcription found. + +**Firewall rule:** this document contains facts only — no code, no function/type names, no copied expression from any reference implementation. Wire-format field names are quoted as on-the-wire protocol data (observable in captures, capture-arbitrated), never as source expression. Facts derived from SSPL/AGPL sources are used facts-only; nothing from them is reproduced. Where the source-derived fact sets agree, the fact is stated once with both citations; where they disagree, the disagreement is stated and tagged `TODO(capture):` — running mitmproxy captures arbitrate. All `TODO(capture)` items are collected in §7. + +**Citation legend:** [APS-iMessage] Apple Platform Security, iMessage security overview · [APS-CKV] Apple Platform Security, Contact Key Verification · [APS-Send] Apple Platform Security, How iMessage sends and receives messages securely · [PQ3] Apple Security Research, iMessage with PQ3 · [JJ] jjtech.dev, iMessage explained · [BM] Beeper blog, How Beeper Mini Works · [BA] Beeper blog, How Beeper Android Works · [OB-FAQ]/[OB-Renewal]/[OB-Hosted]/[OB-PNR]/[OB-QS]/[OB-MHI] OpenBubbles docs · [CF] Apple's publicly documented binary property-list container format (Core Foundation; public Apple material — not a restricted source) · [CAP-REG]/[CAP-COURIER] own mitmproxy captures of a registered iPhone 14,4, iOS 26.1 (2026-09-10; `reference/imessage-captures/`, gitignored) — ground truth, post-PQ3 · [CAP-IDSTLS]/[CAP-BAGCT] direct unauthenticated probes from this machine (2026-09-10, `reference/imessage-captures/2026-09-10/`: openssl s_client TLS captures; byte-exact bag bodies with response headers) · [RP:file] rustpush source (SSPL, facts-only) · [APA:file] apple-private-apis source (MPL 2.0, facts-only) · [IG:file] imessagego source (AGPL, facts-only) · [OA] OpenAbsinthe-Stub public API surface (official mock of a closed-source implementation; call sites in rustpush) — facts-only · [MHG:file] the Mac-Hardware-Info helper's public upstream repo (github.com/OpenBubbles/Mac-Hardware-Info; unlicensed upstream — facts-only, no code reuse). Bare `[RP]`/`[IG]` = fact from that repo with the source file not recorded in the extraction notes. `[RP-issue-21]` = rustpush GitHub issue #21. + +**Provenance notes:** "NGM" (next-generation-message) is research terminology, not a wire string. Two §4.3 strings — the HKDF salt `"LastPawn-MessageKeys"` (rev-12 correction: it is the HKDF salt, with empty info — not an info string, as earlier recorded) and the fixed ASCII prekey-signature label — are interop-required protocol constants that are not directly wire-observable; both independent sources agree on them, and capture arbitration is trivial (a wrong string fails decryption or signature validation). APNs-bag constant names (§3, §7 C18/C20) and Apple private-framework symbol names (e.g. `NACKeyEstablishment`) are Apple-published/observable data, not source expression. + +## 1. Registration & activation + +### 1.1 What validation data is and what it requires + +- Registration with IDS requires a binary blob, "validation data", that proves the client is a genuine Apple device; it is produced from device information including serial number, model, and disk UUID [JJ], also described as machine identifiers: serial number, board ID, and model [OB-FAQ], [OB-Renewal]. The signing context for producing it needs product name, OS build number, platform serial number, logic-board serial (MLB), and a ROM value [RP:macos.rs]. +- Validation data is only valid for 15 minutes after generation [OB-Hosted]. No local 15-minute-validity enforcement is visible in the extraction sources — if the bound is enforced anywhere, it is server-side [RP:macos.rs], [OA]. +- Apple's own generator binary is heavily obfuscated; third-party clients sidestep it by emulating the binary with device properties (serial number etc.) fed in from a plist [JJ], or by fetching a ready-made blob from a trusted real Mac over an authenticated HTTP bridge [RP:relay.rs] or from a "nacserv"-style provider that returns the blob plus device version metadata and a validity timestamp [IG:nacserv]. +- Not all validation data is equivalent: account age and a score determine whether a non-genuine serial is accepted or rejected with the "customer code" error (Hackintosh-analogous) [JJ]. +- Using identifiers derived from a virtual machine is explicitly discouraged: Apple can detect VM-derived identifiers and re-registering with them risks an account ban [OB-FAQ]. One third-party client's defaults present as Mac mini hardware, macOS 13.5, build 22G74 [IG:config.go]. +- For the real-Mac path, validation data is produced by a three-step exchange with Apple: download a certificate from the `id-validation-cert` bag URL; POST a session-info request to `id-initialize-validation`; then run key establishment with the returned session info and produce a signed blob [RP:macos.rs]. Extraction detail (2026-09-10): step 1's response is a plist with a single `cert` data field; step 2 POSTs a single-key body `session-info-request` (data) with no anisette or authentication headers — body only — and receives `session-info` (data) back [RP:macos.rs], [RP:auth.rs]. — 2026-09-11 (rev 19): the step-2 request construction is recorded at the same level of detail — the body is serialized as an XML plist and posted with NO content-type header, from an HTTP client whose only default request header is `Accept-Language: en-US,en;q=0.9` [RP:util.rs], [RP:macos.rs]. A header-less step-1 GET succeeds because the `id-validation-cert` URL resolves to a CDN static object (a genuine device's capture of that same GET shows `static.ess.apple.com`, plain HTTP, `X-Cache: hit-fresh`, a 2021 `Last-Modified`, `application/octet-stream`, 3474 bytes), so step 1's header-less success is NOT evidence that the endpoints accept header-less requests (§7 item 24). The hardware configuration the context consumes carries product name, OS build number, platform serial number, platform UUID, root-disk UUID, board ID, MAC address (6 bytes), ROM (bytes), and MLB (string), plus pre-computed obfuscated "enc" variants of serial/platform-UUID/root-disk-UUID/ROM/MLB keyed by five fixed label strings (`Gq3489ugfi`, `Fyp98tpgj`, `kbjfrfpoJU`, `oycqAZloTNDm`, `abKPld1EcMni`); the derivation producing the enc variants is closed source — `TODO(capture):` → C57. The blob is parseable back into the hardware fields (round-trip constructor in the mock's API surface) [OA], [RP:macos.rs]. +- Current shipped flow (OpenBubbles, 2026): the Mac-side helper (`Mac-Hardware-Info` 1.2.0, notarized) only *reads* the machine identifiers — serial, board ID, model, etc. — via public IORegistry/sysctl APIs (docs: "High Sierra and above", no upper macOS version bound) and exports them as a shareable code (≤ 20 users per code, optional one-time-use/48-hour mode); it never invokes an Apple private framework [OB-MHI], [OB-FAQ]. The client then performs the three-step exchange above itself, in software, keyed with those identifiers and paired with an Apple-Account login — the [JJ] emulation path with an official identifier-export helper in front of it. `TODO(capture):` **C56** — the client-side generation procedure behind the three-step exchange: what the client computes with the `id-initialize-validation` session info (the key-establishment math and blob assembly), how it interacts with the Apple-Account/GSA login and the anisette header set (C6) at activation time, and which pieces are fixed protocol constants vs per-account/per-session state. — 2026-09-10: steps 1–2 of C56 are recorded (bullet above); step 3 — key establishment + signing — is closed source: the public OpenAbsinthe-Stub repo is an official mock whose methods are stubs (its README states the real repository is closed source), so the spec records only the API contract: context creation consumes the certificate chain + hardware configuration and outputs the session-info-request blob; key establishment consumes the server response; signing produces the final blob with no further inputs (all state retained in the context) [RP:macos.rs], [RP:activation.rs], [RP:auth.rs], [OA]. Remaining: wire-verification of the blob against one live activation; the enc-variant derivation rides C57. — 2026-09-11 (rev 14 extraction): the five obfuscation labels are now MAPPED to their fields (previously only listed together): serial → `Gq3489ugfi`, platform UUID → `Fyp98tpgj`, root-disk UUID → `kbjfrfpoJU`, ROM → `oycqAZloTNDm`, MLB → `abKPld1EcMni` [OA:nac.rs]; the derivation producing each `_enc` byte variant is still closed source. The hardware-configuration field set is recorded in full: product name (Apple product-type form, e.g. `Macmini9,1`), MAC address (exactly 6 raw bytes), platform serial, platform UUID, root-disk UUID, board ID, OS build number, ROM (raw bytes), MLB, plus the five `_enc` raw-byte variants [OA:nac.rs]. Step 3's closure is confirmed at the source level: the crate holding the key-establishment math ships as a closed-source stub whose mock methods panic; only the API contract is observable [OA:README.md], [OA:nac.rs]. +- The helper's export-code format (2026-09-11, rev 15 — **C58 closed** by facts-only extraction from the helper's public upstream repo, which is unlicensed — facts only): the payload is a 4-byte ASCII magic `OABS`, a one-byte flag (1 = "prevent sharing" mode, 0 otherwise), then a raw protobuf message (no length prefix; protobuf starts at offset 5). Protobuf field map: outer — 1 nested identifier message, 2 macOS product version (sysctl `kern.osproductversion`), 3 protocol version (hardcoded `1640`), 4 device id (the IOPlatformUUID again), 5 a hardcoded iCloudHelper user-agent string, 6 a hardcoded AOSKit version string; nested identifier message — 1 product name (e.g. `MacBookPro18,3`), 2 MAC address (6 raw bytes), 3 platform serial, 4 platform UUID, 5 root-disk UUID, 6 board ID (Apple Silicon: literal prefix `Mac-` + lowercase hex of the device-tree board-id bytes), 7 OS build number (sysctl `kern.osversion`), 8/9/10/12/14 the five `_enc` obfuscated blobs read from the `IOPower:/` registry under Apple's key names (`Gq3489ugfi` serial, `Fyp98tpgj` platform UUID, `kbjfrfpoJU` root-disk UUID, `oycqAZloTNDm` ROM, `abKPld1EcMni` MLB — matching §1.1's label mapping), 11 ROM (Intel: the device-tree options ROM value; Apple Silicon: last 6 bytes of SHA-256 of `/chosen` `unique-chip-id`), 13 MLB string. Two delivery modes: plain = standard base64 of the payload (clipboard text, or the raw payload bytes as a QR code at medium error correction); restricted = AES-256-CBC/PKCS7 over the payload, ciphertext prefixed with ASCII `Salted__` + 8-byte random salt (legacy OpenSSL `enc` container), key/IV derived by the legacy EVP_BytesToKey KDF (repeated MD5 of previous-hash ‖ password ‖ salt to 48 bytes: first 32 = key, next 16 = IV) keyed by a human activation code (`MB` + 4×4 chars from the alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ123456789`), then base64. The restricted mode additionally uploads the blob (JSON `data` + `id` = lowercase hex SHA-256 of the activation code) to the vendor's server (`hw.openbubbles.app/code`) — the ≤20-user/one-time/48-hour mechanics live SERVER-side; nothing is embedded in the payload (no checksum, signature, compression, or expiry). No public parser exists (the client apps are closed-source); the construction above is the parse specification [MHG:ViewController.swift], [MHG:HwIdentifiers.swift], [MHG:mac_hw_info.pb.swift]. +- The validation blob is submitted as the `validation-data` field of the IDS register body and, base64, in the `X-Mme-Nas-Qualify` header of the iCloud account login [RP:ids/user.rs], [RP:auth.rs]. `TODO(capture):` C5 — exact byte shape of the blob a genuine Mac produces, and the current exchange over the two validation bag URLs. +- Hardware-related anisette headers seen in activation flows: `X-Apple-I-SRL-NO` (serial), `X-Apple-I-MLB` (logic-board serial), `X-Apple-I-ROM` (hex ROM, lowercase) [RP:relay.rs], [APA:anisette]. `TODO(capture):` C6 — the full anisette header set a current client actually sends, including which optional hardware headers appear. — 2026-09-10: the header-set half of C6 is closed by facts-only extraction: per-request anisette headers `X-Apple-I-Client-Time`, `X-Apple-I-TimeZone`, `X-Apple-Locale`, `X-Apple-I-MD-RINFO`, `X-Mme-Device-Id`, `X-Apple-I-MD` (base64 OTP), `X-Apple-I-MD-M` (base64 machine ID), plus per-platform hardware headers — macOS: `X-Apple-I-MLB`, `X-Apple-I-ROM` (lowercase hex), `X-Apple-I-SRL-NO` [APA:omnisette], [APA:icloud-auth], [RP:relay.rs]. They are produced by an ADI provisioning exchange with gsa.apple.com (start/finish provisioning returning machine tokens), then a local OTP [APA:omnisette]. GSA context (cross-referenced with C6): the Albert activation itself needs NO Apple-Account login — only the FairPlay-signed activation-info plist (§1.2); the GSA login (full Apple-ID password, SRP over gsa.apple.com/grandslam/GsService2, s2k/s2k_fo PBKDF2, 2FA-capable) feeds the delegate logins that yield the IDS `auth-token`/`profile-id` and the PET token [RP:auth.rs], [APA:icloud-auth]. The extraction confirms the validation blob rides base64 in the `X-Mme-Nas-Qualify` header of the iCloud sign-in POST (recorded above) [RP:auth.rs]. — 2026-09-11 (rev 14 extraction round over the anisette crates): the value-format and provisioning halves of C6 are closed. Per-request value forms: `X-Apple-I-Client-Time` = UTC now truncated to whole seconds, RFC 3339/ISO-8601 with the offset suffix replaced by `Z` (provisioning requests send the raw offset form without the Z — a recorded inconsistency between code paths); `X-Apple-I-TimeZone` = literal `UTC` (not the device zone); `X-Apple-Locale` = `en_US` hardcoded (a companion `X-Apple-I-Locale: en_US` variant exists on the browser-style header set); `X-Apple-I-MD-RINFO` = verbatim passthrough of the string returned in the finish-provisioning response; `X-Mme-Device-Id` = lowercase hyphenated UUID string of 16 locally random bytes (`keychain_identifier`); `X-Apple-I-MD-M` = base64 (standard, padded) of the 60-byte `mid`; `X-Apple-I-MD-LU` = lowercase hex (no dashes) SHA-256 over the same 16 bytes (real macOS instead uses AuthKit's local-user UUID) — present in some header sets, omitted in others (disagreement — C57). Header sets are cached 60 s; a fresh OTP is generated per cache miss. The ADI provisioning flow: `GET https://gsa.apple.com/grandslam/GsService2/lookup` → XML plist whose `urls` keys `midStartProvisioning`/`midFinishProvisioning` give the endpoint URLs (never hardcoded); POST to the start URL with an XML plist body of two empty top-level dicts `Header` and `Request` → response `Response` → `spim` (base64); a local session is built from `spim` (+ empty hostuuid, DSID `-2`, flavor `Mac`=1 / `IOS`=3, flavor chosen by whether the client-info string contains `iPhone OS`) yielding `cpim` (base64); POST to the finish URL with `Request` = {`cpim`} → response `Response` → `ptm`, `tk` (both base64) and `X-Apple-I-MD-RINFO`; finishing the session with the decoded `tk`/`ptm` yields the machine state: `client_secret` (32 bytes), `mid` (60 bytes, Apple-issued at provisioning — reused for every OTP until re-provisioning, which happens only on a not-provisioned failure, e.g. ADI error -45061), `metadata` (variable blob). Provisioning HTTP specifics: `Content-Type: application/x-www-form-urlencoded` despite the XML-plist body (intentional), title-cased header names forced, HTTP/1.1, Apple root CA pinned (embedded DER), and a fixed companion header set on all three requests: akd `User-Agent`, `X-Apple-Baa-E: -10000`, `X-Apple-Baa-Avail: 2`, `X-Apple-Baa-UE: AKAuthenticationError:-7066|com.apple.devicecheck.error.baa:-10000`, `X-Apple-Host-Baa-E: -7066`, `X-Apple-Client-App-Name: akd`, `Accept: */*`, `Accept-Language: en-US,en;q=0.9`, plus `X-Apple-I-MD-LU`, `X-Mme-Device-Id`, `X-Mme-Client-Info`, `X-Apple-I-Client-Time` and any caller-supplied hardware headers. State persists as an XML plist with `keychain_identifier` (data) + the provisioned fields (`client_secret`, `mid`, `metadata`, `rinfo`, `flavor`) [APA:omnisette]. CLOSED-SOURCE RESIDUE: the OTP (`X-Apple-I-MD`) and trusted-device 2FA-code derivations live inside a closed-source crate — its public repo is a stub with `todo!()` methods; recorded is only the call contract: inputs (32-byte `client_secret`, 60-byte `mid`, `metadata`, flavor) → raw OTP bytes (base64 into the header) / 32-bit 2FA code [OA-stub], [APA:omnisette]. — rides C57/C56 residue; the seam stays injectable in the code. + +### 1.2 Activation endpoints and certificates + +- Public write-ups name `albert.apple.com` as the activation service that sets up the "push" credentials (including the certificate needed for APNs) [BM], [JJ], and `identity.ess.apple.com` as the service that registers the account with iMessage and generates the "identity" credentials used to encrypt/decrypt [BM]. Exact paths in the sources: device activation at `https://albert.apple.com/deviceservices/deviceActivation?device=MacOS` [RP:activation.rs], [IG:pushcert]; IDS registration at `https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register` [IG:register]. All IDS endpoint URLs are resolved from a plist "bag" fetched at runtime (`getBag?ix=3` at `init.ess.apple.com`), which maps bag keys (`id-register`, `id-query`, `id-authenticate-ds-id`, `id-authenticate-phone-number`, `id-authenticate-multiple-users`, `id-validation-cert`, `id-initialize-validation`, …) to URLs [RP:util.rs]; a parallel bag at `init-p01st.push.apple.com/bag` serves APNs settings [RP:util.rs], [IG:courier]. + +- Bag shape and fetching (second-extraction facts): the live bag is fetched over GET from `https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3`; the response body is a plist dictionary with a single `bag` key whose value is embedded plist data — those bytes are themselves a plist parsed into a flat bag-key → URL dictionary [RP:util.rs:57-81]. The fetch is cached for the process lifetime keyed by bag URL; there is no TTL or refresh, and a missing key inside the parsed bag is its own error [RP:util.rs:59-81]. The APNs bag is fetched over plain HTTP from `http://init-p01st.push.apple.com/bag` and resolves integer `APNSCourierHostcount` and string `APNSCourierHostname` [RP:util.rs:56], [RP:aps.rs:1223-1224]. +- The second implementation never fetches a bag at runtime: all endpoint URLs are compile-time pinned, and the bag fetch URL is only mentioned as the manual refresh source. Its pinned URLs: DS-ID authentication at `https://profile.ess.apple.com/WebObjects/VCProfileService.woa/wa/authenticateDS`, phone-number authentication at `https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/authenticatePhoneNumber`, dependent registrations at `.../TDIdentityService.woa/wa/getDependentRegistrations`, handles at `.../VCProfileService.woa/wa/idsGetHandles`, lookup at `https://query.ess.apple.com/WebObjects/QueryService.woa/wa/query` (used only inside the APNs HTTP tunnel, never plain HTTPS), registration at `https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register` [IG:ids/bag.go:19-28], [IG:ids/urls.go:19-23], [IG:lookup.go:519-527]. It has no support at all for the alias-provisioning, spam-report, validation-certificate, validation-initialization, or multi-user phone-auth endpoints [IG:ids/bag.go, repo-wide]. +- IDS HTTPS requests in both implementations use a custom TLS trust pool containing only embedded Apple root certificates, not the system store [RP:util.rs:208-242], [IG:util/appletls/util.go]. — 2026-09-10 [CAP-BAG]: the live bag was fetched directly with an unauthenticated `GET https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3` — no auth, no anisette; the response is a plist with `signature`, `certs`, and `bag` keys, the inner bag a flat dictionary of **405 entries** (full response archived as `reference/imessage-captures/ids-bag-2026-09-10.xml`). This closes the live-bag-contents half of C1 without an instrumented client. Notably the endpoint presented a TLS chain the local system store rejected (consistent with the sources' Apple-root-only pinning). Every id-* URL that either source pins (`id-register`, `id-query`, `id-authenticate-ds-id`, `id-authenticate-phone-number`, `id-authenticate-multiple-users`, `id-get-dependent-registrations`, `id-get-handles`, `id-validation-cert`, `id-initialize-validation`, …) matches the live bag — no drift. The bag also carries the scalar `id-query-refresh-queued-query-interval: 28800` (8 h, queued-refresh interval). `TODO(capture):` C47 — the concrete plist encoding of both bag responses, and of the embedded `bag` payload those responses carry, is not stated in this fact set (the archives are named `.xml`, and that is the whole of the evidence here); neither is the response content type. — 2026-09-10 [CAP-BAGCT]: the encoding half of C47 is settled. `GET https://init.ess.apple.com/WebObjects/VCPermsService.woa/wa/getBag?ix=3` (the `VCPermsService.woa` app also serves the bag — [CAP-BAG] had used `VCInit.woa`) returned `Content-Type: text/xml`, 94,859 bytes, an XML plist, top-level keys `signature`/`certs`/`bag`; the embedded `bag` value is base64 of an XML plist (decodes to `…`). The APNs bag (`GET http://init-p01st.push.apple.com/bag`) returned `Content-Type: application/x-apple-plist` but the body is still an XML plist, 10,675 bytes, byte-identical across two same-day fetches. Verdict: both bag responses and the embedded bag payload are XML plists in every observed case — no binary plist on the bag wire; the content-type labels are unreliable (`text/xml` vs `application/x-apple-plist` with XML bodies) and parsing goes by content, not label. Also [CAP-IDSTLS]: `openssl s_client` with bare-hostname SNI to `init.ess.apple.com:443` and `query.ess.apple.com:443` — init serves leaf `CN=init.ess.apple.com` ← `Apple Server Authentication CA 2` ← `Apple Root CA - G3`; query serves leaf `CN=query.ess.apple.com` ← `Apple Server Authentication CA` (the older CA) ← `Apple Root CA`; neither root is served, and the unanchored chain is rejected by the system store (OpenSSL verify code 20; curl error 60 with the system bundle) — matching the Apple-root-only pool of the sources. Anchor set to embed: `Apple Root CA - G3` and `Apple Root CA` — Apple roots, distinct from the publicly-trusted Comodo/AAA root the courier chain needs ([CAP-COURIERTLS], §3.1); pin roots only, never the leaf. Both root DERs are staged in the worktree as `server/src/main/resources/imessage/AppleRootCA-G3.cer` and `AppleRootCA.cer` (G3 from Apple's live CA page; the G1 `Apple Root CA` is no longer published by Apple — sourced from the public CT log, crt.sh id 1160190, SHA-256 `B0:B1:73:0E:CB:C7:FF:45:05:14:2C:49:F1:29:5E:6E:DA:6B:CA:ED:7E:2C:68:C5:BE:91:B5:A1:10:01:F0:24`; G3 SHA-256 `63:34:3A:BF:B8:9A:6A:03:EB:B5:7E:9B:3F:5F:A7:BE:7C:4F:5C:75:6F:30:17:B3:A8:C4:88:C3:65:3E:91:79`) and each captured intermediate openssl-verifies against its root. +- The Albert activation request is a form post containing an activation-info plist (activation randomness, activation state, build version, a DER certificate request, device class "MacOS", product type, product version, serial number, and a unique device ID) plus a FairPlay certificate chain and a FairPlay signature over the activation-info bytes [RP:activation.rs]. The certificate request is generated over a fresh 1024-bit RSA key with a "Client Push Certificate" common name [RP:activation.rs]; a second implementation agrees on the 1024-bit RSA push key (2048 also works) and notes a generator-version field [IG:pushcert]. The response embeds a plist whose activation record carries the device certificate (plus chain) [RP:activation.rs], [IG:pushcert]. `TODO(capture):` C8 — the exact activation-info field set and FairPlay certificate choice a genuine current client sends (one source reports signing with one of ten bundled FairPlay keys [RP:activation.rs]). — 2026-09-10: the wire half of C8 is closed by facts-only extraction (both source repos). The request is a POST (form-urlencoded) with a single form key `activation-info` whose value is an XML plist carrying `ActivationInfoComplete` (true), `ActivationInfoXML` (a nested XML plist), `FairPlayCertChain`, and `FairPlaySignature`; no timestamp is sent in the request [RP:activation.rs], [IG:activationinfo.go]. Nested activation-info keys: `ActivationRandomness` (uppercase UUIDv4), `ActivationState` ("Unactivated"), `BuildVersion`, `DeviceCertRequest` (PEM CSR), `DeviceClass`, `ProductType`, `ProductVersion`, `SerialNumber`, `UniqueDeviceID`. The CSR is 1024-bit RSA, CN "Client Push Certificate", OU "iPhone", O "Apple Inc.", signed SHA-1WithRSA. FairPlay signing covers the serialized ActivationInfo plist (SHA-1, RSA PKCS1v15) and yields the (chain, signature) pair placed into the activation body; both sources embed FairPlay identities (Go: two embedded files; Rust: ten certificate/key pairs, one chosen at random per activation — provenance unrecorded, → C57) [IG:fairplay.go], [RP:activation.rs]. The response's outer XML `` block is plist-parsed: `device-activation` → `activation-record` → `DeviceCertificate` (PEM), plus `ack-received` and `show-settings` — only the device certificate returns; the private key is client-generated and never sent, and no device token appears in the response [IG:pushcert], [RP:activation.rs]. Courier link: the DeviceCertificate (with the client private key) becomes connect field 12 (§3.3), and the connect nonce is signed with that key — Go: a 20-byte random nonce with the first byte zeroed; Rust: 0x00 ‖ big-endian unix-milliseconds ‖ 8 random bytes; signature = 0x01 0x01 ‖ RSA-SHA1(nonce) (both) [IG:connection.go], [RP:aps.rs] (nonce-shape tension with §3.3's recorded agreement — C57). +- Account login requires an authentication token from Apple ID username and password. With 2FA there are two retrofitted options: legacy (2FA code appended to the password) and GrandSlam, where "anisette data" proves device identity so no repeated 2FA entry is needed; the result is a Password Equivalent Token (PET) usable in place of password+2FA [JJ]. GrandSlam login uses an SRP-6a variant (3072-bit group) [APA:client], [IG:appleid]; the Apple ID login side uses `idmsa.apple.com` endpoints with session, scnt, and client-info headers [IG:appleid]. The iCloud setup login (`setup.icloud.com`) posts a delegates map and yields per-delegate service data; the IDS delegate yields an auth token and a profile id [RP:auth.rs]. +- The short-lived authentication token must be exchanged for a longer-lived authentication certificate; this certificate allows IDS registration but is not sufficient for key lookups [JJ]. +- IDS authentication certificates: for Apple-ID users the CSR is a fresh 2048-bit RSA key whose common name is a SHA-1 of the realm user id, submitted to the `id-authenticate-ds-id` bag URL [RP:auth.rs] (pinned URL variant: `profile.ess.apple.com` authenticateDS [IG:bag]); for phone-number users the realm id has the form "P:" against `id-authenticate-phone-number` [RP:auth.rs] (pinned variant: `identity.ess.apple.com` authenticatePhoneNumber [IG:bag]). A SIM-less phone route (`id-authenticate-multiple-users`) carries a per-request carrier EAP-AKA signature produced through the carrier's entitlement server, with the APNs connect token as client nonce [RP:auth.rs], [RP:util.rs]. `TODO(capture):` C7 — the carrier entitlement exchange per carrier (request/response shape varies by carrier). `TODO(capture):` C54 — the authenticate exchange's unrecorded wire shape: the request body's plist keys, the CSR's encoding ("CSR" is the recorded term; nothing states PKCS#10 or another form) and its self-signature algorithm, the encoding of the CN's SHA-1 (raw digest vs hex vs base64), whether the §1.4 signature header sets are carried, and the response's wire keys (the short-lived token, the longer-lived authentication certificate, and their relationship). — 2026-09-10: C54 is closed by facts-only extraction (both source repos), with three disagreements tagged for capture (C57). Endpoints: bag keys `id-authenticate-ds-id` / `id-authenticate-phone-number`, one authentication per user; Rust resolves them from the bag, Go pins the full URLs — a recorded client-design disagreement (bag resolution vs pinned URLs), not a wire fact; Rust also has `id-authenticate-multiple-users` for the SMS-less flow (recorded above) [RP:ids/user.rs], [IG:bag.go]. Request body keys (agreed): `authentication-data`, `csr`, `realm-user-id`; the CSR is DER (both). `authentication-data` holds `auth-token` for the Apple/DS flow and `push-token` + `sigs` (array) for the phone flow. The SMS-less variant body (Rust only): `csr`, `sig`, `tag`, `user-id`. Self-signature over the CSR DISAGREES: Rust SHA-1 with RSA/PKCS1, Go SHA256WithRSA — `TODO(capture):` (C57). CN encoding: a hex STRING of the SHA-1 digest of the realm-user-id (not raw, not base64); Rust uppercases the hex, Go lowercases — case-only disagreement, `TODO(capture):` (C57). The §1.4 signature header sets are NOT applied to authenticate in either repo; the headers sent are only `user-agent` and `x-protocol-version` (Rust adds `content-encoding: gzip`) [RP:auth.rs], [IG:authdevice.go]. Response keys: `status`, `cert` — flat, no per-user nesting (both); the multiple-users variant returns an `authentication_responses` array with per-entry `cert`, `user-id`, `status` (Rust only) [RP:auth.rs], [IG:authdevice.go]. + +- FairPlay activation material (2026-09-11, rev 17 — facts-only metadata extraction over the reference repos plus one public community file): both implementations embed the SAME single identity — imessagego's two files and rustpush's `certs/legacy-fairplay/` are byte-identical (SHA-256 `bc18fc4d…` for the leaf cert; same RSA modulus for both key containers), and rustpush's recorded "ten pairs, one chosen at random" are an illusion: upstream gitignores the real `certs/fairplay/` directory and its CI fabricates the ten names by copying one "legacy" pair. All copies trace to a 2023 iCloud/SIM activation-bypass community release ("Albert iTunes Bug", `albertsimlockapple` PHP file, claimed MIT license) — the material was NOT extracted by either project's authors. The identity: one 1024-bit RSA keypair + its leaf certificate (single DER cert, not a chain — issuer `Apple iPhone Device CA`, subject CN a UUID-form donor-device identifier, validity 2021-10-11 → 2024-10-11, EXPIRED yet activations still succeed → albert evidently does not validate this chain's expiry). `FairPlaySignature` = RSA-SHA1 PKCS#1v15 over the exact serialized ActivationInfoXML bytes, signed by the embedded private key; `FairPlayCertChain` = that leaf cert alone (Apple's server fills in the rest). No per-account binding, no refresh/rotation path, unlimited activations per identity in the sources; rustpush ships an unmerged "dummy-fairplay" escape hatch for contributors lacking the material. NO source documents a legitimate own-Mac extraction path — where macOS stores the per-device FairPlay activation identity is an OPEN research item (distinct from the activation response's `DeviceCertificate`, which is the client-generated push-key cert, not FairPlay input). +- Capture ground truth [CAP-REG]: on a registered iPhone (iOS 26.1), the single iMessage-relevant artifact visible to a plain HTTP(S)-proxy capture is a plain-HTTP fetch of `http://static.ess.apple.com:80/identity/validation/cert-1.0.plist` by the invitation-registration daemon (user agent `com.apple.invitation-registration [iPhone OS,26.1,23B85,iPhone14,4]`). The plist carries an MD5 in an XML comment (`certificate-production.cer md5=f6e148c97bfbc9867e19c2eeadbab534`) — clients apparently MD5-check the payload and cache it — and a `cert` blob consisting of a 6-byte header (`01 02 00 00 04 16`) followed by a DER certificate: an "Apple System Integration Certification Authority" intermediate issued by Apple Root CA, valid 2011-01-26 to 2019-01-26 — expired, yet still served and still fetched by current iOS. This is the same validation-certificate family as the `id-validation-cert` bag key above, but it is not the validation data itself (§1.1). The pinned hosts (`identity.ess`, `albert`) produce no flows at all through the proxy — their daemons bypass it — so C1 stays open, and the validation-data exchange (C5/C6) remains uncaptured. + +### 1.3 Phone and email handles + +- Phone-number registration without an Apple ID works via an SMS to Apple's "Gateway" service; the carrier-specific gateway responds by SMS and the response contents are submitted to Apple to register the number as iMessage-capable [BM]. An Apple ID login is optional for phone-number-only operation [BM]. At the Apple-account level, phone numbers are verified via the carrier network and SIM, on some networks by SMS with a confirmation dialog [APS-iMessage]. Registering an Apple ID and a phone number "together in the same call" links the handles so all devices on the Apple ID can use both [BM]. Additional email addresses are added by verifying a confirmation link, and all of the user's registered devices show an alert when a new device, phone number, or email address is added [APS-iMessage]. + +### 1.4 IDS registration body and response + +- Registration POSTs to the `id-register` bag URL [RP:ids/user.rs] (pinned URL: identity.ess register path [IG:register]). The body carries: device name, hardware version, language, OS version, software version, `private-device-data` (dictionary), a `services` array, a `users` array, and the `validation-data` blob [RP:ids/user.rs], [IG:register]. Encoding (second-extraction refinement): both implementations serialize the body as an XML plist; the disagreement is compression only — one gzips the XML and sends `content-encoding: gzip` plus `accept-encoding: gzip` [RP:ids/user.rs:1112-1115], the other sends the raw XML with no content-type and no content-encoding header at all [IG:register.go:366-375]. `TODO(capture):` C3 — whether the live server accepts both encodings or one is stale; current sub-service list and capability flags (below). Wire key names (extraction, agreed — the request has exactly eight top-level keys, none beyond): `device-name`, `hardware-version`, `language`, `os-version`, `software-version`, `private-device-data` (dict), `services` (array), `validation-data` (data) [RP:ids/user.rs], [IG:register]. Services-entry keys: `capabilities` (array), `service`, `sub-services`, `users`; capability-record keys: `flags` (int), `name` (string), `version` (int; both sources set 1 — Go's record is flags 1, name "Messenger") [RP:ids/user.rs], [IG:register]. +- `private-device-data` (second-extraction refinement — exact wire keys, agreed): `ap` "0", `d` = seconds since the Apple epoch (2001-01-01) as a decimal string, `dt` = integer 1, `gt` "0", `h` "1", `m` "0", `p` "0", `pb` = OS build string, `pn` = platform name, `pv` = OS version, `s` "0", `t` "0", `u` = device UUID (uppercase string), `v` "1"; all values are strings except the `dt` integer [RP:macos.rs:157-178], [IG:register.go:325-344]. The extraction confirms the full sub-key set as the eighteen keys above (`ap` through `v`, including the four both sources model but omit) [IG:register]. The relay/phone profile sets `m` and `s` to "1" instead [RP:relay.rs:189-225]. Both sources also model four further keys (`c`, `ec`, `ktf`, `ktv`) but deliberately omit them from the built request [RP:macos.rs:159-177], [IG:register.go:57-82,331-332]. +- The `services` array registers `com.apple.madrid` with a `sub-services` list and one capability record named "Messenger" with version 1 — but the flags value is disagreed: 17 [RP:imessage/aps_client.rs:74] vs 1 [IG:register.go:347]. The sub-service lists also disagree: four topics (`com.apple.private.alloy.sms`, `.gelato`, `.biz`, `.gamecenter.imessage`) [RP:imessage/aps_client.rs:19-24] vs seven (those four plus `.safetymonitor`, `.safetymonitor.ownaccount`, `.askto`) [IG:register.go:352-360], [IG:apns/topic.go:24-33]. The first source additionally registers non-iMessage services in the same array when enabled: FaceTime under `com.apple.ess` and `com.apple.private.alloy.facetime.*` (capability name "Invitation") and Find My under `com.apple.private.alloy.multiplex1` (capability name "com.apple.private.alloy") [RP:facetime.rs:26-56], [RP:findmy.rs:26-45]. `TODO(capture):` C3 — current sub-service list and flags. +- Per user entry: `client-data` (public identity key structure with version 2, optional NGM/EC key data with `ec-version` 1 and NGM version 13, a signed prekey record, key-transparency data with `kt-version` 5, plus boolean capability flags), `kt-loggable-data`, `uris`, `user-id`, and a `tag` for phone profiles [RP:ids/user.rs], [IG:register]. `tag` is "SIM" for the first phone user and "SIM2", "SIM3", … for subsequent ones [RP:ids/user.rs:1078-1081]; the other source tags any phone-profile user "SIM" [IG:register.go:233-236]. `uris` entries are single-key dictionaries of handle URIs with `tel:` (E.164) or `mailto:` schemes — and neither implementation constructs them locally: both fetch the registered handle list from the server first and echo it back into the register body [IG:util/uri/uri.go:61-64,177-191], [RP:ids/user.rs:839-843,1040-1042], [IG:ids/user.go:132-163]. `kt-loggable-data` is a protobuf blob carrying the compressed NGM device key and the NGM (13) and key-transparency (5) versions [RP:ids/user.rs:1048-1054], [IG:ids/user.go:279-292]. C53 wire names (extraction, agreed): per-user keys are `client-data` (dict), `uris` (array), `user-id`, `kt-loggable-data` (data; optional in Go, always set in Rust when publishing keys), plus `tag` only for phone-number users — value `SIM`/`SIM` (Go tags "SIM" when the user-id starts with `P:`). Register-request `uris` entries are single-key dictionaries with key `uri` (both). `client-data` base wire keys (Rust): `public-message-identity-key`, `public-message-identity-version`, `ec-version`, `public-message-identity-ngm-version`, `public-message-ngm-device-prekey-data-key`, `kt-version`, plus per-service extras; Go adds the same six plus the boolean `supports-*` feature flags and `nicknames-version`/`optionally-receive-typing-indicators`/`prefers-sdr`/`show-peer-errors` recorded above [RP:ids/user.rs], [IG:register]. +- The capability booleans in `client-data` (wire key names; the union across both sources, all true unless noted): `optionally-receive-typing-indicators`, `show-peer-errors`, `supports-ack-v1`, `supports-audio-messaging-v2`, `supports-autoloopvideo-v1`, `supports-be-v1`, `supports-ca-v1`, `supports-cross-platform-sharing`, `supports-fsm-v1/v2/v3`, `supports-ii-v1`, `supports-impact-v1`, `supports-inline-attachments`, `supports-keep-receipts`, `supports-location-sharing`, `supports-media-v2`, `supports-original-timestamp-v1`, `supports-people-request-messages` (+ `-v2`, `-v3`), `supports-photos-extension-v1/v2`, `supports-protobuf-payload-data-v2`, `supports-rem`, `supports-sa-v1`, `supports-st-v1`, `supports-update-attachments-v1`, `supports-hdr`, `supports-heif`, `supports-dq-nr`, `prefers-sdr` (false), `nicknames-version` 1 [RP:imessage/aps_client.rs:25-73], [IG:register.go:240-299]. Each source sends a further set the other omits — first source only: `is-c2k-equipment`, `supports-activity-sharing-v1`, `supports-shared-exp`, `supports-family-invite-message-bubble`, `supports-live-delivery`, `supports-stick-moji-backs`, `supports-emoji-tapbacks`, `supports-send-later-messages`, `supports-certified-delivery-v1`, `supports-transcript-backgrounds`, `supports-gti`, `supports-polls` [RP:imessage/aps_client.rs:26-72]; second source only: `supports-askto`, `supports-emoji-stickers`, `supports-findmy-plugin-messages`, `supports-sticker-editing`, `supports-recurring-payment-bubbles` [IG:register.go:247-274]. Type discrepancy: the first source encodes the versions as plist real values, the second as plist integers [RP:imessage/aps_client.rs:51-52], [IG:register.go:241,309]. +- The identity keys themselves: an RSA-1280 encryption key plus a P-256 signing key for the legacy scheme, plus a P-256 device key and a P-256 pre-key for the newer EC scheme [RP:ids/user.rs]. +- The request is signed once with the push key and once per user with that user's auth key, using indexed header names [RP:auth.rs], [IG:sign]. Second-extraction correction: **the two sources agree** on the construction (rev 2 recorded a disagreement based on a misreading of a wrapper function's name — the "wrap" is plain concatenation, verified in source): the nonce is 17 bytes — one type byte (0x01 for HTTPS requests; 0x00 exists for APNs use), an 8-byte big-endian unix-milliseconds timestamp, 8 random bytes [RP:auth.rs:685-700], [IG:util/nonce/nonce.go:25-42]; the signing payload is that nonce followed by a big-endian u32 length-prefixed concatenation of (bag key string, query string — empty for register, request body, raw push token) [RP:auth.rs:702-708], [IG:ids/sign.go:35-46]; the signature is RSA-SHA1-PKCS1v1.5 over SHA-1 of (nonce ‖ payload), output prefixed with the two bytes 0x01 0x01, then base64 [RP:auth.rs:710-714], [IG:ids/sign.go:54-59,89-95]. Header wire names: push set `x-push-nonce`/`x-push-cert`/`x-push-sig` (plus `x-push-token` carrying the base64 push token), per-user indexed sets `x-auth-user-id-`/`x-auth-nonce-`/`x-auth-cert-`/`x-auth-sig-` starting at N=0 [RP:ids/user.rs:1108-1121], [IG:ids/sign.go:48-96]; lookups use an `x-id-nonce`/`x-id-cert`/`x-id-sig` variant instead of per-user auth headers [IG:ids/sign.go:63-79]. Extraction: the `x-auth-user-id-` value is the plain user-id string (the profile ID; phone users prefixed `P:`), N is the zero-based user index, and the header is register-only (agreed) [RP:ids/user.rs], [IG:sign]. `TODO(capture):` C4 — confirm server acceptance of this construction against a real device (source-side structure is settled). +- The request carries a push-token header (base64 of the 32-byte APNs connect token) [RP:ids/user.rs]. Protocol-version header values: "1640" hardcoded in one source [IG:ids/user.go:44]; the other takes it from config, with "1660" in its test fixtures [RP:test.rs:303]. User agent: `com.apple.invitation-registration [,,,]` in both [RP:macos.rs:77-79], [IG:ids/config.go:89-92]. Content type on register: `application/x-apple-plist` (one source; the other sends none) [RP:ids/user.rs:1112], [IG:register.go:370-375]. `TODO(capture):` C2 — current protocol-version value. +- The response contains a top-level status (0 = ok) and `message` string, a per-service breakdown (`status`, `users`), and per user: status, the X.509 identity certificate (DER) to store, the registered URIs with per-URI statuses, an optional unsigned-integer `next-hbi` (heartbeat interval in seconds; parsed as absent when missing, with no fallback at parse time [RP:ids/user.rs:1160-1180]), an optional `alert` (title/body/button plus an action object with button/type/url) shown on certain errors, and a top-level `retry-interval` integer that the second source models but never reads [RP:ids/user.rs:1127-1183], [IG:register.go:110-144,429-450]. Notable status codes: 6001 incompatible (Contact Key Verification or Advanced Data Protection enabled), 6004 transient retry, 6005 bad authentication, 6009 iMessage access disabled, 5052 alias removed [RP:ids/user.rs], [RP:identity_manager]. Response wire keys (extraction): top level `status`, `message`, `retry-interval`, `services` — Go names all four; Rust reads only `status` and `services` (disagreement on which exist/are read — `TODO(capture):`, C57). Per-service entries nest under `services`, each with `service`, `users`, `status`; per-user keys `uris`, `user-id`, `cert`, `status`, `alert`; per-URI entries `uri`, `status`; Rust additionally reads an optional per-user `next-hbi` (absent in Go's model, recorded above). `alert` keys (Go): `body`, `button`, `title`, nested `action` with `button`, `type`, `url`; Rust models only `title`, `body`, and optional `action.url`. — 2026-09-10: C53 closed by facts-only extraction (both source repos); the "refresh credentials" marker name (§6.1) remains the one capture-bound half, and the two disagreements above join C57. +- Certificates and authentication keys are generated and stored locally on the client and never touch third-party relay servers; with Apple ID login the third-party app appears as a trusted device on the account [OB-Hosted]. +- OpenBubbles' hosted/relay model: one-time access to a Mac (or an always-online iPhone) generates activation codes via a QR generator; the Mac need not stay online afterward and never receives the Apple Account keys [OB-QS], [OB-FAQ]. One Mac's identifiers can be shared with up to 20 users; a "prevent sharing" mode makes codes single-use, 48-hour-expiring, and non-transferable [OB-FAQ]. + +### 1.5 The Apple-Account (GSA) login chain + +- Facts-only extraction (2026-09-11, rev 14) over the GSA-auth crate and the Mac client's auth flow. Endpoints: the two SRP steps both POST `https://gsa.apple.com/grandslam/GsService2` with `Content-Type: text/x-xml-plist`; the body is an XML plist with top-level `Header` (only `Version` = `1.0.1`) and `Request`. Init request keys: `A2k` (client SRP public value, plist data), `cpd` (a client-provided-data dictionary, below), `o` = `init`, `ps` = [`s2k`, `s2k_fo`], `u` = username. Init response (under `Response`): `s` (salt, data), `B` (server public value, data), `i` (iteration count, integer), `c` (challenge string), optional `sp` (selected protocol, default `s2k`), `Status` (dict with integer `ec`, 0 = ok, and string `em`). Complete request keys: `M1` (proof, data), `c`, `cpd`, `o` = `complete`, `u`. Complete response: `M2` (server proof, data), `spd` (encrypted plist data), `Status` whose `au` string selects secondary auth: `secondaryAuth` (SMS/trusted-phone) or `trustedDeviceSecondaryAuth` [APA:icloud-auth]. +- SRP-6a variant (byte-exact): 2048-bit group, g = 2, SHA-256 throughout. Password key: SHA-256 of the raw password; for `s2k_fo` that digest is ASCII-lowercase-hex-encoded (64 chars) and those bytes feed PBKDF2, for `s2k` the raw 32-byte digest feeds it; PBKDF2-HMAC-SHA256 with server salt `s` and server iteration count `i`, output 32 bytes = the SRP password. Identity hash quirk (GSA-specific, must match): the identity hash uses an EMPTY username — SHA-256(empty ‖ ":" ‖ password-buffer) — while M1's H(username) term uses the REAL username. x = SHA-256(salt ‖ identity-hash); u = SHA-256(A ‖ B); k = SHA-256(N ‖ g padded to N's length); premaster = (B − k·g^x)^(a + u·x) mod N; K = SHA-256(premaster big-endian); M1 = SHA-256(XOR(H(N), H(pad(g))) ‖ H(username) ‖ salt ‖ A ‖ B ‖ K); M2 = SHA-256(A ‖ M1 ‖ K). A is sent as `A2k`; the received `M2` is verified against the computed M2 [APA:icloud-auth]. +- `spd` decryption: AES-256-CBC + PKCS7; key = HMAC-SHA256(key = K, message = ASCII `extra data key:`), IV = first 16 bytes of HMAC-SHA256(key = K, message = ASCII `extra data iv:`). Decrypted plist carries: a per-service token map `t` (service id → token + expiry in epoch-ms or duration in seconds, e.g. `GsIdmsToken`), `acname`, `DsPrsId`, `adsid`, first/last name [APA:icloud-auth]. +- Anisette at login: the anisette base headers (§1.1) ride the POSTs filtered to `X-Apple-I-MD-LU`, `X-Apple-I-MD-RINFO`, `X-Apple-I-MD-M`, `X-Apple-I-MD`, `X-Mme-Device-Id`, plus akd-variant `X-MMe-Client-Info`/User-Agent and `X-Apple-AK-Context-Type`/`X-Apple-Client-App-Name`/`X-Apple-I-Client-Bundle-Id`. The `cpd` dictionary carries anisette values as KEYS inside the body: `X-Apple-I-Client-Time`, `X-Apple-I-MD`, `X-Apple-I-MD-LU`, `X-Apple-I-MD-M`, `X-Apple-I-MD-RINFO`, `X-Mme-Device-Id`, optional `ptkn` (APNs token), plus fixed values: `X-Apple-I-Device-Configuration-Mode` = `0`, `X-Apple-I-Request-UUID` (uppercase UUIDv4, one per login, reused across both steps), `X-Apple-Requested-Partition` = `0`, `X-Apple-Security-Upgrade-Context` = `com.apple.authkit.generic`, `capp`, `cbid`, `cou` = `US`, `loc` = `en_US`, `svct` (e.g. `imessage`), and booleans `X-Apple-Offer-Security-Upgrade`=true, `at`=0, `bootstrap`=true, `ckgen`=true, `fcd`=true, `icdrsDisabled`=false, `icscrec`=true, `pbe`=false, `prkgen`=true, `webAccessEnabled`=false [APA:icloud-auth]. +- 2FA: `au` = `secondaryAuth` → `GET https://gsa.apple.com/auth` returns JSON `AuthenticationExtras` (trustedPhoneNumbers with `numberWithDialCode`/`lastTwoDigits`/`pushMode`/`id`; HTTP 201 = SMS already sent); request a code with PUT `.../auth/verify/phone` body `{phoneNumber:{id}, mode:"sms"}`; verify with POST `.../auth/verify/phone/securitycode` (same body + `securityCode:{code}`). `au` = `trustedDeviceSecondaryAuth` → trigger the code push with `GET .../auth/verify/trusteddevice`, then submit: `GET .../GsService2/validate` with the code in a `security-code` request header. All 2FA requests carry `X-Apple-Identity-Token` = base64 of `adsid:GsIdmsToken` (from `spd`) and a large browser-style fixed header set. Success returns tokens in RESPONSE HEADERS: repeated `X-Apple-GS-Token` (one per GSA service), `X-Apple-HB-Token`, `X-Apple-PE-Token` — each base64 of colon-separated `ID:TOKEN[:...]` with inconsistent duration/expiration trailer forms (epoch-ms vs seconds, heuristically parsed) [APA:icloud-auth]. After a successful 2FA, the full SRP login is run a SECOND time, which then completes as logged-in [APA:icloud-auth]. An on-device variant exists (Circle PAKE over `.../GsService2/circle`: SRP-6a over the 3072-bit group with the DSID as username and the zero-padded 6-digit code as password, 16-byte random salt, DER/ASN.1 step payloads base64 in `pakedata`; the code arrives AES-128-GCM-encrypted over an SRP-key-derived channel with `recv->send`/`send->recv` info labels) [RP:auth.rs], [APA:icloud-auth]. +- PET: arrives as `X-Apple-PE-Token`; stored under service id `com.apple.gs.idms.pet`; default expiry 300 s when no trailer, otherwise parsed seconds/epoch-ms; refreshed by silently re-running the SRP login with the persisted password hash. Used as the HTTP Basic password (`username:PET`) on the iCloud requests below [APA:icloud-auth], [RP:auth.rs]. +- Delegate logins: POST `https://setup.icloud.com/setup/signin/v2/login`, XML plist body with `delegates` (dictionary keyed by bundle id: `com.apple.private.ids` → {`protocol-version`: `4`}; `com.apple.mobileme` → {}), `protocolVersion` = `1.0`, `userInfo` = {`clientId` (uppercase UUIDv4), `language` = `en-US`, `timezone`}. Headers: Basic `username:PET`, `X-Mme-Nas-Qualify` = base64 validation blob (§1.1), `X-Apple-ADSID` = adsid, akd-flavor `X-Mme-Client-Info`, `User-Agent` = `com.apple.iCloudHelper/282 …`, full anisette set. Response plist: `status` (0 = ok), optional `localizedError` + `description`, `delegates` dictionary with per-entry `status`, `statusMessage`, `serviceData`. The IDS delegate's serviceData (kebab-case) yields `authToken` and `profileId` — exactly the IDS `auth-token` (sent as `authentication-data` = {`auth-token`: token}) and the `profile-id` (realm user id) that feed the authenticate exchange (§1.2) and registration (§1.4). The MobileMe delegate's serviceData yields a token map (incl. `mmeAuthToken`) and service configs [RP:auth.rs], [RP:macos.rs]. +- Not present in these sources: no `idmsa.apple.com`, no `scnt`/session-token headers — the session role is played by `X-Apple-Identity-Token` (GSA requests) and Basic `username:PET` (iCloud requests); the idmsa-based flow recorded in §1.2's [IG] citations is a different client's route (C57 note) [APA:icloud-auth], [RP:auth.rs]. +- — 2026-09-11 (rev 15 extraction: C59 closed). Group constants: the GSA login uses the RFC 5054 2048-bit MODP group, g = 2 (the standard Appendix-A prime); the Circle PAKE uses the RFC 5054 3072-bit group with g = 5 [APA:groups.rs], [RP:auth.rs]. The 2FA browser-style header set (all `/auth/verify/*` and `GsService2/validate` requests): the anisette base headers filtered to `X-Apple-I-MD-LU`/`X-Apple-I-MD-RINFO`/`X-Apple-I-MD-M`/`X-Apple-I-MD`/`X-Mme-Device-Id`, plus fixed literals `X-Apple-Client-App-Name`, `X-Apple-I-Client-Bundle-Id`, non-akd `X-MMe-Client-Info`, `X-Apple-I-CDP-Circle-Status: false`, `X-Apple-I-ICSCREC: true`, browser `User-Agent`, `Sec-Fetch-Site: same-origin`, `X-Apple-Requested-Partition: 0`, `X-Apple-I-DeviceUserMode: 0`, `X-Apple-I-Locale: en_US`, `X-Apple-Security-Upgrade-Context: com.apple.authkit.generic`, `Accept-Language: en-US,en;q=0.9`, `X-Apple-I-PRK-Gen: true`, `Sec-Fetch-Mode: cors`, `X-Apple-I-TimeZone: UTC`, `X-Apple-I-OT-Status: false`, `X-Apple-I-TimeZone-Offset: 0`, `X-MMe-Country: US`, `X-Apple-I-CDP-Status: false`, `X-Apple-I-Device-Configuration-Mode: 0`, `Sec-Fetch-Dest: empty`, `X-Apple-AK-Context-Type`, and `X-Apple-I-CFU-State` — a hardcoded base64 whose decoded XML plist is just an empty `` [APA:anisette.rs]. cpd values: `capp` = client app name, `cbid` = bundle id, `svct` = the AK context type — for the Messages flavor: `capp` = `Messages`, `cbid` = `com.apple.MobileSMS`, `svct` = `imessage` (AppleIDSettings flavor: `icloud` / `com.apple.systempreferences.AppleIDSettings`); `cou` = `US`, `loc` = `en_US`; `ptkn` = the APS push token as uppercase hex [APA:anisette.rs], [RP:lib.rs]. akd request values: `User-Agent` = literal `akd/1.0 CFNetwork/1494.0.7 Darwin/23.4.0`; `X-MMe-Client-Info` (akd form) = ` ` (observed ` …` and ` …`); `X-Apple-AK-Context-Type` = `imessage` or `icloud` per flavor [APA:lib.rs], [APA:gsa_auth.rs], [RP:lib.rs]. `spd` details: `GsIdmsToken` is a TOP-LEVEL string key (not inside `t`); `t` entries carry `token` (string) plus `expiry` (epoch-ms) or `duration` (seconds); account-name keys `fn`/`ln` (optional strings) [APA:client.rs]. Host split: `postdata` and `teardown` go to `gsas.apple.com` (with the S) with `X-Apple-I-UrlSwitch-Info` = base64(`adsid:postdata`/`adsid:teardown`); circle requests use `X-Apple-Identity-Token` when 2FA-authenticated, `X-Apple-HB-Token` = base64(`adsid:hbtoken`) otherwise; the app-token checksum is HMAC-SHA256 keyed with the session key over `apptokens` ‖ dsid ‖ app name [APA:client.rs]. Sign-in request values: `User-Agent` = `com.apple.iCloudHelper/282 CFNetwork/1408.0.4 Darwin/22.5.0` (static config constants); `userInfo.timezone` = a hardcoded IANA name (`America/New_York` in the reference config); `X-Mme-Client-Info` wraps the AOSKit form `com.apple.AOSKit/282 (com.apple.accountsd/113)` [RP:auth.rs], [RP:test.rs]. HTTP client notes: Apple root CA pinned, HTTP/1.1 title-cased headers forced, cookie store in use; a parser quirk makes the reference's composed sign-in UA literally `com.apple.iCloudHelper/282 com.apple.iCloudHelper/282` (duplicated token — treat the full static string as the recorded form) [RP:relay.rs]. — 2026-09-11 (small follow-up: the three remaining literals): the 2FA browser `User-Agent` = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)` — static in every source, notably with NO Safari/Version token; the non-akd `X-MMe-Client-Info` for the browser set = the same device-bracket form with inner component `com.apple.akd/1.0 (com.apple.akd/1.0)` (rustpush's variant wraps `com.apple.AuthKit/1 (com.apple.MobileSMS/1262.500.151.1.2)`); `X-Apple-I-CFU-State`'s hardcoded base64 decodes to the FULL plist wrapper — `` + Apple DOCTYPE + `` + `` + `` + trailing newline (182 bytes), NOT a bare ``; the sign-in `X-Mme-Client-Info` is bracketed like the others: ` `; and the sign-in UA quirk is confirmed — the composed header is the duplicated `com.apple.iCloudHelper/282 com.apple.iCloudHelper/282` while the full `com.apple.iCloudHelper/282 CFNetwork/1408.0.4 Darwin/22.5.0` string is the config literal other endpoints draw from [APA:anisette.rs], [APA:lib.rs], [APA:gsa_auth.rs], [RP:lib.rs], [RP:test.rs], [RP:macos.rs]. + +## 2. IDS (identity services) + +### 2.1 Purpose and lookup + +- IDS acts as the keyserver for iMessage (and FaceTime): registration uploads the public encryption and signing keys plus "client data" describing device capabilities; a lookup takes target handles and returns one identity per registered device, each with the device's public key, push token, and session token [JJ]. Apple's guide confirms the device contacts IDS to retrieve public keys and APNs addresses for all devices of an addressee [APS-Send]. "IDS" is believed to stand for "IDentity Services" but this is unofficial [JJ]. +- Lookup is a POST of a plist with a `uris` array to the `id-query` bag URL [RP:ids/user.rs] (pinned URL: `query.ess.apple.com` query path [IG:lookup]), with headers including the sender's own handle, the push token, and the protocol version, a lookup-specific user agent, an optional sub-service topic header, and opt-in flags requesting messages/results [RP:ids/user.rs]; the second source agrees on the self-URI and protocol-version headers [IG:lookup]. Header wire names (extraction, closing C47): inside `h`, Rust sends `x-id-self-uri`, `x-push-token`, `x-protocol-version` (from the stored per-user protocol version), `user-agent` = `com.apple.madrid-lookup `, the `x-id-nonce`/`x-id-cert`/`x-id-sig` signature set, conditionally `x-id-sub-service` (only when the lookup topic differs from the main topic), and `x-required-for-message: true` / `x-result-expected: true` (opt-in flags, both true in most calls) [RP:ids/user.rs]; Go sends `X-ID-Self-URI`, `X-Protocol-Version` (pinned "1640"), `X-ID-Nonce`, `X-ID-Cert`, `X-Push-Token`, `X-ID-Sig` — no user-agent/sub-service/opt-in headers (disagreement on the optional set — C57) [IG:lookup]. No push header set (`x-push-*`) rides lookups in either repo — that C47 half closes negatively. The signature payload matches §1.4: nonce ‖ bag name ("id-query") ‖ empty query string ‖ body ‖ APNs push token, RSA PKCS1v15-SHA1 with the 0x01 0x01 prefix (both) [RP:auth.rs], [IG:sign]. — 2026-09-10: C47's header half closed by facts-only extraction; nothing capture-bound remains here (the optional-set disagreement rides C57). +- Batches are capped per request: 18 handles [RP:identity_manager.rs] vs 20 URIs [IG:lookup]. A "response too large" error (5206) causes the batch to be split in half recursively [RP:ids/user.rs], [IG:lookup]. `TODO(capture):` C13 — real batch cap. +- **Architecture fact (extraction, 2026-09-10 — supersedes the earlier either-transport framing): IDS lookups ride the APNs tunnel, not plain HTTPS.** The lookup request and its headers travel inside a tunnel payload whose header dictionary is keyed `h`; the tunnel-level response carries a status field (Rust checks it; Go's constant for the HTTP-over-APNs request type is 96) [RP:ids/user.rs], [IG:lookup]. The tunnel mechanics stand as recorded: command 96 carries URL, headers, content type, body, and a 16-byte request UUID; the reply carries the same request UUID and body on the same topic (command 97 with a status field, per Go) [RP:auth.rs], [RP:ids/user.rs], [IG:lookup]; a 1-minute timeout applies [IG:lookup]. `TODO(capture):` C12 — which transport real devices use today. +- The response (gzipped plist) maps each handle to a result containing an identities array (one per device), optionally a sender-correlation identifier and a key-transparency account key, and a numeric status [RP:ids/user.rs], [IG:lookup]. Each identity carries the client-data dictionary, a 32-byte push token, a session token, server-declared `session-token-expires-seconds` and `session-token-refresh-seconds`, and optional key-transparency data [RP:ids/user.rs:694-704], [IG:lookup.go:107-109]. Both sources parse the two token timing fields, but only the first consults them: `session-token-expires-seconds` bounds how long the cached identity stays valid, `session-token-refresh-seconds` triggers a proactive re-lookup [RP:identity_manager.rs:52-72]; the second ignores both and uses its own fixed TTLs (§2.2) [IG:lookup.go:242-248]. The semantics of the sender-correlation identifier are not explained by either source. `TODO(capture):` C11 — its exact semantics; C10 — real token TTL values (an instrumented client logs both fields per identity). Element form (extraction): the `uris` elements are PLAIN handle strings (bare `tel:`/`mailto:` strings) in both repos — the contrast with the register request's single-key `uri` dictionaries (§1.4) is real. Response wire keys (extraction): top level `status` + `results`, a map keyed by the plain uri string; per-handle keys `identities`, `kt-account-key`, `sender-correlation-identifier`, `status` (Go names all four; Rust reads `identities` and `sender-correlation-identifier` only — disagreement, C57); per-identity keys, identical six in both: `client-data`, `kt-loggable-data`, `push-token`, `session-token`, `session-token-expires-seconds`, `session-token-refresh-seconds`; client-data keys read: `public-message-identity-key`, `public-message-ngm-device-prekey-data-key`, `ngm-public-identity` (both) plus `supports-certified-delivery-v1` (Rust); Go keeps unrecognized client-data keys in a catch-all, Rust drops them [RP:ids/user.rs], [IG:lookup]. — 2026-09-10: C48 closed by facts-only extraction (both source repos). C55 narrowed: both repos read lookup error statuses from the top-level `status` only — 5206 splits the batch in half (both), 6005 triggers a full re-register then retry (Go at most once per hour), 5040/5041 are defined but unhandled in lookup — and neither checks a per-handle `status`, superseding the per-handle reading that follows and §7 item 19. C16 evidence (§2.3): no PQ3/PQ-typed keys appear anywhere in these results; the identity key is EC P-256 signing + RSA encryption; the EC material present is the NGM prekey identity (EC key + 64-byte signature + timestamp), and `ngm-public-identity`/`kt-loggable-data` decode to a compressed EC key [RP:identity_manager.rs], [IG:lookup]. The lookup-orchestration code reads per-handle result statuses only (the one numeric-status location this fact set records) and treats a whole-batch 5206 the same as a per-handle one. +- Session tokens prove a recent lookup, are required to send to a device, expire, and cannot be shared — only the account that performed the lookup can use them [JJ]; they are passed verbatim in each outgoing message bundle [RP:identity_manager.rs]. This account binding is an edge case for relay architectures where one account performs lookups for others [JJ]. +- After registering, the client receives an "identity keypair" used to perform lookups [JJ]. + +### 2.2 Key caching and multi-device + +- Cache policy is client-side and the two sources differ: one keeps an entry with zero identities stale after 1 hour (hardcoded fallback [RP:identity_manager.rs:25]), otherwise valid while elapsed time is under `session-token-expires-seconds` for *every* identity, with proactive refresh when *any* identity exceeds `session-token-refresh-seconds`, a 60-second minimum spacing between forced refreshes, and the cache keyed by a SHA-1 over (id certificate, current APNs token) so either change invalidates everything [RP:identity_manager.rs:27-28,52-96,212-240]. The other never reads the server-provided token fields: it refreshes positive results after 1 day, negative results after 1 hour, and hard-expires entries after 7 days [IG:lookup.go:242-248,339-352]. The send-side error codes for token problems are 5040 (bad session token) and 5041 (expired session token); neither source implements a recovery path for 5041 [IG:ids/types/idserrors.gen.go:104-105]. `TODO(capture):` C14 — observe real lookup cadence to arbitrate. +- Each device on an account generates its own key set; private keys are never exported; messages go to all of the sender's other devices and all recipient devices as pair-wise sessions, each encrypted with keys uniquely derived per session [PQ3]. The sender encrypts one payload per target identity (per receiving device) and addresses identities by push token [RP:identity_manager.rs]; devices without a published identity key are skipped and duplicate push tokens are deduplicated except when destinations differ [IG:connector]. +- Other registrations of the same account (own devices: UUID, device name, push token, identities, sub-services) are retrievable via the `id-get-dependent-registrations` bag key (GET) [RP:ids/user.rs] (pinned URL: getDependentRegistrations path [IG:bag]); this maps own-device push tokens to UUIDs, used to address specific own devices [RP:identity_manager.rs]. +- Pseudonym "aliases" can be created/deleted via an `id-provision-alias` bag URL with attributes (allowed services, expiry, feature id); aliases are cached with expiry and pruned [RP:ids/user.rs], [RP:identity_manager.rs]. Registered handles can be re-fetched via an `id-get-handles` bag key [RP:ids/user.rs] (pinned URL: idsGetHandles path [IG:bag]). +- A spam-report endpoint (`id-report-spam`) submits per-message metadata for suspected spam [RP:ids/user.rs]. + +### 2.3 Server-initiated control + +- Pushes arrive on the `com.apple.private.ids` topic (hashed on the wire) with a small JSON body carrying a command count: c=32 forces re-registration; c=66 means handles changed (client re-fetches handles and re-registers if they differ); c=34 means devices changed (client invalidates its key cache for all handle pairs) [RP:identity_manager.rs]. The other implementation receives but ignores that topic [IG:apns]. `TODO(capture):` C15 — which of these actually occur on a real account. +- Command 130 ("peer cache invalidate") tells receivers to drop the sender's cached identity; received for self, it triggers a full re-register; sending it with token-only bundles is how a client broadcasts a new own handle as resolvable [RP:imessage/aps_client.rs], [IG:connector]. +- An error 6005 on lookup triggers an IDS re-register and retry, at most once per hour [IG:lookup]; the other source treats 6005 as bad authentication and forces a full re-login [RP:identity_manager.rs]. `TODO(capture):` C39-adjacent — observe real 6005 handling. +- With PQ3, each device registers two public encryption keys with IDS and replaces them regularly with fresh ones: a Kyber-1024 key-encapsulation key and a P-256 key-agreement key, signed (with a validity timestamp) by a P-256 authentication key generated inside the Secure Enclave, itself signed by the Contact Key Verification account key together with attributes such as the supported protocol version [PQ3]. Neither reference client implements PQ3; both register only the legacy/EC key sets [repo-wide]. `TODO(capture):` C16 — whether current lookup results carry PQ3 keys or the legacy client-data. Extraction evidence (2026-09-10): current-source lookup results carry no PQ3/PQ-typed keys — the identity key is EC P-256 signing + RSA encryption, the EC material present is the NGM prekey identity (EC key + 64-byte signature + timestamp), and `ngm-public-identity`/`kt-loggable-data` decode to a compressed EC key (§2.1) [RP:identity_manager.rs], [IG:lookup]; what a real Apple device returns still needs the capture. + +### 2.4 Contact Key Verification — OPEN + +- **Open item — not yet captured.** Contact Key Verification is an opt-in iOS 17.4+ feature: the verifier pins each contact's devices by comparing per-device keys against a verification code shown in the contact card and against Apple's Key Transparency log [APS-CKV]. Its effect is already visible indirectly in IDS registration: status 6001 ("incompatible") is returned when a target has CKV or Advanced Data Protection enabled [RP:ids/user.rs], [RP:identity_manager]. +- Observation plan: enable CKV on the capture device and run a full-interception window. CKV hosts are expected to be pinned (like the courier), so the expected yield is SNI and timing evidence only, not payload. No capture ID assigned yet; scheduled as its own window. + +## 3. APNs courier + +### 3.1 Connection + +- APNs is bidirectional: devices both receive pushes and send data; on connect the device receives a push token used to route notifications to it, distinct from the per-app token of the standard iOS API [JJ]. All iMessage traffic flows over APNs in both directions; a persistent connection is required for real-time delivery [BM]. +- Servers are `*-courier.push.apple.com` where `*` is a number in 1–30 [BM] vs 1–50 [IG:courier]; the host count and hostname come from the APNs bag, and the client connects to "-" but sets TLS SNI to the bare hostname [RP:aps.rs], [RP:util.rs]. `TODO(capture):` C17 — current bag hostname/count and the real range. (2026-09-10: `courier2.push.apple.com` observed live [CAP-COURIER]; bag hostname/count and full range still open.) +- Capture ground truth [CAP-COURIER]: a registered iPhone (iOS 26.1) connected to `courier2.push.apple.com`, with both ports 5223 and 443 in use — under interception the client alternated between them in a ~5–15 s reconnect loop. The courier pins against interception even with a trusted interception CA installed: the upstream TLS handshake is rejected with the OpenSSL error `bad extension`, i.e. the courier TLS stack is not vanilla TLS — consistent with a custom client-certificate/extension design, which arbitrates nothing between the [RP:aps.rs] (no client cert in handshake) and [IG:connection.go] (mutual TLS) readings; C18 stays open. Hosts observed pinned in the same window: `courier2.push.apple.com`, `gateway.icloud.com`, `configuration.apple.com`, `query.ess.apple.com`; with Apple hosts passed through, delivery works normally. A non-transparent HTTP-proxy capture sees no courier traffic at all — the courier bypasses the Wi-Fi HTTP proxy entirely. +- Transport is TCP port 5223 with TLS [RP:aps.rs], [IG:apns/connection.go]; the channel is forward-secret, and metadata such as timestamps and APNs routing information is not encrypted [APS-Send]. The client pins Apple's own root certificate(s) rather than system roots (a `profileidentity.ess.apple.com` root [RP:aps.rs]; a custom Apple root pool [IG:connection.go]). +- Client certificate: public write-ups say connecting requires a client certificate issued by the Albert activation server [JJ], [BM]; one source states the client certificate is NOT used in the TLS handshake and authentication happens in the application-layer connect message instead [RP:aps.rs]; the other configures mutual TLS with the Albert-issued certificate [IG:connection.go]. `TODO(capture):` C18 — whether the handshake presents a client certificate. — 2026-09-28 [PROBE-COURIER] (unauthenticated direct probe from this machine, no client certificate configured): the handshake to `courier.push.apple.com` COMPLETES on both 5223 and 443 (TLS 1.3, TLS_AES_256_GCM_SHA384, chain leaf ← `apple public server ecc ca 12 - g1`, anchored at `AAA Certificate Services`), and the server then sends a fatal TLS alert `certificate_required` on the first read — before any APNs frame can flow. C18 is settled by the server side: the courier uses TLS 1.3 **post-handshake client-certificate authentication** with the Albert-issued certificate; an application-layer-auth-only client would never get past the alert. The application-layer connect message (below) additionally carries the certificate+nonce+signature fields — both layers are in play; the client-cert presentation is the transport gate. +- ALPN: one source offers `apns-pack-v1` and `apns-security-v3` and switches to a compressed "packed" wire format when a pack protocol is negotiated [RP:aps.rs]; the other negotiates only `apns-security-v3` and speaks only the legacy frame format [IG:connection.go]. `TODO(capture):` C19 — negotiated ALPN and frame format in real traffic. (2026-09-10: the live ClientHello offers both `apns-security-v3` and `apns-pack-v1`; the ServerHello selects no ALPN protocol at all — so the "switch when a pack protocol is negotiated" trigger never fires in the observed session, and the legacy frame format is the default assumption [CAP-COURIER].) + +- Handshake ground truth [CAP-COURIER] (pass-through pcap, 2026-09-10): the live client↔courier session is structurally standard TLS 1.3 — the server selects version 0x0304 with cipher TLS_AES_256_GCM_SHA384 — but the ClientHello carries Apple-private negotiation: a private supported-version value 0xfafa offered alongside TLS 1.3, a private elliptic-curve group 0x9a9a offered alongside the standard curves and the IANA X25519+ML-KEM-768 hybrid group (0x11EC; the offered key share, ~1263 B, is consistent with an ML-KEM-768 share), and two private extension types (0x5A5A, empty; 0x1A1A, one byte). Standard status-request, SCT, signature-algorithms, PSK-mode, and compress-certificate extensions are also offered. This refines the `bad extension` reading above: the courier is not a custom TLS protocol — it is TLS 1.3 plus Apple-private groups/extensions that conventional intercepting stacks do not offer or echo. Because TLS 1.3 encrypts all post-ServerHello handshake messages, whether the handshake presents the Albert client certificate (C18) is unobservable from a passive capture; that question now needs client-side evidence, not a better network vantage. (Note: the transport already negotiates a post-quantum hybrid key exchange even though message payloads ride the legacy envelope — PQ appears at the TLS layer today.) + +- Chain ground truth [CAP-COURIERTLS] (direct uninstrumented TLS connect from this machine, 2026-09-10): `openssl s_client` to `2-courier.push.apple.com:5223` with SNI set to the bare `courier.push.apple.com` completes the TCP+TLS layer — the shard-prefixed host naming and bare-hostname SNI of §3.1 work against the live server. The server presented two certificates: leaf `CN = courier.push.apple.com` (Apple Inc.; notBefore 2026-09-02, notAfter 2026-12-02 — ~90-day rotation, so pin the root, never the leaf) and intermediate `CN = Apple Public Server ECC CA 12 - G1` (Apple Inc.), itself issued by the publicly-trusted Comodo root `CN = AAA Certificate Services` — the root itself was not served. A trimmed system store rejected the chain (missing anchor), consistent with the client-side pinning of §3.1; a pinned-root pool must anchor at `AAA Certificate Services` (or Apple's own pool). + +### 3.2 Frame format + +- Legacy frames: a 1-byte command, a 4-byte big-endian total length, then fields, each a 1-byte id, 2-byte big-endian length, and value [RP:aps.rs], [IG:apns/binary.go]. `TODO(capture):` C45 — the scope of the length prefix (fields-only, as in the documented APNs provider framing, vs the whole frame) and the per-command field-id tables are not captured in this spec (§7 item 15); the transport layer is buildable now, command builders are not. — 2026-09-10: C45 closed by facts-only extraction (both source repos): the 4-byte big-endian length covers the whole field block after the command byte (fields-only scope, agreed), and the per-command field-id tables are recorded in §3.3. The tagged table disagreements (connect flags, filter fields, command 14) and the Go-only extra fields ride C57; command builders are now writable from the spec, with captures arbitrating the tagged items. +- The packed format (single source) replaces the length/value fields with tag-based attributes with 4-bit value fields, extension bytes, and rolling 32-entry key/value caches; only a fixed list of short attribute keys is value-cached [RP:aps.rs]. `TODO(capture):` C19 — confirm packed format use and cache-size suffixes. + +### 3.3 Commands + +- Connect (7): fields for the device/push token, a state byte (1), connection flags (base 0x41; an extra bit 0x04 marks a root connection), the client certificate, a nonce, and a signature [RP:aps.rs], [IG:apns/commands.go]. Second-extraction correction: **the two sources agree** on the nonce (rev 2 recorded a disagreement that does not hold in source): it is the same structured 17-byte nonce as the HTTPS signature (§1.4) but with the type byte 0x00 (APNs), and it is signed *alone* — SHA-1 of the raw nonce, RSA-SHA1-PKCS1, 0x01 0x01 prefix [RP:aps.rs:1005-1006 with RP:auth.rs:685-714], [IG:apns/connection.go:205-219]. One source also sends a fixed value 9 in a field with id 0x10 [RP:aps.rs]. Field-id table (extraction, agreed unless noted): 1 device token, 2 state (value 1), 5 flags, 12 push certificate, 13 nonce, 14 signature — sent in the order token/state/flags/cert/nonce/signature [RP:aps.rs], [IG:apns/commands.go]; the 0x10 field is Rust-only (integer 9, a hardcoded version identifier). Flags disagreement: Go's base is `0b1000001` with an added `0b100` bit marking a root connection; Rust sends `0b01000001` — the recorded flag-bit sets disagree — `TODO(capture):` (C57). The C8 extraction pass also records the Go connect nonce as a 20-byte random value with the first byte zeroed, against the structured 17-byte agreement above — `TODO(capture):` (C57). `TODO(capture):` C20 — confirm against a real client's connect frame; the meaning of the 0x10 field. +- Connect response (8): a status byte (0 = ok; 2 = invalid cert), and a 32-byte connect token (possibly new) which the client stores and uses in later messages [RP:aps.rs], [IG:apns/commands.go]. The other source adds: advertised maximum message size (usually 4 KiB), capabilities, a large-message size (usually 15 KiB), and a server timestamp in unix ms [IG:apns/commands.go]. On status 2, one client regenerates its push key/cert via Albert once and resets all IDS registrations [IG:connector]. Field-id table (extraction): 1 status (0 success; Go comments 2 = error), 3 token — Rust parses only 1 and 3; Go-only extras: 4 maximum message size (~4 KiB), 5, 6 capabilities, 8 large-message size (~15 KiB), 10 server timestamp (unix ms) [RP:aps.rs], [IG:apns/commands.go]. `TODO(capture):` C23 — real advertised sizes (Apple's docs say APNs can relay messages up to 4 KB or 16 KB depending on OS version [APS-Send]). +- Filter (9): sent after connect and whenever the topic set changes, carrying the token and per-topic states — enabled, ignored, opportunistic, paused — each topic appearing as the SHA-1 of its topic string [RP:aps.rs]; the other source confirms SHA-1 topic hashes and subscribes to madrid, the SMS alloy topic, and the IDS topic [IG:apns/connection.go]. Field-id sets disagree (extraction): Go sends 1 token, 2 topics (SHA-1-hashed topic list); Rust sends 1 token, 2 enabled, 3 ignored, 4 opportunistic, 5 paused (Go uses only field 2) — `TODO(capture):` (C57) [RP:aps.rs], [IG:apns/commands.go]. +- Send message (10): field ids (extraction, agreed, outgoing): 1 topic, 2 token, 3 payload (a plist), 4 message id (u32 BE; random in 1..2^31−1) [RP:aps.rs], [IG:apns/commands.go]. Incoming adds field 5 expiration (u32; observed 0xffffffff), field 6 timestamp (u64 unix ns), field 7 (observed 0x02) [IG:apns/commands.go]. Rust also parses an optional field 29 as a channel payload (Rust-only) [RP:aps.rs]. `TODO(capture):` C26-adjacent — confirm incoming-only fields. +- Send ack (11): token, the message id being acknowledged, and a status (0 = accepted); incoming notifications are auto-acked with status 0 [RP:aps.rs]; the other source acks every received send message and reports status 0x80 = "sent too early" (retried) and 0x03 = too large [IG:apns/connection.go]. Field ids (extraction): 1 token, 4 message id, 8 status byte; Go additionally reads field 6 (commented as a possible error) — Go-only, `TODO(capture):` (C57) [RP:aps.rs], [IG:apns/commands.go]. +- Keepalive (12) / keepalive ack (13): no fields (both) [RP:aps.rs], [IG:apns/commands.go]. Command 14 disagrees (extraction): Go labels it "filter-topics-ack" (uncertain; field 1 = token); Rust decodes it as a no-storage marker carrying no fields — `TODO(capture):` (C57) [RP:aps.rs], [IG:apns/commands.go]. `TODO(capture):` C22 — real meaning of command 14. +- Set state (20): field 1 state (u8), field 2 interval, constant 0x7fffffff (both) [RP:aps.rs], [IG:apns/connection.go]. Command 29 (0x1d) — subscribe-to-channels — exists in one source only, with fields 1 index, 2 message (protobuf), 3 token, and a response carrying status field 4 [RP:aps.rs]. `TODO(capture):` C24 — whether real clients use channel subscribe. + +### 3.4 Keepalive, reconnection, topics + +- Keepalive: one source pings every 60 seconds and waits for the pong, capping response waits at 15 seconds (a timeout forces a connection reload) [RP:aps.rs]; the other pings every 60 s with the first at 5 s after connect, suppresses pings when traffic arrives, and drops the connection after three silent intervals (180 s) [IG:apns/connection.go]. Reconnect: one source uses exponential backoff capped at 30 seconds, indefinitely [RP:aps.rs]; the other waits 3 s after a failed connect, 6 s if the previous connection lasted under 90 s, otherwise reconnects immediately [IG:connector]. `TODO(capture):` C21 — real keepalive pacing and reconnect behavior of Apple clients. (2026-09-10: see the packet-capture note below — a 2.0 s client activity cadence was observed, not 60 s pings [CAP-COURIER].) +- Topics: `com.apple.madrid` is the iMessage topic [JJ], [RP:aps.rs], [IG:apns/topic.go]; registered sub-service topics include `com.apple.private.alloy.sms`, `com.apple.private.alloy.gelato`, `com.apple.private.alloy.biz`, `com.apple.private.alloy.gamecenter.imessage` [RP:ids/user.rs], plus `com.apple.private.alloy.safetymonitor` (and an "ownaccount" variant), `com.apple.private.alloy.fitnessfriends.imessage`, and `com.apple.private.alloy.askto` [IG:apns/topic.go]. `com.apple.private.ids` carries server-initiated control [RP:identity_manager.rs], [IG:apns/topic.go]. "ESS" is also used as an alternate name for IDS, but the APNs topic FaceTime uses is specifically `com.apple.ess` [JJ]. +- Push credentials (the APNs connection) and identity credentials (encryption keys) are separated by design, so push credentials can be delegated to a notification relay without exposing message content or the ability to send [BM], [BA]. +- iMessage pushes are deleted from APNs on delivery but, unlike normal pushes, are queued for offline devices — stored on Apple servers for up to 30 days [APS-Send]. Rev-12 correction: 160 is **not** a courier frame command in either repo — it is a payload `command` value inside the command-10 plist message type, meaning offline-message-pending/queue-flush (recorded in Go; absent in Rust) [IG:apns/commands.go]. The earlier "sends a cache-flush command (160) on connect" framing is withdrawn; the related no-storage-marker behavior (command 14, §3.3) stands. +- Packet-capture ground truth [CAP-COURIER]: a 1-minute capture of an active courier session (post-PQ3 device) shows a single long-lived TCP session carrying send, receive, edit, and tapback traffic — no per-message connections. While active, the client emits ~40-byte records on a hard 2.0-second cadence — an activity timer, not the 60-second idle keepalive either implementation models; idle behavior and reconnect pacing remain uncaptured (C21). Outbound messages appear as 2.3–2.8 KB bursts; inbound frames are ≈115 B and ≈520 B. Frame format and ALPN are not observable through the pinned TLS (C19 stays open). + +## 4. Payload format + +### 4.1 Outer envelope + +- An iMessage send is one APNs notification on the topic (e.g. madrid or the SMS alloy topic) whose plist payload contains, agreed by both sources: command number `c`; encryption mode `E` ("pair" or "pair-ec", present when a body exists); ciphertext `P`; sender handle `sP`; destination handle `tP`; message UUID bytes `U`; numeric message id `i`; send timestamp `e` in unix nanoseconds; sender's 32-byte push token `t`; session token `sT`; version `v` (8 for sends); no-response-needed flag `nr`; expiration seconds `eX`; trusted-sender flag `htu`; user agent `ua`; fanout chunk index `fcn` (starting at 1); and a `dtl` array of per-target bundles [RP:identity_manager.rs], [IG:sendmessagepayload]. One source adds: retry count `rc`, original send time `oe` (epoch nanos, on retries), queued-message flag `sv`, scheduled delivery time `dmt`, queue id `qI`, FaceTime relay-allocation fields, and a legacy unencrypted body field `p` [RP:identity_manager.rs], [RP:ids/mod.rs]. `TODO(capture):` C26-adjacent — confirm the full current field set. +- Each `dtl` bundle carries: target handle `tP`, a request-delivered boolean `D`, the session token `sT` from the IDS lookup, the target's 32-byte push token `t`, the encrypted payload `P` (absent for payload-less messages), the encryption mode `E` (only for pair-ec), and an optional certified-delivery version `cdv` [RP:identity_manager.rs], [IG:sendmessagepayload]. The delivery-request flag is false for self [IG:connector]; one client excludes its own push token from targets unless a queue id is present [RP:identity_manager.rs]. +- Encryption-mode placement (second-extraction facts): when any encrypted body exists, the top-level `E` is always the legacy value `pair` regardless of the per-recipient scheme actually used, and per-`dtl`-entry `E` is written only for entries encrypted with the EC scheme — legacy entries omit `E` entirely at the entry level [RP:identity_manager.rs:1124-1161]. Payload-less sends (receipts and similar) carry neither `E` nor `P` at all — in the second source's receipt path the encryption routine is never invoked [RP:identity_manager.rs:1124-1135], [IG:connector.go:1743-1751]; incoming messages with no `E` pass through without decryption [RP:identity_manager.rs:814-838]. One source's fan-out builder keeps duplicate entries for the same destination push token when the destination's identities support different schemes, with a note that Apple senders are observed sending the same destination both ways — i.e. mixed per-device schemes within one send appear in real traffic [IG:connector.go:1605-1619]. `TODO(capture):` C25 — settle which schemes current Apple clients emit (§4.5, §7 item 1). +- Value types (extraction, agreed, closing C49): `c` integer; `E` string ("pair"/"pair-ec"); `P` data; `i` integer; `U` data (raw 16-byte UUID); `t` data (push token); `e` integer (unix nanos — Go-only key); `sP`/`tP` strings; `v` integer (8; Rust uses 1850 for the SMS topic, §5.8); `nr` boolean; `fcn` integer; and `dtl` is an ARRAY OF DICTIONARIES — nested message dicts, not nested data plists (both agree) [RP:ids/user.rs], [IG:sendmessagepayload]. Placement: the per-recipient fields (`tP`, `t`, `sT`, `P`, `E`, `D`, `cdv`) sit top-level in Go for normal sends; Rust always nests them inside `dtl` entries and omits a per-entry `E` when it is "pair" (present for "pair-ec"). Rev-12 key-attribution correction to the first bullet of this section: the extraction records `e`, `eX`, `htu` as Go-only and `ua` as Rust-only. Go-only keys: `e`, `oC`, `htu`, `eX`, `cdr`, plus HTTP (`u`, `b`, `cT`, `h`, `hs`, `hr`, `s`), MMCS (`mO`, `mS`, `mL`, `mA`, `mR`, `mU`), content (`cV`, `cH`, `cB`), and failure (`aDI`, `fR`, `fU`, `fM`, `fRM`, `fFS`, `fS`) groups; Rust-only keys: `rc`, `oe`, `sv`, `dmt`, `qI`, `qr`, `qgid`, `qgmc`, `qai`, `qat`, `qv`, `qids`, `ua`. — 2026-09-10: C49 closed by facts-only extraction (both source repos); any residual per-key value-type doubt is capture-bound (§7 item 17). +- The inner plaintext is a binary plist, optionally gzipped [RP:messages.rs], [IG:connector]. Gzip placement (extraction): Go gzips a converted binary plist of the message before encryption; the Rust gzip location was not traced (unresolved — C57) [IG:connector]. +- Binary plist container format (the encoding of any plist above that travels in binary form — inner plaintexts, APNs-tunneled requests, archived balloon payloads; this is Apple's publicly documented container format, not something extracted from the restricted sources): a file is an 8-byte magic `bplist00`, then the object area, then an offset table with one fixed-width entry per object (each the byte offset of that object from the start of the file), then a 32-byte trailer: 5 unused bytes, 1 byte sort version, 1 byte offset-table integer width, 1 byte object-reference width, then three 8-byte big-endian integers — object count, top-object index (the offset-table index of the root object), and the offset table's byte offset. Every multi-byte integer in the format is big-endian. Widths are the minimal 1/2/4/8-byte width that holds the largest value present (object-reference width is the minimal 1/2/4/8 whose 2^(8w) ≥ the object count, so every reference 0..count−1 is representable; offset-table width must hold the final file offset). The trailer's sort-version byte is written as 0. +- Binary plist object encoding (continuing [CF]): each object starts with a marker byte — high nibble type, low nibble info. Simple values: 0x00 null, 0x08 false, 0x09 true. Integers are marker 0x1n and occupy 2^n bytes; the 1-, 2-, and 4-byte forms are unsigned (zero-extended on decode), the 8-byte form is two's-complement signed. Encoders emit non-negative values in the minimal 1/2/4/8-byte width that holds them and negative values only in the 8-byte form. Reals are 0x2n, IEEE 754 big-endian, 2^n bytes (4- or 8-byte). Dates are marker 0x33 plus an 8-byte big-endian double of seconds since 2001-01-01T00:00:00Z. Data 0x4n; ASCII strings 0x5n with a byte count; UTF-16BE strings 0x6n with a length counted in UTF-16 code units (not bytes); arrays 0xAn; dictionaries 0xDn, encoded as key references followed by value references. UIDs are marker 0x8n occupying 1<:::"); screen-effect id `iid`; chat display name `n`; audio flags `a`/`e` (audio / expiring audio); balloon fields `bid`, `bp`, `bpdi`; message-summary info `msi` (content-type number `amc`, source text `ams`, balloon type `amb`); a previous-message UUID `r` [RP:rawmessages.rs], [IG:imessage.go]. +- The XML body's children are either FILE elements (attachments with MMCS download info: owner, URL, hex signature, decryption key, inline-attachment references) or span elements (text parts with part-index attributes and mention markup) [IG:decrypt], [RP:rawmessages.rs]. Text-part positions are counted in UTF-16 code units; part index/start/length is the same indexing scheme used for tapback targets and edit parts [IG:imessage.go]. +- Screen effects use "com.apple.messages.effect.*" names (echo, spotlight, balloons, confetti, love, lasers, fireworks, celebration, shooting star); bubble effects use "com.apple.MobileSMS.expressivesend.*" names (impact, loud, gentle, invisible ink) [IG:imessage.go]. +- Balloon payloads (`bp`) are optionally gzipped and either a raw keyed-archiver plist or a wrapper with payload and attachments; `bpdi` carries MMCS download info (key, size, owner, URL, signature) [IG:imessage.go], [RP:rawmessages.rs]. Rich links live in the URL balloon provider's keyed-archiver payload with URL, title, summary, and icon/image metadata [RP:messages.rs], [IG:balloonparse]. A location-share fallback exists where the text is exactly U+FFFD and the XML carries a breadcrumb-text attribute [IG:decrypt]. +- 1:1 vs group is distinguished by participant count (2 participants = 1:1; more = group) [IG:decrypt]. +- `TODO(capture):` C50 — the plist value type of each body key is not recorded for most keys; the spec's own quoting settles some (versions `gv`/`v` are strings, `tg` a formatted string) but the audio flags, `pv`, the UUID forms (`U`-style bytes vs GUID strings for `r`), and the `msi`/`bpdi` sub-dict value types are unstated. The writer phase assumes booleans for the audio flags, bytes for `r`, and integers for `pv`/`amc`; a capture must arbitrate. — 2026-09-10: C50's protobuf-numbering half is closed by facts-only extraction (§4.3); the body-plist value-type half stays capture-bound. + +### 5.3 Tapbacks / reactions + +- Agreed core: reactions ride on command 100 payloads; six reaction types as integers 2000–2005 (love, like, dislike, laugh, emphasize, question); removal adds 1000 (3000–3999); the reaction carries no UUID of its own and references its target by a target string plus start-index/length fields [RP:messages.rs], [IG:tapback.go]. +- Disagreements/extensions: one source describes the action selector as 2000–2999 for add and 3000–3999 for remove with the offset giving the index, and extends the set with 6 = custom emoji (carrying the emoji string) and 7 = sticker, plus app-extension reaction codes 2/4000/1000 [RP:messages.rs]; the other documents only 2000–2005 [IG:tapback.go]. The target string is disagreed: "bp:" for balloons and "p:/" otherwise [IG:tapback.go] vs a raw target GUID (for balloons) or "p:"-shaped variants [RP:messages.rs]. `TODO(capture):` C32 — capture real reaction codes and target-string formats. +- The emoji mapping used by clients to display reactions: heart, thumbs up/down, laugh, exclamation, question marks [IG:tapback.go]. + +### 5.4 Edits and unsends + +- Agreed: both are command 118 with a fresh message UUID; body fields are the target message GUID (`emg`), part index (`epi`), edit type (`et`, 1 = edit, 2 = unsend), a boolean unsend flag (`rs`), version "1", and for edits the new XML body (`epb`) plus new plain text [RP:rawmessages.rs], [IG:imessage.go]. The part index can be −1 when unsending the subject part [IG:imessage.go]. `TODO(capture):` C38 — confirm the −1 case. No edit history is transmitted — only the current content of the edited part; the edit XML is an HTML document with one span per edited part, with visible text recovered by stripping tags [IG:editparse]. +- A link-preview edit is a separate command 196 whose body decodes like a normal message and only contributes rich-link metadata [IG:decrypt]. `TODO(capture):` C36. + +### 5.5 Typing indicators + +- Agreed start: a command 100 message with the expiration-seconds field = 0, no encrypted payload, trusted-sender and no-response-needed flags set [RP:messages.rs], [IG:connector]. Registration may advertise a receive-typing-indicators capability [IG:register]. +- Disagreed stop: one source stops typing by sending a body where the typing flag is absent and a version field is "1" [RP:messages.rs]; the other signals stop by an empty (zero-length) encrypted payload with participants and version fields set [IG:connector]. `TODO(capture):` C33 — capture a real start/stop pair. + +### 5.6 Delivery and read receipts + +- Agreed: commands 101 (delivered) and 102 (read) exist, are addressed to other accounts' handles (own devices excluded) [RP:messages.rs], and the `D` bundle flag requests a receipt (1:1 messages request it; group messages do not, reactions always do) [RP:messages.rs]. +- Disagreed body: one source says 101/102 carry no payload [RP:messages.rs]; the other says 102 is sent unencrypted with the timestamp of the read event and the message UUID set to the "read up to" GUID, fanned out to all participants [IG:connector]. `TODO(capture):` C34 — capture a real 102. +- Certified delivery (agreed): a delivered message can carry a certified-delivery version plus an opaque receipt; the receiving client acknowledges via command 109 on madrid with the receipt, a new APNs id, the original UUID, the sender's token, version 8, no-response-needed, and optionally the target/sender handles and a notify flag [RP:identity_manager.rs], [IG:connector]. Whether real Apple clients emit these receipts is unknown (one implementation disables the capability). `TODO(capture):` C35. +- Send-time acks (agreed): command 255 echoes the message UUID with a per-device push token and a numeric status; an empty token means a server-level error for the whole send [IG:ackwaiter.go]; one source reports status 0 and 5008 as delivered and 5032 as "refresh keys and resend" [RP:identity_manager.rs], while the other's status table reads 5032 as bad signature (same remedy: re-lookup and resend once) plus 1002 message removed, 1003 message inhibited, 5006 bad push token, 5040/5041 bad/expired session token, 6005 refresh credentials, 6006 refresh registration, 7000 rate limited, 5000 unauthenticated [IG:errors.json]. `TODO(capture):` C39 — confirm the status table. +- Retry policy is client-side and differs: up to 5 retries waiting 60 s × (retry+1) [RP:identity_manager.rs] vs 1 s + up to 19 s of ack aggregation [IG:connector]. A special error string (`ec-com.apple.messageprotection-802`) means "undecryptable", prompting a key-cache refresh [RP:ids/mod.rs]. + +### 5.7 Group events + +- Agreed (command 190): body carries group GUID, group version, properties version `pv` (a monotonic counter that must be tracked), change type, full participant list `sp` (including self, no URI prefixes), target participant list `tp` (membership after change), current name `n`, new name `nn`, old name `old`, and an avatar file-transfer record `tv` [RP:rawmessages.rs], [IG:imessage.go]. Change types: name = "n", avatar = "v", participants = "p"; an additional "vr" is seen but not understood [IG:imessage.go]. Participant add/remove is expressed purely by the sp/tp diff; avatar removal is change type "v" with no file transfer [IG:decrypt], [RP:rawmessages.rs]. `TODO(capture):` C37 — observe "vr" and real pv values. +- Icon changes carry MMCS transfer metadata (created date, filename, embedded MMCS reference, transfer and message GUIDs) [RP:rawmessages.rs]. + +### 5.8 SMS fallback (relay) + +- Incoming SMS/MMS relayed by the user's own iPhone arrive as commands 140/141; outgoing "reflected" SMS/MMS as 143/144. Long messages (>160 chars), a subject or XHTML body, or multiple recipients switch reflected-SMS to reflected-MMS [IG:connector], [RP:rawmessages.rs]. Outgoing messages use the SMS sub-service topic with version field 1850 [RP:identity_manager.rs]. +- Outgoing body (agreed): chat style ("im" DM / "chat" group), recipient records, and message data (GUID, handle, plain body, subject, XHTML, reply-to GUID, service "SMS" or "RCS", version "1") [RP:rawmessages.rs], [IG:imessage.go]. Incoming body (agreed): receiver, sender, UUID, timestamp, content chunk list (MIME type + bytes, with MMS content-location/content-id), MMS subject/type/recipients, and constants including message type "sms"/"mms", a constant "244" field, and service version "1" [RP:rawmessages.rs], [IG:imessage.go]. `TODO(capture):` C40 — confirm constants. +- An SMS enabling handshake exists: request (142) and toggle confirmation (145) with accepted/rejected/disabled states [IG:sendmessagepayload]; one source also reports read-on-device as 147 on the SMS topic addressed only to the own phone token [RP:aps_client.rs], [IG:connector]. +- Keeping a phone number registered via relay apps requires keeping the device on, awake, and connected to Wi-Fi [OB-PNR]. "Verification Failed" on a received message means sender identity could not be verified at receive time — causes include no connectivity to Apple's keyserver, rate-limiting, or (unlikely) an attacker [OB-FAQ]. + +## 6. Renewal & edge cases + +### 6.1 Re-registration cadence + +- Apple requires periodic renewal of the registration, submitting new validation data each time; clients surface the next scheduled renewal, and Apple can force a re-registration at any time [OB-Renewal], [OB-Hosted]. With an iPhone-relay registration the iPhone and relay must stay online at all times; Mac-based registration can renew indefinitely with no Mac online [OB-Renewal]. The exact renewal interval in days is not documented publicly [OB-Renewal]. +- Client-side scheduling policies (second-extraction refinement): the first source reads `next-hbi` as "seconds from registration time until the client should re-register" and schedules the next re-registration at the minimum, across all users, of (registration time + `next-hbi`); when `next-hbi` is absent it falls back to seconds remaining until the identity certificate's `notAfter` minus a 300-second (5-minute) safety margin, and rechecks the wall clock every 60 seconds [RP:ids/user.rs:79-98,1171-1180], [RP:identity_manager.rs:491-525]. Failed registrations retry with exponential backoff — minimum 5 minutes, maximum 1 day, indefinitely — and a triggered refresh is a no-op if one succeeded within the last 15 seconds, with callers waiting at most 30 seconds for the running attempt [RP:identity_manager.rs:434-443], [RP:util.rs:916-917,1116-1133]. The second source recomputes its re-register ticker after every successful registration: it starts from the earliest `notAfter` among all identity certs but caps the interval at 45 days, clamps to a maximum of 1 day and a minimum of 30 minutes, then subtracts a 2-minute buffer [IG:connector.go:178-224]. It also forces re-registration whenever 45 days have elapsed since the last successful registration [IG:connector.go:301]. Its per-cert safety buffer is tiered by approximate certificate lifetime (registered-time → `notAfter`): under 8 hours → re-register when less than 3 minutes remain (fresh phone-number certs are described as short-lived); under 1 day → 30 minutes; under 7 days → 4 hours; otherwise 24 hours [IG:connector.go:317-334]. When its validation-data provider is unreachable it pauses by resetting the ticker to 1 hour [IG:connector.go:238-243]. +- Forced immediate re-registration (second-extraction refinement): a push on the private IDS topic with command 32 (re-register) or 66 (handles changed — re-fetch handles, re-register if they differ) triggers it in the first source; command 34 (devices changed) only invalidates the cached identities for own handles [RP:identity_manager.rs:758-797]. A lookup returning 6005 triggers a re-register and retry, at most once per hour with a two-strike guard, in the second source [IG:lookup.go:447-471]; the first treats a 6005 from registration itself as permanent ("do not retry") pending re-login [RP:identity_manager.rs:369-380]. An APNs command 130 received from self also forces re-registration in the second source [IG:connector.go:824-841]. Disagreement: the second source's receive path explicitly ignores the private IDS topic entirely, so server-pushed commands 32/66/34 do nothing there [IG:connector.go:810-813]. A stored client-data hash differing from what the client would now send triggers immediate re-registration (first source) [RP:identity_manager.rs:400-412]. +- Second-extraction correction on the "~45-day certificate" folklore: 45 days appears in the sources only as the second source's client-side cap on re-registration cadence and on time-since-last-registration; neither source records an observed server-issued certificate validity, and the first source assumes no certificate lifetime at all (the certificate's own `notAfter` is authoritative) [IG:connector.go:181,208,301], [RP:ids/user.rs:79-88]. Treat 45 days as a client policy constant, not a measured Apple certificate TTL. `TODO(capture):` C41 — real `next-hbi` values and whether Apple sends it at all, real identity-certificate `notBefore`/`notAfter` per handle type (phone vs Apple ID), real `session-token-expires-seconds`/`-refresh-seconds` values, and which renewal trigger fires in practice. An instrumented client closes this with a debug log of: the raw register response plist (top-level keys, per-user `next-hbi`, cert DER), cert validity tagged by handle type, the computed next-renewal deadline and the trigger that fired, and the lookup identity dicts' two token fields. +- **Mac-renewal arbitration (2026-09-10 research; Phase 0 go/no-go input):** renewal never needs the Mac again — every re-registration submits freshly generated validation data, produced on-device by the client's own NAC key-establishment session keyed with the cached Mac hardware identifiers [RP:ids/user.rs], [RP:macos.rs]. OpenBubbles documents Mac-based registration as renewing indefinitely with no connection to the Mac, and never requiring the Mac online after setup [OB-Renewal], [OB-FAQ]; corten-matrix likewise generates validation data locally from the hardware key extracted from the Mac once (the extraction produces an already-complete key, so no Mac round-trip is needed later). Contrast: in the iPhone-relay configuration each renewal is an HTTP call to the relay, so the iPhone must be live at every renewal [RP:relay.rs]. Client-side schedule confirmed: earliest of (registration time + `next-hbi`) or the identity-cert `notAfter` − 5 min; failure retries back off 5 min–24 h; re-registration also fires reactively on APS re-register commands and identity errors. IDS status 6005 is a server-side logout needing an Apple-ID relogin (not a Mac/validation-data problem). No public source states the actual day count — treat the ~45-day max-certificate figure as approximate. **Standing risk:** macOS 26 protected `NACKeyEstablishment` with PAC (rustpush issue #21) — an Apple-side protocol change can break local generation; watch rustpush/corten releases. — 2026-09-10: the standing risk **fired** — on macOS 26.6.3 (confirmed live in this workspace) `NACKeyEstablishment` is PAC-protected and SIGABRTs when called [RP-issue-21], and beeper's `mac-registration-provider` is archived (Apr 2026) with support only up to macOS 14.3. Minting validation data currently requires a Mac on macOS ≤ 14.3; the issue is open with no upstream fix or comments. rustpush's own fallback note stands: any genuine Mac's validation data works — the identifiers only need to be valid, not the capturing client's own hardware. — 2026-09-10 correction (OpenBubbles-flow research): the ≤ 14.3 conclusion above is **narrowed** — it binds only helpers that call the framework themselves (beeper's `mac-registration-provider`; rustpush running *on* a Mac, which is what issue #21 breaks). OpenBubbles' shipped flow never calls `NACKeyEstablishment` on the Mac at all: the helper exports identifiers only (public APIs, any macOS ≥ High Sierra including 26.x), and the client performs the generation in software (§1.1, C56). A macOS 26 Mac therefore suffices as the identifier source; the remaining C5/C6 work is client-side software (the generation procedure + Apple-Account login), not hardware access. +- A status of "refresh credentials" in the register response marks that auth pair for refresh [IG:register]; error 6005 forces a full re-login [RP:identity_manager.rs] (see §2.3 for the lookup-side reading). + +### 6.2 Expiry and failure modes + +- Expired-validation-data symptoms beyond the deregistered state, and certificate-expiry behavior after renewal failure, are covered by no source. `TODO(capture):` C43. +- Deregistration: neither reference client implements client-initiated un-registration; deregistration is handled by Apple (e.g. when the account is used elsewhere) [RP:identity_manager.rs]. Operationally: reset the app and delete the device from the Apple Account page; an already-registered number can be deregistered via Apple's selfsolve deregistration page [OB-Renewal]. The MobileMe delegate token state is refreshed when its stored refresh timestamp is older than 7 days (604800 s) — a lazy refresh at fetch time, client-side policy, not a measured server TTL [RP:auth.rs:204-212]. `TODO(capture):` C42 — observe the deregistration flow end-to-end. +- Failure modes for send/receive breakage: stale identity cache (clearable in developer tools), deregistration, enabled Advanced Data Protection, enabled Contact Key Verification (both must be disabled for relay use — matching status 6001), and Apple-side throttling/banning of the account or hardware [OB-FAQ], [RP:ids/user.rs]. +- Apple throttles new or abusive registrations: freshly registered devices may see temporary limits; texting many people can require waiting days or weeks; mass spam from shared hardware identifiers gets the device blocked. Account blocks are usually temporary (a day or two); permanent blocks are typically liftable via Apple support [OB-FAQ]. +- Key rollover: the 2019 protocol added a periodic rekey mechanism providing cryptographic self-healing [PQ3]; under PQ3 the two IDS-registered encryption keys are replaced regularly with fresh, timestamped signatures that senders validate for expiry [PQ3]. `TODO(capture):` C45 — real key-replacement cadence. +- A known sync edge case: messages sent from a relayed client may not appear on the account's other Apple devices for up to about 24 hours after linking the Apple Account [OB-FAQ]. +- Partial group delivery: because every payload is per-device, a group send can partially fail; failures surface per-target via ack statuses (bad push token, expired session token, rate limited, etc.) [RP:identity_manager.rs], [IG:connector]. Aliases and session tokens expire and must be re-provisioned/re-looked-up [RP:identity_manager.rs], [JJ]. +- On connect-ack "invalid certificate", one client regenerates its push certificate once and resets all IDS registrations and keys [IG:connector]. + +## 7. Capture checklist + +Ordered by value. IDs match the inline `TODO(capture):` markers above. + +1. **C25 — pair vs pair-ec vs PQ3 in current traffic.** The single most load-bearing question: do today's Apple clients still send "pair"/"pair-ec" envelopes, or has PQ3 replaced them entirely? Determines whether §4.2–4.3 are sufficient for receive compatibility. Second-extraction sharpening: Apple stated in 2024 that PQ3 "will fully replace the existing protocol within all supported conversations", but neither source repo has any post-2024 evidence, and one repo's fan-out code notes Apple senders shipping the same destination both with and without EC keys in one send (mixed schemes observed pre-PQ3) [IG:connector.go]. Payloads are end-to-end encrypted, so network captures can never settle this — the only path is an instrumented client. Observe: per-recipient `E` values (top-level and per-`dtl` entry) on outgoing and incoming messages; payload-less messages' `E` presence; whether current lookup results still carry the legacy/EC key sets beside the PQ3-registered Kyber-1024 + P-256 keys; the incoming validator final byte; incoming inner counters and gossip fields. +2. **C17 + C18 + C19 — APNs transport stack.** Courier hostname/count from the APNs bag (range 1–30 vs 1–50); whether the TLS handshake presents the Albert client certificate (mutual TLS) or auth happens only in the connect message; negotiated ALPN (`apns-security-v3` vs packed variants) and therefore legacy vs packed frame format. — 2026-09-10: `courier2.push.apple.com` observed live, 5223↔443 both used; vanilla-TLS interception rejected (`bad extension`) — Apple-private TLS negotiation confirmed (see §3.1). 2026-09-10 pcap mining: live session is TLS 1.3 (server picks 0x0304 / TLS_AES_256_GCM_SHA384) with Apple-private version 0xfafa, group 0x9a9a, extensions 0x5A5A/0x1A1A, and the X25519+ML-KEM-768 hybrid group offered; client offers both ALPNs, server selects none → legacy frame format assumed. C18 is unobservable by passive capture under TLS 1.3 — needs client-side evidence. — 2026-09-10 [CAP-APNSBAG]: the APNs bag was fetched directly (`GET http://init-p01st.push.apple.com/bag`, unauthenticated plain HTTP; archived `reference/imessage-captures/apns-bag-2026-09-10.xml`): `APNSCourierHostname: courier.push.apple.com`, `APNSCourierHostcount: 50` — the 1–50 range is confirmed (`courier2` is one host within it); `APNSAllowTLS1_3: 1` is consistent with the observed TLS 1.3 session. C17's hostname/count half is closed; C18 still needs client-side evidence. [CAP-COURIER] +3. **C1 + C3 — registration request shape.** Second-extraction state: the bag fetch URL, nested-plist shape (`bag` key → embedded plist), one-per-process caching, and both clients' full endpoint sets are now known (§1.2) — rustpush resolves 11 bag keys at runtime with no fallbacks [RP]; the other pins everything and never fetches. What remains needs an instrumented client (pinned hosts bypass every proxy tier tried): the **live** bag contents (all `id-*` → URL mappings as served today), the current sub-service list and Messenger capability flags (17 vs 1), the capability-boolean set the server expects, and whether the server accepts both gzip-XML and plain-XML register bodies. — 2026-09-10: the bag contents are now observed directly — both bags fetched unauthenticated, no drift in the pinned URLs ([CAP-BAG]/[CAP-APNSBAG], §1.2). What remains C1/C3-bound to an instrumented client: the current sub-service list and Messenger capability flags (17 vs 1), the capability-boolean set the server expects, and whether the server accepts both gzip-XML and plain-XML register bodies — these live in the register request/response pair, which does require authentication (and Apple throttles abusive registrations, so no blind probing). C2 (protocol-version: "1640" vs "1660") and C4 (signature construction — structure now source-agreed, §1.4) also resolve from the same instrumented-client capture. +4. **C32 + C33 + C34 — semantic wire forms.** Tapback action codes (2006/2007/4000/1000/2 extensions) and target-string formats ("bp:" vs raw GUID vs "p:"-variants); typing start/stop encoding (empty payload vs body-field stop); whether command 102 carries a body (timestamp + read-up-to UUID) or none. +5. **C41 + C10 — lifetimes.** Real `next-hbi` values (and whether Apple sends it at all — one source parses it, the other doesn't read it), real identity-certificate lifetimes per handle type (the "~45 days" figure is now known to be a client-side cap, not an observed cert TTL — §6.1), real `session-token-expires-seconds`/`-refresh-seconds` values (one source consults them, the other ignores them), the effective renewal cadence, and which renewal trigger fires in practice (timer, private-IDS push 32/66, 6005, handle change). All reachable only via an instrumented client's debug log — the exact logging list is in §6.1. +6. **C5 + C6 — validation data & anisette.** Exact validation-data blob shape/inputs on a genuine Mac (and the two validation bag-URL exchanges); the full anisette header set current clients send (including optional hardware headers and `X-Mme-Nas-Qualify`). — 2026-09-11 (rev 14): the anisette halves are closed — header set, per-request value formats, the ADI provisioning flow (§1.1), and the GSA login chain end-to-end (§1.5) are recorded. Remaining open here: the validation blob itself (C5), the OTP/2FA-code derivation (closed-source crate — contract recorded, §1.1), and wire-verification of the whole chain against one live activation. +7. **C20 + C22 + C23 + C24 — APNs message details.** Second-extraction narrowing: C20's connect nonce is now source-agreed (structured 17-byte nonce with type byte 0x00, signed alone — §3.3); what remains is confirming a real client's connect frame and the fixed 0x10 field value. Still open: the meaning of command 14; connect-ack advertised max/large message sizes (4/15 KiB [IG] vs Apple-doc 4/16 KB [APS-Send]); whether channel subscribe (0x1d) is used. +8. **C26 + C27 + C29 — pair-ec details & chunking.** Second-extraction narrowing: C26's validator final byte is source-agreed at 0x0c (§4.3) — capture only confirms Apple's live value and whether it ever differs. Still open: receiver-side counter enforcement, which counter keying/reset behavior Apple uses (per-key-hash persisted from 0 vs per-URI from 1 — §4.3), and real send-chunking (≈5000-byte packing vs ≤20 devices against 15 KiB). +9. **C35 + C36 + C37 + C38 + C39 + C40 — semantics sweep.** Certified-delivery (109) emission in the wild; single-source command numbers (196, 104, 113, 120, 122, 131, 138, 103, 142, 146/149 — §3 table, tagged per source; 160 withdrawn as a frame command, §3.3); group "vr" change type and real pv values; edit part-index −1 (subject); full ack status table; SMS body constants. +10. **C42 + C43 + C44 + C46 — lifecycle edges.** Server-initiated ids-topic commands (c=32/34/66) in practice; deregistration flow; expired-validation-data/renewal-failure behavior; session-token account binding as seen in relay scenarios. +11. **C21 — timing.** Real keepalive pacing (60 s confirm), silence-drop threshold (15 s ack timeout vs 180 s), and reconnect strategy (backoff cap 30 s vs 3/6 s rules). — 2026-09-10: 2.0 s client activity-record cadence observed while active; idle keepalive and reconnect pacing still open [CAP-COURIER]. — 2026-09-10 [CAP-APNSBAG]: Apple's client-side connection-policy constants observed in the APNs bag (client policy, not wire measurement): `KeepAliveV2TimeDriftMaxAllowed: 30`, `APNSDelayedReconnectIntervalAll: 60`, `APNSDelayedReconnectMaxInterval: 90` with Min/MaxIntervalTrigger 30/50 and `APNSDelayedReconnectTLSInterval: 120`, `APNSWiFiKeepAliveEarlyFireConstantInterval: 120`, `ClientConnectionRetryAttempts: 100`, `APNSBagExpiry: 10800`. These bound the pacing questions but do not replace observing a real client. +12. **C28 + C30 — plumbing.** How often the inner gzip layer appears; MMCS command codes (150/151) and header versions in current traffic. +13. **C31 — PQ3 wire format.** If captures show PQ3, dump its container/envelope; Apple's post describes the cryptography but not the wire encoding, and no third-party implementation exists to cross-check. +14. **C7 + C8 — activation edge.** Carrier entitlement exchange per carrier (SIM-less phone auth); exact Albert activation-info field set and FairPlay certificate choice. +15. **C45 — APNs legacy framing gaps (blocks command construction).** The §3.2 legacy frame's per-field 1-byte ids for every command (connect 7: token/state/flags/cert/nonce/signature; connect-response 8: status/token; filter 9; send 10; ack 11; keepalive 12/13; set-state 20; cache-flush 160), plus the length-prefix scope question (§3.2). Frames are TLS-encrypted, so passive captures see nothing — needs an instrumented client or apsd introspection. Until closed, the courier transport (TLS, frames, keepalive, reconnect) is implementable but no command builders can be written. Related: which Apple root certificate(s) to pin for the courier trust pool (§3.1). — 2026-09-10: closed by facts-only extraction (both source repos) — the length-prefix scope is settled (covers the whole field block after the command byte) and the field-id tables are recorded (§3.2, §3.3). What remains here is only the tagged disagreement set — connect flags bits, filter field set, command-14 semantics, ack field 6, connect-response Go-only fields, connect-nonce shape — now tracked under C57 (item 21); the "cache-flush 160" entry above is withdrawn (payload value, not a frame command — §3.3, §5.1). + +16. **C47 + C48 — IDS endpoint encodings and the lookup wire details the phase-1 set never captured.** C47: the plist encoding of the bag responses and of the embedded `bag` payload (§1.2); the lookup request's header wire names beyond the `x-id-*` set and the push-token name recorded in §1.4, and whether a lookup carries the push header set at all beside `x-id-*` (§2.1). C48: the element form of the lookup `uris` array, and the lookup response's per-identity/result wire key names beyond `identities`, `status`, and the two `session-token-*-seconds` fields (§2.1). Phase-2 code takes each of these as configuration instead of guessing, so a wrong name fails loudly rather than silently. The three source repos are closed to the writer phase; these close by capture (an instrumented client's lookup request/response pair, or a bag fetch archived with its content type and byte-exact body) or by a deliberate post-Phase-2 extraction. — 2026-09-10 [CAP-BAGCT]: the C47 encoding half is closed (§1.2) — both bag responses and the embedded bag payload are XML plists; the `text/xml` vs `application/x-apple-plist` labels do not change the parse. Remaining open here: the lookup header wire names, the `uris` element form, and the lookup response key names. — 2026-09-10: closed by facts-only extraction (both source repos) — the header wire names (§2.1), the plain-string `uris` element form, and the response key names (§2.1) are all recorded, and no push header set rides lookups. Nothing capture-bound remains for C47/C48; the optional-header-set and per-handle-key-set disagreements ride C57. +17. **C49 + C50 + C51 + C52 — codec assumptions (Phase-2 component 4).** The value-type assumptions of the outer-envelope/body codecs (C49 §4.1, C50 §5.2), the pair envelope's HMAC key and signature encoding (C51 §4.2), and the pair-ec protobuf field numbers (validator 99 excepted) + HKDF salt (C52 §4.3). All are closed by an instrumented client's encrypt/send round trip; until then the code fails loudly rather than guessing. — 2026-09-10: C49, C51, and C52 closed by facts-only extraction (§4.1, §4.2, §4.3), and C50's protobuf-numbering half is closed (§4.3). What remains: the command-100 body-plist value types (§5.2) and the Rust-side inner-body gzip location (C57) — both capture-bound. +18. **C53 + C54 — registration wire names (Phase-2 component 5).** C53 (§1.4): the register request's top-level keys beyond `private-device-data`/`services`/`users`/`validation-data` (device name, hardware version, language, OS version, software version), the service-entry and capability-record key names inside the `services` array, the register response's per-service breakdown shape and where its `users` sit (top level vs inside it), the per-user identity-certificate and per-URI-status keys, the `alert` object's keys, the "refresh credentials" marker (§6.1), and the value form carried by `x-auth-user-id-`. C54 (§1.2): the authenticate request/response pair — body wire keys, CSR encoding and self-signature algorithm, the CN's encoding of the SHA-1 realm-user-id hash, whether the §1.4 signature header sets apply, and the response's token/certificate wire keys. The component-5 code takes every one of these as caller-supplied configuration or keeps it in a raw map, failing loudly rather than guessing; both close with an instrumented client's authenticate+register capture. — 2026-09-10: closed by facts-only extraction (both source repos) — request/response wire keys, CSR = DER, CN = hex SHA-1 string, no §1.4 signature headers on authenticate, flat `status`/`cert` response (§1.2, §1.4). Remaining: the "refresh credentials" marker name (C53 half, §6.1) stays capture-bound, and the capture-arbitrated disagreements (self-signature hash, CN hex case, bag-vs-pinned endpoint resolution) ride C57. + +19. **C55 — lookup error-status locations (Phase 2 lookup-orchestration component).** Where the lookup response carries its error statuses — 5206 ("response too large", the §2.1 split-in-half trigger), 6005 (bad authentication, §2.3), and the session-token codes 5040/5041 (§2.2 records them as *send-side* codes; nothing states whether a lookup can also return them): top-level response status, per-handle result status, or both. The §2.1 response shape records only a per-handle numeric status, so the lookup-orchestration code reads per-handle statuses only; a capture of one real lookup response's full plist (top-level keys and per-handle status values) closes this alongside C48's response-key half. — 2026-09-10: narrowed by facts-only extraction — both clients read lookup error statuses from the top-level `status` only (5206 → split-in-half, 6005 → re-register + retry, 5040/5041 defined but unhandled); neither checks a per-handle `status`. The per-handle-status reading above (and the lookup-orchestration code built on it) is superseded; a capture still confirms the live wire. + +20. **C56 — client-side validation-data generation (§1.1).** The procedure a client runs in software to produce the validation blob: the key establishment over the `id-initialize-validation` session info (inputs, algorithm, blob assembly), its interaction with the Apple-Account (GSA) login and the anisette header set (C6) at activation time, and which pieces are fixed protocol constants vs per-account/per-session state. The shipped OpenBubbles flow proves the procedure runs entirely client-side from exported Mac identifiers; closing C56 needs a facts-only extraction from a current client's implementation, then wire-verification against one live activation. — 2026-09-10: steps 1–2 recorded by facts-only extraction (`id-validation-cert` → single `cert` data field; `id-initialize-validation` body-only POST → `session-info`; §1.1); step 3's math is closed source — OpenAbsinthe-Stub is an official mock whose methods are stubs, so only the API contract is recorded (§1.1). The GSA login and the full anisette header set are recorded (§1.1). What remains: wire-verification against one live activation, and the enc-variant derivation for the five obfuscation labels (closed source — C57). + +21. **C57 — extraction disagreements & closed-source residue (rev 12).** Gaps that survived the rev-12 extraction round, each with its arbitration path: + - CN hex case: uppercase [RP] vs lowercase [IG] hex of the SHA-1 realm-user-id (§1.2) — one instrumented authenticate capture. + - Authenticate self-signature hash: SHA-1 RSA/PKCS1 [RP] vs SHA256WithRSA [IG] (§1.2) — same capture. + - Connect flags bits: Go base `0b1000001` + optional `0b100` root bit vs Rust `0b01000001` (§3.3) — client-side connect-frame dump. + - Connect nonce shape: Go 20-byte random with first byte zeroed vs the recorded 17-byte structured agreement (§3.3/§1.2) — same dump. + - Filter field set: Go 1/2 vs Rust 1/2/3/4/5 (§3.3) — client-side filter-frame dump. + - Command-14 semantics: Go "filter-topics-ack" (field 1) vs Rust no-storage marker with no fields (§3.3) — same dump. + - Ack field 6 (Go-only, §3.3) and connect-response Go-only fields 4/5/6/8/10 (§3.3) — same dump. + - Protocol-version pinning: "1640" hardcoded [IG] vs taken from the stored per-user protocol version [RP] (§1.4, §2.1) — instrumented-client header log. + - The "enc"-variant derivation for the five obfuscation labels (§1.1) — closed source; needs a new facts-only source or clean-room reconstruction checked against one live device. + - The pair-ec inner-body gzip location on the Rust side (§4.1) — instrumented encrypt/send round trip. + - Multi-topic filter-field encoding: whether one state field holds concatenated 20-byte topic hashes or the fields repeat (§3.3) — client-side filter-frame dump. + - Position of the Rust-only connect field 0x10 (value 9) within the connect field order (§3.3) — same dump. + - Connect-response fields 5 and 6 (capabilities) value shapes (§3.3) — same dump. + - APNs-tunnel framing residue (§2.1, rides C12): the frame field ids of tunnel commands 96/97 (the field SET is recorded, the 1-byte ids are not); whether the tunnel payload rides as framed fields at all, and the headers-field encoding (an `h`-keyed dict is recorded; its serialization on the tunnel is not); the tunnel-level status value table beyond 0 = ok — one instrumented tunnel capture. + - Phone-flow `authentication-data` value encodings: the `push-token` and `sigs` entry shapes inside the authenticate request body (§1.2; key names recorded, value encodings not) — one instrumented phone-user authenticate capture. + - Albert wire value types: `FairPlayCertChain` / `FairPlaySignature` request encodings and `ack-received` / `show-settings` response encodings (§1.2; names recorded, value types not) — same activation capture. + - FairPlay identity provenance/licensing: ten embedded certificate/key pairs with unrecorded provenance (§1.2) — a policy decision, not a wire fact; resolve before any distribution. — 2026-09-11 (rev 17): the provenance half is CLOSED by metadata extraction (§1.2): all known copies are ONE identity (1024-bit RSA + leaf cert), byte-identical across repos, descending from a 2023 iCloud-bypass community release; rustpush's "ten pairs" are ten copies of it; the cert expired 2024-10 yet activations succeed. What remains: the own-Mac extraction method (open research — nothing documents where macOS stores the per-device FairPlay identity), and the distribution decision (bundling the community pair in a public APK is the provenance problem; BYO-identity is the sane architecture but needs the extraction method first). — 2026-09-11 USER DECISION: bundle the community pair (same one every third-party client already activates with; per-user extraction only if Apple ever starts validating) — staged as `resources/imessage/fairplay/leaf.crt` + `identity.key` (SHA-256 `bc18fc4d…` / `03aca53e…`, matching the recorded provenance); own-Mac extraction DEFERRED until needed. + +22. **C58 — the Mac-Hardware-Info export-code format (the decided identifier source).** — 2026-09-11 (rev 15): CLOSED by facts-only extraction from the helper's public upstream repo (unlicensed — facts only): `OABS` magic + flag byte + raw protobuf with the full field map, plain-base64 or OpenSSL-`Salted__` AES-256-CBC delivery modes, sharing mechanics server-side; §1.1 records the parse specification. Residue: the vendor-server upload endpoint (`hw.openbubbles.app/code`) is only relevant to the restricted mode's sharing mechanics, which our app does not need (we parse, not mint). + +23. **C59 — GSA login residue (writer-pass gaps from the §1.5 implementation).** — 2026-09-11 (rev 15): CLOSED by facts-only extraction — the SRP modulus (RFC 5054 2048-bit, g=2; Circle 3072-bit, g=5), the full 2FA browser header set with literal values, the cpd values per flavor, the akd UA/`X-MMe-Client-Info` forms, the `t`-map inner keys and top-level `GsIdmsToken`, `fn`/`ln`, and the sign-in UA/timezone forms are all recorded in §1.5; the implementation converts its caller-supplied configuration to the recorded constants. + +24. **C60 — validation-data step-3 reconstruction hypotheses.** The step-3 math is closed source (confirmed at source level, §1.1), so the implementation reconstructs it from the recorded contract with every non-recorded choice explicit. Each hypothesis below is arbitrable ONLY by one live activation (or an instrumented-Mac capture of the exchange); a wrong choice fails the server side loudly: + - Key agreement: ECDH P-256 (default; Apple's recorded crypto is NIST-curve-heavy) vs X25519 as the implemented alternative. + - KDF: HKDF-SHA256 with EMPTY salt and info (both unrecorded). + - Blob signature: ECDSA P-256/SHA-256 with a context-generated keypair (public key rides the blobs) vs HMAC-SHA256 keyed by the session key (symmetric hypothesis). + - Session-info interpretation: the whole `session-info` blob is the server's raw public point (SEC1 33/65-byte for P-256, 32 raw for X25519). + - Blob framing: TLV v1 (1 tag byte + 2-byte BE length; tags 0x01–0x0E hardware fields in §1.1 order, 0x0F cert, 0x10/0x11 public keys, 0x20 signature last) — an invented framing, nothing recorded. + - Step-2 content-type: `text/x-xml-plist` (inferred from the GSA plist posts; §1.1 records body-only). + All six live as parameters/strategies in the code; capture or live-attempt results collapse them. + - 2026-09-11 (first live step-1 attempt, rev 18): the DEFAULT hypothesis set (P-256 ECDH, HKDF-SHA256 empty salt/info, ECDSA-P256-SHA256, TLV v1, RAW_PUBLIC_POINT, `text/x-xml-plist`) was rejected at STEP 2 — the bag fetch and step-1 cert GET both succeeded (HTTP 200, TLS pinned to the embedded Apple roots, cert parsed), and the step-2 POST returned HTTP 200 with a plist dict of exactly two keys: `status` (integer **5003**) and `message` (a server trace string of the form `SED:3:VEN-PROD:::::<64-hex>`; exact values not recorded), with NO `session-info` key. The exchange died before key establishment, so this data point does NOT yet arbitrate the crypto hypotheses — it shows the step-2 request itself was refused. The meaning of SED status 5003 is unrecorded: `TODO(capture)` the SED error-status table (candidate refusal causes: the body shape under a wrong C60 hypothesis, the C56 content-type, or a required-but-unrecorded step-2 header). + - 2026-09-11 (probe matrix, same session): FIVE step-2 attempts returned the byte-identical 498-byte error shape (`status` 5003): default set; KA=X25519; sig=HMAC-SHA256; content-type `application/x-www-form-urlencoded`; content-type `application/octet-stream`. The refusal is INVARIANT under every flipped variable — the server rejects something common to all attempts. Eliminated as causes: the key-agreement scheme, the signature scheme, the step-2 content-type. Remaining suspects, ranked: (1) the invented TLV v1 framing itself (tags/lengths wrong → body unparsable → generic error), (2) the single-key plist wrapper's encoding (XML `` vs base64-string value form), (3) a required-but-unrecorded step-2 header (protocol drift since the 2024-era recordings, e.g. an anisette set now enforced), (4) the exported Mac identifiers being stale/invalid server-side. Next lever is a wire capture of a genuine client performing the exchange, not more blind flips. + - 2026-09-11 (rev 19, step-2 self-check round): the reconstruction was audited offline against the extracted client's own request construction and the archived genuine captures; the audit MOVES the ranking rather than adding a hypothesis. (a) Wrapper — CLOSED as a suspect: the extracted client serializes the `session-info-request` body with the plist XML writer (the same ``-base64 XML form this implementation emits), so suspect 2 is retired. (b) Content-type — CORRECTED, not just probed: the extracted client sets NO content-type on the step-2 POST (only `Accept-Language` rides the client's default headers), so `text/x-xml-plist` was an unsupported inference carried in from the §1.5 GSA posts and the probe matrix's three content-types were all non-faithful; the code's constant is now absent (§1.1). (c) Invariance — CORRECTED: the five probe attempts did NOT send identical bodies (each mints fresh key-agreement key material, the X25519 and P-256 variants differ in that field's width, and the HMAC variant drops the signing-key field), so "byte-identical response" shows the refusal is independent of the body's CONTENT — which retires the key-agreement and signature *values* as variables at this step (step 2 precedes key establishment entirely) and leaves the body's FRAMING (the invented TLV v1 layout) and something outside the body as the surviving explanations. (d) Headers — WEAKENED as a suspect but not retired: the archived 2026-09-10 capture of a genuine device shows the validation endpoint family reached with the full anisette + BAA header set (`X-Apple-I-MD`, `X-Apple-I-MD-M`, `X-Apple-I-MD-RINFO`, `X-Apple-I-Client-Time`, `X-Apple-I-TimeZone`, `X-Apple-I-Locale`, `x-apple-i-device-type`, the `x-apple-baa-*` set) under an `invitation-registration` user-agent, while this implementation posts body-only; but that captured request is the CDN-served static step-1 cert, which is exactly why it succeeds header-less here, so the capture neither requires nor acquits a header set at step 2. No archived capture contains the step-2 POST (every `.mitm` and every plist in `reference/imessage-captures/` was searched for the request's key). Next levers, in order: (1) live — one faithful step-2 POST (no content-type) plus ONE deliberate control POST whose body is not the session-info-request plist, to learn whether the endpoint examines the body at all (control identical to the 5003 shape ⇒ the refusal precedes the body, so framing is not the cause; control different ⇒ the body is parsed, so framing is the cause); (2) a genuine-client wire capture of the exchange, still the only way to READ the real framing — the Mac-side client performs it in software from exported identifiers, so the capture host is whatever machine runs a client that completes the exchange (the identifier-export helper alone never performs it). + - 2026-09-11 (rev 20, live control experiment): the BODY half of this item is CLOSED. A deliberate control — 20 bytes that are not a plist at all, POSTed to the same live step-2 URL with the same (absent) content-type — was answered exactly like the reconstruction: HTTP 200, 498 bytes, `status` 5003, same nine token classes. The live step-2 URL the bag serves is `https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/initializeValidation`. Consequences: (1) the invented TLV framing, the wrapper encoding, and the step-2 content-type are all retired as causes — the endpoint refuses WITHOUT examining the body, so no further body flip can arbitrate anything; (2) the rev-18 "byte-identical under five variants" reading is corrected: the trace carries a live per-request epoch (the two rev-20 runs' epochs sit 28 s apart, matching the gap between them) and three per-request uuids, so those responses were shape-identical, not byte-identical — and the rev-19 inference built on that reading is superseded by this experiment; (3) the surviving suspect class is the request ENVELOPE: the header set a genuine client carries (the anisette + BAA set, §1.1/C6) or an identity/attestation the endpoint resolves before parsing; (4) next levers, ranked: a one-call envelope probe with a plausible client header set (a UA, `X-Apple-I-Client-Time`/`TimeZone`/`Locale`, `X-Mme-Device-Id` — none of which needs the OTP derivation that is still missing), then the genuine-client wire capture, which is now the only way to see the real envelope. Instrumentation note (dev-only, uncommitted): the live runner dumps the exact request bytes mode-0600 beside the blob and classifies the server trace token-by-token (shape plus boolean match against our own digests/identifiers), so every attempt is post-mortem-analyzable without spending another call. + - 2026-09-11 (rev 21, offline re-read + cross-checks — this SUPERSEDES the rev-20 conclusion that the body half is closed): (a) the rev-20 experiment cannot prove the endpoint ignores the body. Both requests it compared were invalid — the 20-byte control is not a plist at all, and our reconstruction's `session-info-request` is the invented TLV v1 blob, emitted in place of the closed-source library's real output — so "one generic 5003 for both" is equally consistent with "the server parses each, rejects each, and reports a single generic code". Retiring the TLV framing is therefore withdrawn: framing is live again, and it is now the leading suspect, because the reference client's envelope is body-only (below). Content-type and the step-2 crypto *values* stay retired (they were flipped and changed nothing). (b) 5003 = `IDSStatusBadRequest` in the IDS status space [IG:imessage/direct/ids/types/idserrors.gen.go]; that family has dedicated body-level codes (`ParseFailure` 1000, `InvalidField` 1001, `DecodeFailureBase64`/`Hex` 1004/1005, `MissingRequiredKey` 5008) and none came back — weak evidence for a pre-body rejection, equally explicable by this endpoint simply not using the fine-grained codes; not decisive either way. (c) The model client is current and still body-only: `reference/rustpush` IS the OpenBubbles fork (`origin` github.com/OpenBubbles/rustpush), re-fetched 2026-09-11 with a tip identical to the staged snapshot; its step-2 call remains `REQWEST.post(bag_url).body(xml_plist)`, no content-type, `Accept-Language: en-US,en;q=0.9` the sole default header (`src/macos.rs`, `src/util.rs`). If that client completes the exchange today, headers are not the cause — and the two envelopes then differ only at the HTTP layer (client stack, protocol version, header casing). (d) Route confirmed against a genuine device: the archived 2026-09-10 capture's `id-initialize-validation` bag value is byte-identical to the URL the live run posted to. (e) The genuine bag enables `absinthe` attestation on this endpoint family (`absinthe-baa-option-enabled`, `-ios13`, `absinthe-v3-enabled` all true) — the one mechanism that could still demand an envelope this project cannot currently produce. (f) SED trace grammar from the two recorded responses: `SED:3:VEN-PROD:::::<64-hex>:EM`; every uuid and the epoch differ between the runs (the epochs 28 s apart, matching the calls) and the 64-hex differs too and matches no digest of the recorded body, the empty string, or the step-2 URL — per-request state, not a hash of our input. (g) Ranked next levers: the genuine-client capture, now valued primarily for the real `session-info-request` BLOB (plus, in the same exchange, the real step-3 validation blob) rather than for the header set; then, only if the capture shows a richer envelope, the envelope probe. The capture needs a client that *performs* the exchange (an OpenBubbles app or a rustpush-based bridge — the macOS identifier-export helper never does); one is reported available. diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts new file mode 100644 index 0000000..4902558 --- /dev/null +++ b/engine/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + id("com.android.library") // version inherited from the root classpath (AGP 8.12.3) + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "dev.fenn.imessage.engine" + compileSdk = 36 + + defaultConfig { + minSdk = 34 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // The ported components are part of :engine's public API surface (Engine exposes + // IdsStore, CourierClient config/frames, LookupConfig, envelope types directly). + api(project(":codec")) + api(project(":courier")) + api(project(":ids")) + api(project(":crypto")) + api(project(":registration")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) +} + +// Host-side live-probe runner (explicit network use only — never run by `test`). +// Usage from the workspace root: +// tools/build --force --dir imessage :engine:runProbe +// Optional fixture dir override: -PprobeArgs=codec/src/test/resources/imessage +val probeRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + probeRuntime(libs.kotlinx.coroutines.core) + probeRuntime(project(":codec", configuration = "runtimeElements")) + probeRuntime(project(":ids", configuration = "runtimeElements")) +} + +tasks.register("runProbe") { + group = "probe" + description = "Run the authorized live IDS/APNs bag + courier TLS probes (GETs only)." + dependsOn("compileDebugUnitTestKotlin") + mainClass.set("dev.fenn.imessage.engine.probe.ProbeMainKt") + classpath = files( + probeRuntime, + layout.buildDirectory.dir("tmp/kotlin-classes/debug"), + layout.buildDirectory.dir("tmp/kotlin-classes/debugUnitTest"), + ) + workingDir = rootDir + args = (findProperty("probeArgs") as String?)?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() +} diff --git a/engine/src/main/kotlin/dev/fenn/imessage/engine/CourierConnection.kt b/engine/src/main/kotlin/dev/fenn/imessage/engine/CourierConnection.kt new file mode 100644 index 0000000..e08599f --- /dev/null +++ b/engine/src/main/kotlin/dev/fenn/imessage/engine/CourierConnection.kt @@ -0,0 +1,87 @@ +package dev.fenn.imessage.engine + +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierFrame +import java.io.Closeable +import java.io.IOException +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * The coroutine lifecycle over [CourierClient]: TLS socket construction (the pinned trust + * managers plus the optional Albert client certificate as [KeyManager]s — the handshake + * client-certificate question is C18-open), one long-lived [CourierClient.run] session, and + * a [state] flow for the app shell's UI/FGS. No Android service code — the FGS wiring is the + * app shell's; this class only owns the connection coroutine. + * + * Trigger wiring lives with the host: the courier handler forwards frames to the engine's + * frame pipeline ([CourierFrame]s — tunnel replies feed [dev.fenn.imessage.ids.CourierTunnelTransport.onFrame], + * send frames on the private-IDS topic feed [dev.fenn.imessage.ids.PrivateIdsPush]), and + * [dev.fenn.imessage.registration.IdsRenewal.onPrivateIdsCommand] consumes the command counts. + */ +class CourierConnection( + private val courierConfig: CourierClient.Config, + trustManagers: Array, + keyManagers: Array = emptyArray(), +) : Closeable { + + enum class State { DISCONNECTED, CONNECTING, CONNECTED } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val sslContext = SSLContext.getInstance("TLS").apply { init(keyManagers, trustManagers, null) } + private val client = CourierClient(courierConfig) { tcpSocket, sniHost -> + sslContext.socketFactory.createSocket(tcpSocket, sniHost, tcpSocket.port, true) as SSLSocket + } + private var job: Job? = null + private val _state = MutableStateFlow(State.DISCONNECTED) + val state: StateFlow = _state.asStateFlow() + + /** + * Starts (or restarts) the connect/read/keepalive/reconnect loop; inbound frames go to + * [handler.onFrame], [handler.onConnected] fires per established connection. Idempotent + * while running. + */ + fun start(handler: CourierClient.Handler) { + if (job?.isActive == true) return + _state.value = State.CONNECTING + job = scope.launch { + try { + client.run(ForwardingHandler(handler)) + } finally { + _state.value = State.DISCONNECTED + } + } + } + + /** Cancels the session (closes the socket with it). Suspend: waits for the loop to exit. */ + suspend fun stop() { + job?.let { job -> job.cancel(); job.join() } + _state.value = State.DISCONNECTED + } + + fun sendFrame(frame: ByteArray) = client.sendFrame(frame) + + override fun close() = scope.cancel() + + private inner class ForwardingHandler( + private val delegate: CourierClient.Handler, + ) : CourierClient.Handler { + override fun onConnected() { + _state.value = State.CONNECTED + delegate.onConnected() + } + + override fun onFrame(frame: CourierFrame.Frame) = delegate.onFrame(frame) + } +} diff --git a/engine/src/main/kotlin/dev/fenn/imessage/engine/Engine.kt b/engine/src/main/kotlin/dev/fenn/imessage/engine/Engine.kt new file mode 100644 index 0000000..0969f8c --- /dev/null +++ b/engine/src/main/kotlin/dev/fenn/imessage/engine/Engine.kt @@ -0,0 +1,89 @@ +package dev.fenn.imessage.engine + +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.CourierTunnelTransport +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.LookupConfig +import dev.fenn.imessage.ids.LookupTransport +import dev.fenn.imessage.registration.IdsKeyPairs +import dev.fenn.imessage.registration.IdsRenewal +import dev.fenn.imessage.registration.IdsStore +import dev.fenn.imessage.registration.TieredIdentityCachePolicy +import java.io.Closeable +import java.io.File +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager + +/** + * The composition root over the ported engine components: HTTP transport, bag fetch, the + * JSON-file store, the keystore key seam, the renewal scheduler, and the courier connection + * factory — behind one injected [Config]. Everything is offline-testable: point [Config] + * .idsBagUrl/[Config].apnsBagUrl at a local mock and hand a temp [Config].workingDir. + * + * NOT wired here (app-shell composition, recorded as remaining): + * - the actual re-registration exchange ([reRegister] is injected — the live path needs the + * validation-data generation against real endpoints, C5/C6-blocked); + * - the foreground-service tick that calls [renewal].onTimer and the courier handler that + * feeds tunnel replies / private-IDS pushes into the lookup + renewal pipeline; + * - the activation flow UI (IdsActivation consumes [keyPairs] + the store directly). + */ +class Engine( + val config: Config, + private val reRegister: suspend (reason: String) -> Result = { + Result.failure(IllegalStateException("re-registration is not wired (app-shell composition)")) + }, +) : Closeable { + + class Config( + /** The engine's state directory — the app shell passes `filesDir` (or a subdir). */ + val workingDir: File, + /** Endpoint overrides for tests; production keeps the recorded bag URLs. */ + val idsBagUrl: String = IdsBagFetcher.IDS_BAG_URL, + val apnsBagUrl: String = IdsBagFetcher.APNS_BAG_URL, + /** Null = platform trust (tests, plain HTTP); production passes AppleTrust's context. */ + val sslContext: SSLContext? = null, + val connectTimeoutMs: Int = 30_000, + val requestTimeoutMs: Int = 60_000, + ) + + val http: IdsHttp = JavaNetIdsHttp( + sslContext = config.sslContext, + connectTimeoutMs = config.connectTimeoutMs, + requestTimeoutMs = config.requestTimeoutMs, + ) + val bagFetcher = IdsBagFetcher(http, config.idsBagUrl, config.apnsBagUrl) + val store = IdsStore(FileIdsStateDao(config.workingDir), TieredIdentityCachePolicy) + val keyPairs: IdsKeyPairs = KeystoreIdsKeyPairs() + val renewal = IdsRenewal(reRegister = reRegister) + + /** The courier connection over [AppleTrust]'s courier anchor (§3.1 [CAP-COURIERTLS]); the + * Albert client certificate rides as a KeyManager when the app shell supplies one (C18). */ + fun courierConnection( + courierConfig: CourierClient.Config, + keyManagers: Array = emptyArray(), + trustManagers: Array = arrayOf(AppleTrust.courierTrustManager()), + ): CourierConnection = CourierConnection(courierConfig, trustManagers, keyManagers) + + /** A lookup transport over a started courier connection (spec §2.1's tunnel path). */ + fun lookupTransport(fieldIds: CourierCommands.TunnelFieldIds, connection: CourierConnection): LookupTransport = + CourierTunnelTransport( + fieldIds, + sendFrame = { frame -> + connection.sendFrame(CourierFrame.encode(frame.command, frame.fields.map { it.id to it.value })) + }, + ) + + /** Convenience: the signed lookup client against the fetched bag (spec §2.1, C47 headers). */ + suspend fun lookupClient(transport: LookupTransport, config: LookupConfig): IdsLookupClient = + IdsLookupClient(transport, bagFetcher.idsBag(), config) + + override fun close() { + http.close() + } +} diff --git a/engine/src/main/kotlin/dev/fenn/imessage/engine/FileIdsStateDao.kt b/engine/src/main/kotlin/dev/fenn/imessage/engine/FileIdsStateDao.kt new file mode 100644 index 0000000..967a586 --- /dev/null +++ b/engine/src/main/kotlin/dev/fenn/imessage/engine/FileIdsStateDao.kt @@ -0,0 +1,272 @@ +package dev.fenn.imessage.engine + +import dev.fenn.imessage.registration.IdsAccountEntity +import dev.fenn.imessage.registration.IdsCachedIdentityEntity +import dev.fenn.imessage.registration.IdsCachedResultEntity +import dev.fenn.imessage.registration.IdsCachedResultWithIdentities +import dev.fenn.imessage.registration.IdsRegisteredUserEntity +import dev.fenn.imessage.registration.IdsStateDao +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.Base64 +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put + +/** + * JSON-file-backed [IdsStateDao] — the storage seam behind [IdsStore] for the JVM/engine + * layer (the originating repo's Room implementation is the Android counterpart). One state + * file under [dir]: every mutation rewrites it atomically (temp file + [move] — a crash + * before the move leaves the previous state on disk). [dir] is injected — callers hand the + * app's `filesDir`; tests hand a temp directory. Concurrency: a single mutex serializes + * read-modify-write; the file is the source of truth for a fresh instance. + * + * JSON via kotlinx-serialization's JsonObject builders (no `@Serializable` on the ported + * entities — they are plain data classes from the clean-room port; byte fields ride base64). + */ +class FileIdsStateDao( + dir: File, + /** Injectable for tests: the final publish step (temp file → state file). */ + private val move: (Path, Path) -> Unit = { source, target -> + runCatching { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE) } + .getOrElse { Files.move(source, target, StandardCopyOption.REPLACE_EXISTING) } + }, +) : IdsStateDao { + + private val stateFile = File(dir.apply { mkdirs() }, "ids-state.json") + private val mutex = Mutex() + private val json = Json { ignoreUnknownKeys = true } + private var state: StoredState? = null + + private class StoredState { + var account: IdsAccountEntity? = null + val users = LinkedHashMap() + val results = LinkedHashMap() + val identities = LinkedHashMap>() + } + + // -- IdsStateDao ----------------------------------------------------------------------- + + override suspend fun account(): IdsAccountEntity? = mutex.withLock { load().account } + + override suspend fun upsertAccount(entity: IdsAccountEntity) = mutate { it.account = entity } + + override suspend fun registeredUsers(): List = + mutex.withLock { load().users.values.toList() } + + override suspend fun registeredUser(userId: String): IdsRegisteredUserEntity? = + mutex.withLock { load().users[userId] } + + override suspend fun upsertUser(user: IdsRegisteredUserEntity) = mutate { it.users[user.userId] = user } + + override suspend fun removeUser(userId: String) = mutate { it.users.remove(userId) } + + override suspend fun cachedResult(handle: String): IdsCachedResultWithIdentities? = mutex.withLock { + val state = load() + state.results[handle]?.let { IdsCachedResultWithIdentities(it, state.identities[handle].orEmpty()) } + } + + override suspend fun cachedHandles(): List = mutex.withLock { load().results.keys.toList() } + + override suspend fun upsertCachedResult(result: IdsCachedResultEntity) = + mutate { it.results[result.handle] = result } + + override suspend fun upsertCachedIdentities(identities: List) = mutate { state -> + identities.forEach { identity -> + state.identities.getOrPut(identity.handle) { mutableListOf() }.add(identity) + } + } + + override suspend fun deleteCachedResult(handle: String) = mutate { + it.results.remove(handle) + it.identities.remove(handle) + } + + override suspend fun deleteCacheExcept(key: String) = mutate { state -> + state.results.entries.removeIf { it.value.cacheKeySha1 != key } + state.identities.keys.retainAll(state.results.keys) + } + + override suspend fun clearCache() = mutate { + it.results.clear() + it.identities.clear() + } + + // -- storage ---------------------------------------------------------------------------- + + private fun load(): StoredState { + state?.let { return it } + val loaded = StoredState() + if (stateFile.exists()) { + val root = json.parseToJsonElement(stateFile.readText()).jsonObject + root["account"]?.let { if (it !is JsonNull) loaded.account = accountOf(it.jsonObject) } + root["users"]?.jsonArray?.forEach { user -> + val entity = userOf(user.jsonObject) + loaded.users[entity.userId] = entity + } + root["cacheResults"]?.jsonArray?.forEach { result -> + val entity = resultOf(result.jsonObject) + loaded.results[entity.handle] = entity + } + root["cacheIdentities"]?.jsonArray?.forEach { identity -> + val entity = identityOf(identity.jsonObject) + loaded.identities.getOrPut(entity.handle) { mutableListOf() }.add(entity) + } + } + state = loaded + return loaded + } + + private suspend fun mutate(update: (StoredState) -> Unit) = mutex.withLock { + val current = load() + update(current) + persist(current) + } + + /** Temp file + [move] — a crash before the move leaves the previous state on disk. */ + private fun persist(state: StoredState) { + val temp = File(stateFile.parentFile, stateFile.name + ".tmp") + temp.writeBytes(json.encodeToString(JsonObject.serializer(), encode(state)).toByteArray()) + move(temp.toPath(), stateFile.toPath()) + } + + private fun encode(state: StoredState): JsonObject = buildJsonObject { + put("account", state.account?.let(::accountJson) ?: JsonNull) + put("users", JsonArray(state.users.values.map(::userJson))) + put("cacheResults", JsonArray(state.results.values.map(::resultJson))) + put("cacheIdentities", JsonArray(state.identities.values.flatten().map(::identityJson))) + } + + // -- entity JSON mapping ------------------------------------------------------------------ + + private fun accountJson(e: IdsAccountEntity): JsonObject = buildJsonObject { + put("id", e.id) + put("pushCertDer", e.pushCertDer) + put("pushCertChainJson", e.pushCertChainJson) + put("apnsConnectToken", e.apnsConnectToken) + put("macSerial", e.macSerial) + put("macBoardId", e.macBoardId) + put("macUuid", e.macUuid) + put("macMlb", e.macMlb) + put("macRom", e.macRom) + put("macModel", e.macModel) + put("macBuild", e.macBuild) + put("delegateAuthToken", e.delegateAuthToken) + put("delegateProfileId", e.delegateProfileId) + put("delegateRefreshedAtEpochMs", e.delegateRefreshedAtEpochMs) + put("lastRegistrationEpochMs", e.lastRegistrationEpochMs) + put("lastRegisterRawPlist", e.lastRegisterRawPlist) + put("adiMachineTokensPlist", e.adiMachineTokensPlist) + put("adiProvisionedAtEpochMs", e.adiProvisionedAtEpochMs) + put("gsaUsername", e.gsaUsername) + put("gsaPasswordHash", e.gsaPasswordHash) + put("gsaAdsId", e.gsaAdsId) + put("gsaPetToken", e.gsaPetToken) + put("gsaPetExpiresAtEpochMs", e.gsaPetExpiresAtEpochMs) + } + + private fun accountOf(o: JsonObject): IdsAccountEntity = IdsAccountEntity( + id = o.intAt("id") ?: 0, + pushCertDer = o.bytesAt("pushCertDer"), + pushCertChainJson = o.stringAt("pushCertChainJson"), + apnsConnectToken = o.bytesAt("apnsConnectToken"), + macSerial = o.stringAt("macSerial"), + macBoardId = o.stringAt("macBoardId"), + macUuid = o.stringAt("macUuid"), + macMlb = o.stringAt("macMlb"), + macRom = o.stringAt("macRom"), + macModel = o.stringAt("macModel"), + macBuild = o.stringAt("macBuild"), + delegateAuthToken = o.stringAt("delegateAuthToken"), + delegateProfileId = o.stringAt("delegateProfileId"), + delegateRefreshedAtEpochMs = o.longAt("delegateRefreshedAtEpochMs"), + lastRegistrationEpochMs = o.longAt("lastRegistrationEpochMs"), + lastRegisterRawPlist = o.bytesAt("lastRegisterRawPlist"), + adiMachineTokensPlist = o.bytesAt("adiMachineTokensPlist"), + adiProvisionedAtEpochMs = o.longAt("adiProvisionedAtEpochMs"), + gsaUsername = o.stringAt("gsaUsername"), + gsaPasswordHash = o.bytesAt("gsaPasswordHash"), + gsaAdsId = o.stringAt("gsaAdsId"), + gsaPetToken = o.stringAt("gsaPetToken"), + gsaPetExpiresAtEpochMs = o.longAt("gsaPetExpiresAtEpochMs"), + ) + + private fun userJson(e: IdsRegisteredUserEntity): JsonObject = buildJsonObject { + put("userId", e.userId) + put("status", e.status) + put("identityCertDer", e.identityCertDer) + put("identityCertNotAfterEpochMs", e.identityCertNotAfterEpochMs) + put("authCertDer", e.authCertDer) + put("urisJson", e.urisJson) + put("nextHbiSeconds", e.nextHbiSeconds) + put("registeredAtEpochMs", e.registeredAtEpochMs) + } + + private fun userOf(o: JsonObject): IdsRegisteredUserEntity = IdsRegisteredUserEntity( + userId = o.stringAt("userId")!!, + status = o.intAt("status"), + identityCertDer = o.bytesAt("identityCertDer")!!, + identityCertNotAfterEpochMs = o.longAt("identityCertNotAfterEpochMs")!!, + authCertDer = o.bytesAt("authCertDer"), + urisJson = o.stringAt("urisJson")!!, + nextHbiSeconds = o.longAt("nextHbiSeconds"), + registeredAtEpochMs = o.longAt("registeredAtEpochMs")!!, + ) + + private fun resultJson(e: IdsCachedResultEntity): JsonObject = buildJsonObject { + put("handle", e.handle) + put("status", e.status) + put("fetchedAtEpochMs", e.fetchedAtEpochMs) + put("cacheKeySha1", e.cacheKeySha1) + put("rawPlist", e.rawPlist) + } + + private fun resultOf(o: JsonObject): IdsCachedResultEntity = IdsCachedResultEntity( + handle = o.stringAt("handle")!!, + status = o.intAt("status")!!, + fetchedAtEpochMs = o.longAt("fetchedAtEpochMs")!!, + cacheKeySha1 = o.stringAt("cacheKeySha1")!!, + rawPlist = o.bytesAt("rawPlist")!!, + ) + + private fun identityJson(e: IdsCachedIdentityEntity): JsonObject = buildJsonObject { + put("handle", e.handle) + put("deviceIndex", e.deviceIndex) + put("expiresSeconds", e.expiresSeconds) + put("refreshSeconds", e.refreshSeconds) + put("rawPlist", e.rawPlist) + } + + private fun identityOf(o: JsonObject): IdsCachedIdentityEntity = IdsCachedIdentityEntity( + handle = o.stringAt("handle")!!, + deviceIndex = o.intAt("deviceIndex")!!, + expiresSeconds = o.longAt("expiresSeconds"), + refreshSeconds = o.longAt("refreshSeconds"), + rawPlist = o.bytesAt("rawPlist")!!, + ) + + // -- JSON field helpers (stdlib put(String, String?/Number?) covers the rest) ------------- + + private fun JsonObject.stringAt(key: String): String? = primitiveAt(key)?.content + private fun JsonObject.longAt(key: String): Long? = primitiveAt(key)?.content?.toLong() + private fun JsonObject.intAt(key: String): Int? = longAt(key)?.toInt() + private fun JsonObject.bytesAt(key: String): ByteArray? = + primitiveAt(key)?.content?.let { Base64.getDecoder().decode(it) } + + private fun JsonObject.primitiveAt(key: String): JsonPrimitive? = + (this[key] as? JsonPrimitive)?.takeIf { it !is JsonNull } + + private fun JsonObjectBuilder.put(key: String, value: ByteArray?) = + put(key, value?.let { JsonPrimitive(Base64.getEncoder().encodeToString(it)) } ?: JsonNull) +} diff --git a/engine/src/main/kotlin/dev/fenn/imessage/engine/JavaNetIdsHttp.kt b/engine/src/main/kotlin/dev/fenn/imessage/engine/JavaNetIdsHttp.kt new file mode 100644 index 0000000..f3aedbd --- /dev/null +++ b/engine/src/main/kotlin/dev/fenn/imessage/engine/JavaNetIdsHttp.kt @@ -0,0 +1,106 @@ +package dev.fenn.imessage.engine + +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URI +import java.net.URL +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The production [IdsHttp] on the JDK/platform `HttpURLConnection` stack — zero new + * dependencies, works on the JVM (tests) and on Android (app shell). + * + * Port note (task deviation, deliberate): the task named `java.net.http.HttpClient`, but + * `java/net/http` is absent from android.jar (checked platforms android-34/-36) — the class + * cannot compile into an Android library and would not run on the device. The platform's + * `HttpsURLConnection` is the equivalent zero-dependency stack; everything else (constructor + * shape, the SSLContext injection, the `appleTrustClient()` factory) follows the task. + * + * Redirects are never followed — a failed IDS call is the caller's decision (the + * originating `AppleIdsHttp`'s posture). TLS: when [sslContext] is given, its socket factory + * is applied per connection (callers pass `AppleTrust`'s pinned pool, §1.2 [CAP-IDSTLS]); + * null keeps the platform default (tests' plain HTTP is untouched by TLS either way). + */ +class JavaNetIdsHttp( + private val sslContext: SSLContext? = null, + private val connectTimeoutMs: Int = 30_000, + private val requestTimeoutMs: Int = 60_000, +) : IdsHttp { + + override suspend fun get(url: String, headers: Map): IdsHttpResponse = + exchange("GET", url, headers, body = null, contentType = null) + + override suspend fun put( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse = exchange("PUT", url, headers, body, contentType) + + override suspend fun post( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse = exchange("POST", url, headers, body, contentType) + + override fun close() {} + + private suspend fun exchange( + method: String, + url: String, + headers: Map, + body: ByteArray?, + contentType: String?, + ): IdsHttpResponse = withContext(Dispatchers.IO) { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = this@JavaNetIdsHttp.connectTimeoutMs + readTimeout = requestTimeoutMs + instanceFollowRedirects = false + headers.forEach { (name, value) -> setRequestProperty(name, value) } + if (body != null) { + doOutput = true + if (!contentType.isNullOrEmpty()) setRequestProperty("Content-Type", contentType) + setFixedLengthStreamingMode(body.size) + } + (this as? HttpsURLConnection)?.let { https -> + sslContext?.let { https.sslSocketFactory = it.socketFactory } + } + } + try { + if (body != null) connection.outputStream.use { it.write(body) } + val status = connection.responseCode + val responseHeaders = connection.headerFields + .filterKeys { it != null } + .entries.associate { (name, values) -> + name!!.lowercase() to values.joinToString(", ") + } + val responseBody = (if (status in 200..299) connection.inputStream else connection.errorStream) + ?.use { it.readBytes() } ?: ByteArray(0) + IdsHttpResponse(status, responseHeaders, responseBody) + } finally { + connection.disconnect() + } + } + + companion object { + /** Convenience factory: the IDS pinned-Apple-roots client of §1.2 [CAP-IDSTLS]. */ + fun appleTrustClient( + connectTimeoutMs: Int = 30_000, + requestTimeoutMs: Int = 60_000, + ): JavaNetIdsHttp = JavaNetIdsHttp( + sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(AppleTrust.defaultTrustManager()), null) + }, + connectTimeoutMs = connectTimeoutMs, + requestTimeoutMs = requestTimeoutMs, + ) + } +} diff --git a/engine/src/main/kotlin/dev/fenn/imessage/engine/KeystoreIdsKeyPairs.kt b/engine/src/main/kotlin/dev/fenn/imessage/engine/KeystoreIdsKeyPairs.kt new file mode 100644 index 0000000..cc2ab54 --- /dev/null +++ b/engine/src/main/kotlin/dev/fenn/imessage/engine/KeystoreIdsKeyPairs.kt @@ -0,0 +1,73 @@ +package dev.fenn.imessage.engine + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import dev.fenn.imessage.registration.IdsKeyPairs +import dev.fenn.imessage.registration.IdsKeySpec +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.spec.ECGenParameterSpec + +/** + * AndroidKeyStore-backed [IdsKeyPairs]: keys are generated and held inside the keystore — + * callers get signing/decryption handles and public keys, never key material (§1.4: private + * keys are generated and stored locally and never leave the device). + * + * Digest/padding sets are the narrowest covering the recorded constructions: SHA-1-PKCS1 + * for the §1.4 RSA signatures, SHA-1-OAEP for the §4.2 transport block, SHA-256 for the + * §4.3 ECDSA. Keys are generated once per alias and reused ([IdsKeys.create]'s contract — + * re-registration does not mint new keys, §6.1); AndroidKeyStore issues the self-signed + * attestation certificate itself, so no certificate material is built here (a CSR-capable + * certificate chain export, if the app shell ever needs one, is a later concern — the + * registration seam consumes [KeyPair]s only). + * + * JVM unit tests cannot touch AndroidKeyStore — this class is intentionally thin and gets + * verification in the app-shell/emulator step; the software stand-in lives in :registration's + * tests. + */ +class KeystoreIdsKeyPairs( + private val provider: String = "AndroidKeyStore", +) : IdsKeyPairs { + + override fun getOrCreate(alias: String, spec: IdsKeySpec): KeyPair { + val store = KeyStore.getInstance(provider).apply { load(null) } + (store.getEntry(alias, null) as? KeyStore.PrivateKeyEntry)?.let { entry -> + return KeyPair(entry.certificate.publicKey, entry.privateKey) + } + return when (spec) { + is IdsKeySpec.Rsa -> generate(alias, "RSA", rsaSpec(alias, spec)) + is IdsKeySpec.Ec -> generate(alias, "EC", ecSpec(alias)) + } + } + + private fun generate(alias: String, algorithm: String, keySpec: KeyGenParameterSpec): KeyPair { + val generator = KeyPairGenerator.getInstance(algorithm, provider) + generator.initialize(keySpec) + return generator.generateKeyPair() + } + + private fun rsaSpec(alias: String, spec: IdsKeySpec.Rsa): KeyGenParameterSpec { + val purposes = if (spec.forEncryption) { + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + } else { + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY + } + val builder = KeyGenParameterSpec.Builder(alias, purposes).setKeySize(spec.bits) + return if (spec.forEncryption) { + builder.setDigests(KeyProperties.DIGEST_SHA1) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .build() + } else { + builder.setDigests(KeyProperties.DIGEST_SHA1) + .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + .build() + } + } + + private fun ecSpec(alias: String): KeyGenParameterSpec = + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setDigests(KeyProperties.DIGEST_SHA256) + .build() +} diff --git a/engine/src/test/kotlin/dev/fenn/imessage/engine/CourierConnectionTest.kt b/engine/src/test/kotlin/dev/fenn/imessage/engine/CourierConnectionTest.kt new file mode 100644 index 0000000..87ad2e3 --- /dev/null +++ b/engine/src/test/kotlin/dev/fenn/imessage/engine/CourierConnectionTest.kt @@ -0,0 +1,49 @@ +package dev.fenn.imessage.engine + +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.ids.AppleTrust +import java.net.ServerSocket +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +/** What's mockable without a TLS server: lifecycle states and the not-connected guard. */ +class CourierConnectionTest { + + /** A local port with no listener — TCP connect fails fast, no network, no TLS. */ + private fun closedPort(): Int = ServerSocket(0).use { it.localPort } + + private fun connection(port: Int) = CourierConnection( + CourierClient.Config("courier.push.apple.com", port = port, backoffCapSecs = 1), + trustManagers = arrayOf(AppleTrust.courierTrustManager()), + ) + + @Test + fun `starts disconnected and reconnects when nothing listens then stops cleanly`() = runBlocking { + val connection = connection(closedPort()) + assertEquals(CourierConnection.State.DISCONNECTED, connection.state.value) + connection.start(object : CourierClient.Handler {}) + try { + // The connect attempt fails (nothing listens) — the loop retries with backoff. + withTimeout(2_000) { + while (connection.state.value == CourierConnection.State.DISCONNECTED) delay(20) + } + assertEquals(CourierConnection.State.CONNECTING, connection.state.value) + } finally { + connection.stop() + } + assertEquals(CourierConnection.State.DISCONNECTED, connection.state.value) + connection.close() + } + + @Test + fun `sending before a connection exists fails loudly`() { + val connection = connection(closedPort()) + assertFailsWith { + connection.sendFrame(byteArrayOf(12, 0, 0, 0, 0)) + } + } +} diff --git a/engine/src/test/kotlin/dev/fenn/imessage/engine/EngineTest.kt b/engine/src/test/kotlin/dev/fenn/imessage/engine/EngineTest.kt new file mode 100644 index 0000000..b84c0bd --- /dev/null +++ b/engine/src/test/kotlin/dev/fenn/imessage/engine/EngineTest.kt @@ -0,0 +1,92 @@ +package dev.fenn.imessage.engine + +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.registration.idsRegisteredUser +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** + * The composition root against a local mock bag server (offline): bag fetch through the + * engine's HTTP seam, store round-trip through the JSON-file seam, and the renewal host-tick + * hook. No Apple hosts. + */ +class EngineTest { + + private var server: LocalHttpServer? = null + + @AfterTest + fun tearDown() { + server?.stop() + } + + private fun serveBag(): Int { + // A recorded-shape IDS bag: outer signature/certs/bag, the embedded `bag` data itself + // a plist (§1.2 [CAP-BAG]) — built with the :codec writer, served over plain HTTP. + val inner = XmlPlist.encode(mapOf("id-register" to "https://register.example/wa/register")) + val outer = XmlPlist.encode( + mapOf("signature" to ByteArray(8), "certs" to listOf(ByteArray(4)), "bag" to inner), + ) + LocalHttpServer { _ -> LocalHttpServer.Response(200, body = outer) }.also { + server = it; it.start() + } + return server!!.port + } + + private fun tempDir() = kotlin.io.path.createTempDirectory("engine").toFile() + + @Test + fun `bag fetch flows through the engine's http seam`() = runBlocking { + val url = "http://127.0.0.1:${serveBag()}/bag" + Engine(Engine.Config(workingDir = tempDir(), idsBagUrl = url, apnsBagUrl = url)).use { engine -> + assertEquals("https://register.example/wa/register", engine.bagFetcher.idsBag().url("id-register")) + } + } + + @Test + fun `store state persists across engine instances on the same working dir`() = runBlocking { + val dir = tempDir() + Engine(Engine.Config(workingDir = dir)).use { engine -> + assertNull(engine.store.account()) + engine.store.updateAccount { it.copy(macSerial = "ENGINETEST", lastRegistrationEpochMs = 42L) } + } + Engine(Engine.Config(workingDir = dir)).use { engine -> + assertEquals("ENGINETEST", engine.store.account()!!.macSerial) + assertEquals(42L, engine.store.account()!!.lastRegistrationEpochMs) + } + } + + @Test + fun `renewal tick is wired through the facade with the injected re-register`() = runBlocking { + val attempts = mutableListOf() + val engine = Engine(Engine.Config(workingDir = tempDir())) { reason -> + attempts.add(reason) + Result.success(Unit) + } + // Nothing registered → the tick is a no-op. + val attempt = engine.renewal.onTimer(emptyList(), null) + assertEquals(false, attempt.attempted) + assertEquals("not registered", attempt.reason) + assertTrue(attempts.isEmpty()) + // With a due registration the host tick drives the injected re-register. + engine.store.recordRegistration( + idsRegisteredUser( + userId = "u", + status = 0, + // A real parseable DER — the store reads notAfter at record time. + identityCertDer = javaClass.getResourceAsStream("/imessage/AppleRootCA-G3.cer")!!.readBytes(), + uris = listOf("tel:+15551234567"), + registeredAtEpochMs = 0L, + nextHbiSeconds = 0, // deadline in the past + ), + ) + val due = engine.renewal.onTimer(engine.store.registeredUsers(), 0L) + assertEquals(true, due.attempted) + assertEquals(true, due.success) + assertEquals(listOf("timer"), attempts) + } +} diff --git a/engine/src/test/kotlin/dev/fenn/imessage/engine/FileIdsStateDaoTest.kt b/engine/src/test/kotlin/dev/fenn/imessage/engine/FileIdsStateDaoTest.kt new file mode 100644 index 0000000..9004873 --- /dev/null +++ b/engine/src/test/kotlin/dev/fenn/imessage/engine/FileIdsStateDaoTest.kt @@ -0,0 +1,129 @@ +package dev.fenn.imessage.engine + +import dev.fenn.imessage.registration.IdsAccountEntity +import dev.fenn.imessage.registration.IdsCachedIdentityEntity +import dev.fenn.imessage.registration.IdsCachedResultEntity +import dev.fenn.imessage.registration.idsRegisteredUser +import java.io.File +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** The JSON-file store seam: round-trips, cross-instance persistence, crash-atomicity. */ +class FileIdsStateDaoTest { + + private fun tempDir(): File = kotlin.io.path.createTempDirectory("ids-state").toFile() + + private fun user(id: String, registeredAt: Long = 1L) = idsRegisteredUser( + userId = id, + status = 0, + // A real parseable X.509 DER — idsRegisteredUser reads notAfter at store time. + identityCertDer = javaClass.getResourceAsStream("/imessage/AppleRootCA-G3.cer")!!.readBytes(), + uris = listOf("tel:+15551234567"), + registeredAtEpochMs = registeredAt, + nextHbiSeconds = 800_000, + ) + + @Test + fun `fresh store is empty`() = runBlocking { + val dao = FileIdsStateDao(tempDir()) + assertNull(dao.account()) + assertTrue(dao.registeredUsers().isEmpty()) + assertTrue(dao.cachedHandles().isEmpty()) + } + + @Test + fun `account round-trips every field including bytes`() = runBlocking { + val dir = tempDir() + val dao = FileIdsStateDao(dir) + val account = IdsAccountEntity( + macSerial = "EMUVERIFY", + apnsConnectToken = ByteArray(32) { (it * 3).toByte() }, + gsaPasswordHash = ByteArray(32) { 7 }, + lastRegistrationEpochMs = 1_700_000_000_000L, + gsaPetToken = "pet-token", + ) + dao.upsertAccount(account) + val read = dao.account()!! + assertEquals("EMUVERIFY", read.macSerial) + assertContentEquals(account.apnsConnectToken, read.apnsConnectToken) + assertContentEquals(account.gsaPasswordHash, read.gsaPasswordHash) + assertEquals(1_700_000_000_000L, read.lastRegistrationEpochMs) + assertEquals("pet-token", read.gsaPetToken) + + // A fresh instance over the same directory reads the persisted state. + val reopened = FileIdsStateDao(dir) + assertEquals("EMUVERIFY", reopened.account()!!.macSerial) + assertContentEquals(account.apnsConnectToken, reopened.account()!!.apnsConnectToken) + } + + @Test + fun `registered users upsert remove and persist`() = runBlocking { + val dir = tempDir() + val dao = FileIdsStateDao(dir) + dao.upsertUser(user("b")) + dao.upsertUser(user("a")) + assertEquals(listOf("b", "a"), dao.registeredUsers().map { it.userId }) // insertion order + dao.upsertUser(user("a", registeredAt = 2L)) + assertEquals(2L, dao.registeredUser("a")!!.registeredAtEpochMs) + dao.removeUser("b") + assertEquals(listOf("a"), dao.registeredUsers().map { it.userId }) + + assertEquals(listOf("a"), FileIdsStateDao(dir).registeredUsers().map { it.userId }) + } + + @Test + fun `lookup cache round-trips results with their device identities`() = runBlocking { + val dir = tempDir() + val dao = FileIdsStateDao(dir) + dao.upsertCachedResult( + IdsCachedResultEntity("tel:+15551234567", 200, 42L, "key-1", ByteArray(8) { 1 }), + ) + dao.upsertCachedIdentities( + listOf( + IdsCachedIdentityEntity("tel:+15551234567", 0, 3600, 1800, ByteArray(8) { 2 }), + IdsCachedIdentityEntity("tel:+15551234567", 1, 7200, 3600, ByteArray(8) { 3 }), + ), + ) + val cached = dao.cachedResult("tel:+15551234567")!! + assertEquals(200, cached.result.status) + assertEquals("key-1", cached.result.cacheKeySha1) + assertEquals(2, cached.identities.size) + assertEquals(7200L, cached.identities[1].expiresSeconds) + + val reopened = FileIdsStateDao(dir) + assertEquals(2, reopened.cachedResult("tel:+15551234567")!!.identities.size) + } + + @Test + fun `delete cache except keeps only the current key pair's entries`() = runBlocking { + val dao = FileIdsStateDao(tempDir()) + dao.upsertCachedResult(IdsCachedResultEntity("tel:+1", 200, 1L, "current", ByteArray(1))) + dao.upsertCachedResult(IdsCachedResultEntity("tel:+2", 200, 2L, "stale", ByteArray(1))) + dao.upsertCachedIdentities( + listOf(IdsCachedIdentityEntity("tel:+2", 0, 1, 1, ByteArray(1))), + ) + dao.deleteCacheExcept("current") + assertEquals(listOf("tel:+1"), dao.cachedHandles()) + assertNull(dao.cachedResult("tel:+2")) + } + + @Test + fun `a crash before the atomic move leaves the previous state on disk`() = runBlocking { + val dir = tempDir() + val dao = FileIdsStateDao(dir) + dao.upsertAccount(IdsAccountEntity(macSerial = "v1")) + + // The failing instance simulates a crash between the temp-file write and the move. + val crashing = FileIdsStateDao(dir) { _, _ -> throw IllegalStateException("crash") } + assertFailsWith { + crashing.upsertAccount(IdsAccountEntity(macSerial = "v2")) + } + // The temp file was written, the state file untouched: a fresh instance reads v1. + assertEquals("v1", FileIdsStateDao(dir).account()!!.macSerial) + } +} diff --git a/engine/src/test/kotlin/dev/fenn/imessage/engine/JavaNetIdsHttpTest.kt b/engine/src/test/kotlin/dev/fenn/imessage/engine/JavaNetIdsHttpTest.kt new file mode 100644 index 0000000..bfcd454 --- /dev/null +++ b/engine/src/test/kotlin/dev/fenn/imessage/engine/JavaNetIdsHttpTest.kt @@ -0,0 +1,94 @@ +package dev.fenn.imessage.engine + +import java.io.IOException +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** Offline HTTP round-trips against the minimal local server (no TLS needed for transport logic). */ +class JavaNetIdsHttpTest { + + private var server: LocalHttpServer? = null + + @AfterTest + fun tearDown() { + server?.stop() + } + + private fun startServer(handler: (LocalHttpServer.Request) -> LocalHttpServer.Response): Int { + LocalHttpServer(handler).also { server = it; it.start() } + return server!!.port + } + + @Test + fun `get carries headers and returns status body and lowercased headers`() = runBlocking { + val port = startServer { request -> + assertEquals("v1660", request.headers["x-protocol-version"]) + LocalHttpServer.Response(200, mapOf("X-Id-Status" to "ok"), "hello".toByteArray()) + } + val response = JavaNetIdsHttp().get( + "http://127.0.0.1:$port/ids", + headers = mapOf("x-protocol-version" to "v1660"), + ) + assertEquals(200, response.status) + assertContentEquals("hello".toByteArray(), response.body) + // Response header names are lower-cased (the [IdsHttpResponse] contract). + assertEquals("ok", response.headers["x-id-status"]) + } + + @Test + fun `post sends the body and content type and reads the response`() = runBlocking { + val port = startServer { request -> + assertContentEquals(byteArrayOf(1, 2, 3), request.body) + assertEquals("application/x-apple-plist", request.headers["content-type"]) + LocalHttpServer.Response(201, body = request.body) + } + val response = JavaNetIdsHttp().post( + "http://127.0.0.1:$port/post", + headers = emptyMap(), + body = byteArrayOf(1, 2, 3), + contentType = "application/x-apple-plist", + ) + assertEquals(201, response.status) + assertContentEquals(byteArrayOf(1, 2, 3), response.body) + } + + @Test + fun `put works and error statuses carry the body`() = runBlocking { + val port = startServer { request -> + assertEquals("PUT", request.method) + assertContentEquals(byteArrayOf(9, 8), request.body) + LocalHttpServer.Response(500, body = request.body) + } + val response = JavaNetIdsHttp().put( + "http://127.0.0.1:$port/put", + headers = mapOf("h" to "v"), + body = byteArrayOf(9, 8), + contentType = "text/plain", + ) + assertEquals(500, response.status) + assertContentEquals(byteArrayOf(9, 8), response.body) + } + + @Test + fun `a server-side delay past the read timeout surfaces as an IOException`() = runBlocking { + val port = startServer { _ -> + Thread.sleep(2_000) + LocalHttpServer.Response(200) + } + val http = JavaNetIdsHttp(requestTimeoutMs = 100) + assertFailsWith { http.get("http://127.0.0.1:$port/slow") } + assertTrue(true) + } + + @Test + fun `apple trust factory builds without touching the network`() { + // The factory exists so the app shell composes AppleTrust's pinned context without + // touching SSLContext itself. No TLS server here — construction only. + JavaNetIdsHttp.appleTrustClient() + } +} diff --git a/engine/src/test/kotlin/dev/fenn/imessage/engine/LocalHttpServer.kt b/engine/src/test/kotlin/dev/fenn/imessage/engine/LocalHttpServer.kt new file mode 100644 index 0000000..0a41fd6 --- /dev/null +++ b/engine/src/test/kotlin/dev/fenn/imessage/engine/LocalHttpServer.kt @@ -0,0 +1,97 @@ +package dev.fenn.imessage.engine + +import java.io.InputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import kotlin.concurrent.thread + +/** + * Minimal offline HTTP server for engine tests (the JDK's com.sun.net.httpserver is not on + * the unit-test compile classpath — android.jar bootclasspath). One request per connection + * (responds `Connection: close`); handler runs on a daemon accept loop. + */ +class LocalHttpServer(private val handler: (Request) -> Response) { + + class Request(val method: String, val path: String, val headers: Map, val body: ByteArray) + + class Response(val status: Int, val headers: Map = emptyMap(), val body: ByteArray = ByteArray(0)) + + private var serverSocket: ServerSocket? = null + + fun start() { + check(serverSocket == null) + val socket = ServerSocket(0, 50, InetAddress.getLoopbackAddress()) + serverSocket = socket + thread(isDaemon = true, name = "LocalHttpServer") { + while (true) { + val client = try { + socket.accept() + } catch (_: Exception) { + return@thread + } + thread(isDaemon = true) { serve(client) } + } + } + } + + val port: Int get() = serverSocket!!.localPort + + fun stop() { + serverSocket?.close() + } + + private fun serve(socket: Socket) { + try { + socket.use { s -> + val input = s.getInputStream() + val requestLine = input.readLine() ?: return + val headers = HashMap() + while (true) { + val line = input.readLine() + if (line.isNullOrEmpty()) break + val colon = line.indexOf(':') + if (colon > 0) headers[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim() + } + val contentLength = headers["content-length"]?.toIntOrNull() ?: 0 + val body = ByteArray(contentLength) + var read = 0 + while (read < contentLength) { + val n = input.read(body, read, contentLength - read) + if (n < 0) break + read += n + } + val response = handler( + Request( + requestLine.substringBefore(' '), + requestLine.substringAfter(' ').substringBefore(' '), + headers, + body, + ), + ) + val out = s.getOutputStream() + val head = buildString { + append("HTTP/1.1 ").append(response.status).append(" OK\r\n") + response.headers.forEach { (name, value) -> append(name).append(": ").append(value).append("\r\n") } + append("Content-Length: ").append(response.body.size).append("\r\n") + append("Connection: close\r\n\r\n") + } + out.write(head.toByteArray(Charsets.ISO_8859_1)) + out.write(response.body) + out.flush() + } + } catch (_: Exception) { + // client vanished mid-request — nothing to do for a test double + } + } + + private fun InputStream.readLine(): String? { + val out = StringBuilder() + while (true) { + val c = read() + if (c < 0) return if (out.isEmpty()) null else out.toString() + if (c == '\n'.code) return out.toString().trimEnd('\r') + out.append(c.toChar()) + } + } +} diff --git a/engine/src/test/kotlin/dev/fenn/imessage/engine/probe/ProbeMain.kt b/engine/src/test/kotlin/dev/fenn/imessage/engine/probe/ProbeMain.kt new file mode 100644 index 0000000..8336bdf --- /dev/null +++ b/engine/src/test/kotlin/dev/fenn/imessage/engine/probe/ProbeMain.kt @@ -0,0 +1,244 @@ +package dev.fenn.imessage.engine.probe + +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.engine.JavaNetIdsHttp +import java.net.InetSocketAddress +import java.security.MessageDigest +import java.security.cert.Certificate +import javax.security.auth.x500.X500Principal +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import kotlinx.coroutines.runBlocking + +/** + * Host-side live-probe runner — EXPLICIT network use only, never run by `test`. + * Invoke via `tools/build --force --dir imessage :engine:runProbe` (from /home/fenn/Repo/light-phone). + * + * Authorized probes (2026-09-28 go-ahead; GETs only, no registration/IDS/auth/GSA/albert hosts): + * 1. IDS bag fetch — GET init.ess.apple.com getBag?ix=3 over AppleTrust-pinned TLS. + * 2. APNs bag fetch — GET init-p01st.push.apple.com/bag over plain HTTP (§1.2). + * 3. Courier handshake — raw SSLSocket (AppleTrust, no client certificate) to + * courier.push.apple.com on 5223 and 443; record what arrives after. + * + * Probes 1–2 diff the live bag key sets against the committed capture fixtures + * (codec/src/test/resources/imessage/, 2026-09-10). Drift is reported, never failed. + * Cert chains and signature blobs are never printed in full — sizes and SHA-256 digests only. + */ + +private const val IDS_BAG_URL = + "https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3" +private const val APNS_BAG_URL = "http://init-p01st.push.apple.com/bag" +private const val COURIER_HOST = "courier.push.apple.com" +private const val READ_TIMEOUT_MS = 15_000 + +private val WATCHED_KEYS = listOf( + "bag-expiry-timestamp", + "max-uri-multi-query", + "id-query-refresh-queued-query-interval", +) + +fun main(args: Array) = runBlocking { + val fixtureDir = (args.getOrNull(0) ?: "codec/src/test/resources/imessage").trimEnd('/') + + println("=== PROBE 1: IDS bag (pinned TLS) ===") + probeTls("init.ess.apple.com", 443, AppleTrustPools.ids) + probeBag("IDS bag", IDS_BAG_URL, "$fixtureDir/ids-bag-2026-09-10.xml", expectInner = 405) + + println() + println("=== PROBE 2: APNs bag (plain HTTP, §1.2) ===") + probeBag("APNs bag", APNS_BAG_URL, "$fixtureDir/apns-bag-2026-09-10.xml", expectInner = 65) + + println() + println("=== PROBE 3: courier TLS handshake (no client certificate) ===") + probeCourier(5223) + probeCourier(443) + + println() + println("=== PROBES COMPLETE ===") +} + +// -- probe 1/2 shared: bag fetch + shape + fixture diff ------------------------------------- + +private suspend fun probeBag(label: String, url: String, fixturePath: String, expectInner: Int) { + try { + val http = JavaNetIdsHttp.appleTrustClient() + http.use { + val response = it.get(url) + println("$label: HTTP ${response.status}, content-type=${response.headers["content-type"]}, " + + "body=${response.body.size} B, sha256=${response.body.sha256()}") + if (response.status != 200) { + println("$label: unexpected status — body head: ${response.body.decodeToString().take(200)}") + return + } + val outer = stringKeyedDictOrNull(Plist.parse(response.body)) + if (outer == null) { + println("$label: PARSE SHAPE MISMATCH — top level is not a string-keyed dict") + return + } + println("$label: top-level keys = ${outer.keys.sorted()}") + outer["signature"]?.let { sig -> + if (sig is ByteArray) println("$label: signature blob: ${sig.size} B, sha256=${sig.sha256()}") + } + val embedded = outer["bag"] + val inner: Map? = when (embedded) { + is ByteArray -> stringKeyedDictOrNull(Plist.parse(embedded)) + is Map<*, *> -> embedded.entries.associate { it.key.toString() to it.value } + else -> null + } + if (inner == null) { + println("$label: inner bag MISSING or not a dict (got ${typeNameOf(embedded)})") + return + } + println("$label: inner-bag entries = ${inner.size} (fixture: $expectInner)") + + val fixtureBag = loadFixtureInnerBag(fixturePath) + if (fixtureBag == null) { + println("$label: fixture not readable at $fixturePath — key-set diff skipped") + } else { + diffKeySets(label, inner, fixtureBag) + } + } + } catch (e: Exception) { + println("$label: PROBE ERROR — ${e::class.java.name}: ${e.message}") + } +} + +private fun loadFixtureInnerBag(path: String): Map? { + val file = java.io.File(path) + if (!file.exists()) return null + val outer = stringKeyedDictOrNull(Plist.parse(file.readBytes())) ?: return null + val embedded = outer["bag"] ?: return stringKeyedDictOrNull(outer) + return when (embedded) { + is ByteArray -> stringKeyedDictOrNull(Plist.parse(embedded)) + is Map<*, *> -> embedded.entries.associate { it.key.toString() to it.value } + else -> null + } +} + +private fun diffKeySets(label: String, live: Map, fixture: Map) { + val added = live.keys - fixture.keys + val removed = fixture.keys - live.keys + println("$label: keys added since fixture (${added.size}): ${added.sorted()}") + println("$label: keys removed since fixture (${removed.size}): ${removed.sorted()}") + + val changed = live.keys.intersect(fixture.keys).filter { key -> !valuesEqual(live[key], fixture[key]) } + println("$label: values changed on (${changed.size}) shared keys: ${changed.sorted()}") + + for (key in WATCHED_KEYS) { + println("$label: $key live=${live[key]} fixture=${fixture[key]}") + } +} + +/** Structural equality across the plist-native types (bytes compared by content). */ +private fun valuesEqual(a: Any?, b: Any?): Boolean = when { + a is ByteArray && b is ByteArray -> a.contentEquals(b) + a is List<*> && b is List<*> -> a.size == b.size && a.zip(b).all { (x, y) -> valuesEqual(x, y) } + a is Map<*, *> && b is Map<*, *> -> a.size == b.size && + a.keys.all { b.containsKey(it) && valuesEqual(a[it], b[it]) } + else -> a == b +} + +private fun typeNameOf(value: Any?): String = value?.let { it::class.java.simpleName } ?: "null" + +// -- probe 1/3 shared: raw TLS handshake + post-handshake read ------------------------------- + +private fun probeTls(host: String, port: Int, trustManager: TrustManager) { + try { + val context = SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager), null) } + val socket = context.socketFactory.createSocket() as SSLSocket + try { + socket.soTimeout = READ_TIMEOUT_MS + socket.connect(InetSocketAddress(host, port), READ_TIMEOUT_MS) + socket.startHandshake() + val session = socket.session + println("TLS $host:$port — handshake COMPLETE: protocol=${session.protocol}, " + + "cipher=${session.cipherSuite}, peer certs:") + describeCerts(session.peerCertificates.toList()) + } finally { + socket.close() + } + } catch (e: Exception) { + println("TLS $host:$port — handshake FAILED: ${e::class.java.name}: ${e.message}") + } +} + +private fun describeCerts(certs: List) { + certs.forEachIndexed { index, cert -> + if (cert is X509Certificate) { + val cn = cert.subjectX500Principal.getName(X500Principal.CANONICAL) + .substringAfter("cn=").substringBefore(",") + println(" [$index] len=${cert.encoded.size} B, sha256=${cert.encoded.sha256()}, subject CN=$cn") + } else { + println(" [$index] len=${cert.encoded.size} B (non-X.509)") + } + } +} + +private fun probeCourier(port: Int) { + try { + val context = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(AppleTrustPools.courier), null) + } + val socket = context.socketFactory.createSocket() as SSLSocket + try { + socket.soTimeout = READ_TIMEOUT_MS + socket.connect(InetSocketAddress(COURIER_HOST, port), READ_TIMEOUT_MS) + socket.startHandshake() + val session = socket.session + println("courier:$port — handshake COMPLETE: protocol=${session.protocol}, " + + "cipher=${session.cipherSuite}") + describeCerts(session.peerCertificates.toList()) + + // No HTTP request, no APNs frame, no client certificate — just listen. + val buffer = ByteArray(256) + val start = System.currentTimeMillis() + val read = try { + socket.getInputStream().read(buffer) + } catch (e: java.net.SocketTimeoutException) { + -2 + } + val elapsed = System.currentTimeMillis() - start + when { + read > 0 -> println("courier:$port — after handshake: $read B arrived in ${elapsed} ms: " + + buffer.copyOf(read).toHexString()) + read == -1 -> println("courier:$port — after handshake: peer CLOSED the stream " + + "(read returned -1) after ${elapsed} ms — no data, no TLS alert observed") + read == -2 -> println("courier:$port — after handshake: nothing arrived within " + + "$READ_TIMEOUT_MS ms (read timeout, connection still open)") + else -> println("courier:$port — after handshake: unexpected read result $read") + } + try { + socket.close() + println("courier:$port — local close: clean") + } catch (e: Exception) { + println("courier:$port — local close error: ${e::class.java.name}: ${e.message}") + } + } catch (e: javax.net.ssl.SSLException) { + println("courier:$port — TLS-level failure after/during handshake: " + + "${e::class.java.name}: ${e.message}") + } finally { + runCatching { socket.close() } + } + } catch (e: Exception) { + println("courier:$port — PROBE ERROR: ${e::class.java.name}: ${e.message}") + } +} + +// -- AppleTrust pools (same construction the engine's factories use) ------------------------- + +private object AppleTrustPools { + val ids: X509TrustManager by lazy { dev.fenn.imessage.ids.AppleTrust.defaultTrustManager() } + val courier: X509TrustManager by lazy { dev.fenn.imessage.ids.AppleTrust.courierTrustManager() } +} + +// -- small helpers --------------------------------------------------------------------------- + +private fun ByteArray.sha256(): String = + MessageDigest.getInstance("SHA-256").digest(this).joinToString("") { "%02x".format(it) } + +private fun ByteArray.toHexString(): String = take(64) + .joinToString(" ") { "%02x".format(it) } + if (size > 64) " …(${size} B total)" else "" diff --git a/engine/src/test/resources/imessage/AppleRootCA-G3.cer b/engine/src/test/resources/imessage/AppleRootCA-G3.cer new file mode 100644 index 0000000..228bfa3 Binary files /dev/null and b/engine/src/test/resources/imessage/AppleRootCA-G3.cer differ diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..29c0dcb --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4g +kotlin.daemon.jvmargs=-Xmx3g +android.useAndroidX=true +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..9bd66d7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,35 @@ +[versions] +agp = "8.12.3" +kotlin = "2.3.20" +compose-activity = "1.13.0" +kotlinx-coroutines = "1.10.2" +kotlinx-serialization-json = "1.7.3" +room = "2.8.4" +sdk = "0.1.0" + +[libraries] +compose-activity = { module = "androidx.activity:activity-compose", version.ref = "compose-activity" } +junit = { module = "junit:junit", version = "4.13.2" } +kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +# Consumed from the included ../light-sdk build via dependencySubstitution; +# versions must match light-sdk/gradle.properties sdkGroup/sdkVersion. +sdk-ui = { module = "com.thelightphone:sdk-ui", version.ref = "sdk" } +sdk-client = { module = "com.thelightphone:sdk-client", version.ref = "sdk" } +sdk-server = { module = "com.thelightphone:sdk-server", version.ref = "sdk" } +sdk-shared = { module = "com.thelightphone:sdk-shared", version.ref = "sdk" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version = "2.3.6" } +# Resolved from the included ../light-sdk build (no version). +light-sdk = { id = "com.thelightphone.light-sdk" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2a84e18 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ids/build.gradle.kts b/ids/build.gradle.kts new file mode 100644 index 0000000..a9e5175 --- /dev/null +++ b/ids/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + kotlin("jvm") // version inherited from the root classpath (kotlin-gradle-plugin 2.3.20) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":codec")) + implementation(project(":courier")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) +} diff --git a/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsBag.kt b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsBag.kt new file mode 100644 index 0000000..4cbd285 --- /dev/null +++ b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsBag.kt @@ -0,0 +1,119 @@ +package dev.fenn.imessage.ids + +import dev.fenn.imessage.codec.BplistFormatException +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import java.util.concurrent.ConcurrentHashMap + +/** Bag fetch/parse/HTTP failure (spec §1.2). */ +class IdsBagException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * A key inside a parsed bag that the bag does not carry. Spec §1.2: "a missing key inside the + * parsed bag is its own error" — there is no pinned-URL fallback for a vanished bag key. + */ +class BagKeyMissingException(val key: String) : Exception("bag has no key '$key'") + +/** A fetched bag as a flat bag-key → value dictionary (spec §1.2). */ +class Bag(entries: Map) { + + val raw: Map = entries + + fun string(key: String): String { + val value = existing(key) + return value as? String ?: throw IdsBagException("bag key '$key' is ${typeName(value)}, not a string") + } + + /** Same as [string], for keys the spec §1.2 lists as endpoint URLs. */ + fun url(key: String): String = string(key) + + fun long(key: String): Long { + val value = existing(key) + return when (value) { + is Long -> value + is Int -> value.toLong() + else -> throw IdsBagException("bag key '$key' is ${typeName(value)}, not an integer") + } + } + + private fun existing(key: String): Any? = + if (raw.containsKey(key)) raw[key] else throw BagKeyMissingException(key) +} + +/** `APNSCourierHostname`/`APNSCourierHostcount` from the APNs bag (spec §3.1) — feeds the courier client config. */ +data class CourierEndpoint(val hostname: String, val hostCount: Int) + +/** + * Bag fetchers per spec §1.2. + * + * Caching is exactly what the spec states: process-lifetime, keyed by bag URL, no TTL and no + * refresh — keep one fetcher for the process. Two concurrent first calls can each fetch (the + * result is identical either way); a lock would serialize unrelated callers, so there is none. + * + * [plistParser] is the plist seam; the default is the `:codec` format-dispatching + * [Plist.parse] (binary + XML — [CAP-BAGCT] observes XML on the bag wire). + */ +class IdsBagFetcher( + private val http: IdsHttp, + val idsBagUrl: String = IDS_BAG_URL, + val apnsBagUrl: String = APNS_BAG_URL, + private val plistParser: (ByteArray) -> Any? = { Plist.parse(it) }, +) { + + private val cache = ConcurrentHashMap() + + /** The IDS bag. Spec §1.2 [CAP-BAG]: an unauthenticated GET; the response is a plist with `signature`, `certs` and `bag`. */ + suspend fun idsBag(): Bag = cache[idsBagUrl] ?: fetchDict(idsBagUrl).also { cache[idsBagUrl] = it } + + /** The APNs bag, fetched over **plain HTTP** (spec §1.2). */ + suspend fun apnsBag(): Bag = cache[apnsBagUrl] ?: fetchDict(apnsBagUrl).also { cache[apnsBagUrl] = it } + + /** Courier hostname/count for the courier client config (spec §3.1). */ + suspend fun courierEndpoint(): CourierEndpoint { + val bag = apnsBag() + return CourierEndpoint( + hostname = bag.string("APNSCourierHostname"), + hostCount = bag.long("APNSCourierHostcount").toInt(), + ) + } + + private suspend fun fetchDict(url: String): Bag { + val response = http.get(url) + if (response.status != 200) throw IdsBagException("GET $url → HTTP ${response.status}") + return Bag(flatDict(response.body, url)) + } + + /** + * Spec §1.2 records the nested shape for the IDS bag: the dict's `bag` value is embedded + * plist data that is itself parsed into the flat dictionary. The APNs bag's outer shape is + * not recorded (TODO(capture) C47), so a response that is already the flat dict is accepted + * too — a wrong reading still fails loudly on the key lookup in [courierEndpoint]. + */ + private fun flatDict(body: ByteArray, url: String): Map { + val outer = parseDict(body, url) + val embedded = outer["bag"] ?: return outer + return when (embedded) { + is ByteArray -> parseDict(embedded, "$url embedded 'bag'") + is Map<*, *> -> stringKeyedDictOrNull(embedded) + ?: throw IdsBagException("$url embedded 'bag' is not a string-keyed dict") + else -> throw IdsBagException("$url 'bag' is ${typeName(embedded)}, expected data or dict") + } + } + + private fun parseDict(body: ByteArray, url: String): Map { + val parsed = try { + plistParser(body) + } catch (e: PlistFormatException) { + throw IdsBagException("$url is not a plist: ${e.message}", e) + } + return stringKeyedDictOrNull(parsed) ?: throw IdsBagException("$url is ${typeName(parsed)}, expected a dict") + } + + companion object { + const val IDS_BAG_URL = "https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3" + const val APNS_BAG_URL = "http://init-p01st.push.apple.com/bag" + } +} diff --git a/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsHttp.kt b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsHttp.kt new file mode 100644 index 0000000..c448457 --- /dev/null +++ b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsHttp.kt @@ -0,0 +1,97 @@ +package dev.fenn.imessage.ids + +import java.io.Closeable +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import java.io.ByteArrayInputStream +import java.security.KeyStore +import javax.net.ssl.SSLContext + +/** One IDS HTTP round trip: status, response headers (names lower-cased), raw body bytes. */ +class IdsHttpResponse(val status: Int, val headers: Map, val body: ByteArray) + +/** + * The IDS HTTP seam. Tests script responses through it; the production client (ktor/OkHttp + * in the originating repo) was not ported into this module — see the port note on this file. + * Header names are lower-cased in [IdsHttpResponse.headers] so callers compare + * case-insensitively without worrying about the server's casing. + */ +interface IdsHttp : Closeable { + + suspend fun get(url: String, headers: Map = emptyMap()): IdsHttpResponse + + suspend fun put( + url: String, + headers: Map = emptyMap(), + body: ByteArray, + contentType: String = "", + ): IdsHttpResponse + + suspend fun post( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse + + override fun close() {} +} + +/** + * The Apple-root trust pool of spec §1.2/§3.1: IDS HTTPS requests pin Apple roots and never + * the system store ([CAP-IDSTLS]: neither root is served on the wire, and the unanchored + * chain is rejected by the system store). The default pool is the two embedded roots — + * `Apple Root CA - G3` (anchors the init.ess chain) and `Apple Root CA` (anchors the + * query.ess chain) — staged as DER under `resources/imessage/`. Roots only, never + * leaves/intermediates; a chain that does not terminate in one of them must fail loudly. + */ +object AppleTrust { + + private val ROOT_RESOURCES = listOf("/imessage/AppleRootCA-G3.cer", "/imessage/AppleRootCA.cer") + + /** + * The courier anchor of spec §3.1 [CAP-COURIERTLS]: the courier chain terminates at the + * publicly-trusted Comodo root `AAA Certificate Services` (not an Apple root) — the root + * itself is never served on the wire, so the pool must embed it. Pin roots only; the + * courier leaf rotates every ~90 days. + */ + const val COURIER_ROOT_RESOURCE = "/imessage/AAACertificateServices.cer" + + /** The default pool: the two embedded Apple roots (spec §1.2 [CAP-IDSTLS]). */ + fun defaultTrustManager(): X509TrustManager = trustManager(*embeddedRootDers()) + + /** The courier pool: the single embedded `AAA Certificate Services` root (§3.1 [CAP-COURIERTLS]). */ + fun courierTrustManager(): X509TrustManager = trustManager(embeddedRoot(COURIER_ROOT_RESOURCE)) + + /** A socket factory whose only trust anchor is [trustManager]. */ + fun sslSocketFactory(trustManager: X509TrustManager): SSLSocketFactory = + SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager), null) }.socketFactory + + private fun embeddedRoot(path: String): ByteArray = + AppleTrust::class.java.getResourceAsStream(path)?.use { it.readBytes() } + ?: throw IllegalStateException("embedded root $path missing from resources") + + private fun embeddedRootDers(): Array = ROOT_RESOURCES.map { embeddedRoot(it) }.toTypedArray() + + /** A trust manager whose only anchors are [certificatesDer] (DER X.509, any count). */ + fun trustManager(vararg certificatesDer: ByteArray): X509TrustManager { + require(certificatesDer.isNotEmpty()) { "at least one anchor certificate is required" } + val factory = java.security.cert.CertificateFactory.getInstance("X.509") + val store = KeyStore.getInstance("PKCS12").apply { load(null, null) } + certificatesDer.forEachIndexed { i, der -> + store.setCertificateEntry("apple-anchor-$i", factory.generateCertificate(ByteArrayInputStream(der))) + } + val managers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + .apply { init(store) } + .trustManagers + return managers.filterIsInstance().firstOrNull() + ?: throw IllegalStateException("${TrustManagerFactory.getDefaultAlgorithm()} returned no X509TrustManager") + } +} + +// Port note: the originating repo's ktor/OkHttp implementation of [IdsHttp] +// (`AppleIdsHttp`, redirects and retries off, optional pinned trust manager per spec §1.2 +// [CAP-IDSTLS]) was not ported — the HTTP stack deps are not in this repo's catalog yet. +// [IdsHttp] itself and the scripted test doubles cover the seam; port the client when the +// network module lands. diff --git a/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsLookup.kt b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsLookup.kt new file mode 100644 index 0000000..093dc2f --- /dev/null +++ b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsLookup.kt @@ -0,0 +1,268 @@ +package dev.fenn.imessage.ids + +import dev.fenn.imessage.codec.BpArray +import dev.fenn.imessage.codec.BpDict +import dev.fenn.imessage.codec.BpString +import dev.fenn.imessage.codec.Bplist +import dev.fenn.imessage.codec.BplistFormatException +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import java.io.ByteArrayInputStream +import java.security.PrivateKey +import java.util.Base64 +import java.util.zip.GZIPInputStream + +/** Lookup request/response failure (spec §2.1). */ +class IdsLookupException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * The lookup transport seam (spec §2.1 architecture fact): IDS lookups ride the APNs tunnel, + * never plain HTTPS. One round trip = a command-96 tunnel request carrying the URL, headers, + * content type and body; the command-97 reply carries a tunnel-level status and the body. + * Tests script it; production is [CourierTunnelTransport] over the live courier connection. + */ +interface LookupTransport { + + suspend fun exchange( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): TunnelReply +} + +/** The tunnel reply of spec §2.1: the command-97 status field and the response body. */ +class TunnelReply(val status: Int, val body: ByteArray) + +/** + * Everything a lookup needs. All header wire names are recorded (C47 closed, spec §2.1) and + * owned by [IdsLookupClient] — nothing here carries names anymore: + * + * - [versionUa] composes the recorded `user-agent` = `com.apple.madrid-lookup ` + * (§2.1); the `` component is the caller's version string (§1.4's register + * user-agent pattern). + * - [subService] rides as `x-id-sub-service` only when set — §2.1: "only when the lookup + * topic differs from the main topic"; the caller decides applicability. + * - [requiredForMessage]/[resultExpected] are the opt-in flags `x-required-for-message` / + * `x-result-expected`, both true "in most calls" (§2.1), so they default to true. + */ +data class LookupConfig( + val selfUri: String, + val pushToken: ByteArray, + val protocolVersion: String, + val versionUa: String, + val signingKey: PrivateKey, + val certificateDer: ByteArray, + val subService: String? = null, + val requiredForMessage: Boolean = true, + val resultExpected: Boolean = true, +) + +/** + * One device's public identity. The six per-identity wire keys are recorded (C48 closed, + * spec §2.1); only the two token-timing fields have typed accessors — the rest + * (`client-data`, `kt-loggable-data`, `push-token`, `session-token`) stay in [raw] for the + * component that needs them. + */ +data class IdsIdentity( + val expiresSeconds: Long?, + val refreshSeconds: Long?, + val raw: Map, +) + +/** + * One handle's lookup result. [status] is the per-handle `status` wire key (C48, recorded) — + * kept for storage fidelity only: C55 (spec §2.1/§7 item 19) supersedes routing on it, both + * reference clients read lookup error statuses from the top-level `status` only. Absent + * per-handle status parses as 0 (neither reference client consults the value). + */ +data class IdsLookupResult( + val status: Int, + val identities: List, + val raw: Map, +) + +/** + * A lookup response (spec §2.1, C48 closed): top-level `status` — where 5206/6005 and the + * unhandled 5040/5041 are read from (C55) — plus `results`, the map keyed by the plain uri + * string. Per-handle `kt-account-key` / `sender-correlation-identifier` ride in each + * result's [IdsLookupResult.raw]. + */ +data class IdsLookupResponse( + val status: Int, + val results: Map, +) + +/** + * IDS lookup client per spec §2.1: the lookup body — a plist with a `uris` array of PLAIN + * handle strings (bare `tel:`/`mailto:` strings, not the register-style single-key dicts) — + * signed with the §1.4 construction, carried over the tunnel [transport] to the bag's + * `id-query` URL. + * + * Batch size is the caller's decision: §2.1 records 18 handles vs 20 URIs and leaves the cap + * open (TODO(capture) C13), so nothing is hardcoded here. The 5206 "response too large" + * split-in-half retry of §2.1 belongs around this call, in the caller that owns the batch. + * + * [plistParser] is the plist seam (see [IdsBagFetcher]); the default is [Plist.parse]. + */ +class IdsLookupClient( + private val transport: LookupTransport, + private val bag: Bag, + private val config: LookupConfig, + private val plistParser: (ByteArray) -> Any? = { Plist.parse(it) }, +) { + + suspend fun lookup(uris: List): IdsLookupResponse { + val url = bag.url(BAG_KEY) + val body = encodeBody(uris) + val nonce = IdsSigning.nonce(IdsSigning.TYPE_HTTPS) + val signature = IdsSigning.sign(config.signingKey, nonce, signingFields(body)) + + // §1.4 states base64 for the signature only; the nonce and the certificate are sent the + // same way because no other encoding is recorded (§1.4 extraction, C47-closed names). + val headers = LinkedHashMap() + headers["x-id-nonce"] = base64(nonce) + headers["x-id-cert"] = base64(config.certificateDer) + headers["x-id-sig"] = base64(signature) + headers[HEADER_PUSH_TOKEN] = base64(config.pushToken) + headers[HEADER_SELF_URI] = config.selfUri + headers[HEADER_PROTOCOL_VERSION] = config.protocolVersion + headers[HEADER_USER_AGENT] = "$USER_AGENT_PREFIX ${config.versionUa}" + headers[HEADER_REQUIRED_FOR_MESSAGE] = config.requiredForMessage.toString() + headers[HEADER_RESULT_EXPECTED] = config.resultExpected.toString() + config.subService?.let { headers[HEADER_SUB_SERVICE] = it } + + val reply = transport.exchange(url, headers, body, PLIST_CONTENT_TYPE) + if (reply.status != TunnelStatus.OK) { + throw IdsLookupException("lookup tunnel reply status ${reply.status} (expected 0)") + } + return parseResponse(gunzipIfNeeded(reply.body)) + } + + /** + * The signed field tuple of §1.4: (bag key string, query string — empty for a lookup, + * request body, raw push token). The body is the exact bytes sent. + */ + private fun signingFields(body: ByteArray): List = listOf( + BAG_KEY.toByteArray(Charsets.UTF_8), + EMPTY_QUERY, + body, + config.pushToken, + ) + + /** + * Encoding is one swap point because it is unrecorded: §1.4 has the register body as XML, + * §2.1 says only "a plist", so the lookup body goes out as the binary plist of §4.1 + * (TODO(capture) C12) — change this line to move to XML. + */ + private fun encodeBody(uris: List): ByteArray = + Bplist.encode(BpDict(mapOf(BpString("uris") to BpArray(uris.map { BpString(it) })))) + + private fun parseResponse(bytes: ByteArray): IdsLookupResponse { + val parsed = try { + plistParser(bytes) + } catch (e: PlistFormatException) { + throw IdsLookupException("lookup response is not a plist: ${e.message}", e) + } + val dict = stringKeyedDictOrNull(parsed) + ?: throw IdsLookupException("lookup response is ${typeName(parsed)}, expected a dict") + val status = when (val value = dict[TOP_LEVEL_STATUS]) { + is Long -> value.toInt() + is Int -> value + else -> throw IdsLookupException( + "lookup response has no top-level integer 'status' (${typeName(value)}) — " + + "spec §2.1 records it as the C55 error-status location", + ) + } + val resultsDict = stringKeyedDictOrNull(dict[TOP_LEVEL_RESULTS]) + ?: throw IdsLookupException( + "lookup response 'results' is ${typeName(dict[TOP_LEVEL_RESULTS])}, expected a handle-keyed dict", + ) + val results = LinkedHashMap(resultsDict.size) + for ((handle, value) in resultsDict) { + val result = stringKeyedDictOrNull(value) + ?: throw IdsLookupException("lookup result for '$handle' is ${typeName(value)}, expected a dict") + results[handle] = parseResult(result, handle) + } + return IdsLookupResponse(status, results) + } + + private fun parseResult(raw: Map, handle: String): IdsLookupResult { + // The per-handle `status` key is recorded (C48) but read by no reference client (C55): + // parsed leniently for storage fidelity, never routed on. + val status = when (val value = raw["status"]) { + is Long -> value.toInt() + is Int -> value + else -> 0 + } + val identities = when (val value = raw["identities"]) { + null -> emptyList() // registered nowhere: §2.2's zero-identity case + is List<*> -> value.mapIndexed { i, identity -> parseIdentity(identity, handle, i) } + else -> throw IdsLookupException("'identities' for '$handle' is ${typeName(value)}, expected an array") + } + return IdsLookupResult(status, identities, raw) + } + + private fun parseIdentity(raw: Any?, handle: String, index: Int): IdsIdentity { + val dict = stringKeyedDictOrNull(raw) + ?: throw IdsLookupException("identity $index of '$handle' is ${typeName(raw)}, expected a dict") + return IdsIdentity( + expiresSeconds = longOrNull(dict[SESSION_TOKEN_EXPIRES]), + refreshSeconds = longOrNull(dict[SESSION_TOKEN_REFRESH]), + raw = dict, + ) + } + + private fun longOrNull(value: Any?): Long? = when (value) { + is Long -> value + is Int -> value.toLong() + else -> null + } + + private fun base64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + companion object { + /** Bag key for the lookup endpoint (spec §1.2/§2.1). */ + const val BAG_KEY = "id-query" + + /** The plist content type of §1.4 (recorded for register; the lookup's is not, C12). */ + const val PLIST_CONTENT_TYPE = "application/x-apple-plist" + + // C47-closed header wire names (spec §2.1). + const val HEADER_SELF_URI = "x-id-self-uri" + const val HEADER_PUSH_TOKEN = "x-push-token" + const val HEADER_PROTOCOL_VERSION = "x-protocol-version" + const val HEADER_USER_AGENT = "user-agent" + const val HEADER_SUB_SERVICE = "x-id-sub-service" + const val HEADER_REQUIRED_FOR_MESSAGE = "x-required-for-message" + const val HEADER_RESULT_EXPECTED = "x-result-expected" + + /** §2.1: `user-agent` = `com.apple.madrid-lookup `. */ + const val USER_AGENT_PREFIX = "com.apple.madrid-lookup" + + private const val TOP_LEVEL_STATUS = "status" + private const val TOP_LEVEL_RESULTS = "results" + private const val SESSION_TOKEN_EXPIRES = "session-token-expires-seconds" + private const val SESSION_TOKEN_REFRESH = "session-token-refresh-seconds" + private val EMPTY_QUERY = ByteArray(0) + } +} + +/** The command-97 tunnel-level status values the spec supports (§2.1; 0 = ok, APNs convention). */ +object TunnelStatus { + const val OK = 0 +} + +/** + * §2.1 calls the response "a gzipped plist". The tunnel reply body's own magic, not any + * header, decides whether to unwrap. + */ +fun gunzipIfNeeded(body: ByteArray): ByteArray = + if (body.size >= 2 && body[0] == 0x1f.toByte() && body[1] == 0x8b.toByte()) { + GZIPInputStream(ByteArrayInputStream(body)).use { it.readBytes() } + } else { + body + } diff --git a/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsSigning.kt b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsSigning.kt new file mode 100644 index 0000000..e4628c4 --- /dev/null +++ b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsSigning.kt @@ -0,0 +1,72 @@ +package dev.fenn.imessage.ids + +import java.io.ByteArrayOutputStream +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.Signature + +/** + * The signed-request construction of spec §1.4, shared by the register and lookup paths. + * + * Nonce: 17 bytes — 1 type byte, an 8-byte big-endian unix-milliseconds timestamp, 8 random + * bytes. Signing payload: the nonce followed by a big-endian u32 length-prefixed concatenation + * of the request's fields (bag key string, query string, request body, raw push token). + * Signature: RSA-SHA1-PKCS1v1.5 over that payload, output prefixed with the two bytes + * `0x01 0x01` and then base64'd by the caller. + * + * Keys are parameters — Android Keystore integration is a later component and is not wired here. + */ +object IdsSigning { + + /** Nonce length in bytes (spec §1.4). */ + const val NONCE_BYTES = 17 + + /** Nonce type byte for HTTPS requests (spec §1.4). */ + const val TYPE_HTTPS: Byte = 0x01 + + /** Nonce type byte for APNs use (spec §1.4). */ + const val TYPE_APNS: Byte = 0x00 + + /** The two bytes every RSA-SHA1 signature output starts with (§1.4/§3.3). */ + val SIGNATURE_PREFIX_BYTES = byteArrayOf(0x01, 0x01) + + fun nonce( + type: Byte = TYPE_HTTPS, + nowMillis: Long = System.currentTimeMillis(), + random: SecureRandom = SecureRandom(), + ): ByteArray { + require(type == TYPE_HTTPS || type == TYPE_APNS) { + "nonce type must be 0x01 (HTTPS) or 0x00 (APNs) — spec §1.4 records only those two" + } + val out = ByteArray(NONCE_BYTES) + out[0] = type + for (i in 0 until 8) out[1 + i] = (nowMillis ushr ((7 - i) * 8)).toByte() + val randomBytes = ByteArray(8) + random.nextBytes(randomBytes) + randomBytes.copyInto(out, 9) + return out + } + + /** The bytes that get signed: [nonce] then each field as u32-length + bytes (spec §1.4). */ + fun signingBytes(nonce: ByteArray, fields: List): ByteArray { + require(nonce.size == NONCE_BYTES) { "nonce must be $NONCE_BYTES bytes (spec §1.4), was ${nonce.size}" } + val out = ByteArrayOutputStream(nonce.size + fields.sumOf { 4 + it.size }) + out.write(nonce) + for (field in fields) { + out.write((field.size ushr 24) and 0xFF) + out.write((field.size ushr 16) and 0xFF) + out.write((field.size ushr 8) and 0xFF) + out.write(field.size and 0xFF) + out.write(field) + } + return out.toByteArray() + } + + /** `0x01 0x01` + RSA-SHA1-PKCS1v1.5 over [signingBytes] — base64 it for the header. */ + fun sign(key: PrivateKey, nonce: ByteArray, fields: List): ByteArray { + val signer = Signature.getInstance("SHA1withRSA") + signer.initSign(key) + signer.update(signingBytes(nonce, fields)) + return SIGNATURE_PREFIX_BYTES + signer.sign() + } +} diff --git a/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsTunnel.kt b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsTunnel.kt new file mode 100644 index 0000000..346718d --- /dev/null +++ b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsTunnel.kt @@ -0,0 +1,79 @@ +package dev.fenn.imessage.ids + +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull + +/** + * The APNs tunnel transport adapter for IDS lookups (spec §2.1 architecture fact): sends each + * lookup as a command-96 tunnel request over the live courier connection and completes when + * the command-97 response with the same 16-byte request UUID arrives — the correlation the + * spec records — with the §2.1 1-minute timeout around the wait. + * + * Wiring: [sendFrame] resolves the connected courier client (`CourierClient.sendFrame` over + * [CourierFrame.encode]); [onFrame] is called from the courier handler for every inbound + * frame, so a session composes it into its `CourierClient.Handler`. Responses for unknown or + * expired requests are dropped — the waiting caller has already timed out. + */ +class CourierTunnelTransport( + private val fieldIds: CourierCommands.TunnelFieldIds, + private val sendFrame: (CourierFrame.Frame) -> Unit, + private val timeoutMs: Long = TIMEOUT_MS, + private val random: SecureRandom = SecureRandom(), + private val logEvent: (String) -> Unit = {}, +) : LookupTransport { + + private val pending = ConcurrentHashMap>() + + /** Feeds courier frames in; only command-97 responses are consumed. */ + fun onFrame(frame: CourierFrame.Frame) { + if (frame.command != CourierFrame.TUNNEL_RESPONSE) return + val response = runCatching { CourierCommands.parseTunnelResponse(frame, fieldIds) } + .getOrElse { e -> + logEvent("malformed tunnel response dropped: ${e.message}") + return + } + pending.remove(response.requestUuid.hex())?.complete(frame) + ?: logEvent("tunnel response with no waiting request dropped") + } + + override suspend fun exchange( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): TunnelReply { + val requestUuid = ByteArray(CourierCommands.TUNNEL_REQUEST_UUID_BYTES).also(random::nextBytes) + val deferred = CompletableDeferred() + pending[requestUuid.hex()] = deferred // registered before the send — no reply can race it + try { + sendFrame( + CourierCommands.tunnelRequestFrame(url, headers, contentType, body, requestUuid, fieldIds), + ) + val frame = withTimeoutOrNull(timeoutMs) { deferred.await() } + ?: throw IdsLookupException( + "tunnel request timed out after $timeoutMs ms (§2.1's 1-minute timeout)", + ) + val response = CourierCommands.parseTunnelResponse(frame, fieldIds) + if (!response.requestUuid.contentEquals(requestUuid)) { + throw IdsLookupException("tunnel response uuid does not match the request") + } + if (response.status != TunnelStatus.OK) { + throw IdsLookupException("tunnel response status ${response.status} (expected 0)") + } + return TunnelReply(response.status, response.body) + } finally { + pending.remove(requestUuid.hex()) + } + } + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } + + companion object { + /** Spec §2.1: "a 1-minute timeout applies". */ + const val TIMEOUT_MS = 60_000L + } +} diff --git a/ids/src/main/kotlin/dev/fenn/imessage/ids/PrivateIdsPush.kt b/ids/src/main/kotlin/dev/fenn/imessage/ids/PrivateIdsPush.kt new file mode 100644 index 0000000..28706a8 --- /dev/null +++ b/ids/src/main/kotlin/dev/fenn/imessage/ids/PrivateIdsPush.kt @@ -0,0 +1,35 @@ +package dev.fenn.imessage.ids + +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * The private-IDS topic of spec §2.3: server-initiated control pushes ride send (10) frames + * addressed to `com.apple.private.ids` — hashed on the wire (§3.3) — with a small JSON body + * carrying the command count `c` (c=32 re-register, c=66 handles changed, c=34 devices + * changed). + */ +object PrivateIdsPush { + + const val TOPIC = "com.apple.private.ids" + + private val topicHash by lazy { CourierCommands.topicHash(TOPIC) } + + /** The send frame that carries a private-IDS push, or null for anything else. */ + fun commandFromFrame(frame: CourierFrame.Frame): Int? { + if (frame.command != CourierFrame.SEND) return null + val topic = frame.field(CourierCommands.SEND_FIELD_TOPIC) ?: return null + if (!topic.contentEquals(topicHash)) return null + val payload = frame.field(CourierCommands.SEND_FIELD_PAYLOAD) ?: return null + return parseCommand(payload) + } + + /** The JSON body's `c` value; a malformed body is dropped, never thrown into the transport. */ + fun parseCommand(body: ByteArray): Int? = runCatching { + Json.parseToJsonElement(body.decodeToString()).jsonObject["c"] + ?.jsonPrimitive?.content?.toIntOrNull() + }.getOrNull() +} diff --git a/ids/src/main/resources/imessage/AAACertificateServices.cer b/ids/src/main/resources/imessage/AAACertificateServices.cer new file mode 100644 index 0000000..5c715fa Binary files /dev/null and b/ids/src/main/resources/imessage/AAACertificateServices.cer differ diff --git a/ids/src/main/resources/imessage/AppleRootCA-G3.cer b/ids/src/main/resources/imessage/AppleRootCA-G3.cer new file mode 100644 index 0000000..228bfa3 Binary files /dev/null and b/ids/src/main/resources/imessage/AppleRootCA-G3.cer differ diff --git a/ids/src/main/resources/imessage/AppleRootCA.cer b/ids/src/main/resources/imessage/AppleRootCA.cer new file mode 100644 index 0000000..8a9ff24 Binary files /dev/null and b/ids/src/main/resources/imessage/AppleRootCA.cer differ diff --git a/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsBagTest.kt b/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsBagTest.kt new file mode 100644 index 0000000..b96f664 --- /dev/null +++ b/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsBagTest.kt @@ -0,0 +1,157 @@ +package dev.fenn.imessage.ids + +import dev.fenn.imessage.codec.NativePlist +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.coroutines.runBlocking + +/** + * Scripted [IdsHttp] test double: hands back [responses] in order and records every call. + * One response per expected call — an unscripted extra call throws. + */ +class ScriptedIdsHttp(private val responses: List) : IdsHttp { + + class Call( + val method: String, + val url: String, + val headers: Map, + val body: ByteArray, + val contentType: String, + ) + + val calls = mutableListOf() + private var next = 0 + + override suspend fun get(url: String, headers: Map): IdsHttpResponse { + calls.add(Call("GET", url, headers, ByteArray(0), "")) + return responses[next++] + } + + override suspend fun put( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse { + calls.add(Call("PUT", url, headers, body, contentType)) + return responses[next++] + } + + override suspend fun post( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse { + calls.add(Call("POST", url, headers, body, contentType)) + return responses[next++] + } +} + +class IdsBagTest { + + private val idQueryUrl = "https://query.ess.apple.com/WebObjects/QueryService.woa/wa/query" + + // Port note: the originating repo's fixtures serialized the bag responses as XML plists + // (the archived bags are XML per spec §1.2 [CAP-BAGCT]); this repo's plist codec is + // binary-only, so the same shapes ride binary plists — outer dict with `signature`, + // `certs` and `bag`, the embedded `bag` data itself a plist (the interesting combination). + private fun idsBagResponse(): ByteArray { + val inner = NativePlist.encode( + mapOf("id-query" to idQueryUrl, "id-register" to "https://identity.ess.apple.com/register"), + ) + return NativePlist.encode( + mapOf( + "signature" to ByteArray(1), + "certs" to listOf(ByteArray(1)), + "bag" to inner, + ), + ) + } + + private fun apnsBagResponse(): ByteArray = NativePlist.encode( + mapOf( + "APNSCourierHostname" to "courier.push.apple.com", + "APNSCourierHostcount" to 50L, + ), + ) + + @Test + fun idsBagUnwrapsTheEmbeddedFlatDictionary() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), idsBagResponse()))) + val bag = IdsBagFetcher(http).idsBag() + + assertEquals(idQueryUrl, bag.url("id-query")) + assertEquals("https://identity.ess.apple.com/register", bag.url("id-register")) + assertEquals(IdsBagFetcher.IDS_BAG_URL, http.calls.single().url) + } + + @Test + fun missingKeyIsItsOwnError() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), idsBagResponse()))) + val bag = IdsBagFetcher(http).idsBag() + + val missing = assertFailsWith { bag.url("id-authenticate-ds-id") } + assertEquals("id-authenticate-ds-id", missing.key) + assertEquals("bag has no key 'id-authenticate-ds-id'", missing.message) + } + + @Test + fun wrongValueTypeIsAParseError() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), idsBagResponse()))) + val bag = IdsBagFetcher(http).idsBag() + + assertFailsWith { bag.long("id-query") } // a URL where an integer is expected + assertFailsWith { Bag(mapOf("APNSCourierHostcount" to 50L)).string("APNSCourierHostcount") } + assertEquals(50L, Bag(mapOf("APNSCourierHostcount" to 50L)).long("APNSCourierHostcount")) + } + + @Test + fun bagIsFetchedOncePerProcess() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), idsBagResponse()))) + val fetcher = IdsBagFetcher(http) + + fetcher.idsBag() + fetcher.idsBag() + + assertEquals(1, http.calls.size) + } + + @Test + fun non200IsAFetchFailure() { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(503, emptyMap(), ByteArray(0)))) + + runBlocking { assertFailsWith { IdsBagFetcher(http).idsBag() } } + } + + @Test + fun bodyThatIsNotAPlistIsAFetchFailure() { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), "hi".toByteArray()))) + + runBlocking { assertFailsWith { IdsBagFetcher(http).idsBag() } } + } + + @Test + fun apnsBagIsFetchedOverPlainHttpAndYieldsTheCourierEndpoint() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), apnsBagResponse()))) + val fetcher = IdsBagFetcher(http) + + val endpoint = fetcher.courierEndpoint() + + assertEquals("http://init-p01st.push.apple.com/bag", http.calls.single().url) + assertEquals("courier.push.apple.com", endpoint.hostname) + assertEquals(50, endpoint.hostCount) + assertEquals(dev.fenn.imessage.courier.CourierClient.Config(endpoint.hostname, endpoint.hostCount).hostname, endpoint.hostname) + } + + @Test + fun apnsBagMissingHostnameIsALoudKeyError() { + val http = ScriptedIdsHttp( + listOf(IdsHttpResponse(200, emptyMap(), NativePlist.encode(mapOf("other" to "x")))), + ) + + runBlocking { assertFailsWith { IdsBagFetcher(http).courierEndpoint() } } + } +} diff --git a/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsLookupTest.kt b/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsLookupTest.kt new file mode 100644 index 0000000..7384b46 --- /dev/null +++ b/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsLookupTest.kt @@ -0,0 +1,262 @@ +package dev.fenn.imessage.ids + +import java.io.ByteArrayOutputStream +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.util.Base64 +import java.util.zip.GZIPOutputStream +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import dev.fenn.imessage.codec.NativePlist + +/** Scripted [LookupTransport] — the tunnel is faked, never the network. */ +class ScriptedLookupTransport(replies: List) : LookupTransport { + + class Call(val url: String, val headers: Map, val body: ByteArray, val contentType: String) + + val calls = mutableListOf() + private val replies = replies.toList() + private var next = 0 + + override suspend fun exchange( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): TunnelReply { + calls.add(Call(url, headers, body, contentType)) + return replies[next++] + } +} + +class IdsLookupTest { + + private val idQueryUrl = "https://query.ess.apple.com/WebObjects/QueryService.woa/wa/query" + private val handle = "+15551234567" + private val pushToken = ByteArray(32) { it.toByte() } + private val certificateDer = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x00) + private val versionUa = "com.apple.invitation-registration [Test,1,1,Test]" + + private fun config(subService: String? = null) = LookupConfig( + selfUri = "tel:+15557654321", + pushToken = pushToken, + protocolVersion = "1660", + versionUa = versionUa, + signingKey = RSA_KEY.private, + certificateDer = certificateDer, + subService = subService, + ) + + private fun bag() = Bag(mapOf("id-query" to idQueryUrl)) + + private fun transport(vararg replies: TunnelReply) = + ScriptedLookupTransport(replies.toList()) + + /** §2.1: the response is a gzipped plist. */ + private fun gzip(bytes: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + GZIPOutputStream(out).use { it.write(bytes) } + return out.toByteArray() + } + + /** C48-closed response shape: top-level `status` + `results`, handle-keyed. */ + private fun responseBody(status: Int = 0): ByteArray = NativePlist.encode( + mapOf( + "status" to status.toLong(), + "results" to mapOf( + handle to mapOf( + "status" to 0L, + "sender-correlation-identifier" to "corr", + "kt-account-key" to ByteArray(32) { 9 }, + "identities" to listOf( + mapOf( + "client-data" to mapOf( + "public-message-identity-key" to ByteArray(65) { 1 }, + "public-message-ngm-device-prekey-data-key" to ByteArray(32) { 2 }, + "ngm-public-identity" to ByteArray(33) { 3 }, + "supports-certified-delivery-v1" to true, + ), + "kt-loggable-data" to ByteArray(16) { 4 }, + "push-token" to ByteArray(32) { 1 }, + "session-token" to "opaque-session-token", + "session-token-expires-seconds" to 3600L, + "session-token-refresh-seconds" to 1800L, + ), + ), + ), + "not-registered@example.com" to mapOf("status" to 3L, "identities" to emptyList()), + ), + ), + ) + + @Test + fun requestIsSignedAndAddressedToTheBagUrl() = runBlocking { + val t = transport(TunnelReply(0, gzip(responseBody()))) + val uris = listOf("tel:$handle") + + IdsLookupClient(t, bag(), config()).lookup(uris) + + val call = t.calls.single() + assertEquals(idQueryUrl, call.url) + assertEquals("application/x-apple-plist", call.contentType) + + // The signed tuple of §1.4: (bag key, empty query, exact request body, raw push token). + val nonce = Base64.getDecoder().decode(call.headers.getValue("x-id-nonce")) + assertEquals(17, nonce.size) + assertEquals(0x01, nonce[0].toInt()) + val fields = listOf("id-query".toByteArray(), ByteArray(0), call.body, pushToken) + val verifier = Signature.getInstance("SHA1withRSA") + verifier.initVerify(RSA_KEY.public) + verifier.update(IdsSigning.signingBytes(nonce, fields)) + val signature = Base64.getDecoder().decode(call.headers.getValue("x-id-sig")) + assertContentEquals(byteArrayOf(0x01, 0x01), signature.copyOfRange(0, 2)) + assertTrue(verifier.verify(signature.copyOfRange(2, signature.size)), "x-id-sig does not verify") + assertContentEquals(certificateDer, Base64.getDecoder().decode(call.headers.getValue("x-id-cert"))) + } + + @Test + fun headersCarryTheC47ClosedWireNames() = runBlocking { + val t = transport(TunnelReply(0, gzip(responseBody()))) + + IdsLookupClient(t, bag(), config()).lookup(listOf("tel:$handle")) + + val headers = t.calls.single().headers + assertEquals("tel:+15557654321", headers.getValue("x-id-self-uri")) + assertEquals("1660", headers.getValue("x-protocol-version")) + assertEquals( + "${IdsLookupClient.USER_AGENT_PREFIX} $versionUa", + headers.getValue("user-agent"), + ) + assertEquals(Base64.getEncoder().encodeToString(pushToken), headers.getValue("x-push-token")) + // The opt-in flags of §2.1, both true "in most calls" — the default here. + assertEquals("true", headers.getValue("x-required-for-message")) + assertEquals("true", headers.getValue("x-result-expected")) + } + + @Test + fun subServiceHeaderRidesOnlyWhenConfigured() = runBlocking { + val with = transport(TunnelReply(0, gzip(responseBody()))) + val without = transport(TunnelReply(0, gzip(responseBody()))) + + IdsLookupClient(with, bag(), config(subService = "com.apple.private.alloy.sms")) + .lookup(listOf("tel:$handle")) + IdsLookupClient(without, bag(), config()).lookup(listOf("tel:$handle")) + + assertEquals("com.apple.private.alloy.sms", with.calls.single().headers.getValue("x-id-sub-service")) + assertFalse(without.calls.single().headers.containsKey("x-id-sub-service")) + } + + @Test + fun requestBodyCarriesPlainHandleStringsNotRegisterStyleDicts() = runBlocking { + val t = transport(TunnelReply(0, gzip(responseBody()))) + val uris = listOf("tel:$handle", "mailto:user@example.com") + + IdsLookupClient(t, bag(), config()).lookup(uris) + + val decoded = NativePlist.decode(t.calls.single().body) as Map<*, *> + assertEquals(uris, decoded["uris"]) + assertTrue((decoded["uris"] as List<*>).all { it is String }, "uris elements must be plain strings") + } + + @Test + fun gzippedResponseParsesTopLevelStatusAndHandleKeyedResults() = runBlocking { + val t = transport(TunnelReply(0, gzip(responseBody()))) + + val response = IdsLookupClient(t, bag(), config()).lookup(listOf("tel:$handle")) + + assertEquals(0, response.status) + val result = response.results.getValue(handle) + assertEquals(1, result.identities.size) + assertEquals(3600L, result.identities[0].expiresSeconds) + assertEquals(1800L, result.identities[0].refreshSeconds) + // C48-closed keys ride in raw, typed only where the flow needs them. + assertEquals("opaque-session-token", result.identities[0].raw["session-token"]) + assertEquals("corr", result.raw["sender-correlation-identifier"]) + assertTrue(result.raw.containsKey("kt-account-key")) + val clientData = result.identities[0].raw["client-data"] as Map<*, *> + assertTrue(clientData.containsKey("public-message-identity-key")) + assertTrue(clientData.containsKey("public-message-ngm-device-prekey-data-key")) + assertTrue(clientData.containsKey("ngm-public-identity")) + assertTrue(clientData.containsKey("supports-certified-delivery-v1")) + + val empty = response.results.getValue("not-registered@example.com") + assertEquals(3, empty.status) + assertEquals(0, empty.identities.size) + } + + @Test + fun ungzippedResponseParsesToo() = runBlocking { + val t = transport(TunnelReply(0, responseBody())) + + val response = IdsLookupClient(t, bag(), config()).lookup(listOf("tel:$handle")) + + assertEquals(3600L, response.results.getValue(handle).identities[0].expiresSeconds) + } + + @Test + fun topLevelStatusIsSurfacedIncludingErrorCodes() = runBlocking { + // Port note: the constant lived in the un-ported orchestration file — the spec value + // 5206 ("response too large", §2.1) is inlined here. + val t = transport(TunnelReply(0, gzip(responseBody(status = 5206)))) + + val response = IdsLookupClient(t, bag(), config()).lookup(listOf("tel:$handle")) + + assertEquals(5206, response.status) + } + + @Test + fun absentPerHandleStatusParsesLeniently() = runBlocking { + val bytes = NativePlist.encode( + mapOf( + "status" to 0L, + "results" to mapOf(handle to mapOf("identities" to emptyList())), + ), + ) + val t = transport(TunnelReply(0, bytes)) + + val response = IdsLookupClient(t, bag(), config()).lookup(listOf("tel:$handle")) + + // C55: neither reference client reads a per-handle status — absence is not an error. + assertEquals(0, response.results.getValue(handle).status) + } + + @Test + fun nonZeroTunnelStatusThrows() { + val t = transport(TunnelReply(7, gzip(responseBody()))) + + assertFailsWith { + runBlocking { IdsLookupClient(t, bag(), config()).lookup(listOf("tel:$handle")) } + } + } + + @Test + fun unparseableBodyThrows() { + val t = transport(TunnelReply(0, "not a plist".toByteArray())) + + assertFailsWith { + runBlocking { IdsLookupClient(t, bag(), config()).lookup(listOf("tel:$handle")) } + } + } + + @Test + fun missingIdQueryKeyIsTheBagError() { + val t = transport() + val client = IdsLookupClient(t, Bag(emptyMap()), config()) + + assertFailsWith { + runBlocking { client.lookup(listOf("tel:$handle")) } + } + assertEquals(0, t.calls.size) + } + + private companion object { + /** Test-only key, generated per class — never a hardcoded key. */ + val RSA_KEY: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + } +} diff --git a/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsSigningTest.kt b/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsSigningTest.kt new file mode 100644 index 0000000..92cbc18 --- /dev/null +++ b/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsSigningTest.kt @@ -0,0 +1,89 @@ +package dev.fenn.imessage.ids + +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.SecureRandom +import java.security.Signature +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 IdsSigningTest { + + private val fixedMillis = 1_700_000_000_000L + + // Known answer for fixedMillis = 1700000000000 = 0x0000018BCFE56800 (spec §1.4 nonce layout). + private val expectedMillisBytes = byteArrayOf( + 0x00, 0x00, 0x01, 0x8B.toByte(), 0xCF.toByte(), 0xE5.toByte(), 0x68, 0x00, + ) + private val expectedRandomBytes = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) + + private fun fixedRandom() = object : SecureRandom() { + override fun nextBytes(bytes: ByteArray) { + for (i in bytes.indices) bytes[i] = (i + 1).toByte() + } + } + + @Test + fun nonceIsTypeByteThenBigEndianMillisThenRandom() { + val nonce = IdsSigning.nonce(IdsSigning.TYPE_HTTPS, fixedMillis, fixedRandom()) + + assertEquals(17, nonce.size) + assertEquals(0x01, nonce[0].toInt()) + assertContentEquals(expectedMillisBytes, nonce.copyOfRange(1, 9)) + assertContentEquals(expectedRandomBytes, nonce.copyOfRange(9, 17)) + } + + @Test + fun nonceTypeByteIsTheOnlyDifference() { + val apns = IdsSigning.nonce(IdsSigning.TYPE_APNS, fixedMillis, fixedRandom()) + assertEquals(0x00, apns[0].toInt()) + assertFailsWith { IdsSigning.nonce(0x42.toByte(), fixedMillis, fixedRandom()) } + } + + @Test + fun signingBytesAreNonceThenLengthPrefixedFields() { + val nonce = IdsSigning.nonce(IdsSigning.TYPE_HTTPS, fixedMillis, fixedRandom()) + val bytes = IdsSigning.signingBytes(nonce, listOf("id-query".toByteArray(), ByteArray(0), byteArrayOf(0xAA.toByte()))) + + // nonce | 00 00 00 08 "id-query" | 00 00 00 00 | 00 00 00 01 AA + val expected = nonce + + byteArrayOf(0, 0, 0, 8) + "id-query".toByteArray() + + byteArrayOf(0, 0, 0, 0) + + byteArrayOf(0, 0, 0, 1, 0xAA.toByte()) + assertContentEquals(expected, bytes) + assertEquals(17 + 12 + 4 + 5, bytes.size) + } + + @Test + fun nonceLengthIsEnforced() { + assertFailsWith { IdsSigning.signingBytes(ByteArray(16), emptyList()) } + } + + @Test + fun signatureIsPrefixedAndVerifiesOverTheSigningBytes() { + val nonce = IdsSigning.nonce(IdsSigning.TYPE_HTTPS, fixedMillis, fixedRandom()) + val fields = listOf("id-query".toByteArray(), ByteArray(0), "body".toByteArray(), byteArrayOf(9, 9, 9)) + val signature = IdsSigning.sign(RSA_KEY.private, nonce, fields) + + assertContentEquals(byteArrayOf(0x01, 0x01), signature.copyOfRange(0, 2)) + assertTrue(verify(RSA_KEY, nonce, fields, signature)) + // A different push token must not verify against that signature. + assertFalse(verify(RSA_KEY, nonce, listOf("id-query".toByteArray(), ByteArray(0), "body".toByteArray(), byteArrayOf(8, 8, 8)), signature)) + } + + private fun verify(keyPair: KeyPair, nonce: ByteArray, fields: List, signature: ByteArray): Boolean { + val verifier = Signature.getInstance("SHA1withRSA") + verifier.initVerify(keyPair.public) + verifier.update(IdsSigning.signingBytes(nonce, fields)) + return verifier.verify(signature.copyOfRange(2, signature.size)) + } + + private companion object { + /** Test-only key, generated per class — never a hardcoded key. */ + val RSA_KEY: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + } +} diff --git a/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsTunnelTest.kt b/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsTunnelTest.kt new file mode 100644 index 0000000..34933b9 --- /dev/null +++ b/ids/src/test/kotlin/dev/fenn/imessage/ids/IdsTunnelTest.kt @@ -0,0 +1,242 @@ +package dev.fenn.imessage.ids + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import java.io.ByteArrayOutputStream +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.util.zip.GZIPOutputStream +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield + +/** + * The §2.1 tunnel command builders and the [CourierTunnelTransport] adapter, + * scripted end to end: no courier socket, no network — frames go into a list + * and command-97 responses are fed back through [CourierTunnelTransport + * .onFrame]. + */ +class IdsTunnelTest { + + /** The field-id mapping is caller-supplied (spec §2.1 records the field set, not ids). */ + private val fieldIds = CourierCommands.TunnelFieldIds( + requestUrl = 3, + requestHeaders = 4, + requestContentType = 5, + requestBody = 6, + requestUuid = 7, + replyUuid = 2, + replyStatus = 3, + replyBody = 4, + ) + + private val requestUuid = ByteArray(16) { (it + 1).toByte() } + + @Test + fun tunnelRequestFrameCarriesTheRecordedFieldSet() { + val headers = mapOf("x-id-self-uri" to "tel:+15551234567", "x-protocol-version" to "1660") + + val frame = CourierCommands.tunnelRequestFrame( + url = "https://query.ess.apple.com/query", + headers = headers, + contentType = "application/x-apple-plist", + body = byteArrayOf(1, 2, 3), + requestUuid = requestUuid, + ids = fieldIds, + ) + + assertEquals(CourierFrame.TUNNEL_REQUEST, frame.command) + assertEquals(96, frame.command) + assertEquals("https://query.ess.apple.com/query", frame.field(3)!!.decodeToString()) + // §2.1: the headers ride inside the tunnel payload in a dictionary keyed `h`. + val headersDict = NativePlist.decode(frame.field(4)!!) as Map<*, *> + assertEquals(headers, headersDict["h"]) + assertEquals("application/x-apple-plist", frame.field(5)!!.decodeToString()) + assertContentEquals(byteArrayOf(1, 2, 3), frame.field(6)) + assertContentEquals(requestUuid, frame.field(7)) + } + + @Test + fun tunnelRequestUuidMustBe16Bytes() { + assertFailsWith { + CourierCommands.tunnelRequestFrame( + "https://x", emptyMap(), "application/x-apple-plist", ByteArray(0), ByteArray(17), fieldIds, + ) + } + } + + @Test + fun tunnelResponseParsesTheRecordedFieldSet() { + val frame = CourierFrame.Frame( + CourierFrame.TUNNEL_RESPONSE, + listOf( + CourierFrame.Frame.Field(fieldIds.replyUuid, requestUuid), + CourierFrame.Frame.Field(fieldIds.replyStatus, byteArrayOf(0)), + CourierFrame.Frame.Field(fieldIds.replyBody, byteArrayOf(9, 8)), + ), + ) + + val response = CourierCommands.parseTunnelResponse(frame, fieldIds) + + assertContentEquals(requestUuid, response.requestUuid) + assertEquals(0, response.status) + assertContentEquals(byteArrayOf(9, 8), response.body) + } + + @Test + fun tunnelResponseParsingRejectsOtherCommandsAndMissingFields() { + assertFailsWith { + CourierCommands.parseTunnelResponse(CourierFrame.Frame(CourierFrame.SEND, emptyList()), fieldIds) + } + assertFailsWith { + CourierCommands.parseTunnelResponse(CourierFrame.Frame(CourierFrame.TUNNEL_RESPONSE, emptyList()), fieldIds) + } + } + + @Test + fun transportCompletesWhenTheMatchingResponseArrives() = runBlocking { + val sent = mutableListOf() + val transport = CourierTunnelTransport(fieldIds, sendFrame = { sent.add(it) }, timeoutMs = 5_000) + + val exchange = async { transport.exchange("https://q", mapOf("h" to "v"), byteArrayOf(1), "ct") } + while (sent.isEmpty()) yield() + val request = sent.single() + assertEquals(CourierFrame.TUNNEL_REQUEST, request.command) + val uuid = request.field(fieldIds.requestUuid)!! + assertEquals(CourierCommands.TUNNEL_REQUEST_UUID_BYTES, uuid.size) + + transport.onFrame( + CourierFrame.Frame( + CourierFrame.TUNNEL_RESPONSE, + listOf( + CourierFrame.Frame.Field(fieldIds.replyUuid, uuid), + CourierFrame.Frame.Field(fieldIds.replyStatus, byteArrayOf(0)), + CourierFrame.Frame.Field(fieldIds.replyBody, byteArrayOf(7)), + ), + ), + ) + val reply = exchange.await() + assertEquals(0, reply.status) + assertContentEquals(byteArrayOf(7), reply.body) + } + + @Test + fun transportTimesOutPerTheSpecMinute() = runBlocking { + val transport = CourierTunnelTransport(fieldIds, sendFrame = {}, timeoutMs = 20) + + val outcome = runCatching { + transport.exchange("https://q", emptyMap(), ByteArray(0), "ct") + }.exceptionOrNull() + + assertIs(outcome) + assertTrue(outcome.message!!.contains("timed out")) + } + + @Test + fun aResponseWithNoWaitingRequestIsDropped() = runBlocking { + val log = mutableListOf() + val transport = CourierTunnelTransport(fieldIds, sendFrame = {}, logEvent = { log.add(it) }) + + transport.onFrame( + CourierFrame.Frame( + CourierFrame.TUNNEL_RESPONSE, + listOf( + CourierFrame.Frame.Field(fieldIds.replyUuid, ByteArray(16) { 9 }), + CourierFrame.Frame.Field(fieldIds.replyStatus, byteArrayOf(0)), + ), + ), + ) + transport.onFrame(CourierFrame.Frame(CourierFrame.SEND, emptyList())) // not a tunnel response + + assertTrue(log.isNotEmpty()) + // The non-tunnel frame is ignored entirely: no extra log entry beyond the drop. + assertEquals(1, log.size) + } + + @Test + fun lookupOverTheTunnelRoundTripsEndToEnd() = runBlocking { + val sent = mutableListOf() + val transport = CourierTunnelTransport(fieldIds, sendFrame = { sent.add(it) }, timeoutMs = 5_000) + val response = NativePlist.encode( + mapOf( + "status" to 0L, + "results" to mapOf("tel:+15551234567" to mapOf("identities" to emptyList())), + ), + ) + val gzipped = ByteArrayOutputStream().also { GZIPOutputStream(it).use { g -> g.write(response) } }.toByteArray() + + val outcome = async { + IdsLookupClient(transport, Bag(mapOf("id-query" to "https://q")), lookupConfig()) + .lookup(listOf("tel:+15551234567")) + } + while (sent.isEmpty()) yield() + val uuid = sent.single().field(fieldIds.requestUuid)!! + transport.onFrame( + CourierFrame.Frame( + CourierFrame.TUNNEL_RESPONSE, + listOf( + CourierFrame.Frame.Field(fieldIds.replyUuid, uuid), + CourierFrame.Frame.Field(fieldIds.replyStatus, byteArrayOf(0)), + CourierFrame.Frame.Field(fieldIds.replyBody, gzipped), + ), + ), + ) + + assertEquals(0, outcome.await().status) + } + + private fun lookupConfig() = LookupConfig( + selfUri = "tel:+15557654321", + pushToken = ByteArray(32), + protocolVersion = "1660", + versionUa = "[Test,1,1,Test]", + signingKey = RSA_KEY.private, + certificateDer = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x00), + ) + + private companion object { + /** Test-only key, generated per class — never a hardcoded key. */ + val RSA_KEY: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + } +} + +/** The private-IDS push routing of §2.3 as the engine's frame handler sees it. */ +class PrivateIdsPushTest { + + private fun idsPushFrame(body: String): CourierFrame.Frame = + CourierCommands.sendFrame( + CourierCommands.topicHash(PrivateIdsPush.TOPIC), + ByteArray(32), + body.toByteArray(), + messageId = 1, + ) + + @Test + fun extractsTheCommandCountFromAnIdsTopicPush() { + assertEquals(34, PrivateIdsPush.commandFromFrame(idsPushFrame("""{"c":34}"""))) + } + + @Test + fun ignoresOtherTopics() { + val frame = CourierCommands.sendFrame( + CourierCommands.topicHash("com.apple.madrid"), ByteArray(32), """{"c":34}""".toByteArray(), 1, + ) + assertNull(PrivateIdsPush.commandFromFrame(frame)) + } + + @Test + fun ignoresNonSendFramesAndMalformedBodies() { + assertNull(PrivateIdsPush.commandFromFrame(CourierFrame.Frame(CourierFrame.KEEPALIVE_ACK, emptyList()))) + assertNull(PrivateIdsPush.commandFromFrame(idsPushFrame("not json"))) + assertNull(PrivateIdsPush.commandFromFrame(idsPushFrame("""{"other":1}"""))) + } +} diff --git a/ids/src/test/resources/imessage/AppleServerAuthenticationCA-2010.pem b/ids/src/test/resources/imessage/AppleServerAuthenticationCA-2010.pem new file mode 100644 index 0000000..d24695d --- /dev/null +++ b/ids/src/test/resources/imessage/AppleServerAuthenticationCA-2010.pem @@ -0,0 +1,47 @@ +-----BEGIN CERTIFICATE----- +MIID+DCCAuCgAwIBAgIII2l0BK3LgxQwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UE +BhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsTHUFwcGxlIENlcnRp +ZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBSb290IENBMB4XDTE0 +MDMwODAxNTMwNFoXDTI5MDMwODAxNTMwNFowbTEnMCUGA1UEAwweQXBwbGUgU2Vy +dmVyIEF1dGhlbnRpY2F0aW9uIENBMSAwHgYDVQQLDBdDZXJ0aWZpY2F0aW9uIEF1 +dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5Jhawy4ercRWSjt+qPuGA11O6pGDM +fIVy9zB8CU9XDUr/4V7JS1ATAmSxvTk10dcEUcEY+iL6rt+YGNa/Tk1DEPoliJ/T +QIV25SKBtlRFc5qL45xIGoZ6w1Hi2pX4pH3bMN5sDsTF9WyY56b6VyAdGXN6Ds1j +D7cniC7hmmiCuEBsYxYkZivnsuJUfeeIOaIbgT4C0znYl3dKMgzWCgqzBJvxcm9j +qBUebDfoD9tTkNYpXLxqV5tGeAo+JOqaP6HYP/XbbqhsgrXdmTjsklaUpsVzJtGu +CLLGUueOdkuJuFQPbuDZQtsqZYdGFLuWuFe7UeaEE/cNobaJrHzRIXSrAgMBAAGj +gaYwgaMwHQYDVR0OBBYEFCzFbVLdMe+M7AiB7d/cykMARQHQMA8GA1UdEwEB/wQF +MAMBAf8wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wLgYDVR0fBCcw +JTAjoCGgH4YdaHR0cDovL2NybC5hcHBsZS5jb20vcm9vdC5jcmwwDgYDVR0PAQH/ +BAQDAgEGMBAGCiqGSIb3Y2QGAgwEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQAj8QZ+ +UEGBol7TcKRJka/YzGeMoSV9xJqTOS/YafsbQVtE19lryzslCRry9OPHnOiwW/Df +3SIlERWTuUle2gxmel7Xb/Bj1GWMxHpUfVZPZZr92sSyyLC4oct94EeoQBW4Fhnt +W2GO36rQzdI6wH46nyJO39/0ThrNk//Q8EVVZDM+1OXaaKATinYwJ9S/+B529vnD +AO+xg+pTbVw1xw0HAbr4Ybn+xZprQ2GBA+u6X3Cd6G+UJEvczpKoLqI1PONJ4BZ3 +otxruY0YQrk2lkMyxst2mTU22FbGmF3Db6V+lcLVegoCIGZ4kvJnpCMN6Am9zCEx +EKC9vrXdTN1GA5mZ +-----END CERTIFICATE----- +--- +Server certificate +subject=CN = query.ess.apple.com, O = Apple Inc., ST = California, C = US +issuer=CN = Apple Server Authentication CA, OU = Certification Authority, O = Apple Inc., C = US +--- +No client certificate CA names sent +Peer signing digest: SHA256 +Peer signature type: RSA-PSS +Server Temp Key: X25519, 253 bits +--- +SSL handshake has read 3091 bytes and written 401 bytes +Verification error: unable to get local issuer certificate +--- +New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 +Server public key is 2048 bit +Secure Renegotiation IS NOT supported +Compression: NONE +Expansion: NONE +No ALPN negotiated +Early data was not sent +Verify return code: 20 (unable to get local issuer certificate) +--- +DONE diff --git a/ids/src/test/resources/imessage/AppleServerAuthenticationCA2-G3.pem b/ids/src/test/resources/imessage/AppleServerAuthenticationCA2-G3.pem new file mode 100644 index 0000000..445a002 --- /dev/null +++ b/ids/src/test/resources/imessage/AppleServerAuthenticationCA2-G3.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIIC7DCCAnKgAwIBAgIIW0OkxHdBzOwwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTcwMTA1MjAzOTU4WhcNMzExMDIxMDAwMDAwWjB1MSkwJwYDVQQDDCBBcHBsZSBT +ZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgMjEmMCQGA1UECwwdQXBwbGUgQ2VydGlm +aWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYT +AlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEz+Muhn4KiyKLzXgfmvcuESbE +MfG3t2AtNnjT94GFUYSbuwCon24UZOZN+8wZn89WAGrZUZx34uIHxna+7hD1LqOB ++TCB9jAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFLuw3qFYM4iapIqZ3r69 +66/ayySrMEgGCCsGAQUFBwEBBDwwOjA4BggrBgEFBQcwAYYsaHR0cDovL29jc3Au +YXBwbGUuY29tL29jc3AwMy1hcHBsZXJvb3RjYWczMDQwNwYDVR0fBDAwLjAsoCqg +KIYmaHR0cDovL2NybC5hcHBsZS5jb20vYXBwbGVyb290Y2FnMy5jcmwwHQYDVR0O +BBYEFNEx0cpREQ/NyZu1M9q3zr5WhDU7MA4GA1UdDwEB/wQEAwIBBjAQBgoqhkiG +92NkBgIMBAIFADAKBggqhkjOPQQDAwNoADBlAjEAieK078D03E4+GF1Oz0+Oy2WV +K8fp836ut88rHrmsro+H/cMEwAATlJlxipamyoYfAjALSohKmzrVZ3ksmz6LVd5Q +HixZymHQoE+q0ernZle4nWGXRBSCVY1ZDxz//PafOuk= +-----END CERTIFICATE----- +--- +Server certificate +subject=CN = init.ess.apple.com, O = Apple Inc., ST = California, C = US +issuer=CN = Apple Server Authentication CA 2, OU = Apple Certification Authority, O = Apple Inc., C = US +--- +No client certificate CA names sent +Peer signing digest: SHA256 +Peer signature type: ECDSA +Server Temp Key: X25519, 253 bits +--- +SSL handshake has read 1909 bytes and written 400 bytes +Verification error: unable to get local issuer certificate +--- +New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 +Server public key is 256 bit +Secure Renegotiation IS NOT supported +Compression: NONE +Expansion: NONE +No ALPN negotiated +Early data was not sent +Verify return code: 20 (unable to get local issuer certificate) +--- +DONE diff --git a/registration/build.gradle.kts b/registration/build.gradle.kts new file mode 100644 index 0000000..61e21a1 --- /dev/null +++ b/registration/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + kotlin("jvm") // version inherited from the root classpath (kotlin-gradle-plugin 2.3.20) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":codec")) + implementation(project(":courier")) + implementation(project(":ids")) + implementation(project(":crypto")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/AdiProvisioning.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/AdiProvisioning.kt new file mode 100644 index 0000000..569abfe --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/AdiProvisioning.kt @@ -0,0 +1,103 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +/** ADI provisioning exchange failure (spec §1.1). */ +class AdiProvisioningException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * One caller-assembled provisioning round trip (spec §1.1: start/finish provisioning with + * gsa.apple.com). Everything on the wire that the spec does not record — the endpoint paths, + * the request body keys, the body encoding, the anisette/auth header usage at provisioning + * time (C56) — is supplied by the caller here; nothing is invented. + */ +data class AdiProvisioningRequest( + val url: String, + val headers: Map, + val body: ByteArray, + val contentType: String, +) + +/** + * One provisioning response. [body] is always the raw bytes; [plist] is the body parsed as a + * string-keyed plist dict, or null when the body is not one — the response encoding is not + * recorded (TODO(capture) C6), so a null here means "unrecorded encoding hit", and the + * response's field names are read by the caller off the raw map, never guessed here. + */ +data class AdiProvisioningResponse( + val status: Int, + val headers: Map, + val body: ByteArray, + val plist: Map?, +) + +/** + * The ADI provisioning exchange plumbing of spec §1.1: a start-provisioning round trip and a + * finish-provisioning round trip against gsa.apple.com, whose responses carry the machine + * tokens the anisette headers derive from. Only the host and the start/finish shape are + * recorded — paths, encodings, and field names stay caller-supplied/raw (TODO(capture) C6), + * with the transport injected so tests script the exchange and production stays gated. + */ +class AdiProvisioner(private val http: IdsHttp) { + + suspend fun start(request: AdiProvisioningRequest): AdiProvisioningResponse = + exchange("start-provisioning", request) + + suspend fun finish(request: AdiProvisioningRequest): AdiProvisioningResponse = + exchange("finish-provisioning", request) + + private suspend fun exchange(step: String, request: AdiProvisioningRequest): AdiProvisioningResponse { + val response = http.post(request.url, request.headers, request.body, request.contentType) + if (response.status != 200) { + throw AdiProvisioningException("$step POST ${request.url} → HTTP ${response.status}") + } + return AdiProvisioningResponse( + status = response.status, + headers = response.headers, + body = response.body, + plist = plistDictOrNull(response.body, step, request.url), + ) + } + + private fun plistDictOrNull(body: ByteArray, step: String, url: String): Map? = + try { + stringKeyedDictOrNull(Plist.parse(body)) + } catch (_: PlistFormatException) { + null + } catch (e: Exception) { + throw AdiProvisioningException("$step response from $url failed to decode: ${e.message}", e) + } + + companion object { + /** The one recorded host fact (spec §1.1); the paths on it are unrecorded. */ + const val GSA_HOST = "gsa.apple.com" + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/AlbertActivation.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/AlbertActivation.kt new file mode 100644 index 0000000..77ce688 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/AlbertActivation.kt @@ -0,0 +1,214 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.net.URLEncoder +import java.security.KeyPair +import java.security.SecureRandom + +/** + * The FairPlay (certificate chain, signature) pair of spec §1.2. The signature is SHA-1 RSA + * PKCS1v15 over the serialized ActivationInfo plist; both inputs come from the caller. The + * default caller supply is the bundled community identity ([BundledFairPlaySigner], §7 item + * 21 user decision); tests inject a scripted signer — the injection point stays overridable. + */ +class FairPlayMaterial(val certChain: ByteArray, val signature: ByteArray) + +/** + * Supplies the §1.2 FairPlay pair for one activation. [sign] receives the exact serialized + * ActivationInfo plist bytes (§1.2: "FairPlay signing covers the serialized ActivationInfo + * plist") and returns the chain and signature placed into the activation body. + */ +fun interface FairPlaySigner { + fun sign(activationInfoPlist: ByteArray): FairPlayMaterial +} + +/** + * The nested ActivationInfoXML plist of spec §1.2 — keys `ActivationRandomness` (uppercase + * UUIDv4), `ActivationState` ("Unactivated"), `BuildVersion`, `DeviceCertRequest` (PEM CSR), + * `DeviceClass`, `ProductType`, `ProductVersion`, `SerialNumber`, `UniqueDeviceID`. + */ +data class AlbertActivationInfo( + val activationRandomness: String, + val buildVersion: String, + /** `DeviceCertRequest` — the PEM CSR of §1.2 ([AlbertCsr.create]). */ + val deviceCertRequestPem: String, + val deviceClass: String = DEVICE_CLASS_MACOS, + val productType: String, + val productVersion: String, + val serialNumber: String, + val uniqueDeviceId: String, +) { + fun toDict(): Map = linkedMapOf( + "ActivationRandomness" to activationRandomness, + "ActivationState" to ACTIVATION_STATE, + "BuildVersion" to buildVersion, + "DeviceCertRequest" to deviceCertRequestPem, + "DeviceClass" to deviceClass, + "ProductType" to productType, + "ProductVersion" to productVersion, + "SerialNumber" to serialNumber, + "UniqueDeviceID" to uniqueDeviceId, + ) + + companion object { + /** §1.2 records device class "MacOS" — matching the activation URL's `?device=MacOS`. */ + const val DEVICE_CLASS_MACOS = "MacOS" + + const val ACTIVATION_STATE = "Unactivated" + + /** An uppercase UUIDv4 (§1.2). */ + fun newActivationRandomness(random: SecureRandom = SecureRandom()): String { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x40).toByte() + bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte() + val hex = bytes.joinToString("") { "%02x".format(it) } + return buildString { + append(hex, 0, 8); append('-') + append(hex, 8, 12); append('-') + append(hex, 12, 16); append('-') + append(hex, 16, 20); append('-') + append(hex, 20, 32) + }.uppercase() + } + } +} + +/** + * The `DeviceCertRequest` CSR of spec §1.2 — recorded outright, not disagreed: a fresh + * 1024-bit RSA key, CN "Client Push Certificate", OU "iPhone", O "Apple Inc.", signed + * SHA-1WithRSA. The private key is client-generated and never sent; it later pairs with the + * returned DeviceCertificate (courier connect field 12, §3.3). + */ +object AlbertCsr { + const val COMMON_NAME = "Client Push Certificate" + const val ORGANIZATIONAL_UNIT = "iPhone" + const val ORGANIZATION = "Apple Inc." + const val RSA_BITS = 1024 + + /** The recorded self-signature hash (§1.2 "SHA-1WithRSA"). */ + val SELF_SIGNATURE_HASH = CsrSignatureHash.SHA1 + + fun create(keyPair: KeyPair): IdsCsr = + IdsCsrBuilder.create( + keyPair, + COMMON_NAME, + ORGANIZATIONAL_UNIT, + ORGANIZATION, + SELF_SIGNATURE_HASH, + ) +} + +/** The parsed Albert activation response (§1.2: `device-activation` → `activation-record`). */ +data class AlbertActivationResponse( + /** The device certificate (PEM) — client key never sent, so this is the only returned material. */ + val deviceCertificatePem: String?, + /** `ack-received` / `show-settings` — value types not recorded (§1.2), carried verbatim. */ + val ackReceived: Any?, + val showSettings: Any?, + val raw: Map, +) + +/** + * The Albert activation of spec §1.2 (C8's wire half): POST form-urlencoded with the single + * form key `activation-info`, whose value is an XML plist carrying `ActivationInfoComplete` + * (true), `ActivationInfoXML` (the nested XML plist), `FairPlayCertChain` and + * `FairPlaySignature`. No timestamp is sent; no Apple-Account login is needed (§1.1). + * + * The response's outer `` block is plist-parsed ([XmlPlist.decodeProtocol]): + * `device-activation` → `activation-record` → `DeviceCertificate` (PEM), plus + * `ack-received`/`show-settings`. No device token appears in the response (§1.2). + */ +class AlbertActivator( + private val http: IdsHttp, + private val url: String = DEFAULT_URL, + private val fairPlay: FairPlaySigner = BundledFairPlaySigner(), +) { + + suspend fun activate( + info: AlbertActivationInfo, + fairPlay: FairPlaySigner = this.fairPlay, + ): AlbertActivationResponse { + val activationInfoXml = XmlPlist.encode(info.toDict()) + val material = fairPlay.sign(activationInfoXml) + val body = linkedMapOf( + "ActivationInfoComplete" to true, + "ActivationInfoXML" to activationInfoXml.toString(Charsets.UTF_8), + "FairPlayCertChain" to material.certChain, + "FairPlaySignature" to material.signature, + ) + val form = FORM_KEY + "=" + URLEncoder.encode( + XmlPlist.encode(body).toString(Charsets.UTF_8), + Charsets.UTF_8, + ) + val response = http.post(url, emptyMap(), form.toByteArray(Charsets.UTF_8), CONTENT_TYPE) + if (response.status != 200) throw IdsActivationException("POST $url → HTTP ${response.status}") + return parseResponse(response.body) + } + + private fun parseResponse(bytes: ByteArray): AlbertActivationResponse { + val dict = try { + XmlPlist.decodeProtocol(bytes) + } catch (e: PlistFormatException) { + throw IdsActivationException("activation response is not a plist: ${e.message}", e) + } + val root = stringKeyedDictOrNull(dict) + ?: throw IdsActivationException("activation response is ${typeName(dict)}, expected a dict") + val activationRecord = when (val deviceActivation = root["device-activation"]) { + null -> null + is Map<*, *> -> deviceActivation["activation-record"] as? Map<*, *> + ?: throw IdsActivationException( + "activation response has 'device-activation' but no 'activation-record' dict", + ) + else -> throw IdsActivationException( + "activation 'device-activation' is ${typeName(deviceActivation)}, expected a dict", + ) + } + val certificate = activationRecord?.get("DeviceCertificate") as? String + return AlbertActivationResponse( + deviceCertificatePem = certificate, + ackReceived = root["ack-received"], + showSettings = root["show-settings"], + raw = root, + ) + } + + companion object { + /** The recorded activation endpoint (§1.2). */ + const val DEFAULT_URL = "https://albert.apple.com/deviceservices/deviceActivation?device=MacOS" + + /** The single form key (§1.2). */ + const val FORM_KEY = "activation-info" + + const val CONTENT_TYPE = "application/x-www-form-urlencoded" + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/AnisetteHeaders.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/AnisetteHeaders.kt new file mode 100644 index 0000000..c3aaa51 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/AnisetteHeaders.kt @@ -0,0 +1,169 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 + +/** Anisette header assembly failure (a seam produced no value where one is required). */ +class AnisetteException(message: String) : Exception(message) + +/** + * The §1.1 hardware-configuration field set (one struct for both consumers): the three + * hardware anisette headers the header provider encodes, plus the full identifier set the + * Mac-Hardware-Info export code carries (parsed by chunk B2's [MacHardwareInfoExportCode]). + * + * The export-only fields default to null — [AnisetteHeaderProvider] consumes only the three + * header values; the parser fills the whole struct. + */ +data class MacHardwareIdentifiers( + /** `X-Apple-I-SRL-NO` — the platform serial. */ + val serialNumber: String, + /** `X-Apple-I-MLB` — the logic-board serial. */ + val logicBoardSerial: String, + /** `X-Apple-I-ROM` raw bytes — encoded lowercase hex on the wire (§1.1). */ + val rom: ByteArray, + /** Apple product-type form, e.g. `Macmini9,1` (§1.1). */ + val productName: String? = null, + /** The MAC address — exactly 6 raw bytes (§1.1). */ + val macAddress: ByteArray? = null, + /** The platform UUID. */ + val platformUuid: String? = null, + /** The root-disk UUID. */ + val rootDiskUuid: String? = null, + /** The board ID, e.g. `Mac-…` (§1.1). */ + val boardId: String? = null, + /** The OS build number (§1.1). */ + val osBuildNumber: String? = null, + /** The five `_enc` obfuscated raw-byte variants, §1.1 label mapping. */ + val serialEnc: ByteArray? = null, + val platformUuidEnc: ByteArray? = null, + val rootDiskUuidEnc: ByteArray? = null, + val romEnc: ByteArray? = null, + val mlbEnc: ByteArray? = null, +) + +/** + * The capture-bound anisette values the spec names but does not give forms for + * (TODO(capture) C6): the `X-Apple-I-Client-Time` format, the `X-Apple-I-TimeZone`, + * `X-Apple-Locale`, and `X-Apple-I-MD-RINFO` values, and the `X-Mme-Device-Id` value + * form. All caller-supplied — nothing here is invented. + */ +class AnisetteValueFormats( + /** Formats the request time (epoch ms) into the `X-Apple-I-Client-Time` wire value. */ + val clientTime: (nowEpochMs: Long) -> String, + /** The `X-Apple-I-TimeZone` value. */ + val timeZone: String, + /** The `X-Apple-Locale` value. */ + val locale: String, + /** The `X-Apple-I-MD-RINFO` value. */ + val rinfo: String, + /** The `X-Mme-Device-Id` value (form unrecorded). */ + val deviceId: String, +) + +/** The inputs of the local OTP derivation: machine state plus a one-time identifier (§1.1). */ +class AnisetteOtpInput( + /** The machine identifier — the same bytes [AnisetteHeaderProvider] base64s into `X-Apple-I-MD-M`. */ + val machineIdentifier: ByteArray, + /** The one-time identifier of this OTP request (provenance unrecorded — caller-supplied). */ + val oneTimeIdentifier: ByteArray, +) + +/** + * The local OTP derivation seam (§1.1: ADI provisioning yields machine tokens, "then a local + * OTP"). The derivation algorithm is not recorded — TODO(capture) C6 — so this stays injectable: + * production supplies the real derivation once the capture names it; tests supply known answers. + * The output is the raw OTP bytes (the provider base64s them into `X-Apple-I-MD`). + */ +fun interface AnisetteOtp { + fun otp(input: AnisetteOtpInput): ByteArray +} + +/** + * The per-request anisette header set of spec §1.1 (C6 header-set half, closed by + * facts-only extraction) — exactly the seven recorded headers: + * + * `X-Apple-I-Client-Time`, `X-Apple-I-TimeZone`, `X-Apple-Locale`, `X-Apple-I-MD-RINFO`, + * `X-Mme-Device-Id`, `X-Apple-I-MD` (base64 OTP), `X-Apple-I-MD-M` (base64 machine ID), + * + * plus the macOS hardware headers when [MacHardwareIdentifiers] is supplied: + * `X-Apple-I-MLB`, `X-Apple-I-ROM` (lowercase hex), `X-Apple-I-SRL-NO`. + * + * The machine identifier's provenance is the ADI provisioning machine tokens (§1.1) — its + * wire form is unrecorded (TODO(capture) C6), so it arrives as raw caller-supplied bytes. + */ +class AnisetteHeaderProvider( + private val machineIdentifier: ByteArray, + private val macHardware: MacHardwareIdentifiers?, + private val otp: AnisetteOtp, + private val formats: AnisetteValueFormats, +) { + + /** + * The header map, in the §1.1 recording order. [nowEpochMs] feeds the client-time format; + * [oneTimeIdentifier] feeds the OTP seam. An OTP seam that returns no bytes fails loudly — + * an absent `X-Apple-I-MD` is never silently sent. + */ + fun headers(nowEpochMs: Long, oneTimeIdentifier: ByteArray): Map { + val otpBytes = otp.otp(AnisetteOtpInput(machineIdentifier, oneTimeIdentifier)) + if (otpBytes.isEmpty()) throw AnisetteException("OTP seam returned no bytes for X-Apple-I-MD") + val out = LinkedHashMap() + out[CLIENT_TIME] = formats.clientTime(nowEpochMs) + out[TIME_ZONE] = formats.timeZone + out[LOCALE] = formats.locale + out[RINFO] = formats.rinfo + out[DEVICE_ID] = formats.deviceId + out[MACHINE_OTP] = base64(otpBytes) + out[MACHINE_ID] = base64(machineIdentifier) + macHardware?.let { + out[MAC_MLB] = it.logicBoardSerial + out[MAC_ROM] = it.rom.joinToString("") { byte -> "%02x".format(byte) } + out[MAC_SRL_NO] = it.serialNumber + } + return out + } + + private fun base64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + companion object { + /** Recorded wire names (spec §1.1) — pinned here and in [AnisetteHeadersTest]. */ + const val CLIENT_TIME = "X-Apple-I-Client-Time" + const val TIME_ZONE = "X-Apple-I-TimeZone" + const val LOCALE = "X-Apple-Locale" + const val RINFO = "X-Apple-I-MD-RINFO" + const val DEVICE_ID = "X-Mme-Device-Id" + const val MACHINE_OTP = "X-Apple-I-MD" + const val MACHINE_ID = "X-Apple-I-MD-M" + const val MAC_MLB = "X-Apple-I-MLB" + const val MAC_ROM = "X-Apple-I-ROM" + const val MAC_SRL_NO = "X-Apple-I-SRL-NO" + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt new file mode 100644 index 0000000..5dfaba1 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt @@ -0,0 +1,124 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.math.BigInteger +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.PKCS8EncodedKeySpec + +/** + * The bundled FairPlay identity of spec §1.2 (rev 17; §7 item 21 user decision): the staged + * classpath resources `imessage/fairplay/leaf.crt` (DER leaf certificate) and + * `imessage/fairplay/identity.key` (PEM PKCS#1 `RSA PRIVATE KEY`). `FairPlaySignature` is + * SHA-1 RSA PKCS#1v15 over the exact serialized ActivationInfoXML bytes; `FairPlayCertChain` + * is the DER leaf alone, passed through verbatim (§1.2 — Apple's server fills in the rest). + * + * The default [FairPlaySigner] of [AlbertActivator]; tests inject a scripted signer instead. + * Only status text reaches [logEvent] — no key or certificate material ever does. + */ +class BundledFairPlaySigner( + private val logEvent: (String) -> Unit = {}, +) : FairPlaySigner { + + private val leafChain: ByteArray by lazy { resource(LEAF_CERT_RESOURCE) } + + private val identityKey: RSAPrivateKey by lazy { loadPrivateKey() } + + override fun sign(activationInfoPlist: ByteArray): FairPlayMaterial { + logEvent("fairplay: signing the activation-info plist with the bundled identity") + val signer = Signature.getInstance(SIGNATURE_ALGORITHM) + signer.initSign(identityKey) + signer.update(activationInfoPlist) + return FairPlayMaterial(leafChain, signer.sign()) + } + + /** The DER leaf's public key — the identity the signature must verify against (§1.2). */ + fun certificatePublicKey(): RSAPublicKey = certificatePublicKey(leafChain) + + /** + * SHA-256 of the private key's RSA modulus — the only form in which the identity key is + * comparable from outside (validation tests pair it against the leaf certificate's key). + */ + fun identityModulusSha256(): String = modulusSha256(identityKey.modulus) + + /** + * The staged key is PKCS#1 PEM; the JCE wants PKCS#8. The PKCS#1 body is wrapped verbatim + * into a minimal PKCS#8 envelope with the existing DER writer ([Der]) — never parsed, + * never re-encoded beyond the envelope. + */ + private fun loadPrivateKey(): RSAPrivateKey { + val pkcs1 = Pem.decode(resource(IDENTITY_KEY_RESOURCE).decodeToString(), PKCS1_TYPE) + val pkcs8 = Der.sequence( + Der.integer(BigInteger.ZERO), + Der.sequence(Der.oid(OID_RSA_ENCRYPTION), Der.nullValue()), + Der.octetString(pkcs1), + ) + val key = KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8)) + return key as? RSAPrivateKey + ?: throw IllegalStateException("bundled FairPlay identity key is ${key.algorithm}, expected RSA") + } + + private fun resource(name: String): ByteArray = + javaClass.getResourceAsStream(name)?.use { it.readBytes() } + ?: throw IllegalStateException("bundled FairPlay resource missing from the classpath: $name") + + companion object { + const val LEAF_CERT_RESOURCE = "/imessage/fairplay/leaf.crt" + const val IDENTITY_KEY_RESOURCE = "/imessage/fairplay/identity.key" + + /** §1.2: the FairPlay signature is SHA-1 RSA PKCS#1v15. */ + const val SIGNATURE_ALGORITHM = "SHA1withRSA" + + const val PKCS1_TYPE = "RSA PRIVATE KEY" + + private const val OID_RSA_ENCRYPTION = "1.2.840.113549.1.1.1" + + /** Parses a DER certificate chain blob into its RSA public key. */ + fun certificatePublicKey(derChain: ByteArray): RSAPublicKey { + val certificate = CertificateFactory.getInstance("X.509") + .generateCertificate(derChain.inputStream()) + return certificate.publicKey as? RSAPublicKey + ?: throw IllegalStateException( + "FairPlay leaf certificate key is ${certificate.publicKey.algorithm}, expected RSA", + ) + } + + /** SHA-256 of a key modulus, hex — the identity-comparison form (never the raw bytes). */ + fun modulusSha256(modulus: BigInteger): String = + MessageDigest.getInstance("SHA-256").digest(modulus.toByteArray()) + .joinToString("") { "%02x".format(it) } + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt new file mode 100644 index 0000000..f79b13d --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt @@ -0,0 +1,193 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +/** + * The GSA flavor of spec §1.5 (C59): which client identity the login impersonates. Recorded + * per-flavor cpd/header values — Messages: `capp` = `Messages`, `cbid` = `com.apple.MobileSMS`, + * `svct`/`X-Apple-AK-Context-Type` = `imessage`; AppleIDSettings: `icloud` / + * `com.apple.systempreferences.AppleIDSettings` / `icloud`. + */ +enum class GsaFlavor( + /** `capp` / `X-Apple-Client-App-Name`. */ + val clientAppName: String, + /** `cbid` / `X-Apple-I-Client-Bundle-Id`. */ + val clientBundleId: String, + /** `svct` / `X-Apple-AK-Context-Type`. */ + val akContextType: String, +) { + MESSAGES("Messages", "com.apple.MobileSMS", "imessage"), + APPLE_ID_SETTINGS("icloud", "com.apple.systempreferences.AppleIDSettings", "icloud"), +} + +/** + * The GSA `cpd` (client-provided-data) dictionary of spec §1.5 — a dict inside the request + * body carrying the anisette values as KEYS plus the fixed-value set. Anisette values come + * from chunk A's [AnisetteHeaderProvider]; the `X-Apple-I-MD-LU` value is derived here from + * the keychain identifier per §1.1 (lowercase hex SHA-256 over the same 16 bytes). + * + * `capp`/`cbid`/`svct` ride the recorded per-flavor values ([GsaFlavor], C59); `ptkn` (APNs + * token) is optional. + * + * Recorded fixed values, pinned: `X-Apple-I-Device-Configuration-Mode` = `0`, + * `X-Apple-I-Request-UUID` (uppercase UUIDv4 — one per login, reused across both steps, + * passed in), `X-Apple-Requested-Partition` = `0`, + * `X-Apple-Security-Upgrade-Context` = `com.apple.authkit.generic`, `cou` = `US`, + * `loc` = `en_US`, and the booleans `X-Apple-Offer-Security-Upgrade`=true, `bootstrap`=true, + * `ckgen`=true, `fcd`=true, `icdrsDisabled`=false, `icscrec`=true, `pbe`=false, + * `prkgen`=true, `webAccessEnabled`=false, `at`=0 (the spec lists `at` among the booleans + * but records its value as `0`; encoded as the plist boolean false). + */ +class GsaCpd( + /** The §1.1 anisette headers (from [AnisetteHeaderProvider.headers]). */ + private val anisette: Map, + /** The 16-byte keychain identifier — feeds the `X-Apple-I-MD-LU` derivation (§1.1). */ + private val keychainIdentifier: ByteArray, + /** The recorded per-flavor `capp`/`cbid`/`svct` values (§1.5, C59). */ + val flavor: GsaFlavor = GsaFlavor.MESSAGES, + /** Optional APNs token (`ptkn`). */ + private val apnsToken: String? = null, +) { + + /** The cpd dict for one login; [requestUuid] is the uppercase UUIDv4 shared by both steps. */ + fun dictFor(requestUuid: String): Map { + val dict = LinkedHashMap() + ANISETTE_KEYS.forEach { key -> anisette[key]?.let { dict[key] = it } } + dict["X-Apple-I-MD-LU"] = mdLu() + dict["X-Apple-I-Device-Configuration-Mode"] = "0" + dict[REQUEST_UUID_KEY] = requestUuid + dict["X-Apple-Requested-Partition"] = "0" + dict["X-Apple-Security-Upgrade-Context"] = SECURITY_UPGRADE_CONTEXT + dict["capp"] = flavor.clientAppName + dict["cbid"] = flavor.clientBundleId + dict["cou"] = "US" + dict["loc"] = "en_US" + dict["svct"] = flavor.akContextType + dict["X-Apple-Offer-Security-Upgrade"] = true + dict["at"] = false + dict["bootstrap"] = true + dict["ckgen"] = true + dict["fcd"] = true + dict["icdrsDisabled"] = false + dict["icscrec"] = true + dict["pbe"] = false + dict["prkgen"] = true + dict["webAccessEnabled"] = false + apnsToken?.let { dict["ptkn"] = it } + return dict + } + + /** §1.1: lowercase hex (no dashes) SHA-256 over the 16-byte keychain identifier. */ + private fun mdLu(): String = java.security.MessageDigest.getInstance("SHA-256") + .digest(keychainIdentifier) + .joinToString("") { "%02x".format(it) } + + /** + * The §1.5 header set that rides the GSA POSTs: the base anisette headers filtered to + * `X-Apple-I-MD-LU`, `X-Apple-I-MD-RINFO`, `X-Apple-I-MD-M`, `X-Apple-I-MD`, + * `X-Mme-Device-Id` — the same values the cpd carries, as headers. The akd-variant + * headers ([GsaHeaderConfig]) are appended by the caller. + */ + fun headersFor(): Map = buildMap { + anisette[AnisetteHeaderProvider.MACHINE_OTP]?.let { put(AnisetteHeaderProvider.MACHINE_OTP, it) } + anisette[AnisetteHeaderProvider.RINFO]?.let { put(AnisetteHeaderProvider.RINFO, it) } + anisette[AnisetteHeaderProvider.MACHINE_ID]?.let { put(AnisetteHeaderProvider.MACHINE_ID, it) } + anisette[AnisetteHeaderProvider.DEVICE_ID]?.let { put(AnisetteHeaderProvider.DEVICE_ID, it) } + put("X-Apple-I-MD-LU", mdLu()) + } + + companion object { + /** The anisette header names the §1.5 cpd carries as keys (values from the provider). */ + val ANISETTE_KEYS = listOf( + AnisetteHeaderProvider.CLIENT_TIME, + AnisetteHeaderProvider.MACHINE_OTP, + AnisetteHeaderProvider.RINFO, + AnisetteHeaderProvider.MACHINE_ID, + AnisetteHeaderProvider.DEVICE_ID, + ) + + const val SECURITY_UPGRADE_CONTEXT = "com.apple.authkit.generic" + const val REQUEST_UUID_KEY = "X-Apple-I-Request-UUID" + } +} + +/** + * The §1.5 akd-variant headers of the GSA POSTs (C59, values recorded): the akd + * `User-Agent` literal, the akd-form `X-MMe-Client-Info` template with the device-dependent + * parts (hardware model, OS name/version/build) as configuration, and the flavor's + * `X-Apple-AK-Context-Type`/`X-Apple-Client-App-Name`/`X-Apple-I-Client-Bundle-Id` values. + */ +data class GsaHeaderConfig( + val flavor: GsaFlavor = GsaFlavor.MESSAGES, + /** The `` slot of the akd `X-MMe-Client-Info` template (§1.5, device-dependent). */ + val hardwareModel: String, + /** The `` slot (§1.5, device-dependent), e.g. `macOS` / `iPhone OS`. */ + val osName: String, + /** The `` slot (§1.5, device-dependent). */ + val osVersion: String, + /** The `` slot (§1.5, device-dependent). */ + val osBuild: String, +) { + + /** The akd-form template: ` `. */ + val clientInfo: String + get() = "<$hardwareModel> <${osName};${osVersion};${osBuild}> " + + /** + * The recorded non-akd browser-set `X-MMe-Client-Info` (§1.5 rev-15 closure): the same + * device-bracket form with the fixed inner component `com.apple.akd/1.0 (com.apple.akd/1.0)`. + */ + val browserSetClientInfo: String + get() = "<$hardwareModel> <${osName};${osVersion};${osBuild}> " + + /** + * The recorded bracketed sign-in `X-Mme-Client-Info` (§1.5 rev-15 closure): the device-bracket + * form wrapping the recorded AOSKit inner component ([GsaDelegateLoginClient.AOSKIT_CLIENT_INFO]). + */ + val signInClientInfo: String + get() = "<$hardwareModel> <${osName};${osVersion};${osBuild}> <$AOSKIT_CLIENT_INFO>" + + fun toHeaders(): Map = linkedMapOf( + "X-MMe-Client-Info" to clientInfo, + "User-Agent" to AKD_USER_AGENT, + "X-Apple-AK-Context-Type" to flavor.akContextType, + "X-Apple-Client-App-Name" to flavor.clientAppName, + "X-Apple-I-Client-Bundle-Id" to flavor.clientBundleId, + ) + + companion object { + /** The recorded akd `User-Agent` literal (§1.5, C59). */ + const val AKD_USER_AGENT = "akd/1.0 CFNetwork/1494.0.7 Darwin/23.4.0" + + /** The recorded AOSKit inner component (§1.5, C59) — wrapped by [signInClientInfo]. */ + const val AOSKIT_CLIENT_INFO = "com.apple.AOSKit/282 (com.apple.accountsd/113)" + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt new file mode 100644 index 0000000..ec160f6 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt @@ -0,0 +1,238 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 + +/** + * One iCloud sign-in request of spec §1.5: the XML-plist body (`delegates`, `protocolVersion`, + * `userInfo`) and the recorded header set. `X-Mme-Nas-Qualify` (the validation blob) is minted + * by the chunk-C device verification (§1.1) — caller-supplied here. + * + * Recorded values (C59) as defaults: `userInfo.timezone` = the hardcoded IANA name + * (`America/New_York` in the reference config), and the `X-Mme-Client-Info` recorded bracketed + * AOSKit form ` ` (§1.5 rev-15 closure) — + * the device slots are device-dependent, so the bare recorded AOSKit inner component + * ([GsaDelegateLoginClient.AOSKIT_CLIENT_INFO]) is the struct's fallback default; + * [GsaHeaderConfig.signInClientInfo] builds the exact bracketed form, and + * [GsaLoginChain.idsDelegateCredentials] defaults to it when the device config is wired. + */ +data class IcloudSignInRequest( + /** The Apple-ID username — the Basic auth username with the PET as password (§1.5). */ + val username: String, + /** The PET (§1.5) — never logged. */ + val pet: String, + /** `X-Apple-ADSID` (§1.5, from the decrypted spd). */ + val adsid: String, + /** The §1.1 validation blob, base64'd into `X-Mme-Nas-Qualify`. */ + val validationData: ByteArray, + /** `userInfo.clientId` — uppercase UUIDv4 (§1.5). */ + val clientId: String, + /** `userInfo.timezone` — the recorded hardcoded-IANA-name default (§1.5 C59). */ + val timezone: String = GsaDelegateLoginClient.SIGNIN_TIMEZONE, + /** `X-Mme-Client-Info` — the recorded AOSKit form as default (§1.5 C59). */ + val clientInfo: String = GsaDelegateLoginClient.AOSKIT_CLIENT_INFO, + /** The full §1.1 anisette header set. */ + val anisette: Map, +) + +/** One `delegates` entry of the sign-in response (§1.5: `status`, `statusMessage`, `serviceData`). */ +data class IcloudDelegateResult( + val status: Int?, + val statusMessage: String?, + val serviceData: Map?, + val raw: Map, +) + +/** The iCloud sign-in response of spec §1.5. */ +data class IcloudSignInResponse( + /** 0 = ok (§1.5). */ + val status: Int, + val localizedError: String?, + val delegates: Map, + val raw: Map, +) { + /** + * The IDS delegate's kebab-case serviceData → the §1.2 authenticate inputs: the `auth-token` + * (sent as `authentication-data` = {`auth-token`: token}) and the `profile-id` (realm user + * id). Loud on a missing or non-ok delegate — spec §1.5. + */ + fun idsDelegateCredentials(): IdsDelegateCredentials { + if (status != GsaDelegateLoginClient.STATUS_OK) { + throw GsaLoginException( + "iCloud sign-in status $status (${localizedError ?: "no localizedError"}) — spec §1.5", + ) + } + val delegate = delegates[GsaDelegateLoginClient.IDS_DELEGATE_BUNDLE_ID] + ?: throw GsaLoginException( + "iCloud sign-in response has no ${GsaDelegateLoginClient.IDS_DELEGATE_BUNDLE_ID} delegate " + + "(§1.5; has ${delegates.keys})", + ) + if (delegate.status != GsaDelegateLoginClient.STATUS_OK) { + throw GsaLoginException( + "${GsaDelegateLoginClient.IDS_DELEGATE_BUNDLE_ID} delegate status ${delegate.status} " + + "(${delegate.statusMessage ?: "no statusMessage"}) — spec §1.5", + ) + } + val serviceData = delegate.serviceData + ?: throw GsaLoginException( + "${GsaDelegateLoginClient.IDS_DELEGATE_BUNDLE_ID} delegate carries no serviceData (§1.5)", + ) + val authToken = serviceData["auth-token"] as? String + ?: throw GsaLoginException( + "IDS delegate serviceData has no 'auth-token' string (§1.5, got ${typeName(serviceData["auth-token"])})", + ) + val profileId = serviceData["profile-id"] as? String + ?: throw GsaLoginException( + "IDS delegate serviceData has no 'profile-id' string (§1.5, got ${typeName(serviceData["profile-id"])})", + ) + return IdsDelegateCredentials(authToken, profileId) + } +} + +/** The IDS delegate credentials the §1.2 authenticate exchange consumes (spec §1.5). */ +data class IdsDelegateCredentials( + /** Sent as `authentication-data` = {`auth-token`: token} (§1.2). */ + val authToken: String, + /** The realm user id (§1.2). */ + val profileId: String, +) + +/** + * The delegate login of spec §1.5: POST `https://setup.icloud.com/setup/signin/v2/login` with + * Basic `username:PET` auth and the recorded header set; the response's `com.apple.private.ids` + * delegate serviceData yields the IDS `authToken`/`profileId`. + */ +class GsaDelegateLoginClient( + private val http: IdsHttp, + private val endpoint: String = SIGNIN_ENDPOINT, + private val logEvent: (String) -> Unit = {}, +) { + + suspend fun signIn(request: IcloudSignInRequest): IcloudSignInResponse { + logEvent("gsa: iCloud sign-in") + val basic = Base64.getEncoder() + .encodeToString("${request.username}:${request.pet}".toByteArray(Charsets.UTF_8)) + val headers = buildMap { + putAll(request.anisette) + put("Authorization", "Basic $basic") + put(HEADER_NAS_QUALIFY, Base64.getEncoder().encodeToString(request.validationData)) + put(HEADER_ADSID, request.adsid) + put(HEADER_CLIENT_INFO, request.clientInfo) + put("User-Agent", SIGNIN_USER_AGENT) + } + val body = linkedMapOf( + "delegates" to linkedMapOf( + IDS_DELEGATE_BUNDLE_ID to linkedMapOf("protocol-version" to "4"), + MOBILEME_DELEGATE_BUNDLE_ID to linkedMapOf(), + ), + "protocolVersion" to PROTOCOL_VERSION, + "userInfo" to linkedMapOf( + "clientId" to request.clientId, + "language" to "en-US", + "timezone" to request.timezone, + ), + ) + val encoded = XmlPlist.encode(body) + val response = http.post(endpoint, headers, encoded, CONTENT_TYPE) + if (response.status != 200) { + throw GsaLoginException("iCloud sign-in POST $endpoint → HTTP ${response.status}") + } + return parseResponse(response.body) + } + + private fun parseResponse(bytes: ByteArray): IcloudSignInResponse { + val parsed = try { + Plist.parse(bytes) + } catch (e: PlistFormatException) { + throw GsaLoginException("iCloud sign-in response is not a plist: ${e.message}", e) + } + val dict = stringKeyedDictOrNull(parsed) + ?: throw GsaLoginException("iCloud sign-in response is ${typeName(parsed)}, expected a dict") + val delegates = when (val value = dict["delegates"]) { + null -> emptyMap() + is Map<*, *> -> stringKeyedDictOrNull(value)?.mapValues { (_, entry) -> + parseDelegate(stringKeyedDictOrNull(entry) + ?: throw GsaLoginException( + "iCloud sign-in delegates entry is ${typeName(entry)}, expected a dict", + )) + } ?: throw GsaLoginException("iCloud sign-in 'delegates' has non-string keys (§1.5)") + else -> throw GsaLoginException("iCloud sign-in 'delegates' is ${typeName(value)}, expected a dict") + } + return IcloudSignInResponse( + status = integerOrNull(dict["status"]) + ?: throw GsaLoginException("iCloud sign-in response has no integer 'status' (§1.5)"), + localizedError = dict["localizedError"] as? String, + delegates = delegates, + raw = dict, + ) + } + + private fun parseDelegate(dict: Map) = IcloudDelegateResult( + status = integerOrNull(dict["status"]), + statusMessage = dict["statusMessage"] as? String, + serviceData = dict["serviceData"]?.let { + stringKeyedDictOrNull(it) + ?: throw GsaLoginException("delegate serviceData is ${typeName(it)}, expected a dict (§1.5)") + }, + raw = dict, + ) + + companion object { + const val SIGNIN_ENDPOINT = "https://setup.icloud.com/setup/signin/v2/login" + const val CONTENT_TYPE = "application/x-apple-plist" + const val PROTOCOL_VERSION = "1.0" + const val IDS_DELEGATE_BUNDLE_ID = "com.apple.private.ids" + const val MOBILEME_DELEGATE_BUNDLE_ID = "com.apple.mobileme" + const val HEADER_NAS_QUALIFY = "X-Mme-Nas-Qualify" + const val HEADER_ADSID = "X-Apple-ADSID" + const val HEADER_CLIENT_INFO = "X-Mme-Client-Info" + const val STATUS_OK = 0 + + /** + * The recorded sign-in `User-Agent` (§1.5 C59): a static config constant. The reference + * composes it via a parser quirk into the duplicated-token string — the full static + * string is the recorded form. + */ + const val SIGNIN_USER_AGENT = "com.apple.iCloudHelper/282 com.apple.iCloudHelper/282" + + /** The recorded `userInfo.timezone` default — a hardcoded IANA name (§1.5 C59). */ + const val SIGNIN_TIMEZONE = "America/New_York" + + /** + * The recorded AOSKit form the sign-in `X-Mme-Client-Info` wraps (§1.5 C59). The recorded + * full header is the bracketed form ` ` + * (§1.5 rev-15 closure) — device-dependent, built by [GsaHeaderConfig.signInClientInfo]; + * this bare inner form is the fallback default without device slots. + */ + const val AOSKIT_CLIENT_INFO = "com.apple.AOSKit/282 (com.apple.accountsd/113)" + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt new file mode 100644 index 0000000..781ca48 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt @@ -0,0 +1,232 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +/** GSA login-chain HTTP or wire failure (spec §1.5). */ +class GsaLoginException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** The `Status` dict of a §1.5 GSA response: integer `ec` (0 = ok) and string `em`. */ +data class GsaStatus( + val ec: Int, + val em: String?, +) { + companion object { + const val EC_OK = 0 + + /** §1.5: the complete response's `au` selects the secondary-auth variant. */ + const val AU_SECONDARY_AUTH = "secondaryAuth" + const val AU_TRUSTED_DEVICE = "trustedDeviceSecondaryAuth" + } +} + +/** The init request of §1.5: `A2k`, `cpd`, `o` = `init`, `ps`, `u` under the `Header`/`Request` wrapper. */ +data class GsaInitRequest( + val username: String, + /** The client SRP public value A (§1.5) — plist data. */ + val a2k: ByteArray, + /** The §1.5 cpd dictionary (built through [GsaCpd]). */ + val cpd: Map, + /** The offered password protocols; the recorded `ps` array (§1.5). */ + val protocols: List = GsaSrpPasswordKey.OFFERED_PROTOCOLS, + /** §1.5: the anisette base headers (filtered set) ride the POSTs. */ + val headers: Map = emptyMap(), +) + +/** The init response under `Response` (§1.5). */ +data class GsaInitResponse( + /** `s` — the server salt (plist data). */ + val salt: ByteArray, + /** `B` — the server public value (plist data, used verbatim in the hashes). */ + val serverPublic: ByteArray, + /** `i` — the PBKDF2 iteration count. */ + val iterations: Int, + /** `c` — the challenge string echoed in the complete request. */ + val challenge: String, + /** `sp` — the selected password protocol; default `s2k` when absent (§1.5). */ + val protocol: String, + val status: GsaStatus, + val raw: Map, +) + +/** The complete request of §1.5: `M1`, `c`, `cpd`, `o` = `complete`, `u`. */ +data class GsaCompleteRequest( + val username: String, + val m1: ByteArray, + val challenge: String, + val cpd: Map, + /** §1.5: the anisette base headers (filtered set) ride the POSTs. */ + val headers: Map = emptyMap(), +) + +/** + * The complete response under `Response` (§1.5): `M2` (server proof), `spd` (encrypted plist + * data), `Status` whose `au` string selects secondary auth when present. [pet] carries the + * `X-Apple-PE-Token` of the response headers when one was sent (§1.5 PET). + */ +data class GsaCompleteResponse( + /** `M2` — verified against the computed proof by [GsaLoginClient.complete]. */ + val m2: ByteArray?, + /** `spd` — encrypted; decrypt with [GsaSpdDecryptor] and the session key K. */ + val spd: ByteArray?, + /** `au` — secondary-auth selector; absent (null) means the login completed as logged-in. */ + val secondaryAuth: String?, + val status: GsaStatus, + val pet: GsaPet?, + val raw: Map, +) + +/** + * The two GSA SRP POSTs of spec §1.5, both to `https://gsa.apple.com/grandslam/GsService2` + * with `Content-Type: text/x-xml-plist` and the `Header`/`Request` XML-plist wrapper + * (`Header` carries only `Version` = `1.0.1`). + * + * The transport is injected ([IdsHttp]); the Apple root CA pin applies to GSA traffic too + * (§1.1/§1.5) — production passes the pinned [AppleTrust] pool. No credential material (SRP + * values, proofs, keys, tokens) is ever passed to [logEvent]. + */ +class GsaLoginClient( + private val http: IdsHttp, + private val endpoint: String = GSA_ENDPOINT, + private val logEvent: (String) -> Unit = {}, +) { + + suspend fun init(request: GsaInitRequest): GsaInitResponse { + logEvent("gsa: init") + val response = post(XmlPlist.encode(request.toBody()), request.headers) + val dict = responseDict(response, "init") + val status = statusOf(dict, "init") + if (status.ec != GsaStatus.EC_OK) throw GsaLoginException("GSA init failed: ec=${status.ec} em=${status.em ?: "-"}") + return GsaInitResponse( + salt = dataField(dict, "s", "init response"), + serverPublic = dataField(dict, "B", "init response"), + iterations = (integerOrNull(dict["i"]) + ?: throw GsaLoginException("GSA init response has no integer 'i' iteration count (§1.5)")) + .also { if (it <= 0) throw GsaLoginException("GSA init response 'i' iteration count is $it") }, + challenge = dict["c"] as? String + ?: throw GsaLoginException("GSA init response has no 'c' challenge string (§1.5)"), + protocol = dict["sp"] as? String ?: GsaSrpPasswordKey.PROTOCOL_S2K, + status = status, + raw = dict, + ) + } + + suspend fun complete(request: GsaCompleteRequest, nowEpochMs: Long): GsaCompleteResponse { + logEvent("gsa: complete") + val response = post(XmlPlist.encode(request.toBody()), request.headers) + val dict = responseDict(response, "complete") + val status = statusOf(dict, "complete") + val secondaryAuth = dict["au"] as? String + if (secondaryAuth == null && status.ec != GsaStatus.EC_OK) { + throw GsaLoginException("GSA complete failed: ec=${status.ec} em=${status.em ?: "-"}") + } + return GsaCompleteResponse( + m2 = dict["M2"] as? ByteArray, + spd = dict["spd"] as? ByteArray, + secondaryAuth = secondaryAuth, + status = status, + pet = GsaTokenHeaders.pet(response.headers.header(HEADER_PE_LOWER), nowEpochMs), + raw = dict, + ) + } + + private fun Map.header(lowercasedName: String): String? = + entries.firstOrNull { it.key.equals(lowercasedName, ignoreCase = true) }?.value + + private suspend fun post(encoded: ByteArray, headers: Map): IdsHttpResponse { + val response = http.post(endpoint, headers, encoded, CONTENT_TYPE) + if (response.status != 200) { + throw GsaLoginException("GSA POST $endpoint → HTTP ${response.status}") + } + return response + } + + private fun responseDict(response: IdsHttpResponse, step: String): Map { + val parsed = try { + Plist.parse(response.body) + } catch (e: PlistFormatException) { + throw GsaLoginException("GSA $step response is not a plist: ${e.message}", e) + } + val top = stringKeyedDictOrNull(parsed) + ?: throw GsaLoginException("GSA $step response is ${typeName(parsed)}, expected a dict") + val inner = stringKeyedDictOrNull(top["Response"]) + ?: throw GsaLoginException( + "GSA $step response has no 'Response' dict (${typeName(top["Response"])}) — spec §1.5", + ) + return inner + } + + private fun statusOf(dict: Map, step: String): GsaStatus { + val status = stringKeyedDictOrNull(dict["Status"]) + ?: throw GsaLoginException("GSA $step response has no 'Status' dict (§1.5)") + return GsaStatus( + ec = integerOrNull(status["ec"]) + ?: throw GsaLoginException("GSA $step 'Status' has no integer 'ec' (§1.5)"), + em = status["em"] as? String, + ) + } + + private fun dataField(dict: Map, key: String, where: String): ByteArray = + dict[key] as? ByteArray + ?: throw GsaLoginException("GSA $where has no '$key' data field (§1.5, got ${typeName(dict[key])})") + + private fun GsaInitRequest.toBody(): Map = linkedMapOf( + "Header" to linkedMapOf("Version" to HEADER_VERSION), + "Request" to linkedMapOf( + "A2k" to a2k, + "cpd" to cpd, + "o" to OPERATION_INIT, + "ps" to protocols, + "u" to username, + ), + ) + + private fun GsaCompleteRequest.toBody(): Map = linkedMapOf( + "Header" to linkedMapOf("Version" to HEADER_VERSION), + "Request" to linkedMapOf( + "M1" to m1, + "c" to challenge, + "cpd" to cpd, + "o" to OPERATION_COMPLETE, + "u" to username, + ), + ) + + companion object { + const val GSA_ENDPOINT = "https://gsa.apple.com/grandslam/GsService2" + const val CONTENT_TYPE = "text/x-xml-plist" + const val HEADER_VERSION = "1.0.1" + const val OPERATION_INIT = "init" + const val OPERATION_COMPLETE = "complete" + + /** Response headers are lower-cased by the seam (§1.5 token headers). */ + const val HEADER_PE_LOWER = "x-apple-pe-token" + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt new file mode 100644 index 0000000..d3c7b08 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt @@ -0,0 +1,334 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.UUID + +/** The §1.5 login outcome: logged-in (with the decrypted `spd` and the PET), or 2FA required. */ +sealed interface GsaLoginResult { + + data class Authenticated( + /** The decrypted §1.5 `spd` (token map, `adsid`, `DsPrsId`, `acname`). */ + val spd: GsaSpd?, + /** The PET from the response headers (§1.5); null when none was sent. */ + val pet: GsaPet?, + ) : GsaLoginResult + + /** + * The complete response's `Status.au` selected secondary auth (§1.5). [identityToken] is + * the `X-Apple-Identity-Token` built from the decrypted spd when one arrived; null when no + * spd came back, in which case the caller cannot run 2FA yet (§1.5 derives the token from + * the spd's `adsid` + `GsIdmsToken`). + */ + data class SecondaryAuthRequired( + val variant: String, + val identityToken: String?, + ) : GsaLoginResult +} + +/** + * The §1.5 persisted login state: the inputs the silent PET re-login needs (username + the + * SHA-256 "persisted password hash", §1.5 PET) plus the stored PET and its expiry. The hash + * is key material — it is persisted but never logged. + */ +data class GsaPersistedCredentials( + val username: String, + /** SHA-256 of the raw password — re-fed through the s2k derivation on silent re-login. */ + val passwordHash: ByteArray, + val adsid: String?, + val petToken: String?, + val petExpiresAtEpochMs: Long?, +) + +/** The credential-persistence seam behind the orchestrator; [IdsStore] adapter below. */ +interface GsaCredentialStore { + suspend fun load(): GsaPersistedCredentials? + suspend fun save(credentials: GsaPersistedCredentials) +} + +/** + * The §1.5 adapter: the GSA login state rides the [IdsStore] account row's v3 columns. + */ +class IdsStoreGsaCredentials(private val store: IdsStore) : GsaCredentialStore { + + override suspend fun load(): GsaPersistedCredentials? { + val account = store.account() ?: return null + val username = account.gsaUsername ?: return null + val passwordHash = account.gsaPasswordHash ?: return null + return GsaPersistedCredentials( + username = username, + passwordHash = passwordHash, + adsid = account.gsaAdsId, + petToken = account.gsaPetToken, + petExpiresAtEpochMs = account.gsaPetExpiresAtEpochMs, + ) + } + + override suspend fun save(credentials: GsaPersistedCredentials) { + store.updateAccount { account -> + account.copy( + gsaUsername = credentials.username, + gsaPasswordHash = credentials.passwordHash, + gsaAdsId = credentials.adsid, + gsaPetToken = credentials.petToken, + gsaPetExpiresAtEpochMs = credentials.petExpiresAtEpochMs, + ) + } + } +} + +/** + * The GSA Apple-ID login chain of spec §1.5, end to end: the SRP-6a login ([GsaSrpClient], + * [GsaSrpPasswordKey]), M2 verification, `spd` decryption, the two 2FA variants + * ([GsaTwoFactorClient]) with the recorded "run the SRP login a second time after 2FA" + * behavior, PET persistence with re-login-on-expiry, and the delegate login + * ([GsaDelegateLoginClient]) yielding the §1.2 IDS credentials. + * + * All transports are injected; no credential material (passwords, hashes, + * proofs, session keys, tokens) is ever passed to [logEvent]. + */ +class GsaLoginChain( + private val http: IdsHttp, + private val cpd: GsaCpd, + private val credentialStore: GsaCredentialStore? = null, + /** The recorded §1.5 browser-style 2FA header set (C59); null omits it. */ + private val twoFactorBrowserHeaders: GsaTwoFactorHeaders? = null, + /** The §1.5 akd-variant headers of the GSA POSTs (C59 values) — null omits them. */ + private val headerConfig: GsaHeaderConfig? = null, + private val random: SecureRandom = SecureRandom(), + private val clock: () -> Long = System::currentTimeMillis, + private val logEvent: (String) -> Unit = {}, +) { + + private val loginClient = GsaLoginClient(http, logEvent = logEvent) + private val delegateClient = GsaDelegateLoginClient(http, logEvent = logEvent) + + /** §1.5: the filtered anisette header set + the akd-variant headers, one per login. */ + private fun gsaHeaders(): Map = + headerConfig?.let { cpd.headersFor() + it.toHeaders() } ?: cpd.headersFor() + + /** Full login with the raw password; the hash is computed here and never logged. */ + suspend fun login(username: String, password: String): GsaLoginResult = + loginWithPasswordHash(username, sha256(password.toByteArray(Charsets.UTF_8))) + + /** + * Login with the persisted password hash (§1.5 PET: "refreshed by silently re-running the + * SRP login with the persisted password hash"). Runs init → complete, verifies the server + * M2, decrypts `spd`, persists the credentials, and returns either + * [GsaLoginResult.Authenticated] or [GsaLoginResult.SecondaryAuthRequired]. + */ + suspend fun loginWithPasswordHash(username: String, passwordHash: ByteArray): GsaLoginResult { + val requestUuid = newRequestUuid() + val cpdDict = cpd.dictFor(requestUuid) + val gsaHeaders = gsaHeaders() + val srp = GsaSrpClient(random) + val a2k = srp.begin() + val init = loginClient.init( + GsaInitRequest(username = username, a2k = a2k, cpd = cpdDict, headers = gsaHeaders), + ) + val passwordKey = GsaSrpPasswordKey.derive(passwordHash, init.salt, init.iterations, init.protocol) + val proof = srp.proof( + username = username.toByteArray(Charsets.UTF_8), + passwordKey = passwordKey, + salt = init.salt, + serverPublic = init.serverPublic, + ) + val complete = loginClient.complete( + GsaCompleteRequest( + username = username, + m1 = proof.m1, + challenge = init.challenge, + cpd = cpdDict, + headers = gsaHeaders, + ), + clock(), + ) + val receivedM2 = complete.m2 + ?: throw GsaLoginException("GSA complete response has no 'M2' data field (§1.5)") + if (!receivedM2.contentEquals(proof.m2)) { + throw GsaLoginException("GSA complete: server M2 proof does not match the computed proof (§1.5)") + } + val spd = complete.spd?.let { GsaSpd.parse(GsaSpdDecryptor.decrypt(it, proof.k)) } + return when (val secondaryAuth = complete.secondaryAuth) { + null -> { + val result = GsaLoginResult.Authenticated(spd = spd, pet = complete.pet) + credentialStore?.save( + GsaPersistedCredentials( + username = username, + passwordHash = passwordHash, + adsid = spd?.adsid, + petToken = complete.pet?.token, + petExpiresAtEpochMs = complete.pet?.expiresAtEpochMs, + ), + ) + result + } + GsaStatus.AU_SECONDARY_AUTH, GsaStatus.AU_TRUSTED_DEVICE -> + GsaLoginResult.SecondaryAuthRequired( + variant = secondaryAuth, + identityToken = spd?.identityToken(), + ) + else -> throw GsaLoginException( + "GSA complete 'au' is '$secondaryAuth' — not a recorded secondary-auth variant (§1.5)", + ) + } + } + + /** §1.5: `GET .../auth` — the trusted phone numbers (for the SMS 2FA variant). */ + suspend fun trustedPhoneNumbers(identityToken: String): GsaAuthExtras = + twoFactorClient().trustedPhoneNumbers(identityToken) + + /** + * §1.5: resolve secondary auth with [securityCode], then — the recorded behavior — run the + * full SRP login a SECOND time, which then completes as logged-in. + * + * [identityToken] is the `X-Apple-Identity-Token` from [GsaLoginResult.SecondaryAuthRequired]; + * [phoneNumberId] is required for the `secondaryAuth` (SMS) variant. + */ + suspend fun completeSecondaryAuth( + username: String, + passwordHash: ByteArray, + variant: String, + identityToken: String, + securityCode: String, + phoneNumberId: String? = null, + ): GsaLoginResult { + val twoFactor = twoFactorClient() + when (variant) { + GsaStatus.AU_SECONDARY_AUTH -> { + val phoneId = requireNotNull(phoneNumberId) { + "the SMS 2FA variant needs a trusted-phone id (§1.5 AuthenticationExtras)" + } + twoFactor.requestSmsCode(identityToken, phoneId) + twoFactor.submitSmsCode(identityToken, phoneId, securityCode, clock()) + } + GsaStatus.AU_TRUSTED_DEVICE -> { + twoFactor.triggerTrustedDevice(identityToken) + twoFactor.submitTrustedDeviceCode(identityToken, securityCode, clock()) + } + else -> throw GsaLoginException( + "unknown secondary-auth variant '$variant' (§1.5 records $GSA_STATUS_SECONDARY and $GSA_STATUS_TRUSTED)", + ) + } + logEvent("gsa 2fa: accepted — re-running the SRP login (§1.5)") + return loginWithPasswordHash(username, passwordHash) + } + + /** + * The stored PET when it has not expired; otherwise the recorded silent re-login with the + * persisted password hash (§1.5). Null when nothing usable is persisted or no store is + * wired. The §1.5 PET default expiry (300 s, no trailer) is applied at parse time. + */ + suspend fun petToken(nowEpochMs: Long = clock()): String? { + val store = credentialStore ?: return null + val saved = store.load() ?: return null + saved.petToken?.let { token -> + saved.petExpiresAtEpochMs?.let { expiry -> if (expiry > nowEpochMs) return token } + } + if (saved.passwordHash.isEmpty()) return null + val result = loginWithPasswordHash(saved.username, saved.passwordHash) + return (result as? GsaLoginResult.Authenticated)?.pet?.token + } + + /** + * The §1.5 delegate login: POST `setup.icloud.com/setup/signin/v2/login` with Basic + * `username:PET`; returns the IDS delegate's `authToken`/`profileId`. [validationData] is + * the §1.1 blob (the chunk-C exchange mints it — caller-supplied here). [timezone] defaults + * to the recorded value (§1.5 C59); [clientInfo] defaults to the recorded bracketed AOSKit + * form when the device config is wired ([GsaHeaderConfig.signInClientInfo]), falling back to + * the bare recorded AOSKit inner component. + */ + suspend fun idsDelegateCredentials( + username: String, + pet: String, + adsid: String, + validationData: ByteArray, + timezone: String = GsaDelegateLoginClient.SIGNIN_TIMEZONE, + clientInfo: String = headerConfig?.signInClientInfo ?: GsaDelegateLoginClient.AOSKIT_CLIENT_INFO, + anisette: Map, + clientId: String = newRequestUuid(), + ): IdsDelegateCredentials = delegateClient.signIn( + IcloudSignInRequest( + username = username, + pet = pet, + adsid = adsid, + validationData = validationData, + clientId = clientId, + timezone = timezone, + clientInfo = clientInfo, + anisette = anisette, + ), + ).idsDelegateCredentials() + + /** + * The natural next step of the chain: hand the delegate credentials to the existing §1.2 + * authenticate path — `authentication-data` = {`auth-token`: …}, realm user id = + * `profile-id`. + */ + suspend fun authenticateIds( + credentials: IdsDelegateCredentials, + bagKey: String, + csrDer: ByteArray, + authenticator: IdsAuthenticator, + ): IdsAuthenticateResponse = authenticator.authenticate( + bagKey = bagKey, + realmUserId = credentials.profileId, + csrDer = csrDer, + authenticationData = AuthenticationData.AuthToken(credentials.authToken), + ) + + private fun twoFactorClient() = GsaTwoFactorClient( + http = http, + browserHeaders = twoFactorBrowserHeaders, + logEvent = logEvent, + ) + + private fun GsaSpd.identityToken(): String = gsaIdentityToken( + adsid = adsid + ?: throw GsaLoginException("spd has no 'adsid' — cannot build the X-Apple-Identity-Token (§1.5)"), + idmsToken = gsIdmsToken + ?: throw GsaLoginException( + "spd has no top-level 'GsIdmsToken' string — cannot build the X-Apple-Identity-Token (§1.5)", + ), + ) + + private fun newRequestUuid(): String = UUID.randomUUID().toString().uppercase() + + private fun sha256(bytes: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(bytes) + + companion object { + const val GSA_STATUS_SECONDARY = GsaStatus.AU_SECONDARY_AUTH + const val GSA_STATUS_TRUSTED = GsaStatus.AU_TRUSTED_DEVICE + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSpd.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSpd.kt new file mode 100644 index 0000000..50a61b6 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSpd.kt @@ -0,0 +1,175 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * The `spd` decryption of spec §1.5: AES-256-CBC + PKCS7, key = HMAC-SHA256(K, `extra data + * key:`), IV = the first 16 bytes of HMAC-SHA256(K, `extra data iv:`). The decrypted payload + * is a plist carrying the per-service token map `t`, `acname`, `DsPrsId`, `adsid` and the + * first/last name fields. + */ +object GsaSpdDecryptor { + + private val KEY_LABEL = "extra data key:".toByteArray(Charsets.US_ASCII) + private val IV_LABEL = "extra data iv:".toByteArray(Charsets.US_ASCII) + + fun key(k: ByteArray): ByteArray = hmac(k, KEY_LABEL) + + fun iv(k: ByteArray): ByteArray = hmac(k, IV_LABEL).copyOfRange(0, 16) + + /** Decrypts and plist-parses [spd]; a failed PKCS7 pad or a non-dict payload fails loudly. */ + fun decrypt(spd: ByteArray, k: ByteArray): Map { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key(k), "AES"), IvParameterSpec(iv(k))) + val plain = try { + cipher.doFinal(spd) + } catch (e: Exception) { + throw GsaLoginException("spd decryption failed (§1.5): ${e.message}", e) + } + val parsed = try { + Plist.parse(plain) + } catch (e: PlistFormatException) { + throw GsaLoginException("decrypted spd is not a plist: ${e.message}", e) + } + return stringKeyedDictOrNull(parsed) + ?: throw GsaLoginException("decrypted spd is ${typeName(parsed)}, expected a dict") + } + + private fun hmac(k: ByteArray, message: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(k, "HmacSHA256")) + return mac.doFinal(message) + } +} + +/** + * One entry of the §1.5 token map `t` (C59, keys recorded): service id → `token` (string) plus + * `expiry` (epoch-ms) or `duration` (seconds). + */ +data class GsaServiceToken( + /** The service id this entry is keyed by. */ + val serviceId: String, + /** The `token` string. */ + val token: String, + /** The `expiry` value, epoch-ms — null when the entry carries `duration` instead. */ + val expiresAtEpochMs: Long?, + /** The `duration` value in seconds — null when the entry carries `expiry` instead. */ + val durationSeconds: Long?, + val raw: Map, +) + +/** + * The parsed §1.5 `spd` fields. Recorded keys (C59): the token map `t` with `token`/`expiry`/ + * `duration` inner keys, the TOP-LEVEL `GsIdmsToken` string (not inside `t`), `acname`, + * `DsPrsId`, `adsid`, and the account-name keys `fn` (first name) / `ln` (last name). + */ +data class GsaSpd( + /** The per-service token map `t`, keyed by service id. */ + val tokens: Map, + /** The TOP-LEVEL `GsIdmsToken` string (§1.5 C59) — pairs with `adsid` in the identity token. */ + val gsIdmsToken: String?, + val dsPrsId: Long?, + val adsid: String?, + val accountName: String?, + /** The `fn` first name (optional string, §1.5 C59). */ + val firstName: String?, + /** The `ln` last name (optional string, §1.5 C59). */ + val lastName: String?, + val raw: Map, +) { + + /** + * The typed token entry for [serviceId]; loud on a missing entry (entries carry the + * recorded `token`/`expiry`/`duration` keys — validated at parse time). + */ + fun serviceToken(serviceId: String): GsaServiceToken = + tokens[serviceId] + ?: throw GsaLoginException("spd token map has no '$serviceId' entry (§1.5; has ${tokens.keys})") + + companion object { + /** The recorded TOP-LEVEL spd key whose value pairs with `adsid` in the identity token (§1.5). */ + const val SERVICE_IDMS_TOKEN = "GsIdmsToken" + + fun parse(raw: Map): GsaSpd { + val tokensRaw = raw["t"] + ?: throw GsaLoginException("spd has no 't' token-map dict (§1.5, got null)") + val tokens = stringKeyedDictOrNull(tokensRaw)?.mapValues { (serviceId, entry) -> + val entryDict = stringKeyedDictOrNull(entry) + ?: throw GsaLoginException("spd token-map entry is ${typeName(entry)}, expected a dict (§1.5)") + readToken(serviceId, entryDict) + } ?: throw GsaLoginException("spd has no 't' token-map dict (§1.5, got ${typeName(tokensRaw)})") + return GsaSpd( + tokens = tokens, + gsIdmsToken = raw[SERVICE_IDMS_TOKEN] as? String, + dsPrsId = longOrNull(raw["DsPrsId"]), + adsid = raw["adsid"] as? String, + accountName = raw["acname"] as? String, + firstName = raw["fn"] as? String, + lastName = raw["ln"] as? String, + raw = raw, + ) + } + + private fun readToken(serviceId: String, entry: Map): GsaServiceToken { + val token = entry["token"] as? String + ?: throw GsaLoginException( + "spd token-map entry '$serviceId' has no 'token' string (§1.5, got ${typeName(entry["token"])})", + ) + val expiry = longOrNull(entry["expiry"]) + val duration = longOrNull(entry["duration"]) + if (expiry == null && duration == null) { + throw GsaLoginException( + "spd token-map entry '$serviceId' carries neither 'expiry' nor 'duration' (§1.5)", + ) + } + return GsaServiceToken( + serviceId = serviceId, + token = token, + expiresAtEpochMs = expiry, + durationSeconds = duration, + raw = entry, + ) + } + } +} + +/** + * The §1.5 `X-Apple-Identity-Token`: base64 of `adsid:GsIdmsToken` (both from the decrypted + * spd). All 2FA requests carry it (§1.5). + */ +fun gsaIdentityToken(adsid: String, idmsToken: String): String = + Base64.getEncoder().encodeToString("$adsid:$idmsToken".toByteArray(Charsets.UTF_8)) diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSrp.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSrp.kt new file mode 100644 index 0000000..2fca3cd --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSrp.kt @@ -0,0 +1,232 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.math.BigInteger +import java.security.SecureRandom +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** SRP math or password-key derivation failure (spec §1.5). */ +class GsaSrpException(message: String) : Exception(message) + +/** + * The recorded SRP group of the GSA login chain (spec §1.5, C59): the RFC 5054 2048-bit MODP + * group — the standard Appendix-A prime — with g = 2, as 256 big-endian modulus bytes hashed + * verbatim in the k/M1 derivations. + */ +object GsaSrpGroup { + + /** RFC 5054 Appendix A, 2048-bit group (the standard Appendix-A prime, spec §1.5). */ + private const val PRIME_HEX = + "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050" + + "A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50" + + "E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8" + + "55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B" + + "CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748" + + "544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6" + + "AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6" + + "94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73" + + val generator: BigInteger = BigInteger.TWO + + /** The modulus as big-endian bytes — hashed verbatim in the k/M1 derivations (§1.5). */ + val prime: ByteArray = PRIME_HEX.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + val n: BigInteger = BigInteger(1, prime) + + val byteLength: Int = prime.size +} + +/** + * The password-key derivation of spec §1.5 ("s2k" vs "s2k_fo"): SHA-256 of the raw password, + * then — for `s2k_fo` — that digest ASCII-lowercase-hex-encoded (64 chars) is what feeds + * PBKDF2, for `s2k` the raw 32-byte digest feeds it; PBKDF2-HMAC-SHA256 over the server salt + * with the server iteration count, 32 output bytes. + * + * The input here is the SHA-256 of the raw password (§1.5's derivation start — and exactly the + * "persisted password hash" the silent re-login reuses, §1.5 PET), so nothing is re-hashed + * here. PBKDF2 is hand-rolled over HmacSHA256 because the input is raw bytes, + * not chars (JCE's char[]-based PBE would re-encode them). + */ +object GsaSrpPasswordKey { + + const val PROTOCOL_S2K = "s2k" + const val PROTOCOL_S2K_FO = "s2k_fo" + + /** The `ps` array of the init request (§1.5) — both protocols offered. */ + val OFFERED_PROTOCOLS = listOf(PROTOCOL_S2K, PROTOCOL_S2K_FO) + + fun derive(passwordDigest: ByteArray, salt: ByteArray, iterations: Int, protocol: String): ByteArray { + require(iterations > 0) { "SRP iteration count must be positive, got $iterations" } + val pbkdf2Input = when (protocol) { + PROTOCOL_S2K -> passwordDigest + PROTOCOL_S2K_FO -> passwordDigest.joinToString("") { byte -> "%02x".format(byte) } + .toByteArray(Charsets.US_ASCII) + else -> throw GsaSrpException( + "unrecognized SRP password protocol '$protocol' (expected $PROTOCOL_S2K or $PROTOCOL_S2K_FO)", + ) + } + return pbkdf2HmacSha256(pbkdf2Input, salt, iterations, 32) + } + + /** RFC 2898 PBKDF2 with HMAC-SHA256, [dkLen]-byte output. */ + private fun pbkdf2HmacSha256(password: ByteArray, salt: ByteArray, iterations: Int, dkLen: Int): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(password, "HmacSHA256")) + val blocks = (dkLen + mac.macLength - 1) / mac.macLength + val out = ByteArray(blocks * mac.macLength) + for (block in 1..blocks) { + mac.update(salt) + mac.update(intToBigEndian(block)) + var u = mac.doFinal() + val t = u.copyOf() + for (round in 2..iterations) { + u = mac.doFinal(u) + for (i in t.indices) t[i] = (t[i].toInt() xor u[i].toInt()).toByte() + } + System.arraycopy(t, 0, out, (block - 1) * mac.macLength, mac.macLength) + } + return out.copyOf(dkLen) + } + + private fun intToBigEndian(value: Int): ByteArray = byteArrayOf( + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte(), + ) +} + +/** One complete SRP client run: the M1 proof to send, the M2 to expect, and the session key K. */ +class SrpProof( + /** The client proof to send as the complete request's `M1` (§1.5). */ + val m1: ByteArray, + /** The server proof to expect in the complete response's `M2` (§1.5). */ + val m2: ByteArray, + /** The session key K — decrypts the complete response's `spd` (§1.5). Never logged. */ + internal val k: ByteArray, +) + +/** + * The GSA SRP-6a client of spec §1.5, byte-exact: the RFC 5054 2048-bit group with g = 2 + * ([GsaSrpGroup]), SHA-256 throughout, and the GSA identity-hash quirk — the identity + * hash is SHA-256 over an EMPTY username, ":" and the password buffer, while M1's H(username) + * term uses the REAL username. + * + * Wire-encoding notes, from the §1.5 formulas: `pad` appears only in the k derivation (g + * left-padded to N's length) and in M1's H(pad(g)) term — every other hashed byte string (A, + * B, salt, K, premaster) enters as the minimal big-endian / as-received form. A is sent as + * `A2k` in exactly the minimal form hashed here; B is hashed exactly as received. + */ +class GsaSrpClient(private val random: SecureRandom = SecureRandom()) { + + private val group = GsaSrpGroup + + private var privateA: BigInteger? = null + private var publicA: ByteArray? = null + + /** + * Generates the ephemeral private value and computes A = g^a mod N. Returns A as the + * minimal big-endian bytes sent as `A2k`. A 255-byte private value is always < N for any + * 2048-bit prime (top bit set), so no rejection sampling is needed. + */ + fun begin(): ByteArray { + val aBytes = ByteArray(group.byteLength - 1) + random.nextBytes(aBytes) + val a = BigInteger(1, aBytes) + require(a.signum() > 0) { "SRP private value must not be zero" } + privateA = a + publicA = minimalBytes(group.generator.modPow(a, group.n)) + return publicA!!.copyOf() + } + + /** + * Computes the proofs from the server's init response. [username] is the REAL username + * (M1's H(username) term); the identity-hash quirk applies inside. [passwordKey] is the + * §1.5 derivation output ([GsaSrpPasswordKey.derive]); [salt] and [serverPublic] are the + * init response's `s` and `B` verbatim. + */ + fun proof( + username: ByteArray, + passwordKey: ByteArray, + salt: ByteArray, + serverPublic: ByteArray, + ): SrpProof { + val a = privateA ?: throw GsaSrpException("begin() must run before proof()") + val aBytes = publicA!! + val b = BigInteger(1, serverPublic) + if (b >= group.n) throw GsaSrpException("server public value B is not in [0, N)") + if (b.signum() == 0) throw GsaSrpException("server public value B is zero") + + // GSA quirk (§1.5): EMPTY username in the identity hash — H(":" ‖ password buffer). + val identityHash = sha256(byteArrayOf(':'.code.toByte()) + passwordKey) + val x = BigInteger(1, sha256(salt + identityHash)) + val u = BigInteger(1, sha256(aBytes + serverPublic)) + val k = BigInteger(1, sha256(group.prime + padLeft(group.generator.toByteArray(), group.byteLength))) + + val base = b.subtract(k.multiply(group.generator.modPow(x, group.n))).mod(group.n) + val premaster = base.modPow(a.add(u.multiply(x)), group.n) + val sessionK = sha256(minimalBytes(premaster)) + + val m1 = sha256( + xor(sha256(group.prime), sha256(padLeft(group.generator.toByteArray(), group.byteLength))) + + sha256(username) + salt + aBytes + serverPublic + sessionK, + ) + val m2 = sha256(aBytes + m1 + sessionK) + return SrpProof(m1 = m1, m2 = m2, k = sessionK) + } + + private fun padLeft(bytes: ByteArray, length: Int): ByteArray { + require(bytes.size <= length) { "value longer than the group modulus" } + if (bytes.size == length) return bytes + // BigInteger.toByteArray may carry a leading sign byte — drop it before padding. + val unsigned = if (bytes.isNotEmpty() && bytes[0] == 0.toByte() && bytes.size - 1 <= length) { + bytes.copyOfRange(1, bytes.size) + } else { + bytes + } + return ByteArray(length - unsigned.size) + unsigned + } + + private fun minimalBytes(value: BigInteger): ByteArray { + val signed = value.toByteArray() + return if (signed.size > 1 && signed[0] == 0.toByte()) signed.copyOfRange(1, signed.size) else signed + } + + private fun xor(a: ByteArray, b: ByteArray): ByteArray { + require(a.size == b.size) { "XOR operands differ in length" } + return ByteArray(a.size) { (a[it].toInt() xor b[it].toInt()).toByte() } + } + + private fun sha256(bytes: ByteArray): ByteArray = + java.security.MessageDigest.getInstance("SHA-256").digest(bytes) +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTokens.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTokens.kt new file mode 100644 index 0000000..bb7182f --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTokens.kt @@ -0,0 +1,135 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 + +/** + * One token parsed out of a §1.5 response header (`X-Apple-GS-Token`, `X-Apple-HB-Token`, + * `X-Apple-PE-Token`): each is base64 of colon-separated `ID:TOKEN[:DURATION][:EXP]` with + * inconsistent duration/expiration trailer forms (epoch-ms vs seconds, heuristically parsed). + */ +data class GsaTokenHeader( + /** The leading service id before the first colon. */ + val serviceId: String, + /** The token itself (the second field). */ + val token: String, + /** The `DURATION` trailer in seconds, when a two-trailer form carries one. */ + val durationSeconds: Long?, + /** + * The expiration, epoch ms: the `EXP` trailer when present, else the single trailer. + * Heuristic (§1.5 "heuristically parsed"): a trailer ≥ 10^11 is epoch-ms, below it is + * epoch-seconds and is scaled to ms. + */ + val expiresAtEpochMs: Long?, +) + +/** + * Parser for the §1.5 response-header token sets. Repeated headers arrive as comma-joined + * values ([AppleIdsHttp] joins them with ", "); base64 payloads contain neither commas nor + * colons, so splitting on the comma first is safe. + * + * Field grammar (§1.5): `ID:TOKEN[:DURATION][:EXP]`. A single trailer is read as the + * expiration (the PET recording — "default expiry 300 s when no trailer, otherwise parsed + * seconds/epoch-ms" — describes the trailer as the expiry); two trailers are DURATION then + * EXP. A non-numeric trailer is surfaced as null rather than failing — the trailer forms are + * recorded as inconsistent — but an `ID:TOKEN` shape is required or the value fails loudly. + */ +object GsaTokenHeaders { + + /** One token is parsed per header occurrence; [headerValue] may hold comma-joined repeats. */ + fun parse(headerName: String, headerValue: String, nowEpochMs: Long): List = + headerValue.split(',').mapNotNull { occurrence -> parseOne(headerName, occurrence.trim(), nowEpochMs) } + + private fun parseOne(headerName: String, value: String, nowEpochMs: Long): GsaTokenHeader? { + if (value.isEmpty()) return null + val decoded = try { + String(Base64.getMimeDecoder().decode(value), Charsets.UTF_8) + } catch (e: IllegalArgumentException) { + throw GsaLoginException("$headerName is not decodable base64 (spec §1.5 token header)", e) + } + val fields = decoded.split(':') + if (fields.size < 2) { + throw GsaLoginException("$headerName payload '$decoded' is not the recorded ID:TOKEN shape (§1.5)") + } + val trailers = fields.drop(2).map { it.trim() }.filter { it.isNotEmpty() } + return when (trailers.size) { + 0 -> GsaTokenHeader(fields[0], fields[1], durationSeconds = null, expiresAtEpochMs = null) + 1 -> GsaTokenHeader( + fields[0], fields[1], + durationSeconds = null, + expiresAtEpochMs = trailers[0].toLongOrNull()?.asExpiry(), + ) + else -> GsaTokenHeader( + fields[0], fields[1], + durationSeconds = trailers[0].toLongOrNull(), + expiresAtEpochMs = trailers[1].toLongOrNull()?.asExpiry(), + ) + } + } + + private fun Long.asExpiry(): Long = if (this >= EPOCH_MS_THRESHOLD) this else this * 1000 + + /** Above this, a trailer can only be epoch-ms (epoch-seconds stay below 10^10). */ + const val EPOCH_MS_THRESHOLD = 100_000_000_000L + + /** The §1.5 PET default: 300 s when the header carries no trailer. */ + const val PET_DEFAULT_EXPIRY_SECONDS = 300L + + /** + * The PET entry from a token-header set, with its expiry: the trailer when present, + * otherwise now + the recorded 300 s default (§1.5). + */ + fun pet(headerValue: String?, nowEpochMs: Long): GsaPet? { + val parsed = headerValue?.let { parse(HEADER_PE, it, nowEpochMs) } ?: return null + val first = parsed.firstOrNull() ?: return null + return GsaPet( + token = first.token, + expiresAtEpochMs = first.expiresAtEpochMs ?: nowEpochMs + PET_DEFAULT_EXPIRY_SECONDS * 1000, + ) + } + + /** Every token in a comma-joined [X-Apple-GS-Token] value, keyed by service id. */ + fun gsTokens(headerValue: String?, nowEpochMs: Long): Map { + val parsed = headerValue?.let { parse(HEADER_GS, it, nowEpochMs) } ?: return emptyMap() + return parsed.associateBy { it.serviceId } + } + + const val HEADER_GS = "X-Apple-GS-Token" + const val HEADER_HB = "X-Apple-HB-Token" + const val HEADER_PE = "X-Apple-PE-Token" +} + +/** The Password Equivalent Token (§1.5): the Basic password on the iCloud requests. */ +data class GsaPet( + val token: String, + val expiresAtEpochMs: Long, +) diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt new file mode 100644 index 0000000..ea55182 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt @@ -0,0 +1,304 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * One trusted-phone-number entry of the §1.5 `AuthenticationExtras` JSON (recorded fields: + * `numberWithDialCode`, `lastTwoDigits`, `pushMode`, `id`). + */ +data class GsaTrustedPhoneNumber( + val numberWithDialCode: String?, + val lastTwoDigits: String?, + val pushMode: Boolean?, + val id: Long?, + val raw: Map, +) + +/** The parsed `AuthenticationExtras` of `GET https://gsa.apple.com/auth` (§1.5). */ +data class GsaAuthExtras( + val trustedPhoneNumbers: List, +) + +/** The token headers a successful 2FA exchange returns (§1.5: tokens in response headers). */ +data class GsaSecondaryAuthTokens( + /** `X-Apple-GS-Token` — one per GSA service, keyed by service id. */ + val gsTokens: Map, + /** `X-Apple-HB-Token` occurrences. */ + val hbTokens: List, + /** `X-Apple-PE-Token` — the PET, 300 s default expiry when no trailer (§1.5). */ + val pet: GsaPet?, +) + +/** + * The §1.5 2FA browser-style header set (C59, values recorded): the anisette base headers + * filtered to `X-Apple-I-MD-LU`/`X-Apple-I-MD-RINFO`/`X-Apple-I-MD-M`/`X-Apple-I-MD`/ + * `X-Mme-Device-Id` plus the recorded fixed literals and the flavor's app-name/bundle-id/ + * AK-context values. + * + * The two former TODO(capture) members are recorded literals now (§1.5 rev-15 closure): the + * browser `User-Agent` defaults to [Companion.BROWSER_USER_AGENT], and the non-akd + * `X-MMe-Client-Info` is the recorded device-bracket template built from [device]'s + * device-dependent slots (null omits the header — the slots are caller configuration). + */ +class GsaTwoFactorHeaders( + private val flavor: GsaFlavor, + /** The filtered anisette set incl. the derived `X-Apple-I-MD-LU` — [GsaCpd.headersFor]. */ + private val anisette: Map, + /** Device slots of the recorded non-akd `X-MMe-Client-Info` template (§1.5); null omits the header. */ + val device: GsaHeaderConfig? = null, + /** Browser `User-Agent` — the recorded literal is the default (§1.5). */ + val userAgent: String = BROWSER_USER_AGENT, +) { + + /** The recorded device-bracket form, or null when no device slots are configured. */ + val clientInfo: String? + get() = device?.browserSetClientInfo + + fun toHeaders(): Map = buildMap { + putAll(anisette) + put("X-Apple-Client-App-Name", flavor.clientAppName) + put("X-Apple-I-Client-Bundle-Id", flavor.clientBundleId) + clientInfo?.let { put("X-MMe-Client-Info", it) } + put("X-Apple-I-CDP-Circle-Status", "false") + put("X-Apple-I-ICSCREC", "true") + put("User-Agent", userAgent) + put("Sec-Fetch-Site", "same-origin") + put("X-Apple-Requested-Partition", "0") + put("X-Apple-I-DeviceUserMode", "0") + put("X-Apple-I-Locale", "en_US") + put("X-Apple-Security-Upgrade-Context", "com.apple.authkit.generic") + put("Accept-Language", "en-US,en;q=0.9") + put("X-Apple-I-PRK-Gen", "true") + put("Sec-Fetch-Mode", "cors") + put("X-Apple-I-TimeZone", "UTC") + put("X-Apple-I-OT-Status", "false") + put("X-Apple-I-TimeZone-Offset", "0") + put("X-MMe-Country", "US") + put("X-Apple-I-CDP-Status", "false") + put("X-Apple-I-Device-Configuration-Mode", "0") + put("Sec-Fetch-Dest", "empty") + put("X-Apple-AK-Context-Type", flavor.akContextType) + put("X-Apple-I-CFU-State", CFU_STATE) + } + + companion object { + /** + * The decoded CFU-State wrapper, assembled from the recorded shape (§1.5). The recorded + * decoded length is exactly 182 bytes, and the standard newline-after-each-element + * Apple layout matches it byte-for-byte — pinned in [GsaTwoFactorTest]. + */ + val CFU_STATE_PLIST: String = + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + /** + * `X-Apple-I-CFU-State` (§1.5, C59): a hardcoded base64 whose decoded XML plist is the + * FULL plist wrapper — prolog + Apple DOCTYPE + `` + `` + + * `` + trailing newline (182 bytes) — NOT a bare ``. + */ + val CFU_STATE: String = + java.util.Base64.getEncoder().encodeToString(CFU_STATE_PLIST.toByteArray(Charsets.UTF_8)) + + /** + * The recorded browser `User-Agent` literal (§1.5 rev-15 closure): static in every + * source, notably with NO Safari/Version token. + */ + const val BROWSER_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)" + + fun of( + cpd: GsaCpd, + device: GsaHeaderConfig? = null, + userAgent: String = BROWSER_USER_AGENT, + ) = GsaTwoFactorHeaders(cpd.flavor, cpd.headersFor(), device, userAgent) + } +} + +/** + * The §1.5 2FA paths. + * + * `secondaryAuth` (SMS/trusted phone): `GET .../auth` returns the `AuthenticationExtras` JSON + * (HTTP 201 = SMS already sent); request a code with `PUT .../auth/verify/phone`; verify with + * `POST .../auth/verify/phone/securitycode`. + * + * `trustedDeviceSecondaryAuth`: trigger the code push with `GET .../auth/verify/trusteddevice`, + * then submit `GET .../GsService2/validate` with the code in a `security-code` request header. + * + * All requests carry `X-Apple-Identity-Token` (§1.5) and the recorded browser-style fixed + * header set ([GsaTwoFactorHeaders], §1.5 C59); null omits it. + * + * No credential material (codes, tokens, identity token) is ever passed to [logEvent]. + */ +class GsaTwoFactorClient( + private val http: IdsHttp, + private val browserHeaders: GsaTwoFactorHeaders? = null, + private val authBase: String = "https://gsa.apple.com/auth", + private val validateUrl: String = "https://gsa.apple.com/grandslam/GsService2/validate", + private val trustedDeviceUrl: String = "https://gsa.apple.com/auth/verify/trusteddevice", + private val logEvent: (String) -> Unit = {}, +) { + + /** + * `GET .../auth` — the trusted phone numbers. HTTP 200 parses the extras; HTTP 201 means + * the SMS was already sent (§1.5); anything else fails loudly. + */ + suspend fun trustedPhoneNumbers(identityToken: String): GsaAuthExtras { + logEvent("gsa 2fa: authentication extras") + val response = http.get(authBase, headers(identityToken)) + if (response.status != 200 && response.status != 201) { + throw GsaLoginException("GSA auth extras GET $authBase → HTTP ${response.status} (§1.5)") + } + if (response.status == 201) logEvent("gsa 2fa: SMS already sent (HTTP 201)") + val json = try { + Json.parseToJsonElement(response.body.decodeToString()) + } catch (e: Exception) { + throw GsaLoginException("GSA auth extras response is not JSON (§1.5): ${e.message}", e) + } + val numbers = json.jsonObject["trustedPhoneNumbers"]?.jsonArray ?: return GsaAuthExtras(emptyList()) + return GsaAuthExtras(numbers.map { entry -> + val obj = entry.jsonObject + GsaTrustedPhoneNumber( + numberWithDialCode = obj["numberWithDialCode"].textOrNull(), + lastTwoDigits = obj["lastTwoDigits"].textOrNull(), + pushMode = obj["pushMode"].textOrNull()?.toBooleanStrictOrNull(), + id = obj["id"].textOrNull()?.toLongOrNull(), + raw = obj.mapValues { (_, value) -> value.textOrNull() }, + ) + }) + } + + /** `PUT .../auth/verify/phone` — request the SMS code for phone id [phoneNumberId]. */ + suspend fun requestSmsCode(identityToken: String, phoneNumberId: String) { + logEvent("gsa 2fa: request SMS code") + val response = http.put( + "$authBase/verify/phone", + headers(identityToken) + mapOf(CONTENT_TYPE_HEADER to JSON_CONTENT_TYPE), + phoneBody(phoneNumberId).toString().toByteArray(Charsets.UTF_8), + JSON_CONTENT_TYPE, + ) + if (response.status != 200 && response.status != 201) { + throw GsaLoginException("GSA SMS request PUT $authBase/verify/phone → HTTP ${response.status} (§1.5)") + } + } + + /** `POST .../auth/verify/phone/securitycode` — verify the SMS code; returns the token headers. */ + suspend fun submitSmsCode( + identityToken: String, + phoneNumberId: String, + securityCode: String, + nowEpochMs: Long, + ): GsaSecondaryAuthTokens { + logEvent("gsa 2fa: submit SMS code") + val response = http.post( + "$authBase/verify/phone/securitycode", + headers(identityToken) + mapOf(CONTENT_TYPE_HEADER to JSON_CONTENT_TYPE), + buildJsonObject { + putJsonObject("phoneNumber") { put("id", phoneNumberId) } + put("mode", "sms") + putJsonObject("securityCode") { put("code", securityCode) } + }.toString().toByteArray(Charsets.UTF_8), + JSON_CONTENT_TYPE, + ) + return tokensOf(response, nowEpochMs) + } + + /** `GET .../auth/verify/trusteddevice` — push the code to the trusted devices. */ + suspend fun triggerTrustedDevice(identityToken: String) { + logEvent("gsa 2fa: trigger trusted device") + val response = http.get(trustedDeviceUrl, headers(identityToken)) + if (response.status != 200 && response.status != 201) { + throw GsaLoginException( + "GSA trusted-device trigger GET $trustedDeviceUrl → HTTP ${response.status} (§1.5)", + ) + } + } + + /** `GET .../GsService2/validate` with the code in the `security-code` header (§1.5). */ + suspend fun submitTrustedDeviceCode( + identityToken: String, + securityCode: String, + nowEpochMs: Long, + ): GsaSecondaryAuthTokens { + logEvent("gsa 2fa: submit trusted device code") + val response = http.get(validateUrl, headers(identityToken) + mapOf("security-code" to securityCode)) + return tokensOf(response, nowEpochMs) + } + + private suspend fun tokensOf(response: IdsHttpResponse, nowEpochMs: Long): GsaSecondaryAuthTokens { + if (response.status != 200 && response.status != 201) { + throw GsaLoginException("GSA 2FA verification → HTTP ${response.status} (§1.5)") + } + return GsaSecondaryAuthTokens( + gsTokens = GsaTokenHeaders.gsTokens(response.headers.header(HEADER_GS), nowEpochMs), + hbTokens = response.headers.header(HEADER_HB) + ?.let { GsaTokenHeaders.parse(HEADER_HB, it, nowEpochMs) } ?: emptyList(), + pet = GsaTokenHeaders.pet(response.headers.header(HEADER_PE), nowEpochMs), + ) + } + + private fun phoneBody(phoneNumberId: String) = buildJsonObject { + putJsonObject("phoneNumber") { put("id", phoneNumberId) } + put("mode", "sms") + } + + private fun JsonElement?.textOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it !is JsonNull }?.content + + /** §1.5: every 2FA request carries the identity token plus the recorded header set. */ + private fun headers(identityToken: String): Map = + (browserHeaders?.toHeaders() ?: emptyMap()) + mapOf(IDENTITY_TOKEN_HEADER to identityToken) + + private fun Map.header(lowercasedName: String): String? = + entries.firstOrNull { it.key.equals(lowercasedName, ignoreCase = true) }?.value + + companion object { + const val IDENTITY_TOKEN_HEADER = "X-Apple-Identity-Token" + const val CONTENT_TYPE_HEADER = "Content-Type" + const val JSON_CONTENT_TYPE = "application/json" + const val HEADER_GS = GsaTokenHeaders.HEADER_GS + const val HEADER_HB = GsaTokenHeaders.HEADER_HB + const val HEADER_PE = GsaTokenHeaders.HEADER_PE + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt new file mode 100644 index 0000000..5b93edc --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt @@ -0,0 +1,573 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.gunzipIfNeeded +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.io.ByteArrayOutputStream +import java.security.PrivateKey +import java.util.Base64 +import java.util.zip.GZIPOutputStream + +/** Register/authenticate HTTP or parse failure (spec §1.2/§1.4). */ +class IdsActivationException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * The `private-device-data` dictionary of spec §1.4 — every wire key recorded, all values + * strings except `dt` (integer 1). The relay/phone profile flips `m` and `s` from "0" to "1". + * The four further keys both sources model (`c`, `ec`, `ktf`, `ktv`) are deliberately omitted, + * as both implementations omit them. + */ +data class PrivateDeviceData( + /** `pb` — OS build string. */ + val osBuild: String, + /** `pn` — platform name. */ + val platformName: String, + /** `pv` — OS version. */ + val osVersion: String, + /** `u` — device UUID, uppercased. */ + val deviceUuid: String, + /** `d` — seconds since the Apple epoch (2001-01-01), as a decimal string. */ + val appleEpochSeconds: Long, + /** Relay/phone profile: `m`/`s` become "1" instead of "0" (§1.4). */ + val relayProfile: Boolean = false, +) { + fun toDict(): Map = linkedMapOf( + "ap" to "0", + "d" to appleEpochSeconds.toString(), + "dt" to 1, + "gt" to "0", + "h" to "1", + "m" to if (relayProfile) "1" else "0", + "p" to "0", + "pb" to osBuild, + "pn" to platformName, + "pv" to osVersion, + "s" to if (relayProfile) "1" else "0", + "t" to "0", + "u" to deviceUuid.uppercase(), + "v" to "1", + ) +} + +/** + * One user entry of spec §1.4. The recorded wire keys are `user-id`, `uris`, `tag`, + * `client-data` and `kt-loggable-data`; `tag` is present only for phone users ("SIM", "SIM2", + * …). [userId] is the plain profile-ID string — phone users carry the `P:` prefix here, and + * that same string is the `x-auth-user-id-` header value (§1.4, register-only). + * + * [uris] are single-key `tel:`/`mailto:` dictionaries fetched from the server and echoed back + * — never constructed locally (§1.4); fetching them is the caller's step. + * + * [clientData] rides verbatim: the base wire keys are recorded (§1.4: `public-message-identity-key`, + * `public-message-identity-version`, `ec-version`, `public-message-identity-ngm-version`, + * `public-message-ngm-device-prekey-data-key`, `kt-version` plus the `supports-*` booleans) but + * the exact capability set is disagreed between the sources (C3) and the key structures belong + * to the identity component — the caller assembles the dict. Same for [ktLoggableData]'s + * protobuf contents (C52). + */ +data class RegisterUser( + val userId: String, + val uris: List>, + val tag: String?, + /** The per-user auth key of §1.4 (2048-bit RSA, §1.2). */ + val authKey: PrivateKey, + /** The auth certificate this user's CSR was exchanged for (§1.2). */ + val authCertificateDer: ByteArray, + val clientData: Map, + val ktLoggableData: ByteArray?, +) { + fun toDict(): Map = buildMap { + put("user-id", userId) + put("uris", uris) + put("client-data", clientData) + tag?.let { put("tag", it) } + ktLoggableData?.let { put("kt-loggable-data", it) } + } +} + +/** + * One capability record of a §1.4 service entry: `flags` (int), `name` (string), `version` + * (int). The record both sources send is name "Messenger", version 1 — but the flags value is + * disagreed (17 vs 1, capture-bound C3); the default follows the section's prose, which reads + * the 17 variant first. + */ +data class RegisterCapability( + val flags: Int = 17, + val name: String = "Messenger", + val version: Int = 1, +) { + fun toDict(): Map = linkedMapOf( + "flags" to flags, + "name" to name, + "version" to version, + ) +} + +/** + * One `services` array entry of spec §1.4: `service`, `sub-services`, `users`, `capabilities`. + * The sub-service list is disagreed (four topics vs seven, C3): [subServices] defaults to the + * four-topic list the section's prose reads first; the seven-topic variant is + * [SUB_SERVICES_SEVEN]. The users array rides inside the service entry — §1.4 records the + * request as exactly eight top-level keys, none beyond. + */ +data class RegisterService( + val service: String, + val subServices: List = SUB_SERVICES, + val users: List, + val capabilities: List = listOf(RegisterCapability()), +) { + fun toDict(): Map = linkedMapOf( + "capabilities" to capabilities.map { it.toDict() }, + "service" to service, + "sub-services" to subServices, + "users" to users.map { it.toDict() }, + ) + + companion object { + /** The iMessage service value (§1.4). */ + const val MADRID = "com.apple.madrid" + + /** Four topics — the first-recorded sub-service list (C3). */ + val SUB_SERVICES = listOf( + "com.apple.private.alloy.sms", + "com.apple.private.alloy.gelato", + "com.apple.private.alloy.biz", + "com.apple.private.alloy.gamecenter.imessage", + ) + + /** The second-recorded variant: the four topics plus three more (C3). */ + val SUB_SERVICES_SEVEN = SUB_SERVICES + listOf( + "com.apple.private.alloy.safetymonitor", + "com.apple.private.alloy.safetymonitor.ownaccount", + "com.apple.private.alloy.askto", + ) + } +} + +/** + * The register body of spec §1.4, encoded as an XML plist — exactly the eight recorded + * top-level keys, none beyond (§1.4): `device-name`, `hardware-version`, `language`, + * `os-version`, `software-version`, `private-device-data`, `services`, `validation-data`. + */ +data class RegisterBody( + val deviceName: String, + val hardwareVersion: String, + val language: String, + val osVersion: String, + val softwareVersion: String, + val privateDeviceData: PrivateDeviceData, + val services: List, + /** The `validation-data` blob (§1.1) — carried verbatim; minting it is C5/C56-blocked. */ + val validationData: ByteArray, +) { + fun toDict(): Map = linkedMapOf( + "device-name" to deviceName, + "hardware-version" to hardwareVersion, + "language" to language, + "os-version" to osVersion, + "software-version" to softwareVersion, + "private-device-data" to privateDeviceData.toDict(), + "services" to services.map { it.toDict() }, + "validation-data" to validationData, + ) + + /** The body's distinct users, in first-occurrence order — one signature per user (§1.4). */ + val users: List + get() = services.flatMap { it.users }.distinctBy { it.userId } +} + +/** + * Everything a register request needs (spec §1.4). Header wire names are the recorded ones: + * the `x-push-*`/`x-auth-*-` signature sets (§1.4) plus `x-protocol-version` and + * `user-agent` (§1.2/§2.1 record the names). + */ +data class RegisterConfig( + /** The 32-byte APNs connect token, signed into the payload and sent as `x-push-token`. */ + val pushToken: ByteArray, + val pushKey: PrivateKey, + /** The Albert device certificate — issued by [AlbertActivator] (§1.2). */ + val pushCertificateDer: ByteArray, + /** "1640" hardcoded in one source, "1660" in the other's fixtures — C2 open. */ + val protocolVersion: String, + /** `com.apple.invitation-registration [,,,]` (§1.4). */ + val userAgent: String, +) + +/** A per-URI result of the register response (§1.4): `uri` + `status`. */ +data class IdsRegisterUriResult( + val uri: String, + val status: Int?, +) + +/** The `alert` object of the register response (§1.4): `title`/`body`/`button` + nested `action`. */ +data class IdsRegisterAlert( + val title: String?, + val body: String?, + val button: String?, + val action: IdsRegisterAlertAction?, +) + +/** The nested `action` object of a register `alert` (§1.4): `button`, `type`, `url`. */ +data class IdsRegisterAlertAction( + val button: String?, + val type: String?, + val url: String?, +) + +/** A per-user result of the register response (§1.4 keys `uris`, `user-id`, `cert`, `status`, `alert`). */ +data class IdsRegisterUserResult( + val userId: String?, + val status: Int?, + /** + * §6.1: a status equal to the configured refresh-credentials marker marks that auth pair + * for refresh. The marker string is capture-bound — caller-supplied, never guessed. + */ + val refreshCredentials: Boolean, + /** The X.509 identity certificate (DER) to store. */ + val cert: ByteArray?, + val uris: List, + val alert: IdsRegisterAlert?, + /** §6.1: seconds from registration until re-registration; absent → cert `notAfter` − 5 min. */ + val nextHbi: Long?, + val raw: Map, +) + +/** A per-service result of the register response (§1.4: `service`, `users`, `status`). */ +data class IdsRegisterServiceResult( + val service: String?, + val status: Int?, + val refreshCredentials: Boolean, + val users: List, + val raw: Map, +) + +/** + * The register response of spec §1.4. Top level `status`, `message`, `retry-interval`, + * `services` — one source names all four, the other reads only `status` and `services` (C57); + * the spec's wording models all four, so the two read-only ones stay optional here. + */ +data class IdsRegisterResponse( + val status: Int, + val message: String?, + val retryInterval: Long?, + val services: List, + val raw: Map, +) { + companion object { + const val STATUS_OK = 0 + /** Contact Key Verification or Advanced Data Protection enabled (§1.4). */ + const val STATUS_INCOMPATIBLE = 6001 + const val STATUS_TRANSIENT_RETRY = 6004 + const val STATUS_BAD_AUTHENTICATION = 6005 + const val STATUS_ACCESS_DISABLED = 6009 + const val STATUS_ALIAS_REMOVED = 5052 + } +} + +/** + * IDS register client per spec §1.4: POST the XML-plist body to the bag's `id-register` URL, + * signed once with the push key and once per user with that user's auth key (§1.4's indexed + * header sets). The body goes out with the recorded content type and no gzip — the + * gzip-vs-plain disagreement is C3 and the un-gzipped variant is the lower-assumption one. + * + * [refreshCredentialsMarker] is the §6.1 "refresh credentials" status string — capture-bound, + * so caller-supplied. With it unset (the default), any string-valued per-user/per-service + * status fails the parse loudly rather than being silently dropped. + */ +class IdsRegistrar( + private val http: IdsHttp, + private val bag: Bag, + private val config: RegisterConfig, + private val refreshCredentialsMarker: String? = null, +) { + + suspend fun register(body: RegisterBody): IdsRegisterResponse { + val url = bag.url(BAG_KEY) + val encoded = XmlPlist.encode(body.toDict()) + val headers = signatureHeaders(encoded, body.users) + headers[PROTOCOL_VERSION_HEADER] = config.protocolVersion + headers[USER_AGENT_HEADER] = config.userAgent + val response = http.post(url, headers, encoded, CONTENT_TYPE) + if (response.status != 200) throw IdsActivationException("POST $url → HTTP ${response.status}") + return parseResponse(gunzipIfNeeded(response.body)) + } + + /** §1.4: push set + per-user indexed sets, all over the same signed tuple. */ + private fun signatureHeaders( + encodedBody: ByteArray, + users: List, + ): LinkedHashMap { + val fields = listOf( + BAG_KEY.toByteArray(Charsets.UTF_8), + EMPTY_QUERY, + encodedBody, + config.pushToken, + ) + val out = LinkedHashMap() + val pushNonce = IdsSigning.nonce(IdsSigning.TYPE_HTTPS) + out["x-push-nonce"] = base64(pushNonce) + out["x-push-cert"] = base64(config.pushCertificateDer) + out["x-push-sig"] = base64(IdsSigning.sign(config.pushKey, pushNonce, fields)) + out["x-push-token"] = base64(config.pushToken) + users.forEachIndexed { i, user -> + val nonce = IdsSigning.nonce(IdsSigning.TYPE_HTTPS) + out["x-auth-user-id-$i"] = user.userId + out["x-auth-nonce-$i"] = base64(nonce) + out["x-auth-cert-$i"] = base64(user.authCertificateDer) + out["x-auth-sig-$i"] = base64(IdsSigning.sign(user.authKey, nonce, fields)) + } + return out + } + + private fun parseResponse(bytes: ByteArray): IdsRegisterResponse { + val parsed = try { + Plist.parse(bytes) + } catch (e: PlistFormatException) { + throw IdsActivationException("register response is not a plist: ${e.message}", e) + } + val dict = stringKeyedDictOrNull(parsed) + ?: throw IdsActivationException("register response is ${typeName(parsed)}, expected a dict") + val status = integerOrNull(dict["status"]) + ?: throw IdsActivationException( + "register response has no integer 'status' (${typeName(dict["status"])}) — spec §1.4 records one", + ) + val services = when (val value = dict["services"]) { + null -> emptyList() + is List<*> -> value.mapIndexed { i, entry -> + val service = stringKeyedDictOrNull(entry) + ?: throw IdsActivationException("register services[$i] is ${typeName(entry)}, expected a dict") + val (serviceStatus, serviceRefresh) = parseStatus(service["status"], "services[$i]") + IdsRegisterServiceResult( + service = service["service"] as? String, + status = serviceStatus, + refreshCredentials = serviceRefresh, + users = when (val users = service["users"]) { + null -> emptyList() + is List<*> -> users.mapIndexed { j, entry -> + parseUser(stringKeyedDictOrNull(entry) + ?: throw IdsActivationException( + "register services[$i].users[$j] is ${typeName(entry)}, expected a dict", + ), "services[$i].users[$j]") + } + else -> throw IdsActivationException( + "register services[$i].users is ${typeName(users)}, expected an array", + ) + }, + raw = service, + ) + } + else -> throw IdsActivationException("register 'services' is ${typeName(value)}, expected an array") + } + return IdsRegisterResponse( + status = status, + message = dict["message"] as? String, + retryInterval = longOrNull(dict["retry-interval"]), + services = services, + raw = dict, + ) + } + + private fun parseUser(user: Map, where: String): IdsRegisterUserResult { + val (status, refresh) = parseStatus(user["status"], where) + return IdsRegisterUserResult( + userId = user["user-id"] as? String, + status = status, + refreshCredentials = refresh, + cert = user["cert"] as? ByteArray, + uris = when (val uris = user["uris"]) { + null -> emptyList() + is List<*> -> uris.mapIndexed { k, entry -> + val uri = stringKeyedDictOrNull(entry) + ?: throw IdsActivationException("$where.uris[$k] is ${typeName(entry)}, expected a dict") + IdsRegisterUriResult( + uri = uri["uri"] as? String + ?: throw IdsActivationException("$where.uris[$k] has no 'uri' string"), + status = integerOrNull(uri["status"]), + ) + } + else -> throw IdsActivationException("$where.uris is ${typeName(uris)}, expected an array") + }, + alert = (user["alert"] as? Map<*, *>)?.let { alert -> + IdsRegisterAlert( + title = alert["title"] as? String, + body = alert["body"] as? String, + button = alert["button"] as? String, + action = (alert["action"] as? Map<*, *>)?.let { action -> + IdsRegisterAlertAction( + button = action["button"] as? String, + type = action["type"] as? String, + url = action["url"] as? String, + ) + }, + ) + }, + nextHbi = longOrNull(user["next-hbi"]), + raw = user, + ) + } + + /** A status is an integer, or the §6.1 "refresh credentials" marker when configured. */ + private fun parseStatus(value: Any?, where: String): Pair = when (value) { + null -> null to false + is Long -> value.toInt() to false + is Int -> value to false + is String -> + if (value == refreshCredentialsMarker) null to true + else throw IdsActivationException( + "$where status is the string '$value' — if that is the 'refresh credentials' " + + "marker (spec §6.1), configure it explicitly", + ) + else -> throw IdsActivationException("$where status is ${typeName(value)}, expected an integer") + } + + private fun base64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + companion object { + /** Bag key for the register endpoint (spec §1.2/§1.4). */ + const val BAG_KEY = "id-register" + + /** The recorded content type (§1.4); the other source sends none — C3. */ + const val CONTENT_TYPE = "application/x-apple-plist" + + /** Recorded header wire names (§1.2/§2.1). */ + const val PROTOCOL_VERSION_HEADER = "x-protocol-version" + const val USER_AGENT_HEADER = "user-agent" + + private val EMPTY_QUERY = ByteArray(0) + } +} + +/** + * The `authentication-data` dict of spec §1.2: `auth-token` for the Apple/DS flow, or + * `push-token` + `sigs` (array) for the phone flow. The value encodings of `push-token` and + * `sigs` are not recorded (only their key names are) — they are supplied verbatim. + */ +sealed interface AuthenticationData { + + data class AuthToken(val token: String) : AuthenticationData + + data class Push(val pushToken: Any?, val sigs: List) : AuthenticationData + + fun toDict(): Map = when (this) { + is AuthToken -> mapOf("auth-token" to token) + is Push -> mapOf("push-token" to pushToken, "sigs" to sigs) + } +} + +/** The authenticate response of spec §1.2: flat `status` + `cert`, no per-user nesting. */ +data class IdsAuthenticateResponse( + val status: Int, + /** The authentication certificate this exchange issues (DER) — enables registration (§1.2). */ + val cert: ByteArray?, + val raw: Map, +) + +/** + * The §1.2 authenticate exchange. One authentication per user, to the `id-authenticate-ds-id` + * (Apple/DS flow) or `id-authenticate-phone-number` bag URL; phone realm user ids have the + * "P:" form. The request body is an XML plist with keys `authentication-data`, `csr` + * (DER), `realm-user-id`; the CSR's CN is the hex SHA-1 of the realm user id ([IdsCsrBuilder]). + * + * Headers are ONLY `user-agent` and `x-protocol-version` plus `content-encoding: gzip` (the + * §1.2 extraction; the §1.4 signature header sets are NOT applied to authenticate). The + * SMS-less multi-user route needs carrier EAP-AKA data (C7) and is out of scope. + * + * Two §1.2 values ride C57 as caller parameters on the CSR builder: the CN hex case + * ([HexCase], default UPPER) and the CSR self-signature hash ([CsrSignatureHash], default + * SHA-1) — both built before this class is reached. + */ +class IdsAuthenticator( + private val http: IdsHttp, + private val bag: Bag, + private val protocolVersion: String, + private val userAgent: String, +) { + + suspend fun authenticate( + bagKey: String, + realmUserId: String, + csrDer: ByteArray, + authenticationData: AuthenticationData, + ): IdsAuthenticateResponse { + require(bagKey == BAG_KEY_DS_ID || bagKey == BAG_KEY_PHONE_NUMBER) { + "bag key must be $BAG_KEY_DS_ID or $BAG_KEY_PHONE_NUMBER (spec §1.2); '$bagKey' is neither" + } + val url = bag.url(bagKey) + val body = linkedMapOf( + "authentication-data" to authenticationData.toDict(), + "csr" to csrDer, + "realm-user-id" to realmUserId, + ) + val encoded = gzip(XmlPlist.encode(body)) + val headers = linkedMapOf( + USER_AGENT_HEADER to userAgent, + PROTOCOL_VERSION_HEADER to protocolVersion, + "content-encoding" to "gzip", + ) + val response = http.post(url, headers, encoded, CONTENT_TYPE) + if (response.status != 200) throw IdsActivationException("POST $url → HTTP ${response.status}") + return parseResponse(gunzipIfNeeded(response.body)) + } + + private fun parseResponse(bytes: ByteArray): IdsAuthenticateResponse { + val parsed = try { + Plist.parse(bytes) + } catch (e: PlistFormatException) { + throw IdsActivationException("authenticate response is not a plist: ${e.message}", e) + } + val dict = stringKeyedDictOrNull(parsed) + ?: throw IdsActivationException("authenticate response is ${typeName(parsed)}, expected a dict") + return IdsAuthenticateResponse( + status = integerOrNull(dict["status"]) + ?: throw IdsActivationException( + "authenticate response has no integer 'status' (${typeName(dict["status"])}) — spec §1.2 records one", + ), + cert = dict["cert"] as? ByteArray, + raw = dict, + ) + } + + private fun gzip(bytes: ByteArray): ByteArray = ByteArrayOutputStream(bytes.size).use { buffer -> + GZIPOutputStream(buffer).use { it.write(bytes) } + buffer.toByteArray() + } + + companion object { + const val BAG_KEY_DS_ID = "id-authenticate-ds-id" + const val BAG_KEY_PHONE_NUMBER = "id-authenticate-phone-number" + + /** Not recorded for authenticate (§1.2); the register content type is the nearest fact. */ + const val CONTENT_TYPE = "application/x-apple-plist" + + const val PROTOCOL_VERSION_HEADER = "x-protocol-version" + const val USER_AGENT_HEADER = "user-agent" + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsCsr.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsCsr.kt new file mode 100644 index 0000000..5e81b67 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsCsr.kt @@ -0,0 +1,215 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.math.BigInteger +import java.security.KeyPair +import java.security.MessageDigest +import java.security.Signature +import java.security.interfaces.RSAPublicKey +import java.util.Base64 + +/** + * Case of the hex encoding of the authenticate CSR's common name — the SHA-1 digest of the + * realm user id is a hex STRING (not raw, not base64, §1.2). Case is a C57 disagreement: + * one source uppercases, the other lowercases. Default [UPPER] matches the section's prose, + * which reads the uppercase variant first. + */ +enum class HexCase { UPPER, LOWER } + +/** + * The hash a CSR's self-signature uses. On the authenticate CSR this is a C57 disagreement + * (§1.2: SHA-1 RSA/PKCS1 vs SHA256WithRSA) — a parameter with the SHA-1 default. The Albert + * push CSR's hash is recorded outright (SHA-1WithRSA, §1.2) and does not ride this choice. + */ +enum class CsrSignatureHash(val javaAlgorithm: String, val oid: String) { + SHA1("SHA1withRSA", "1.2.840.113549.1.1.5"), + SHA256("SHA256withRSA", "1.2.840.113549.1.1.11"), +} + +/** PEM armor (§1.2: the `DeviceCertRequest` is a PEM CSR; the Albert `DeviceCertificate` returns as PEM). */ +object Pem { + const val CSR_TYPE = "CERTIFICATE REQUEST" + const val CERTIFICATE_TYPE = "CERTIFICATE" + + fun encode(type: String, der: ByteArray): String { + val lines = Base64.getEncoder().encodeToString(der).chunked(64).joinToString("\n") + return "-----BEGIN $type-----\n$lines\n-----END $type-----\n" + } + + fun decode(pem: String, type: String): ByteArray { + val begin = "-----BEGIN $type-----" + val end = "-----END $type-----" + val start = pem.indexOf(begin) + require(start >= 0) { "no $begin block found" } + val stop = pem.indexOf(end, start + begin.length) + require(stop > start) { "unterminated $begin block" } + val body = pem.substring(start + begin.length, stop).filterNot { it.isWhitespace() } + return Base64.getDecoder().decode(body) + } +} + +/** A PKCS#10 certification request: DER bytes and their PEM armor (§1.2 "CSR", DER on the wire). */ +class IdsCsr(val der: ByteArray, val pem: String) + +/** + * Minimal DER writer + PKCS#10 builder. The JDK has no public PKCS#10 API, so the request is + * hand-encoded: CertificationRequest ::= SEQUENCE { CertificationRequestInfo, signature + * AlgorithmIdentifier, signature BIT STRING }, with the request info carrying version 0, the + * subject RDN sequence, the RSA SubjectPublicKeyInfo, and empty [0] attributes. + */ +object IdsCsrBuilder { + + /** + * The authenticate CSR's common name: the hex SHA-1 of the realm user id (§1.2). Phone + * realm user ids have the "P:" form. Hex case rides C57 — see [HexCase]. + */ + fun realmUserIdCn(realmUserId: String, hexCase: HexCase = HexCase.UPPER): String { + val digest = MessageDigest.getInstance("SHA-1").digest(realmUserId.toByteArray(Charsets.UTF_8)) + val hex = digest.joinToString("") { "%02x".format(it) } + return if (hexCase == HexCase.UPPER) hex.uppercase() else hex + } + + /** + * Builds and self-signs a CSR with [keyPair]'s private key. [selfSignatureHash] is the C57 + * parameter (§1.2) — note the Android Keystore spec in [IdsKeys] currently provisions + * SHA-1 digests only, so SHA-256 needs a keystore-spec change before it works there. + */ + fun create( + keyPair: KeyPair, + commonName: String, + organizationalUnit: String? = null, + organization: String? = null, + selfSignatureHash: CsrSignatureHash = CsrSignatureHash.SHA1, + ): IdsCsr { + val public = keyPair.public as? RSAPublicKey + ?: throw IllegalArgumentException("CSR key must be RSA, was ${keyPair.public.algorithm}") + val rdns = buildList { + add(Der.setOf(nameEntry(OID_COMMON_NAME, commonName))) + organizationalUnit?.let { add(Der.setOf(nameEntry(OID_ORGANIZATIONAL_UNIT, it))) } + organization?.let { add(Der.setOf(nameEntry(OID_ORGANIZATION, it))) } + } + val publicKeyInfo = Der.sequence( + Der.sequence(Der.oid(OID_RSA_ENCRYPTION), Der.nullValue()), + Der.bitString( + Der.sequence(Der.integer(public.modulus), Der.integer(public.publicExponent)), + ), + ) + val requestInfo = Der.sequence( + Der.integer(BigInteger.ZERO), + Der.sequence(*rdns.toTypedArray()), + publicKeyInfo, + Der.contextConstructed(0), + ) + val signer = Signature.getInstance(selfSignatureHash.javaAlgorithm) + signer.initSign(keyPair.private) + signer.update(requestInfo) + val der = Der.sequence( + requestInfo, + Der.sequence(Der.oid(selfSignatureHash.oid), Der.nullValue()), + Der.bitString(signer.sign()), + ) + return IdsCsr(der, Pem.encode(Pem.CSR_TYPE, der)) + } + + private fun nameEntry(oid: String, value: String): ByteArray = + Der.sequence(Der.oid(oid), Der.utf8String(value)) + + private const val OID_COMMON_NAME = "2.5.4.3" + private const val OID_ORGANIZATIONAL_UNIT = "2.5.4.11" + private const val OID_ORGANIZATION = "2.5.4.10" + private const val OID_RSA_ENCRYPTION = "1.2.840.113549.1.1.1" +} + +internal object Der { + + fun sequence(vararg parts: ByteArray): ByteArray = tagged(0x30, concat(parts)) + fun setOf(vararg parts: ByteArray): ByteArray = tagged(0x31, concat(parts)) + fun integer(value: BigInteger): ByteArray = tagged(0x02, value.toByteArray()) + fun utf8String(value: String): ByteArray = tagged(0x0c, value.toByteArray(Charsets.UTF_8)) + fun octetString(value: ByteArray): ByteArray = tagged(0x04, value) + fun bitString(value: ByteArray): ByteArray = tagged(0x03, byteArrayOf(0) + value) + fun nullValue(): ByteArray = byteArrayOf(0x05, 0x00) + fun contextConstructed(tag: Int, body: ByteArray = ByteArray(0)): ByteArray = tagged(0xa0 or tag, body) + + fun oid(dotted: String): ByteArray { + val arcs = dotted.split('.').map { it.toLong() } + require(arcs.size >= 2) { "OID needs at least two arcs: $dotted" } + require(arcs[0] in 0..2 && arcs[1] >= 0) { "OID first arcs out of range: $dotted" } + val body = ArrayList() + body.add((arcs[0] * 40 + arcs[1]).toByte()) + for (arc in arcs.drop(2)) { + require(arc >= 0) { "negative OID arc: $dotted" } + // little-endian base-128 (continuation bit high), reversed to wire order below + val group = ArrayList() + var v = arc + group.add((v and 0x7f).toByte()) + v = v ushr 7 + while (v != 0L) { + group.add(((v and 0x7f).toInt() or 0x80).toByte()) + v = v ushr 7 + } + group.reverse() + body.addAll(group) + } + return tagged(0x06, body.toByteArray()) + } + + private fun concat(parts: Array): ByteArray { + val size = parts.sumOf { it.size } + val out = ByteArray(size) + var at = 0 + for (part in parts) { + part.copyInto(out, at) + at += part.size + } + return out + } + + private fun tagged(tag: Int, body: ByteArray): ByteArray { + val header = ArrayList(5) + header.add(tag.toByte()) + when { + body.size < 0x80 -> header.add(body.size.toByte()) + body.size < 0x100 -> { + header.add(0x81.toByte()) + header.add(body.size.toByte()) + } + body.size < 0x10000 -> { + header.add(0x82.toByte()) + header.add((body.size ushr 8).toByte()) + header.add(body.size.toByte()) + } + else -> throw IllegalArgumentException("DER body too long: ${body.size}") + } + return header.toByteArray() + body + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsKeys.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsKeys.kt new file mode 100644 index 0000000..8d96d79 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsKeys.kt @@ -0,0 +1,85 @@ +package dev.fenn.imessage.registration + +import java.security.KeyPair +import java.security.MessageDigest +import java.security.spec.ECGenParameterSpec + +/** What a key is for, in keystore-generation terms (spec §1.4/§1.2). */ +sealed interface IdsKeySpec { + + /** + * RSA key. Signing keys (push §1.4, auth §1.2 — 2048) sign with SHA-1-PKCS1v1.5 + * ([IdsSigning]); the identity encryption key (§1.4 — 1280) decrypts the legacy pair + * envelope's RSA-OAEP transport block (§4.2). + */ + data class Rsa(val bits: Int, val forEncryption: Boolean = false) : IdsKeySpec + + /** P-256 EC key: identity signing, device key, pre-key (§1.4). */ + data object Ec : IdsKeySpec +} + +/** + * Key-pair seam. Production holds keys in the Android Keystore (the originating repo's + * `AndroidKeystoreKeyPairs`, not ported — see the port note below) so private keys never + * leave secure hardware; tests use a software implementation because the AndroidKeyStore + * provider does not exist on the JVM. + * + * Keys are generated once per alias and reused ([getOrCreate]); rotation (renewal, pre-key + * refresh — spec §6.1) is a later component's policy decision and can be expressed as a new + * alias + eventual deletion, nothing here hardcodes a lifetime. + */ +interface IdsKeyPairs { + fun getOrCreate(alias: String, spec: IdsKeySpec): KeyPair +} + +// Port note: the originating repo's Android Keystore implementation of this seam +// (`AndroidKeystoreKeyPairs`, non-exportable private keys, digest/padding sets covering the +// §1.4 SHA-1-PKCS1 signatures, §4.3 ECDSA-SHA256 and §4.2 OAEP(SHA-1) transport) is Android- +// only and waits for the Android integration; whether a device keystore accepts SHA-1 OAEP +// for the 1280-bit identity encryption key is only verifiable on a real device — +// TODO(device-test). + +/** The six key pairs one registration needs (spec §1.2/§1.4/§2.1 "identity keypair"). */ +class IdsActivationKeys( + /** RSA-2048 — signs every IDS request (§1.4); its CSR goes to Albert for the push cert ([AlbertCsr], §1.2). */ + val push: KeyPair, + /** RSA-2048 — per-user auth key; its CSR (§1.2) is exchanged for the auth certificate. */ + val auth: KeyPair, + /** RSA-1280 — legacy identity encryption key, decrypts the pair envelope's transport block (§4.2). */ + val identityEncryption: KeyPair, + /** P-256 — legacy identity signing key. */ + val identitySigning: KeyPair, + /** P-256 — EC scheme device key (§4.3). */ + val device: KeyPair, + /** P-256 — EC scheme pre-key (§4.3); rotation on re-registration is a later component's policy. */ + val prekey: KeyPair, +) + +object IdsKeys { + const val PUSH_ALIAS = "ids-push" + const val IDENTITY_ENCRYPTION_ALIAS = "ids-identity-encryption" + const val IDENTITY_SIGNING_ALIAS = "ids-identity-signing" + const val DEVICE_ALIAS = "ids-device" + const val PREKEY_ALIAS = "ids-prekey" + + /** + * The auth key is per user (§1.2) — the alias is namespaced by a SHA-1 of the user id so + * no user-controlled string becomes a keystore alias verbatim. + */ + fun authAlias(userId: String): String { + val digest = MessageDigest.getInstance("SHA-1") + .digest(userId.toByteArray(Charsets.UTF_8)) + return "ids-auth-" + digest.joinToString("") { "%02x".format(it) } + } + + /** Generates on first call, reuses afterwards — re-registration does not mint new keys (§6.1). */ + fun create(keyPairs: IdsKeyPairs, authUserId: String): IdsActivationKeys = IdsActivationKeys( + push = keyPairs.getOrCreate(PUSH_ALIAS, IdsKeySpec.Rsa(bits = 2048)), + auth = keyPairs.getOrCreate(authAlias(authUserId), IdsKeySpec.Rsa(bits = 2048)), + identityEncryption = + keyPairs.getOrCreate(IDENTITY_ENCRYPTION_ALIAS, IdsKeySpec.Rsa(bits = 1280, forEncryption = true)), + identitySigning = keyPairs.getOrCreate(IDENTITY_SIGNING_ALIAS, IdsKeySpec.Ec), + device = keyPairs.getOrCreate(DEVICE_ALIAS, IdsKeySpec.Ec), + prekey = keyPairs.getOrCreate(PREKEY_ALIAS, IdsKeySpec.Ec), + ) +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsRenewal.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsRenewal.kt new file mode 100644 index 0000000..163bcb1 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsRenewal.kt @@ -0,0 +1,142 @@ +package dev.fenn.imessage.registration + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.math.min + +/** + * The §6.1 re-registration deadline across all registered users: the earliest + * per-user deadline ([nextRenewalAtEpochMs]: min of registration + `next-hbi` + * and cert `notAfter` − 5 min), capped by the 45-day since-last-registration + * rule. The cap and the per-cert buffers are client policy constants, not + * measurements (`TODO(capture)` C41) — layered here, the caller layer, not + * baked into the store. No users and no last-registration time → null. + */ +fun renewalDeadlineEpochMs( + users: List, + lastRegistrationEpochMs: Long?, +): Long? { + val cap = lastRegistrationEpochMs?.plus(IdsRenewal.CAP_MS) + val byUsers = users.minOfOrNull { + nextRenewalAtEpochMs(it.registeredAtEpochMs, it.nextHbiSeconds, it.identityCertNotAfterEpochMs) + } + return listOfNotNull(byUsers, cap).minOrNull() +} + +/** + * The §6.1 re-registration scheduler: the timer deadline ([renewalDeadlineEpochMs]), + * the reactive triggers (private-IDS c=32/c=66, lookup 6005), the 15-second + * triggered-refresh dedup, and the 5-minute-to-1-day failure backoff. The + * registration itself is injected ([reRegister]) — it needs live validation + * data that is `TODO(capture)` C5/C6-blocked (a Mac ≤ 14.3), so nothing here + * touches the network and the JVM tests drive it end to end. + * + * Wiring point: ChatSyncService's periodic tick calls [onTimer] (the 60-second + * wall-clock recheck of §6.1 is the tick cadence), and the courier push + * handler calls [onPrivateIdsCommand] / [onLookupError6005] once a courier + * connection exists (component 2's transport is not wired to a service yet). + * Command 34 is not a re-register — the caller invalidates the identity cache + * itself (`IdsStore.clearCache()`). + */ +class IdsRenewal( + private val nowMs: () -> Long = { System.currentTimeMillis() }, + private val reRegister: suspend (reason: String) -> Result, +) { + + /** One trigger decision: [attempted] false = nothing ran (not due, deduped, + * rate-limited, not a re-register trigger); a failure carries no error — + * the [reRegister] owner logs it. */ + data class Attempt(val attempted: Boolean, val success: Boolean?, val reason: String) + + companion object { + /** + * §6.1: force re-registration 45 days after the last successful one — + * a client policy cap (`TODO(capture)` C41), not a measured Apple TTL. + */ + const val CAP_MS: Long = 45L * 24 * 60 * 60 * 1000 + + /** §6.1: failed registrations retry with exponential backoff, minimum 5 minutes, maximum 1 day. */ + const val BACKOFF_MIN_MS: Long = 5 * 60 * 1000L + const val BACKOFF_MAX_MS: Long = 24 * 60 * 60 * 1000L + + /** §6.1: a triggered refresh is a no-op within 15 s of the last attempt (any attempt, so a forced trigger cannot hammer a failing registration). */ + const val DEDUP_MS: Long = 15_000L + + /** §6.1: a lookup 6005 triggers re-register and retry at most once per hour. */ + const val RATE_6005_MS: Long = 60 * 60 * 1000L + } + + private val mutex = Mutex() + private var failures = 0 + private var backoffUntilMs = 0L + private var lastAttemptMs = 0L + private var last6005Ms = 0L + + /** The periodic tick: re-register when the deadline has passed and the failure backoff has run out. */ + suspend fun onTimer( + users: List, + lastRegistrationEpochMs: Long?, + ): Attempt = mutex.withLock { + val deadline = renewalDeadlineEpochMs(users, lastRegistrationEpochMs) + ?: return Attempt(false, null, "not registered") + val now = nowMs() + if (now < deadline || now < backoffUntilMs) Attempt(false, null, "not due") + else attempt("timer") + } + + /** + * Private-IDS topic push (§2.3/§6.1): c=32 forces re-registration; c=66 + * re-fetches handles and re-registers when they differ from + * [registeredHandles] (a null [refetchHandles] or an unchanged set does + * nothing); any other command (including c=34 — cache invalidation, the + * caller's job) does nothing. + */ + suspend fun onPrivateIdsCommand( + command: Int, + registeredHandles: List = emptyList(), + refetchHandles: (suspend () -> List?)? = null, + ): Attempt = mutex.withLock { + when (command) { + 32 -> attempt("c=32") + 66 -> { + val fetched = refetchHandles?.invoke() + if (fetched != null && fetched.toSet() != registeredHandles.toSet()) attempt("c=66") + else Attempt(false, null, "c=66 handles unchanged") + } + else -> Attempt(false, null, "c=$command not a re-register trigger") + } + } + + /** Lookup 6005 (§2.3/§6.1): re-register and retry, at most once per hour. */ + suspend fun onLookupError6005(): Attempt = mutex.withLock { + val now = nowMs() + if (now - last6005Ms < RATE_6005_MS) Attempt(false, null, "6005 rate-limited") + else { + last6005Ms = now + attempt("6005") + } + } + + private suspend fun attempt(reason: String): Attempt { + val now = nowMs() + if (lastAttemptMs != 0L && now - lastAttemptMs < DEDUP_MS) { + return Attempt(false, null, "deduped within 15 s") + } + lastAttemptMs = now + return reRegister(reason).fold( + onSuccess = { + failures = 0 + backoffUntilMs = 0L + Attempt(true, true, reason) + }, + onFailure = { + failures++ + backoffUntilMs = now + backoffMs() + Attempt(true, false, reason) + }, + ) + } + + /** §6.1: 5 minutes doubling, capped at 1 day. */ + private fun backoffMs(): Long = min(BACKOFF_MAX_MS, BACKOFF_MIN_MS shl (failures - 1).coerceAtMost(12)) +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsState.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsState.kt new file mode 100644 index 0000000..63aa8ba --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsState.kt @@ -0,0 +1,41 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.ids.IdsLookupResult + +/** + * **C14 decision** (`TODO(capture)` C14 open): the fixed-tier cache policy — + * positive results refreshed after 1 day, negative (zero-identity) results + * after 1 hour, hard expiry after 7 days. Chosen over the server-token-field + * policy because the real token lifetimes are unmeasured (C10 open, real + * `session-token-expires/refresh-seconds` values unknown) and this policy + * needs no unmeasured server constants while still bounding staleness on both + * sides. The SHA-1 (id cert, APNs token) invalidation key of §2.2 applies in + * both policies and lives in the store. One object to swap when C14 closes. + */ +object TieredIdentityCachePolicy : IdentityCachePolicy { + + /** §2.2: positive results refresh after 1 day, negative after 1 hour, hard-expire after 7 days. */ + const val POSITIVE_TTL_MS: Long = 24 * 60 * 60 * 1000L + const val NEGATIVE_TTL_MS: Long = 60 * 60 * 1000L + const val HARD_TTL_MS: Long = 7L * 24 * 60 * 60 * 1000 + + override fun freshness( + result: IdsLookupResult, + fetchedAtEpochMs: Long, + nowEpochMs: Long, + ): CacheFreshness { + val ageMs = nowEpochMs - fetchedAtEpochMs + return when { + ageMs >= HARD_TTL_MS -> CacheFreshness.STALE + ageMs >= if (result.identities.isEmpty()) NEGATIVE_TTL_MS else POSITIVE_TTL_MS -> + CacheFreshness.REFRESH + else -> CacheFreshness.FRESH + } + } +} + +// Port note: the originating repo's `object IdsState` (Android Context + Room `IdsStateDatabase` +// singleton + a UI-facing `Status` snapshot whose renewal deadline came from the un-ported +// §6.1 scheduler) is not ported — this module has no Android runtime. The store is the plain +// [IdsStore] seam over an injected [IdsStateDao]; a future Android integration layer +// reconstructs the singleton and the status snapshot on top of it. diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsStore.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsStore.kt new file mode 100644 index 0000000..9a9bb70 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsStore.kt @@ -0,0 +1,356 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupResult +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.ByteArrayInputStream +import java.security.MessageDigest +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +/** + * The persisted iMessage state (spec §1.2/§1.4/§2.1/§2.2/§6.1/§6.2): registration artifacts, + * session-token lifetimes, and the identity cache send/receive consults. + * + * Only what the spec records a client must keep is stored. Unrecorded response fields (C53/C54) + * ride along as opaque XML-plist blobs — re-encoded with [XmlPlist] so nothing is dropped and + * nothing is guessed; their typed columns wait for the capture that names the wire keys. + * + * Port note: the originating repo backed this with Room (`IdsStateDatabase` + DAO + * annotations + SQLite migrations). This repo has no Android/Room on the JVM, so the storage + * contract is the plain [IdsStateDao] interface and the caller supplies the implementation — + * the registration dry run drives an in-memory DAO (no SQLite, no Android Context); the Room + * implementation waits for the Android integration. + */ +interface IdsStateDao { + suspend fun account(): IdsAccountEntity? + + suspend fun upsertAccount(entity: IdsAccountEntity) + + suspend fun registeredUsers(): List + + suspend fun registeredUser(userId: String): IdsRegisteredUserEntity? + + suspend fun upsertUser(user: IdsRegisteredUserEntity) + + suspend fun removeUser(userId: String) + + suspend fun cachedResult(handle: String): IdsCachedResultWithIdentities? + + suspend fun cachedHandles(): List + + suspend fun upsertCachedResult(result: IdsCachedResultEntity) + + suspend fun upsertCachedIdentities(identities: List) + + suspend fun deleteCachedResult(handle: String) + + suspend fun deleteCacheExcept(key: String) + + suspend fun clearCache() +} + +/** Singleton row (id always 0) — account-wide artifacts, none keyed per user. */ +data class IdsAccountEntity( + val id: Int = 0, + /** Albert activation record device certificate, DER (§1.2; the push key itself is Keystore-side). */ + val pushCertDer: ByteArray? = null, + /** The activation record's certificate chain, JSON array of base64 DERs (§1.2 "plus chain"). */ + val pushCertChainJson: String? = null, + /** 32-byte APNs connect token from the connect-ack, refreshed whenever a new one arrives (§3.3). */ + val apnsConnectToken: ByteArray? = null, + // Mac hardware identifiers the renewal's validation-data generation keys off (§6.1 Mac-renewal + // arbitration). MLB/ROM stay null when the extractor could not derive them (C5/C6). + val macSerial: String? = null, + val macBoardId: String? = null, + val macUuid: String? = null, + val macMlb: String? = null, + val macRom: String? = null, + val macModel: String? = null, + val macBuild: String? = null, + /** IDS delegate auth token + profile id from the iCloud setup login (§1.2). */ + val delegateAuthToken: String? = null, + val delegateProfileId: String? = null, + /** §6.2: delegate token refreshed lazily when this is older than 7 days (604800 s). */ + val delegateRefreshedAtEpochMs: Long? = null, + /** Last successful registration — the 45-day forced-renewal cap of §6.1 reads this (C41). */ + val lastRegistrationEpochMs: Long? = null, + /** The most recent register response's unrecorded fields (per-service breakdown, `alert`, + * per-URI statuses, the §6.1 "refresh credentials" marker — wire names C53), XML-plist + * encoded. Opaque until the capture names the keys; typed columns then. */ + val lastRegisterRawPlist: ByteArray? = null, + /** The ADI provisioning machine tokens of §1.1 (start/finish provisioning with gsa.apple.com): + * their wire names and value forms are unrecorded (TODO(capture) C6), so the response dict + * rides along verbatim, XML-plist encoded — typed columns when the capture names the keys. */ + val adiMachineTokensPlist: ByteArray? = null, + /** When the machine tokens were provisioned — re-provisioning policy is the caller's. */ + val adiProvisionedAtEpochMs: Long? = null, + /** GSA login chain (§1.5): the Apple-ID username + the persisted password hash + * (SHA-256 of the raw password) the silent PET re-login re-feeds through s2k. + * The hash is key material — never logged. */ + val gsaUsername: String? = null, + val gsaPasswordHash: ByteArray? = null, + /** The decrypted spd's `adsid` (§1.5) — pairs with the GsIdmsToken in the identity token. */ + val gsaAdsId: String? = null, + /** The PET (`X-Apple-PE-Token`, §1.5) and its expiry (300 s default, §1.5). */ + val gsaPetToken: String? = null, + val gsaPetExpiresAtEpochMs: Long? = null, +) + +/** One registered user (§1.4 register response, per user). */ +data class IdsRegisteredUserEntity( + val userId: String, + /** Per-user status from the register response (§1.4's recorded failure codes). */ + val status: Int?, + /** The X.509 identity certificate (DER) the spec says to store (§1.4). */ + val identityCertDer: ByteArray, + /** Parsed at store time from [identityCertDer] — the §6.1 fallback renewal bound reads it. */ + val identityCertNotAfterEpochMs: Long, + /** The longer-lived authentication certificate of §1.2 (signs per-user register requests). */ + val authCertDer: ByteArray? = null, + /** Registered handle URIs (`tel:`/`mailto:`) — the register body echoes the server's list (§1.4). */ + val urisJson: String, + /** `next-hbi` of §1.4/§6.1 — seconds from registration until re-registration; null when absent. */ + val nextHbiSeconds: Long? = null, + val registeredAtEpochMs: Long, +) + +/** One handle's cached lookup result (§2.1 response, per handle). Zero-identity rows are kept. */ +data class IdsCachedResultEntity( + val handle: String, + val status: Int, + val fetchedAtEpochMs: Long, + /** [identityCacheKeySha1] of the (id certificate, APNs token) the entry was fetched under (§2.2). */ + val cacheKeySha1: String, + /** The whole result dict, XML-plist encoded — sender-correlation id and key-transparency + * account key included (§2.1; semantics of the former are C11, key names C48). */ + val rawPlist: ByteArray, +) + +/** One device identity inside a cached result — the per-device token-timing fields are typed + * because both §2.2 cache policies read them; the identity dict itself (client-data, push + * token, session token — wire names C48) stays an opaque XML-plist blob. */ +data class IdsCachedIdentityEntity( + val handle: String, + val deviceIndex: Int, + val expiresSeconds: Long?, + val refreshSeconds: Long?, + val rawPlist: ByteArray, +) + +data class IdsCachedResultWithIdentities( + val result: IdsCachedResultEntity, + val identities: List, +) + +/** + * Freshness verdict for a cached lookup entry. **Policy is open — TODO(capture) C14**: §2.2 + * records two disagreeing client policies (one reads the server token fields with a SHA-1 + * invalidation key and a 60-second forced-refresh spacing; the other ignores them and uses + * fixed 1-day/1-hour/7-day tiers), and no default here pretends to be measured. The caller + * injects one; the 60-second spacing of the first policy is caller-side pacing state, not a + * store concern. + */ +enum class CacheFreshness { FRESH, REFRESH, STALE } + +fun interface IdentityCachePolicy { + fun freshness( + result: IdsLookupResult, + fetchedAtEpochMs: Long, + nowEpochMs: Long, + ): CacheFreshness +} + +/** A cached lookup entry plus the injected policy's verdict on it. */ +data class CachedLookup( + val fetchedAtEpochMs: Long, + val result: IdsLookupResult, + val freshness: CacheFreshness, +) + +/** + * §2.2's cache invalidation key: SHA-1 over the id certificate and the current APNs connect + * token, so either change invalidates every cached entry. Concatenation is cert-then-token, + * the order the spec names; hex, lowercase. + */ +fun identityCacheKeySha1(identityCertDer: ByteArray, apnsConnectToken: ByteArray): String = + MessageDigest.getInstance("SHA-1") + .digest(identityCertDer + apnsConnectToken) + .joinToString("") { "%02x".format(it) } + +/** + * §6.1's re-registration schedule (first-source reading, the one with no invented constants): + * the earliest of (registration time + `next-hbi`) and the identity certificate's `notAfter` + * minus a 5-minute safety margin; absent `next-hbi` → the certificate bound alone. The second + * source's tiered buffers and 45-day cap are client policy constants, not measurements (C41) — + * the caller layers those on top if it wants them, they are not baked in here. + */ +fun nextRenewalAtEpochMs( + registeredAtEpochMs: Long, + nextHbiSeconds: Long?, + identityCertNotAfterEpochMs: Long, +): Long { + val byCert = identityCertNotAfterEpochMs - 300_000 + val byHbi = nextHbiSeconds?.let { registeredAtEpochMs + it * 1000 } ?: return byCert + return minOf(byHbi, byCert) +} + +/** The X.509 `notAfter` of a DER certificate, epoch ms. */ +fun x509NotAfterEpochMs(der: ByteArray): Long = + (CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(der)) as X509Certificate).notAfter.time + +/** [IdsRegisteredUserEntity] factory: parses the certificate's `notAfter` at store time. */ +fun idsRegisteredUser( + userId: String, + status: Int?, + identityCertDer: ByteArray, + uris: List, + registeredAtEpochMs: Long, + authCertDer: ByteArray? = null, + nextHbiSeconds: Long? = null, +): IdsRegisteredUserEntity = IdsRegisteredUserEntity( + userId = userId, + status = status, + identityCertDer = identityCertDer, + identityCertNotAfterEpochMs = x509NotAfterEpochMs(identityCertDer), + authCertDer = authCertDer, + urisJson = Json.encodeToString(uris), + nextHbiSeconds = nextHbiSeconds, + registeredAtEpochMs = registeredAtEpochMs, +) + +fun registeredUserUris(user: IdsRegisteredUserEntity): List = + Json.decodeFromString(user.urisJson) + +/** Activation record chain, JSON array of base64 DERs (see [IdsAccountEntity.pushCertChainJson]). */ +fun pushCertChainJson(chainDers: List): String = + Json.encodeToString(chainDers.map { java.util.Base64.getEncoder().encodeToString(it) }) + +fun pushCertChainDers(json: String?): List = + json?.let { parsed -> + Json.decodeFromString>(parsed).map { java.util.Base64.getDecoder().decode(it) } + } ?: emptyList() + +/** ADI machine-token dict → opaque plist blob for [IdsAccountEntity.adiMachineTokensPlist]. */ +fun adiMachineTokensPlist(raw: Map): ByteArray = XmlPlist.encode(raw) + +/** The stored ADI machine-token dict back out, or null when nothing is provisioned. */ +fun adiMachineTokensRaw(plist: ByteArray?): Map? { + plist ?: return null + val parsed = Plist.parse(plist) + return stringKeyedDictOrNull(parsed) + ?: throw PlistFormatException("stored ADI machine tokens are ${typeName(parsed)}, expected a dict") +} + +internal fun cachedResultEntities( + handle: String, + result: IdsLookupResult, + cacheKeySha1: String, + fetchedAtEpochMs: Long, +): Pair> = IdsCachedResultEntity( + handle = handle, + status = result.status, + fetchedAtEpochMs = fetchedAtEpochMs, + cacheKeySha1 = cacheKeySha1, + rawPlist = XmlPlist.encode(result.raw), +) to result.identities.mapIndexed { index, identity -> + IdsCachedIdentityEntity( + handle = handle, + deviceIndex = index, + expiresSeconds = identity.expiresSeconds, + refreshSeconds = identity.refreshSeconds, + rawPlist = XmlPlist.encode(identity.raw), + ) +} + +internal fun lookupResultOf( + result: IdsCachedResultEntity, + identities: List, +): IdsLookupResult = IdsLookupResult( + status = result.status, + identities = identities.sortedBy { it.deviceIndex }.map { + IdsIdentity(it.expiresSeconds, it.refreshSeconds, decodePlistDict(it.rawPlist)) + }, + raw = decodePlistDict(result.rawPlist), +) + +private fun decodePlistDict(bytes: ByteArray): Map { + val parsed = Plist.parse(bytes) + return parsed as? Map + ?: throw PlistFormatException("stored raw state is ${parsed?.javaClass?.simpleName}, expected a dict") +} + +/** + * The iMessage state store over [IdsStateDao] (port note above: the Room database of the + * originating repo is replaced by this plain DAO seam). Component 6 scope: persistence of + * what the spec records, the injected cache policy (C14), and the recorded renewal schedule. + * No UI, no renewal scheduling, no network (component 7 owns those). + */ +class IdsStore( + private val dao: IdsStateDao, + private val cachePolicy: IdentityCachePolicy, +) { + + suspend fun account(): IdsAccountEntity? = dao.account() + + /** Read-modify-write of the singleton account row (missing row = a fresh default). */ + suspend fun updateAccount(update: (IdsAccountEntity) -> IdsAccountEntity): IdsAccountEntity = + update(dao.account() ?: IdsAccountEntity()).also { dao.upsertAccount(it) } + + suspend fun recordRegistration(user: IdsRegisteredUserEntity) = dao.upsertUser(user) + + suspend fun registeredUsers(): List = dao.registeredUsers() + + suspend fun registeredUser(userId: String): IdsRegisteredUserEntity? = dao.registeredUser(userId) + + suspend fun removeRegisteredUser(userId: String) = dao.removeUser(userId) + + /** + * Persists one handle's lookup result under the (id cert, APNs token) cache key of §2.2, + * replacing any prior entry for the handle. Zero-identity results are stored (negative + * caching); the identity dicts ride along as opaque XML-plist blobs (wire names C48). + */ + suspend fun storeLookup( + handle: String, + result: IdsLookupResult, + identityCertDer: ByteArray, + apnsConnectToken: ByteArray, + nowEpochMs: Long, + ) { + val key = identityCacheKeySha1(identityCertDer, apnsConnectToken) + val (resultEntity, identityEntities) = cachedResultEntities(handle, result, key, nowEpochMs) + dao.deleteCachedResult(handle) + dao.upsertCachedResult(resultEntity) + dao.upsertCachedIdentities(identityEntities) + } + + /** + * §2.2: an id-certificate or APNs-token change invalidates every cached entry — anything + * not fetched under the current key pair is dropped. + */ + suspend fun invalidateCache(identityCertDer: ByteArray, apnsConnectToken: ByteArray) { + dao.deleteCacheExcept(identityCacheKeySha1(identityCertDer, apnsConnectToken)) + } + + /** The cached entry for [handle] (null if none), with the injected policy's verdict. */ + suspend fun cachedLookup(handle: String, nowEpochMs: Long): CachedLookup? { + val row = dao.cachedResult(handle) ?: return null + val result = lookupResultOf(row.result, row.identities) + return CachedLookup( + fetchedAtEpochMs = row.result.fetchedAtEpochMs, + result = result, + freshness = cachePolicy.freshness(result, row.result.fetchedAtEpochMs, nowEpochMs), + ) + } + + suspend fun cachedHandles(): List = dao.cachedHandles() + + suspend fun clearCache() = dao.clearCache() +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/MacHardwareInfoExport.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/MacHardwareInfoExport.kt new file mode 100644 index 0000000..ed680e1 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/MacHardwareInfoExport.kt @@ -0,0 +1,330 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.security.MessageDigest +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** Mac-Hardware-Info export-code parse failure (spec §1.1, C58). */ +class MacHardwareInfoExportException(message: String, cause: Throwable? = null) : + Exception(message, cause) + +/** + * One parsed Mac-Hardware-Info export code (spec §1.1, C58): the `OABS` payload — flag byte, + * outer protobuf fields, and the nested identifier message as the §1.1 hardware-configuration + * struct ([MacHardwareIdentifiers]). + */ +class MacHardwareInfoExport( + /** The one-byte flag: true = "prevent sharing" mode (§1.1). */ + val preventSharing: Boolean, + /** Outer field 2 — the macOS product version (sysctl `kern.osproductversion`). */ + val macOsProductVersion: String?, + /** Outer field 3 — the protocol version (hardcoded [MacHardwareInfoExportCode.PROTOCOL_VERSION]). */ + val protocolVersion: Long?, + /** Outer field 4 — the device id (the IOPlatformUUID again). */ + val deviceId: String?, + /** Outer field 5 — the hardcoded iCloudHelper user-agent string. */ + val icloudHelperUserAgent: String?, + /** Outer field 6 — the hardcoded AOSKit version string. */ + val aosKitVersion: String?, + /** The nested identifier message (outer field 1) — required, fails loudly when absent. */ + val identifiers: MacHardwareIdentifiers, +) + +/** + * The Mac-Hardware-Info export-code parser (spec §1.1, C58 — the parse specification; the + * helper itself is closed source and unlicensed upstream — facts only). + * + * Wire format: 4-byte ASCII magic `OABS`, one flag byte (1 = prevent sharing), then a raw + * protobuf message (no length prefix) starting at offset 5. Outer fields: 1 nested identifier + * message, 2 macOS product version, 3 protocol version (varint), 4 device id, 5 + * iCloudHelper user-agent, 6 AOSKit version. Nested identifier fields: 1 product name, + * 2 MAC address (6 raw bytes), 3 platform serial, 4 platform UUID, 5 root-disk UUID, + * 6 board ID, 7 OS build number, 8/9/10/12/14 the five `_enc` blobs, 11 ROM, 13 MLB. + * + * Two delivery modes: plain = standard base64 of the payload (clipboard text, or the raw + * payload bytes as a QR code); restricted = OpenSSL-`Salted__` AES-256-CBC/PKCS7 container + * keyed by the human activation code (legacy EVP_BytesToKey: repeated MD5 of previous-hash ‖ + * password ‖ salt to 48 bytes — first 32 = key, next 16 = IV), then base64. [parse] accepts + * either base64 text, [parsePayload] the raw payload bytes (the QR path). + * + * The decoder is hand-rolled and tolerant: singular fields (last one wins), UTF-8 strings, + * raw bytes, unknown fields skipped; a wrong magic or a missing identifier message fails + * loudly. No third-party protobuf dependency. + */ +object MacHardwareInfoExportCode { + + /** The 4-byte ASCII payload magic (§1.1). */ + const val MAGIC = "OABS" + + /** The hardcoded protocol version the helper puts in outer field 3 (§1.1). */ + const val PROTOCOL_VERSION = 1640L + + private const val RESTRICTED_PREFIX = "Salted__" + private const val SALT_LENGTH = 8 + private const val DERIVED_LENGTH = 32 + 16 + + /** Parses the payload bytes — the QR-code path (§1.1: raw payload bytes, medium EC). */ + fun parsePayload(payload: ByteArray): MacHardwareInfoExport { + if (payload.size < 5) { + throw MacHardwareInfoExportException( + "export payload is ${payload.size} bytes — too short for the $MAGIC magic + flag (§1.1)", + ) + } + if (!String(payload, 0, 4, Charsets.US_ASCII).contentEquals(MAGIC)) { + throw MacHardwareInfoExportException( + "export payload does not start with the $MAGIC magic (§1.1)", + ) + } + val flag = payload[4].toInt() and 0xff + if (flag > 1) { + throw MacHardwareInfoExportException("export payload flag byte is $flag — expected 0 or 1 (§1.1)") + } + val fields = decodeFields(payload, from = 5) + var identifierBytes: ByteArray? = null + var macOsProductVersion: String? = null + var protocolVersion: Long? = null + var deviceId: String? = null + var icloudHelperUserAgent: String? = null + var aosKitVersion: String? = null + for (field in fields) { + when (field.number) { + 1 -> identifierBytes = field.lengthDelimited + 2 -> macOsProductVersion = field.string() + 3 -> protocolVersion = field.varint + 4 -> deviceId = field.string() + 5 -> icloudHelperUserAgent = field.string() + 6 -> aosKitVersion = field.string() + else -> {} // unknown field — skipped (§1.1: tolerant parse) + } + } + val identifierPayload = identifierBytes + ?: throw MacHardwareInfoExportException("export payload has no outer field 1 identifier message (§1.1)") + return MacHardwareInfoExport( + preventSharing = flag == 1, + macOsProductVersion = macOsProductVersion, + protocolVersion = protocolVersion, + deviceId = deviceId, + icloudHelperUserAgent = icloudHelperUserAgent, + aosKitVersion = aosKitVersion, + identifiers = parseIdentifiers(identifierPayload), + ) + } + + /** + * Parses base64 export text — the clipboard path. Plain mode (§1.1: base64 of the payload) + * needs no [activationCode]; restricted mode (the `Salted__` OpenSSL container) fails + * without the caller-supplied activation code. + */ + fun parse(text: String, activationCode: String? = null): MacHardwareInfoExport { + val decoded = try { + Base64.getMimeDecoder().decode(text.trim()) + } catch (e: IllegalArgumentException) { + throw MacHardwareInfoExportException("export text is not decodable base64 (§1.1)", e) + } + return if (decoded.size >= 8 && String(decoded, 0, 8, Charsets.US_ASCII) == RESTRICTED_PREFIX) { + parseRestricted(decoded, activationCode) + } else { + parsePayload(decoded) + } + } + + /** Restricted mode: decrypt the `Salted__` container with [activationCode], then parse. */ + private fun parseRestricted(container: ByteArray, activationCode: String?): MacHardwareInfoExport { + val code = activationCode + ?: throw MacHardwareInfoExportException( + "export is restricted mode (Salted__ container, §1.1) — the activation code is required", + ) + if (container.size < RESTRICTED_PREFIX.length + SALT_LENGTH + 16) { + throw MacHardwareInfoExportException("restricted export container is too short (§1.1)") + } + val salt = container.copyOfRange(RESTRICTED_PREFIX.length, RESTRICTED_PREFIX.length + SALT_LENGTH) + val derived = evpBytesToKey(code.toByteArray(Charsets.UTF_8), salt) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(derived, 0, 32, "AES"), + IvParameterSpec(derived, 32, 16), + ) + val payload = try { + cipher.doFinal(container, RESTRICTED_PREFIX.length + SALT_LENGTH, + container.size - RESTRICTED_PREFIX.length - SALT_LENGTH) + } catch (e: Exception) { + throw MacHardwareInfoExportException( + "restricted export decryption failed — wrong activation code or corrupt container (§1.1)", + e, + ) + } + return parsePayload(payload) + } + + /** + * The legacy OpenSSL EVP_BytesToKey KDF (§1.1): repeated MD5 of previous-hash ‖ password ‖ + * salt, concatenated to [DERIVED_LENGTH] bytes — first 32 = AES-256 key, next 16 = IV. + */ + private fun evpBytesToKey(password: ByteArray, salt: ByteArray): ByteArray { + val md5 = MessageDigest.getInstance("MD5") + val out = ByteArray(DERIVED_LENGTH) + var prev = ByteArray(0) + var at = 0 + while (at < DERIVED_LENGTH) { + md5.reset() + prev = md5.digest(prev + password + salt) + val chunk = minOf(prev.size, DERIVED_LENGTH - at) + System.arraycopy(prev, 0, out, at, chunk) + at += chunk + } + return out + } + + private fun parseIdentifiers(payload: ByteArray): MacHardwareIdentifiers { + var productName: String? = null + var macAddress: ByteArray? = null + var serial: String? = null + var platformUuid: String? = null + var rootDiskUuid: String? = null + var boardId: String? = null + var osBuildNumber: String? = null + var serialEnc: ByteArray? = null + var platformUuidEnc: ByteArray? = null + var rootDiskUuidEnc: ByteArray? = null + var rom: ByteArray? = null + var romEnc: ByteArray? = null + var mlb: String? = null + var mlbEnc: ByteArray? = null + for (field in decodeFields(payload)) { + when (field.number) { + 1 -> productName = field.string() + 2 -> macAddress = field.lengthDelimited + 3 -> serial = field.string() + 4 -> platformUuid = field.string() + 5 -> rootDiskUuid = field.string() + 6 -> boardId = field.string() + 7 -> osBuildNumber = field.string() + 8 -> serialEnc = field.lengthDelimited + 9 -> platformUuidEnc = field.lengthDelimited + 10 -> rootDiskUuidEnc = field.lengthDelimited + 11 -> rom = field.lengthDelimited + 12 -> romEnc = field.lengthDelimited + 13 -> mlb = field.string() + 14 -> mlbEnc = field.lengthDelimited + else -> {} // unknown field — skipped (§1.1: tolerant parse) + } + } + return MacHardwareIdentifiers( + serialNumber = serial + ?: throw MacHardwareInfoExportException("export identifier message has no field 3 platform serial (§1.1)"), + logicBoardSerial = mlb + ?: throw MacHardwareInfoExportException("export identifier message has no field 13 MLB string (§1.1)"), + rom = rom + ?: throw MacHardwareInfoExportException("export identifier message has no field 11 ROM bytes (§1.1)"), + productName = productName, + macAddress = macAddress, + platformUuid = platformUuid, + rootDiskUuid = rootDiskUuid, + boardId = boardId, + osBuildNumber = osBuildNumber, + serialEnc = serialEnc, + platformUuidEnc = platformUuidEnc, + rootDiskUuidEnc = rootDiskUuidEnc, + romEnc = romEnc, + mlbEnc = mlbEnc, + ) + } + + // ---- tolerant protobuf decode (hand-rolled, IdsCsr.kt-style minimalism) ---- + + private const val WIRE_VARINT = 0 + private const val WIRE_I64 = 1 + private const val WIRE_LEN = 2 + private const val WIRE_I32 = 5 + + private class Field( + val number: Int, + val wireType: Int, + val varint: Long?, + val lengthDelimited: ByteArray?, + ) { + fun string(): String? = lengthDelimited?.toString(Charsets.UTF_8) + } + + private fun decodeFields(bytes: ByteArray, from: Int = 0): List { + val fields = ArrayList() + var i = from + while (i < bytes.size) { + val tag = readVarint(bytes, i).also { i = it.second }.first + val number = (tag ushr 3).toInt() + require(number > 0) { "protobuf field number 0" } + when (tag.toInt() and 0x7) { + WIRE_VARINT -> { + val value = readVarint(bytes, i).also { i = it.second }.first + fields.add(Field(number, WIRE_VARINT, value, null)) + } + WIRE_I64 -> { + fields.add(Field(number, WIRE_I64, null, bytes.copyOfRange(i, i + 8))) + i += 8 + } + WIRE_LEN -> { + val length = readVarint(bytes, i).also { i = it.second }.first + val lengthInt = length.toInt() + require(lengthInt >= 0 && i + lengthInt <= bytes.size) { "protobuf length out of range" } + fields.add(Field(number, WIRE_LEN, null, bytes.copyOfRange(i, i + lengthInt))) + i += lengthInt + } + WIRE_I32 -> { + fields.add(Field(number, WIRE_I32, null, bytes.copyOfRange(i, i + 4))) + i += 4 + } + else -> throw MacHardwareInfoExportException( + "protobuf wire type ${tag and 0x7} (groups are not expected in the export format, §1.1)", + ) + } + } + return fields + } + + private fun readVarint(bytes: ByteArray, start: Int): Pair { + var value = 0L + var shift = 0 + var i = start + while (true) { + if (i >= bytes.size) throw MacHardwareInfoExportException("protobuf varint runs past the end (§1.1)") + value = value or ((bytes[i].toLong() and 0x7f) shl shift) + if (bytes[i].toInt() and 0x80 == 0) return value to i + 1 + shift += 7 + if (shift > 63) throw MacHardwareInfoExportException("protobuf varint longer than 64 bits (§1.1)") + i++ + } + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataExchange.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataExchange.kt new file mode 100644 index 0000000..a3d9c95 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataExchange.kt @@ -0,0 +1,170 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.security.SecureRandom +import java.util.Base64 + +/** The §1.1 validation-data exchange failure (steps 1–2 wire shapes, or the bag keys). */ +class ValidationDataException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** The two validation bag keys (§1.1) — endpoint URLs come from the bag, never hardcoded. */ +object ValidationBagKeys { + const val CERT = "id-validation-cert" + const val INITIALIZE = "id-initialize-validation" +} + +/** + * The recorded wire halves of the three-step exchange (spec §1.1 C56, steps 1–2 concrete): + * + * - Step 1 — GET the `id-validation-cert` bag URL; the response is a plist with a single + * `cert` data field. + * - Step 2 — POST the `id-initialize-validation` bag URL, body = a single-key plist + * `session-info-request` (data), with NO anisette and NO authentication headers — body + * only — and the response carries `session-info` (data). + * + * The step-2 body is an XML plist (the extracted client serializes the request with the plist + * XML writer) and is posted with no content-type (§1.1). + */ +class ValidationCertClient( + private val http: IdsHttp, + private val bags: IdsBagFetcher, + private val logEvent: (String) -> Unit = {}, +) { + + /** Step 1: the step-1 certificate (the chain the §1.1 context consumes, leaf first). */ + suspend fun fetchCertificate(): ByteArray { + val url = bags.idsBag().url(ValidationBagKeys.CERT) + logEvent("validation: fetching the id-validation-cert certificate") + val response = http.get(url) + if (response.status != 200) { + throw ValidationDataException("GET $url → HTTP ${response.status} (§1.1 step 1)") + } + val dict = dictOf(response.body, "step-1 response") + return dict["cert"] as? ByteArray + ?: throw ValidationDataException( + "step-1 response has no 'cert' data field (§1.1; got ${typeName(dict["cert"])})", + ) + } + + /** + * Step 2: POST the single-key `session-info-request` plist body — no anisette, no auth + * headers (§1.1: body only) — and return the response's `session-info` data. + */ + suspend fun initializeValidation(sessionInfoRequest: ByteArray): ByteArray { + val url = bags.idsBag().url(ValidationBagKeys.INITIALIZE) + logEvent("validation: initializing the validation session") + val body = XmlPlist.encode(linkedMapOf("session-info-request" to sessionInfoRequest)) + val response = http.post(url, emptyMap(), body, CONTENT_TYPE) + if (response.status != 200) { + throw ValidationDataException("POST $url → HTTP ${response.status} (§1.1 step 2)") + } + val dict = dictOf(response.body, "step-2 response") + return dict["session-info"] as? ByteArray + ?: throw ValidationDataException( + "step-2 response has no 'session-info' data field (§1.1; got ${typeName(dict["session-info"])})", + ) + } + + private fun dictOf(body: ByteArray, what: String): Map { + val parsed = try { + Plist.parse(body) + } catch (e: PlistFormatException) { + throw ValidationDataException("$what is not a plist (§1.1): ${e.message}", e) + } + return stringKeyedDictOrNull(parsed) + ?: throw ValidationDataException("$what is ${typeName(parsed)}, expected a dict (§1.1)") + } + + companion object { + /** + * The step-2 POST carries NO content-type: the extracted client posts the XML-plist body + * through a client whose only default request header is `Accept-Language`, with no + * content-type set on the request (§1.1 — our earlier `text/x-xml-plist` was an + * unsupported inference from the §1.5 GSA posts, and is one of the pinned constants). + */ + const val CONTENT_TYPE = "" + } +} + +/** One completed generation: the §1.1 validation blob and when it was minted. */ +class ValidationData( + /** The final blob — verbatim into the register body's `validation-data` field (§1.4). */ + val blob: ByteArray, + /** Generation time; the 15-minute validity bound (§1.1) is server-side — callers decide freshness. */ + val generatedAtEpochMs: Long, +) { + /** The blob base64 — the `X-Mme-Nas-Qualify` header value of the §1.5 sign-in POST. */ + fun nasQualify(): String = Base64.getEncoder().encodeToString(blob) +} + +/** + * The §1.1 three-step exchange orchestrator: Mac identifiers (the helper-parser output or + * caller-built) → step 1 + step 2 ([ValidationCertClient]) → the step-3 contract context + * ([ValidationDataGenerator]) → the final blob ([ValidationData]). + * + * The blob is the hand-off point to the two recorded sinks, both of which take these bytes + * verbatim and need no adapter: the IDS register body's `validation-data` field + * ([IdsActivationRequest.validationData], §1.4) and, via [ValidationData.nasQualify], the + * `X-Mme-Nas-Qualify` header of the §1.5 GSA delegate login + * ([GsaLoginChain.idsDelegateCredentials]). Units stay separable — every stage is its own + * class with injected transports. + */ +class ValidationDataExchange( + private val http: IdsHttp, + private val bags: IdsBagFetcher, + private val strategies: ValidationDataStrategies = ValidationDataStrategies(), + private val random: SecureRandom = SecureRandom(), + private val logEvent: (String) -> Unit = {}, +) { + + /** + * Runs the recorded steps 1–2 against the bag URLs and the reconstruction step 3 in + * [ValidationDataGenerator]; returns the final blob. [nowEpochMs] stamps the result. + */ + suspend fun generate(identifiers: MacHardwareIdentifiers, nowEpochMs: Long): ValidationData { + val hardware = HardwareConfiguration.from(identifiers) + val client = ValidationCertClient(http, bags, logEvent) + val certificate = client.fetchCertificate() + val context = ValidationDataGenerator( + certificateChain = listOf(certificate), + hardware = hardware, + strategies = strategies, + random = random, + ) + val sessionInfoRequest = context.createSessionInfoRequest() + val sessionInfo = client.initializeValidation(sessionInfoRequest) + context.establishKeys(sessionInfo) + val blob = context.sign() + logEvent("validation: blob generated") + return ValidationData(blob, nowEpochMs) + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataGenerator.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataGenerator.kt new file mode 100644 index 0000000..b20fcff --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataGenerator.kt @@ -0,0 +1,455 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.crypto.Hkdf +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.math.BigInteger +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PublicKey +import java.security.SecureRandom +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.interfaces.XECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.NamedParameterSpec +import java.security.spec.XECPublicKeySpec +import javax.crypto.KeyAgreement + +/** + * The hardware configuration the step-3 context consumes (spec §1.1 field set, fully recorded): + * product name (Apple product-type form), MAC address (exactly 6 raw bytes), platform serial, + * platform UUID, root-disk UUID, board ID, OS build number, ROM (raw bytes), MLB, plus the five + * `_enc` obfuscated raw-byte variants under the five recorded fixed labels + * ([EncLabel]). The `_enc` derivations are closed source (C57) — the values arrive pre-computed + * in the Mac-Hardware-Info export payload ([MacHardwareInfoExport]) and pass through verbatim; + * nothing here derives them. + */ +class HardwareConfiguration( + val productName: String, + val osBuildNumber: String, + val platformSerial: String, + val platformUuid: String, + val rootDiskUuid: String, + val boardId: String, + val macAddress: ByteArray, + val rom: ByteArray, + val mlb: String, + val serialEnc: ByteArray, + val platformUuidEnc: ByteArray, + val rootDiskUuidEnc: ByteArray, + val romEnc: ByteArray, + val mlbEnc: ByteArray, +) { + + /** The five recorded obfuscation labels mapped to their fields (§1.1 rev-14 extraction). */ + val encVariants: Map + get() = mapOf( + EncLabel.SERIAL to serialEnc, + EncLabel.PLATFORM_UUID to platformUuidEnc, + EncLabel.ROOT_DISK_UUID to rootDiskUuidEnc, + EncLabel.ROM to romEnc, + EncLabel.MLB to mlbEnc, + ) + + /** The recorded §1.1 obfuscation labels, mapped to their fields. */ + object EncLabel { + const val SERIAL = "Gq3489ugfi" + const val PLATFORM_UUID = "Fyp98tpgj" + const val ROOT_DISK_UUID = "kbjfrfpoJU" + const val ROM = "oycqAZloTNDm" + const val MLB = "abKPld1EcMni" + } + + companion object { + const val MAC_ADDRESS_BYTES = 6 + + /** Builds the configuration from a parsed export payload; loud on any §1.1-required absence. */ + fun from(identifiers: MacHardwareIdentifiers): HardwareConfiguration = from( + productName = identifiers.productName, + macAddress = identifiers.macAddress, + platformSerial = identifiers.serialNumber, + platformUuid = identifiers.platformUuid, + rootDiskUuid = identifiers.rootDiskUuid, + boardId = identifiers.boardId, + osBuildNumber = identifiers.osBuildNumber, + rom = identifiers.rom, + mlb = identifiers.logicBoardSerial, + serialEnc = identifiers.serialEnc, + platformUuidEnc = identifiers.platformUuidEnc, + rootDiskUuidEnc = identifiers.rootDiskUuidEnc, + romEnc = identifiers.romEnc, + mlbEnc = identifiers.mlbEnc, + ) + + /** + * Explicit-fields variant: callers outside the export path name every required input. + * The five `_enc` blobs pass through verbatim (C57: the derivation is closed source). + */ + fun from( + productName: String?, + macAddress: ByteArray?, + platformSerial: String, + platformUuid: String?, + rootDiskUuid: String?, + boardId: String?, + osBuildNumber: String?, + rom: ByteArray, + mlb: String, + serialEnc: ByteArray?, + platformUuidEnc: ByteArray?, + rootDiskUuidEnc: ByteArray?, + romEnc: ByteArray?, + mlbEnc: ByteArray?, + ): HardwareConfiguration { + fun optional(value: Any?, name: String): ByteArray { + if (value == null || (value is ByteArray && value.isEmpty())) { + throw ValidationDataException("hardware configuration is missing $name (§1.1)") + } + return value as ByteArray + } + fun optionalText(value: String?, name: String): String = + value?.takeIf { it.isNotEmpty() } + ?: throw ValidationDataException("hardware configuration is missing $name (§1.1)") + + val mac = optional(macAddress, "the MAC address") + if (mac.size != MAC_ADDRESS_BYTES) { + throw ValidationDataException( + "hardware MAC address is ${mac.size} bytes — §1.1 requires exactly $MAC_ADDRESS_BYTES", + ) + } + return HardwareConfiguration( + productName = optionalText(productName, "the product name"), + osBuildNumber = optionalText(osBuildNumber, "the OS build number"), + platformSerial = platformSerial, + platformUuid = optionalText(platformUuid, "the platform UUID"), + rootDiskUuid = optionalText(rootDiskUuid, "the root-disk UUID"), + boardId = optionalText(boardId, "the board ID"), + macAddress = mac, + rom = rom, + mlb = mlb, + serialEnc = optional(serialEnc, "the ${EncLabel.SERIAL} (_enc serial) blob"), + platformUuidEnc = optional(platformUuidEnc, "the ${EncLabel.PLATFORM_UUID} blob"), + rootDiskUuidEnc = optional(rootDiskUuidEnc, "the ${EncLabel.ROOT_DISK_UUID} blob"), + romEnc = optional(romEnc, "the ${EncLabel.ROM} blob"), + mlbEnc = optional(mlbEnc, "the ${EncLabel.MLB} blob"), + ) + } + } +} + +/** + * Every non-recorded derivation choice of the step-3 reconstruction, as an explicit named + * parameter (see [ValidationDataGenerator] — the live server is the arbiter; a capture swaps + * these without touching the recorded contract). + */ +data class ValidationDataStrategies( + /** Key-agreement scheme. Default P-256 ECDH — Apple's recorded crypto is NIST-curve-heavy (§4.3). */ + val keyAgreement: KeyAgreementScheme = KeyAgreementScheme.ECDH_P256, + /** Key-derivation function over the shared secret. */ + val kdf: Kdf = Kdf.HKDF_SHA256, + /** HKDF `salt` — unrecorded; empty = RFC 5869's zero salt. */ + val kdfSalt: ByteArray = ByteArray(0), + /** HKDF `info` — unrecorded; empty. */ + val kdfInfo: ByteArray = ByteArray(0), + /** Final-blob signature algorithm ("signed blob", §1.1 — asymmetric reading is the default). */ + val signature: BlobSignature = BlobSignature.ECDSA_P256_SHA256, + /** Final/session blob framing. */ + val framing: BlobFraming = BlobFraming.TLV_V1, + /** How the server `session-info` response is interpreted. */ + val sessionInfo: SessionInfoParsing = SessionInfoParsing.RAW_PUBLIC_POINT, +) { + /** Key-agreement schemes the reconstruction implements. */ + enum class KeyAgreementScheme { ECDH_P256, X25519 } + + /** KDFs. */ + enum class Kdf { HKDF_SHA256 } + + /** Signature algorithms. HMAC is the symmetric fallback hypothesis. */ + enum class BlobSignature { ECDSA_P256_SHA256, HMAC_SHA256 } + + /** Blob framings. TLV_V1 = the [ValidationDataFraming] tag/2-byte-length/value layout. */ + enum class BlobFraming { TLV_V1 } + + /** + * Session-info interpretations. RAW_PUBLIC_POINT (default): the whole `session-info` data + * IS the server's public key-agreement point (33-byte compressed / 65-byte uncompressed + * SEC1 for P-256; 32 raw u-coordinate bytes for X25519). + */ + enum class SessionInfoParsing { RAW_PUBLIC_POINT } +} + +/** The recorded blob-field tags of the default TLV framing ([ValidationDataFraming]). */ +object ValidationDataFields { + const val PRODUCT_NAME = 0x01 + const val OS_BUILD_NUMBER = 0x02 + const val PLATFORM_SERIAL = 0x03 + const val PLATFORM_UUID = 0x04 + const val ROOT_DISK_UUID = 0x05 + const val BOARD_ID = 0x06 + const val MAC_ADDRESS = 0x07 + const val ROM = 0x08 + const val MLB = 0x09 + const val SERIAL_ENC = 0x0A + const val PLATFORM_UUID_ENC = 0x0B + const val ROOT_DISK_UUID_ENC = 0x0C + const val ROM_ENC = 0x0D + const val MLB_ENC = 0x0E + const val CERTIFICATE = 0x0F + const val KA_PUBLIC_KEY = 0x10 + const val SIGNING_PUBLIC_KEY = 0x11 + const val SIGNATURE = 0x20 +} + +/** + * The TLV_V1 framing: per field, one tag byte + a 2-byte big-endian length + the value. + * [parse] is the recorded "parseable back into the hardware fields" round-trip (§1.1). + */ +object ValidationDataFraming { + + /** One framed field. */ + data class Field(val tag: Int, val value: ByteArray) + + fun frame(fields: List): ByteArray { + val out = java.io.ByteArrayOutputStream() + for (field in fields) { + out.write(field.tag) + out.write((field.value.size ushr 8) and 0xff) + out.write(field.value.size and 0xff) + out.write(field.value) + } + return out.toByteArray() + } + + fun parse(blob: ByteArray): List { + val fields = ArrayList() + var i = 0 + while (i < blob.size) { + if (i + 3 > blob.size) { + throw ValidationDataException("validation blob framing truncated at offset $i") + } + val tag = blob[i].toInt() and 0xff + val length = ((blob[i + 1].toInt() and 0xff) shl 8) or (blob[i + 2].toInt() and 0xff) + if (i + 3 + length > blob.size) { + throw ValidationDataException("validation blob field $tag length $length runs past the end") + } + fields.add(Field(tag, blob.copyOfRange(i + 3, i + 3 + length))) + i += 3 + length + } + return fields + } +} + +/** + * The step-3 validation-data context (spec §1.1 C56): key establishment over the + * `id-initialize-validation` session info, blob assembly, and the final signature. + * + * ponytail: everything below the recorded contract is a HYPOTHESIS. Step 3's math is closed + * source — upstream ships stubs, so §1.1 records only the API contract, which is the fixed + * shape of this class: context creation consumes the certificate chain + the hardware + * configuration and outputs the session-info-request blob ([createSessionInfoRequest]); key + * establishment consumes the server response ([establishKeys]); signing produces the final blob + * with no further inputs ([sign]) — all state retained in the context. The live server is the + * arbiter (C5/C6 remaining); every non-recorded choice is an explicit + * [ValidationDataStrategies] parameter and the recorded 15-minute validity bound stays + * server-side (no local enforcement recorded — none applied). + * + * No key or blob material is ever passed to a log seam; this class has none. + */ +class ValidationDataGenerator( + /** The step-1 certificate chain, leaf first (§1.1: the context consumes the chain). */ + certificateChain: List, + private val hardware: HardwareConfiguration, + private val strategies: ValidationDataStrategies = ValidationDataStrategies(), + private val random: SecureRandom = SecureRandom(), +) { + + init { + if (certificateChain.isEmpty()) { + throw ValidationDataException("the validation context needs the step-1 certificate chain (§1.1)") + } + } + + private val certificateChain: List = certificateChain.toList() + private val kaKeys: KeyPair = generateKaKeys() + private val signingKeys: KeyPair? = + if (strategies.signature == ValidationDataStrategies.BlobSignature.ECDSA_P256_SHA256) { + EcKeys.p256(random) + } else { + null + } + private var sessionKey: ByteArray? = null + + /** + * Contract output 1 (§1.1): the session-info-request blob — the best-faith assembly of the + * hardware configuration, the certificate chain, and this context's fresh key-agreement + * (and, for the ECDSA strategy, signing) public keys, under the configured framing. + */ + fun createSessionInfoRequest(): ByteArray = ValidationDataFraming.frame(allFields()) + + /** + * Contract output 2 (§1.1): key establishment over the server's `session-info` response. + * The shared secret is the configured key agreement against the server point the configured + * parsing extracts; the session key is the configured KDF over it. + */ + fun establishKeys(sessionInfo: ByteArray) { + val shared = when (strategies.keyAgreement) { + ValidationDataStrategies.KeyAgreementScheme.ECDH_P256 -> agree( + "ECDH", kaKeys.private, EcKeys.p256Public(sessionInfo), + ) + ValidationDataStrategies.KeyAgreementScheme.X25519 -> agree( + "X25519", kaKeys.private, EcKeys.x25519Public(sessionInfo), + ) + } + val prk = Hkdf.extract(strategies.kdfSalt, shared) + sessionKey = Hkdf.expand(prk, strategies.kdfInfo, SESSION_KEY_BYTES) + } + + /** + * Contract output 3 (§1.1): the final signed blob — the framed context fields plus the + * configured signature over them, under [ValidationDataFields.SIGNATURE]. Fails loudly + * before [establishKeys]. + */ + fun sign(): ByteArray { + val key = sessionKey + ?: throw ValidationDataException("establishKeys must run before sign (§1.1 contract order)") + val body = ValidationDataFraming.frame(allFields()) + val signature = when (strategies.signature) { + ValidationDataStrategies.BlobSignature.ECDSA_P256_SHA256 -> { + val signer = Signature.getInstance("SHA256withECDSA") + signer.initSign(requireNotNull(signingKeys).private) + signer.update(body) + signer.sign() // JCE DER encoding — the wire encoding is a reconstruction choice + } + ValidationDataStrategies.BlobSignature.HMAC_SHA256 -> Hkdf.hmac(key, body) + } + return body + ValidationDataFraming.frame( + listOf(ValidationDataFraming.Field(ValidationDataFields.SIGNATURE, signature)), + ) + } + + private fun allFields(): List = buildList { + add(field(ValidationDataFields.PRODUCT_NAME, hardware.productName.toByteArray(Charsets.UTF_8))) + add(field(ValidationDataFields.OS_BUILD_NUMBER, hardware.osBuildNumber.toByteArray(Charsets.UTF_8))) + add(field(ValidationDataFields.PLATFORM_SERIAL, hardware.platformSerial.toByteArray(Charsets.UTF_8))) + add(field(ValidationDataFields.PLATFORM_UUID, hardware.platformUuid.toByteArray(Charsets.UTF_8))) + add(field(ValidationDataFields.ROOT_DISK_UUID, hardware.rootDiskUuid.toByteArray(Charsets.UTF_8))) + add(field(ValidationDataFields.BOARD_ID, hardware.boardId.toByteArray(Charsets.UTF_8))) + add(field(ValidationDataFields.MAC_ADDRESS, hardware.macAddress)) + add(field(ValidationDataFields.ROM, hardware.rom)) + add(field(ValidationDataFields.MLB, hardware.mlb.toByteArray(Charsets.UTF_8))) + add(field(ValidationDataFields.SERIAL_ENC, hardware.serialEnc)) + add(field(ValidationDataFields.PLATFORM_UUID_ENC, hardware.platformUuidEnc)) + add(field(ValidationDataFields.ROOT_DISK_UUID_ENC, hardware.rootDiskUuidEnc)) + add(field(ValidationDataFields.ROM_ENC, hardware.romEnc)) + add(field(ValidationDataFields.MLB_ENC, hardware.mlbEnc)) + certificateChain.forEach { cert -> + add(field(ValidationDataFields.CERTIFICATE, cert)) + } + add(field(ValidationDataFields.KA_PUBLIC_KEY, kaPublicBytes())) + signingKeys?.let { + add(field(ValidationDataFields.SIGNING_PUBLIC_KEY, EcCrypto.compress(it.public))) + } + } + + private fun field(tag: Int, value: ByteArray) = ValidationDataFraming.Field(tag, value) + + private fun kaPublicBytes(): ByteArray = when (strategies.keyAgreement) { + ValidationDataStrategies.KeyAgreementScheme.ECDH_P256 -> EcCrypto.compress(kaKeys.public) + ValidationDataStrategies.KeyAgreementScheme.X25519 -> EcKeys.x25519Raw(kaKeys.public) + } + + private fun generateKaKeys(): KeyPair = when (strategies.keyAgreement) { + ValidationDataStrategies.KeyAgreementScheme.ECDH_P256 -> EcKeys.p256(random) + ValidationDataStrategies.KeyAgreementScheme.X25519 -> EcKeys.x25519(random) + } + + private fun agree(algorithm: String, privateKey: java.security.PrivateKey, serverPublic: PublicKey): ByteArray { + val agreement = KeyAgreement.getInstance(algorithm) + agreement.init(privateKey) + agreement.doPhase(serverPublic, true) + return agreement.generateSecret() + } + + private companion object { + const val SESSION_KEY_BYTES = 32 + } +} + +/** JCE key/point helpers for the reconstruction's schemes (no third-party crypto dependency). */ +internal object EcKeys { + + fun p256(random: SecureRandom): KeyPair = + KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1"), random) + }.generateKeyPair() + + fun x25519(random: SecureRandom): KeyPair = + KeyPairGenerator.getInstance("X25519").apply { + initialize(NamedParameterSpec.X25519, random) + }.generateKeyPair() + + /** + * A P-256 public point from raw bytes: 33-byte compressed or 65-byte uncompressed SEC1 + * (the [ValidationDataStrategies.SessionInfoParsing.RAW_PUBLIC_POINT] expectation). + */ + fun p256Public(raw: ByteArray): ECPublicKey = when { + raw.size == 33 -> EcCrypto.decompress(raw) + raw.size == 65 && (raw[0].toInt() and 0xff) == 0x04 -> + EcCrypto.decompress(byteArrayOf(if (raw[64].toInt() and 1 == 1) 0x03 else 0x02) + raw.copyOfRange(1, 33)) + else -> throw ValidationDataException( + "session-info is not a P-256 public point (${raw.size} bytes) — " + + "the RAW_PUBLIC_POINT reconstruction does not apply (§1.1 reconstruction)", + ) + } + + fun x25519Public(raw: ByteArray): PublicKey { + if (raw.size != 32) { + throw ValidationDataException( + "session-info is not a raw X25519 u-coordinate (${raw.size} bytes) — " + + "the RAW_PUBLIC_POINT reconstruction does not apply (§1.1 reconstruction)", + ) + } + val u = BigInteger(1, raw.reversedArray()) + return KeyFactory.getInstance("X25519").generatePublic(XECPublicKeySpec(NamedParameterSpec.X25519, u)) + } + + /** The 32-byte little-endian u-coordinate of an X25519 public key. */ + fun x25519Raw(key: PublicKey): ByteArray { + val u = (key as XECPublicKey).u + val bigEndian = u.toByteArray() + val out = ByteArray(32) + val reversed = bigEndian.reversedArray() + System.arraycopy(reversed, 0, out, 0, minOf(32, reversed.size)) + return out + } +} diff --git a/registration/src/main/resources/imessage/fairplay/identity.key b/registration/src/main/resources/imessage/fairplay/identity.key new file mode 100644 index 0000000..4f4c270 --- /dev/null +++ b/registration/src/main/resources/imessage/fairplay/identity.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAtSOfdkr7T5DHU54eU8ghiwXtRu1K+boomjhbRNGLwDgb5pjL +T0srB+47EnpUZw/JPj883hxZi4eOeU2AG4Ij6KUV2whvgzWpA6Y9OcXgJOoj4qIS +Ban/Do9PeH2sQaLjacvz0Du/l3fEvCd1mRvljmqss9AgkwQ8yyxZux/F3o6+O0P4 +qTGasQ+W+wCoXjIbyWcj6WY9nHs2Y3IR08VEbjUbyxRMd48O5XP/hqqHnrVpnEuO +zA1yvpXRFZZcE44z84MYf1ZS676TKnBHhshA0bOeHZnfmyVkTfrn3s1o6CDAk21H +YQkLRRpuYMap1b4xQ/RMfi07eXKZz34R9gB6iQIDAQABAoIBAAmd+A/oQDqDtcl/ +Oh35oKpIaMYcwB/NHe2+Mz5On8xXTkfqcdTmaUT1BYbgHs4P/P/EjL9AIxLw4gQX +tMcBHFX0OdpUstL1DOGoOFTyaAmdssUMFMffNC76Nd5wUa50bv2Feg7itTPeBIB2 +2dwEqqXqjPvI/4m+rlmdI7B3KeK8e4bbO0oUMN1aZyoba6Ls+GHLPvvIhz7Hmr47 +JlwhNgZOKsdMdJwDvCXfHK5V0VhKO2OylJ7CANFlSf2IyV8SjMvRs5d2O8uiC3og +9Tp6qGh0PVGDG1XSAegfVjpSB/giZSc9eWjMTEd+zaqV8MMfvwWHcjyM4sKksKay +xfKCCxcCgYEA3Eueld67aOIuAYBr2tpKw0bmyMvbk24ttGOLzIzAeoBhabyWO4jp +TLFsc0W1/p0SZXLhXUjmd+ljJUQRdhsBlA5PvWJdaDc/3d+pMjimuTGX46K96yri +7X3tzLdGZMaa86O/Bn9cdQyXzZV/9a5xDZbf5JH8YN5mSurOsZCGr28CgYEA0n9a +eiCxDsik/p1y8OD3ePkhNGBrW2XAbasmfmx2aYJlJQ4SEQt2jI9jlbY7X5z7cFIH +njFUQkfH/1/dMZ14gwb4nSI1fGQXLodoQdJszdfnNqdp49DyeQKm7FAoamxSBSxo +DZSgjUKMCJ1lIz7FT7n+iPUe/UsmjWcGzuuZ+YcCgYB7sh5O6uzyfKX2g+SL06zN +L3+g2g7mHqSdOJQj/BiL8JBCLflxXnQZczqBE3CYkhvlSPdQSQHX7P2VzD29wnZF +ay/NGHF4diVvcQnxQCwlqrotwJDBfqhDUEV5gKOPLJGPIq+9d0A0HSkT79EU46lp +RwFat7otrlNj16rNu2hVEwKBgHyVK3OEzcY2MpEPluvB+jX7HQ/m0SNzAYTm+w1w +HFUt+7cGsOKtqihe5U3CYTmhq586rY+PGpNv8IrQrhWjYaQLCDHYAVfx1VaQhcAP +Tp413n6WbzGKhTfhvfG3L3RtT84IDj2bbbqtXDvtC+LkT9tz9ZYRnZnEVLjz9S+a +ebMZAoGAM0CNQjNOavmsGM9X2kTy807G52YaK3SRKIGGUpPWf3hOIvJuULw6Xxgi +g4OJM1lQyqMmy7lQKoboHvkPXWiemHKe/XBeaY5Q3qBaqFWJjvpNYFoNp0B9XKCd +ua5XYby2yYe2Pzv2YIO78RdfIhy84c+p88ae/TFPz8pi8UosCss= +-----END RSA PRIVATE KEY----- diff --git a/registration/src/main/resources/imessage/fairplay/leaf.crt b/registration/src/main/resources/imessage/fairplay/leaf.crt new file mode 100644 index 0000000..8666725 Binary files /dev/null and b/registration/src/main/resources/imessage/fairplay/leaf.crt differ diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt new file mode 100644 index 0000000..4f8e464 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt @@ -0,0 +1,665 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.math.BigInteger +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * In-memory [IdsStateDao] backing the dry run's [IdsStore]: the Room layer needs an Android + * Context (see [IdsStoreTest]'s header note), so the dry run drives the real [IdsStore] and + * entity/DAO surface over this DAO — the persistence contract (upsert, read-modify-write, + * registered-user rows) is exercised; only SQLite is swapped out. + */ +internal class InMemoryIdsStateDao : IdsStateDao { + + var accountRow: IdsAccountEntity? = null + private val users = LinkedHashMap() + private val results = LinkedHashMap() + private val identities = LinkedHashMap>() + + override suspend fun account(): IdsAccountEntity? = accountRow + + override suspend fun upsertAccount(entity: IdsAccountEntity) { + accountRow = entity + } + + override suspend fun registeredUsers(): List = users.values.toList() + + override suspend fun registeredUser(userId: String): IdsRegisteredUserEntity? = users[userId] + + override suspend fun upsertUser(user: IdsRegisteredUserEntity) { + users[user.userId] = user + } + + override suspend fun removeUser(userId: String) { + users.remove(userId) + } + + override suspend fun cachedResult(handle: String): IdsCachedResultWithIdentities? = + results[handle]?.let { IdsCachedResultWithIdentities(it, identities[handle].orEmpty()) } + + override suspend fun cachedHandles(): List = results.keys.toList() + + override suspend fun upsertCachedResult(result: IdsCachedResultEntity) { + results[result.handle] = result + } + + override suspend fun upsertCachedIdentities(list: List) { + list.forEach { identity -> + identities.getOrPut(identity.handle) { mutableListOf() }.add(identity) + } + } + + override suspend fun deleteCachedResult(handle: String) { + results.remove(handle) + identities.remove(handle) + } + + override suspend fun deleteCacheExcept(key: String) { + results.entries.removeIf { it.value.cacheKeySha1 != key } + } + + override suspend fun clearCache() { + results.clear() + identities.clear() + } +} + +/** + * The scripted fake-Apple environment of the activation dry run: ONE routing [IdsHttp] that + * plays every server in the recorded journey — the IDS bag, the two validation endpoints, + * gsa.apple.com ADI provisioning, albert activation, the GSA SRP/2FA login, the iCloud + * sign-in, and the authenticate/register endpoints. + * + * Every handler is assertive: it checks the recorded wire shape of its hop and fails loudly + * (`require`, with the spec section in the message) on a wrong shape, a wrong header set, or + * an unexpected path. What a fake server cannot know (client-generated keys, the minted + * validation blob, the test OTP) arrives via `expected*` setters the test wires after minting + * — then it is asserted server-side like everything else. + * + * The GSA SRP half is the same second independent server-side implementation of the §1.5 + * formulas the GsaLoginChainTest uses (K derived from the client's A; identity hash over the + * EMPTY username; M1/M2 over the recorded concatenations). + */ +internal class DryRunAppleEnvironment( + /** SHA-256 of the raw password — the test's password-hash input to the SRP verifier. */ + private val passwordHash: ByteArray, +) : IdsHttp { + + // ---- endpoints (recorded hosts/paths; the bag-key URLs are client-resolved fixtures) ---- + + val certUrl = "https://id.example.apple.com/WebObjects/VCValidation.woa/wa/validationCert" + val initUrl = "https://id.example.apple.com/WebObjects/VCValidation.woa/wa/initializeValidation" + val adiStartUrl = "https://${AdiProvisioner.GSA_HOST}/adi/start-provisioning" + val adiFinishUrl = "https://${AdiProvisioner.GSA_HOST}/adi/finish-provisioning" + val authenticateUrl = "https://profile.ess.apple.com/WebObjects/VCProfileService.woa/wa/authenticateDS" + val registerUrl = "https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register" + + // ---- fixture data the server side owns ---- + + /** The §1.1 step-1 `cert` payload (any bytes — the step-3 reconstruction consumes them verbatim). */ + val validationCert = ByteArray(24) { (it + 1).toByte() } + + /** The server's P-256 key-agreement point, served as the §1.1 step-2 `session-info`. */ + private val sessionInfoPoint = + EcCrypto.compress(EcKeys.p256(SecureRandom()).public) + + /** The machine identifier the ADI finish response yields — its wire form is unrecorded (C6). */ + val machineIdentifier = ByteArray(32) { (it + 0x11).toByte() } + private val adiSessionBytes = ByteArray(8) { (it + 0x40).toByte() } + + /** Real, parseable X.509 stand-ins (staged Apple intermediates) for the two issued certs. */ + val deviceCertDer = resourceDer("/imessage/AppleServerAuthenticationCA-2010.pem") + val identityCertDer = resourceDer("/imessage/AppleServerAuthenticationCA2-G3.pem") + val deviceCertPem = Pem.encode(Pem.CERTIFICATE_TYPE, deviceCertDer) + + /** The handle the environment "has registered" for this account (§1.4 URI echo). */ + val registeredUri = "tel:+15550001111" + + /** The trusted-device 2FA code the environment accepts (§1.5). */ + val securityCode = "987654" + + // ---- credentials the environment serves; its handlers assert the client echoes them ---- + + val adsid = "adsid-1" + val profileId = "profile-id-1" + val authToken = "ids-auth-token-1" + + /** Client-minted values, wired by the test right after they exist — then asserted server-side. */ + var expectedValidationBlob: ByteArray? = null + var expectedCsrDer: ByteArray? = null + + // ---- observed state ---- + + var bagFetches = 0 + var validationCertFetches = 0 + var initializeCount = 0 + var adiStartCount = 0 + var adiFinishCount = 0 + var albertActivations = 0 + var gsaInitCount = 0 + var gsaCompleteCount = 0 + var trustedDeviceTriggers = 0 + var validateCount = 0 + var signInCount = 0 + var authenticateCount = 0 + var registerCount = 0 + + var delegateUsername: String? = null + var delegatePet: String? = null + var validateSecurityCode: String? = null + + /** The recorded register call (headers + exact body bytes), for the test-side §1.4 signature verification. */ + class RegisterCall(val headers: Map, val body: ByteArray) + val registerCalls = mutableListOf() + + private val calls = mutableListOf>() + fun callUrls(): List = calls.map { it.second } + + // ---- GSA SRP server state ---- + + private val group = GsaSrpGroup + private val salt = ByteArray(16) { it.toByte() } + private var session: Session? = null + private var petCounter = 0 + + private class Session( + val aPublic: ByteArray, + val serverPublic: ByteArray, + val verifier: BigInteger, + val serverPrivate: BigInteger, + ) + + // ---- routing ---- + + override suspend fun get(url: String, headers: Map): IdsHttpResponse { + calls.add("GET" to url) + return when (url) { + IdsBagFetcher.IDS_BAG_URL -> bag(headers) + certUrl -> validationCertStep(headers) + "https://gsa.apple.com/auth/verify/trusteddevice" -> trustedDeviceTrigger() + "https://gsa.apple.com/grandslam/GsService2/validate" -> validate(headers) + else -> throw IllegalStateException("dry-run fake: unexpected GET $url") + } + } + + override suspend fun post( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse { + calls.add("POST" to url) + return when (url) { + initUrl -> initialize(headers, body, contentType) + adiStartUrl -> adiStart(headers, body, contentType) + adiFinishUrl -> adiFinish(headers, body, contentType) + AlbertActivator.DEFAULT_URL -> albert(headers, body, contentType) + "https://gsa.apple.com/grandslam/GsService2" -> grandslam(body) + GsaDelegateLoginClient.SIGNIN_ENDPOINT -> delegateSignIn(headers, body) + authenticateUrl -> authenticate(headers, body, contentType) + registerUrl -> register(headers, body, contentType) + else -> throw IllegalStateException("dry-run fake: unexpected POST $url") + } + } + + override suspend fun put( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse = throw IllegalStateException("dry-run fake: unexpected PUT $url") + + // ---- bag + validation exchange (§1.1 steps 1–2, §1.2 bag) ---- + + private fun bag(headers: Map): IdsHttpResponse { + require(headers.isEmpty()) { "bag GET is unauthenticated (§1.2 [CAP-BAG]) — got ${headers.keys}" } + bagFetches++ + val inner = XmlPlist.encode( + linkedMapOf( + ValidationBagKeys.CERT to certUrl, + ValidationBagKeys.INITIALIZE to initUrl, + IdsAuthenticator.BAG_KEY_DS_ID to authenticateUrl, + IdsRegistrar.BAG_KEY to registerUrl, + ), + ) + // The recorded IDS bag shape: outer plist with an embedded `bag` plist data (§1.2). + return plist(200, linkedMapOf("signature" to "fixture", "bag" to inner)) + } + + private fun validationCertStep(headers: Map): IdsHttpResponse { + require(headers.isEmpty()) { "step-1 GET carries no recorded headers (§1.1) — got ${headers.keys}" } + validationCertFetches++ + return plist(200, linkedMapOf("cert" to validationCert)) + } + + private fun initialize(headers: Map, body: ByteArray, contentType: String): IdsHttpResponse { + // §1.1 step 2: body only — NO anisette, NO authentication headers. + require(headers.isEmpty()) { "step-2 POST is body-only (§1.1) — got ${headers.keys}" } + require(contentType == ValidationCertClient.CONTENT_TYPE) { + "step-2 content type '$contentType' (§1.1: the recorded client sets NO content-type)" + } + val request = Plist.parse(body) as? Map<*, *> + require(request != null && request.keys == setOf("session-info-request")) { + "step-2 body is not the single-key session-info-request plist (§1.1)" + } + require(request["session-info-request"] is ByteArray) { "step-2 session-info-request is not data (§1.1)" } + initializeCount++ + return plist(200, linkedMapOf("session-info" to sessionInfoPoint)) + } + + // ---- ADI provisioning (§1.1; paths/keys/encodings unrecorded — C6, structural only) ---- + + private fun adiStart(headers: Map, body: ByteArray, contentType: String): IdsHttpResponse { + adiStartCount++ + require(body.decodeToString().contains("start")) { "ADI start request body does not carry its phase marker" } + return plist(200, linkedMapOf("session" to adiSessionBytes)) + } + + private fun adiFinish(headers: Map, body: ByteArray, contentType: String): IdsHttpResponse { + adiFinishCount++ + require(body.decodeToString().contains("finish")) { "ADI finish request body does not carry its phase marker" } + // Machine tokens: field names are unrecorded (C6) — the machine identifier rides here. + return plist( + 200, + linkedMapOf( + "machine-token" to ByteArray(16) { (it + 0x21).toByte() }, + "machine-identifier" to machineIdentifier, + ), + ) + } + + // ---- Albert activation (§1.2) ---- + + private fun albert(headers: Map, body: ByteArray, contentType: String): IdsHttpResponse { + require(contentType == AlbertActivator.CONTENT_TYPE) { "activation post is not form-urlencoded (§1.2)" } + require(headers.isEmpty()) { "activation post carries no recorded headers (§1.2) — got ${headers.keys}" } + val text = body.decodeToString() + require(text.startsWith(AlbertActivator.FORM_KEY + "=")) { "activation post has more than the one form key (§1.2)" } + val formValue = java.net.URLDecoder.decode(text.substringAfter('='), Charsets.UTF_8) + val outer = Plist.parse(formValue.toByteArray()) as? Map<*, *> + ?: throw IllegalStateException("activation form value is not a plist (§1.2)") + require(outer.keys == setOf("ActivationInfoComplete", "ActivationInfoXML", "FairPlayCertChain", "FairPlaySignature")) { + "activation-info outer keys are ${outer.keys} (§1.2)" + } + require(outer["ActivationInfoComplete"] == true) { "ActivationInfoComplete is not true (§1.2)" } + + val activationInfoXml = (outer["ActivationInfoXML"] as String).toByteArray(Charsets.UTF_8) + val nested = Plist.parse(activationInfoXml) as Map<*, *> + require(nested.keys == setOf( + "ActivationRandomness", "ActivationState", "BuildVersion", "DeviceCertRequest", + "DeviceClass", "ProductType", "ProductVersion", "SerialNumber", "UniqueDeviceID", + )) { "ActivationInfoXML keys are ${nested.keys} (§1.2)" } + require(nested["ActivationState"] == "Unactivated") { "ActivationState must be Unactivated (§1.2)" } + require(nested["DeviceClass"] == "MacOS") { "DeviceClass must be MacOS (§1.2)" } + val csrPem = nested["DeviceCertRequest"] as String + require(csrPem.contains(Pem.CSR_TYPE)) { "DeviceCertRequest is not a PEM CSR (§1.2)" } + val csrValues = csrSubjectValues(Pem.decode(csrPem, Pem.CSR_TYPE)) + require(csrValues == listOf(AlbertCsr.COMMON_NAME, AlbertCsr.ORGANIZATIONAL_UNIT, AlbertCsr.ORGANIZATION)) { + "DeviceCertRequest subject is $csrValues (§1.2: Client Push Certificate / iPhone / Apple Inc.)" + } + + // §1.2: the FairPlay signature is SHA-1 RSA PKCS#1v15 over the exact ActivationInfoXML + // bytes, verifying against the FairPlayCertChain's public key. + val verifier = java.security.Signature.getInstance(BundledFairPlaySigner.SIGNATURE_ALGORITHM) + verifier.initVerify(BundledFairPlaySigner.certificatePublicKey(outer["FairPlayCertChain"] as ByteArray)) + verifier.update(activationInfoXml) + require(verifier.verify(outer["FairPlaySignature"] as ByteArray)) { + "FairPlaySignature does not verify against FairPlayCertChain's public key (§1.2)" + } + albertActivations++ + return IdsHttpResponse(200, emptyMap(), protocolWrap( + XmlPlist.encode( + linkedMapOf( + "device-activation" to linkedMapOf( + "activation-record" to linkedMapOf("DeviceCertificate" to deviceCertPem), + ), + "ack-received" to true, + "show-settings" to false, + ), + ), + )) + } + + /** The response's outer `` block (§1.2). */ + private fun protocolWrap(plist: ByteArray): ByteArray { + val inner = plist.decodeToString().substring(plist.decodeToString().indexOf(" + +$inner""".toByteArray() + } + + // ---- GSA SRP login (§1.5) ---- + + private fun grandslam(body: ByteArray): IdsHttpResponse { + val request = (XmlPlist.decode(body) as Map<*, *>)["Request"] as Map<*, *> + require(request["cpd"] is Map<*, *>) { "GSA request carries no cpd dict (§1.5)" } + return if (request["o"] == "init") init(request) else complete(request) + } + + private fun init(request: Map<*, *>): IdsHttpResponse { + gsaInitCount++ + val aPublic = request["A2k"] as ByteArray + val passwordKey = GsaSrpPasswordKey.derive(passwordHash, salt, 1000, "s2k") + // §1.5 identity-hash quirk: SHA-256 over EMPTY username, i.e. just ':' ‖ password key. + val x = BigInteger(1, sha256(salt + sha256(byteArrayOf(':'.code.toByte()) + passwordKey))) + val verifier = group.generator.modPow(x, group.n) + val serverPrivate = BigInteger(1, ByteArray(group.byteLength - 1).also { SecureRandom().nextBytes(it) }) + val k = BigInteger(1, sha256(group.prime + padGenerator())) + val serverPublic = k.multiply(verifier).add(group.generator.modPow(serverPrivate, group.n)).mod(group.n) + session = Session(aPublic, minimalBytes(serverPublic), verifier, serverPrivate) + return plist( + 200, + mapOf( + "Response" to linkedMapOf( + "s" to salt, + "B" to minimalBytes(serverPublic), + "i" to 1000L, + "c" to "challenge", + "sp" to "s2k", + "Status" to linkedMapOf("ec" to 0L, "em" to ""), + ), + ), + ) + } + + private fun complete(request: Map<*, *>): IdsHttpResponse { + gsaCompleteCount++ + val s = session ?: throw IllegalStateException("GSA complete before init") + val m1 = request["M1"] as ByteArray + val u = BigInteger(1, sha256(s.aPublic + s.serverPublic)) + val premaster = BigInteger(1, s.aPublic).multiply(s.verifier.modPow(u, group.n)) + .mod(group.n).modPow(s.serverPrivate, group.n) + val serverK = sha256(minimalBytes(premaster)) + val expectedM1 = sha256( + xor(sha256(group.prime), sha256(padGenerator())) + + sha256((request["u"] as String).toByteArray()) + salt + s.aPublic + s.serverPublic + serverK, + ) + require(expectedM1.contentEquals(m1)) { "dry-run fake: client M1 did not verify (§1.5 SRP)" } + val computedM2 = sha256(s.aPublic + m1 + serverK) + val response = linkedMapOf( + "M2" to computedM2, + "spd" to encryptSpd(serverK), + "Status" to linkedMapOf("ec" to 0L), + ) + if (gsaCompleteCount == 1) response["au"] = GsaStatus.AU_TRUSTED_DEVICE + petCounter++ + return IdsHttpResponse( + 200, + mapOf("X-Apple-PE-Token" to base64("com.apple.gs.idms.pet:pet-$petCounter")), + XmlPlist.encode(mapOf("Response" to response)), + ) + } + + private fun encryptSpd(serverK: ByteArray): ByteArray { + val plain = XmlPlist.encode( + linkedMapOf( + "adsid" to adsid, + "DsPrsId" to 123456789L, + "GsIdmsToken" to "idms-token-1", + "t" to emptyMap(), + ), + ) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(GsaSpdDecryptor.key(serverK), "AES"), + IvParameterSpec(GsaSpdDecryptor.iv(serverK)), + ) + return cipher.doFinal(plain) + } + + private fun trustedDeviceTrigger(): IdsHttpResponse { + trustedDeviceTriggers++ + return IdsHttpResponse(200, emptyMap(), ByteArray(0)) + } + + private fun validate(headers: Map): IdsHttpResponse { + validateSecurityCode = headers["security-code"] + require(validateSecurityCode == securityCode) { + "trusted-device validate carried code '$validateSecurityCode' — expected the fixture code (§1.5)" + } + val identityToken = requireNotNull(headers["X-Apple-Identity-Token"]) { + "trusted-device validate carried no X-Apple-Identity-Token (§1.5: all 2FA requests carry base64 adsid:GsIdmsToken)" + } + val decoded = String(java.util.Base64.getDecoder().decode(identityToken), Charsets.UTF_8) + require(decoded == "$adsid:idms-token-1") { + "validate identity token form mismatch (§1.5: base64 adsid:GsIdmsToken)" + } + validateCount++ + petCounter++ + return IdsHttpResponse( + 200, + mapOf("X-Apple-PE-Token" to base64("com.apple.gs.idms.pet:pet-$petCounter")), + ByteArray(0), + ) + } + + // ---- iCloud delegate sign-in (§1.5) ---- + + private fun delegateSignIn(headers: Map, body: ByteArray): IdsHttpResponse { + val basic = headers["Authorization"] ?: throw IllegalStateException("sign-in has no Basic auth (§1.5)") + val decoded = String(Base64.getDecoder().decode(basic.removePrefix("Basic ")), Charsets.UTF_8) + delegateUsername = decoded.substringBefore(":") + delegatePet = decoded.substringAfter(":") + + val blob = expectedValidationBlob + ?: throw IllegalStateException("sign-in hit before the test wired expectedValidationBlob") + require(headers["X-Mme-Nas-Qualify"] == base64Bytes(blob)) { + "X-Mme-Nas-Qualify is not the base64 of the step-3 validation blob (§1.1/§1.5)" + } + require(headers["X-Apple-ADSID"] == adsid) { "sign-in X-Apple-ADSID mismatch (§1.5)" } + // The full anisette set rides the sign-in (§1.5): base OTP/machine-id headers + the + // macOS hardware headers. + require(headers.containsKey("X-Apple-I-MD") && headers.containsKey("X-Apple-I-MD-M")) { + "sign-in carries no anisette OTP/machine-id headers (§1.1/§1.5)" + } + require(headers.containsKey("X-Apple-I-MLB") && headers.containsKey("X-Apple-I-SRL-NO")) { + "sign-in carries no macOS hardware headers (§1.1/§1.5)" + } + require(headers["X-Mme-Client-Info"]?.startsWith("<") == true) { + "sign-in X-Mme-Client-Info is not the recorded bracketed form (§1.5)" + } + require(headers["User-Agent"] == GsaDelegateLoginClient.SIGNIN_USER_AGENT) { + "sign-in User-Agent is not the recorded iCloudHelper form (§1.5)" + } + + val request = Plist.parse(body) as? Map<*, *> ?: throw IllegalStateException("sign-in body is not a plist (§1.5)") + val delegates = request["delegates"] as? Map<*, *> ?: throw IllegalStateException("sign-in body has no delegates dict (§1.5)") + val idsDelegate = delegates["com.apple.private.ids"] as? Map<*, *> + require(idsDelegate != null && idsDelegate["protocol-version"] == "4") { + "sign-in delegates miss com.apple.private.ids {protocol-version: 4} (§1.5)" + } + require(delegates.containsKey("com.apple.mobileme")) { "sign-in delegates miss com.apple.mobileme (§1.5)" } + require(request["protocolVersion"] == "1.0") { "sign-in protocolVersion must be 1.0 (§1.5)" } + val userInfo = request["userInfo"] as? Map<*, *> + require(userInfo != null && userInfo.keys == setOf("clientId", "language", "timezone")) { + "sign-in userInfo shape wrong (§1.5)" + } + require((userInfo["language"] as String) == "en-US") { "sign-in userInfo.language must be en-US (§1.5)" } + + signInCount++ + return plist( + 200, + linkedMapOf( + "status" to 0L, + "delegates" to linkedMapOf( + "com.apple.private.ids" to linkedMapOf( + "status" to 0L, + "statusMessage" to "OK", + "serviceData" to linkedMapOf( + "auth-token" to authToken, + "profile-id" to profileId, + ), + ), + "com.apple.mobileme" to linkedMapOf( + "status" to 0L, + "serviceData" to linkedMapOf("mmeAuthToken" to "mme-token-fixture"), + ), + ), + ), + ) + } + + // ---- authenticate (§1.2) ---- + + private fun authenticate(headers: Map, body: ByteArray, contentType: String): IdsHttpResponse { + // §1.2: ONLY user-agent and x-protocol-version, plus content-encoding: gzip. + require(headers.keys == setOf("user-agent", "x-protocol-version", "content-encoding")) { + "authenticate header set is ${headers.keys} (§1.2: user-agent, x-protocol-version, content-encoding only)" + } + require(headers["content-encoding"] == "gzip") { "authenticate body is not gzipped (§1.2)" } + val request = Plist.parse(gunzip(body)) as? Map<*, *> + ?: throw IllegalStateException("authenticate body is not a plist (§1.2)") + require(request.keys == setOf("authentication-data", "csr", "realm-user-id")) { + "authenticate body keys are ${request.keys} (§1.2)" + } + require(request["authentication-data"] == mapOf("auth-token" to authToken)) { + "authenticate authentication-data is not {auth-token: …} (§1.2/§1.5)" + } + require(request["realm-user-id"] == profileId) { "authenticate realm-user-id mismatch (§1.2)" } + val csr = expectedCsrDer + ?: throw IllegalStateException("authenticate hit before the test wired expectedCsrDer") + require((request["csr"] as ByteArray).contentEquals(csr)) { "authenticate CSR bytes mismatch (§1.2)" } + authenticateCount++ + return plist(200, linkedMapOf("status" to 0L, "cert" to identityCertDer)) + } + + // ---- register (§1.4) ---- + + private fun register(headers: Map, body: ByteArray, contentType: String): IdsHttpResponse { + require(contentType == IdsRegistrar.CONTENT_TYPE) { "register content type is not the recorded plist type (§1.4)" } + require(headers.containsKey("x-push-nonce") && headers.containsKey("x-push-cert") && headers.containsKey("x-push-sig")) { + "register carries no push signature set (§1.4)" + } + require(headers.containsKey("x-push-token")) { "register carries no x-push-token (§1.4)" } + require(headers.containsKey("x-auth-user-id-0") && headers.containsKey("x-auth-nonce-0") && + headers.containsKey("x-auth-cert-0") && headers.containsKey("x-auth-sig-0")) { + "register carries no indexed per-user auth signature set (§1.4)" + } + require(headers["x-auth-user-id-0"] == profileId) { "x-auth-user-id-0 is not the profile id (§1.4)" } + + val request = Plist.parse(body) as? Map<*, *> ?: throw IllegalStateException("register body is not a plist (§1.4)") + // §1.4: exactly the eight recorded top-level keys, none beyond. + require(request.keys == setOf( + "device-name", "hardware-version", "language", "os-version", "software-version", + "private-device-data", "services", "validation-data", + )) { "register top-level keys are ${request.keys} (§1.4)" } + val blob = expectedValidationBlob + ?: throw IllegalStateException("register hit before the test wired expectedValidationBlob") + require((request["validation-data"] as ByteArray).contentEquals(blob)) { + "register validation-data is not the step-3 blob verbatim (§1.1/§1.4)" + } + val pdd = request["private-device-data"] as? Map<*, *> + require(pdd != null && pdd.keys.size == 14 && (pdd["dt"] as Long) == 1L && (pdd["v"] as String) == "1") { + "private-device-data shape wrong (§1.4)" + } + val services = request["services"] as? List<*> + val service = services?.singleOrNull() as? Map<*, *> + require(service != null && service["service"] == RegisterService.MADRID) { + "register services must carry com.apple.madrid (§1.4)" + } + require(service["sub-services"] == RegisterService.SUB_SERVICES) { "register sub-services mismatch (§1.4)" } + val capability = (service["capabilities"] as List<*>).single() as Map<*, *> + require(capability == mapOf("flags" to 17L, "name" to "Messenger", "version" to 1L)) { + "register capability record mismatch (§1.4)" + } + val user = (service["users"] as List<*>).single() as Map<*, *> + require(user["user-id"] == profileId) { "register user-id mismatch (§1.4)" } + require(user["uris"] == listOf(mapOf("uri" to registeredUri))) { + "register uris are not the server-echoed handle list (§1.4)" + } + + registerCount++ + registerCalls.add(RegisterCall(headers, body)) + return plist( + 200, + linkedMapOf( + "status" to 0L, + "message" to "ok", + "retry-interval" to 600L, + "services" to listOf( + linkedMapOf( + "service" to RegisterService.MADRID, + "status" to 0L, + "users" to listOf( + linkedMapOf( + "user-id" to profileId, + "status" to 0L, + "next-hbi" to 86400L, + "cert" to identityCertDer, + "uris" to listOf(linkedMapOf("uri" to registeredUri, "status" to 0L)), + ), + ), + ), + ), + ), + ) + } + + // ---- helpers ---- + + private fun plist(status: Int, dict: Map) = IdsHttpResponse(200, emptyMap(), XmlPlist.encode(dict)) + + private fun base64(text: String) = Base64.getEncoder().encodeToString(text.toByteArray(Charsets.UTF_8)) + + private fun base64Bytes(bytes: ByteArray) = Base64.getEncoder().encodeToString(bytes) + + private fun gunzip(bytes: ByteArray): ByteArray = + java.util.zip.GZIPInputStream(bytes.inputStream()).use { it.readBytes() } + + private fun sha256(bytes: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(bytes) + + private fun xor(a: ByteArray, b: ByteArray): ByteArray = + ByteArray(a.size) { (a[it].toInt() xor b[it].toInt()).toByte() } + + private fun padGenerator(): ByteArray = ByteArray(group.byteLength - 1) + 0x02.toByte() + + private fun minimalBytes(value: BigInteger): ByteArray { + val signed = value.toByteArray() + return if (signed.size > 1 && signed[0] == 0.toByte()) signed.copyOfRange(1, signed.size) else signed + } + + private fun resourceDer(pemResource: String): ByteArray { + val pem = javaClass.getResourceAsStream(pemResource)!!.bufferedReader().readText() + val base64Body = pem.substringAfter("-----BEGIN CERTIFICATE-----") + .substringBefore("-----END CERTIFICATE-----") + return java.security.cert.CertificateFactory.getInstance("X.509") + .generateCertificate(java.io.ByteArrayInputStream(Base64.getMimeDecoder().decode(base64Body))) + .encoded + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt new file mode 100644 index 0000000..af73f35 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt @@ -0,0 +1,591 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Signature +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** + * The END-TO-END activation dry run (spec §1.1–§1.5): the COMPLETE journey in one flow against + * one scripted fake-Apple environment ([DryRunAppleEnvironment]) — + * + * 1. parse a Mac-Hardware-Info export payload (all recorded fields incl. the five `_enc` + * blobs, §1.1 C58) → hardware identifiers; + * 2. ADI provisioning (start/finish → machine tokens) → the real [AnisetteHeaderProvider] + * header set (OTP via the injected test seam); + * 3. the three-step validation-data exchange (bag cert fetch, body-only session-info POST, + * step-3 reconstruction blob) — the blob is asserted verbatim in BOTH sinks later + * (`validation-data` in the register body, base64 in `X-Mme-Nas-Qualify`); + * 4. albert activation (FairPlay signature verified server-side against the wire + * FairPlayCertChain; device certificate issued); + * 5. GSA login (M1 verified server-side, spd decrypted, trusted-device 2FA with the recorded + * "second login round", PET persisted) → delegate sign-in (Basic username:PET, + * X-Mme-Nas-Qualify = the step-3 blob) → IDS auth-token/profile-id; + * 6. authenticate (gzipped CSR exchange, CN = hex SHA-1 of the realm user id) → identity + * certificate → register (the eight recorded keys, users inside service entries, push + + * per-user signature sets) → per-URI statuses; + * 7. the store ends with the exact end state the session gate needs: provisioned ADI state, + * PET + delegate tokens, device/identity/auth certificates, a registered user row with + * URI statuses. + * + * Every hop is spec-pinned (the fake servers fail loudly), so the ONLY untested hop in a live + * attempt is the real network. + * + * **Structural spots** (spec-unrecorded, asserted only as wiring, never as wire facts): + * - ADI provisioning paths, body keys, and response field names, and the machine-identifier + * wire form (§1.1, TODO(capture) C6) — the exchange composes, nothing more is claimed. + * - The OTP derivation (C6) — the test seam answers; the provider's value forms are the + * recorded ones (`X-Apple-I-Client-Time` = whole-seconds RFC 3339 `Z`, §1.1). + * - Step-3 blob content (§7 item 24 / C60): only the framing (TLV parse, SIGNATURE last) is + * asserted — the reconstruction's six hypotheses are live-arbitrated, not fakeable here. + * - The APNs connect token in the register config comes from the courier connect-ack (§3.3), + * out of this journey's scope — a fixture token rides structurally. + * - Register `uris` are the server's echoed handle list (§1.4) — the environment's registered + * handle is echoed; the id-get-handles fetch is not part of this journey. + * - The issued certificates are real parseable X.509 stand-ins (staged Apple intermediates): + * a fake server issues no real certs, and the store requires parseable DER (`notAfter`). + */ +class ActivationDryRunTest { + + private val username = "user@example.com" + private val password = "correct horse battery staple" + private val passwordHash = MessageDigest.getInstance("SHA-256").digest(password.toByteArray(Charsets.UTF_8)) + + @Test + fun `the complete activation journey composes end to end against the scripted apple environment`() = runBlocking { + val now = 1_770_000_000_000L + val env = DryRunAppleEnvironment(passwordHash) + + // ------------------------------------------------------------------ + // 1. Parse the Mac-Hardware-Info export payload (§1.1 C58). + // ------------------------------------------------------------------ + val export = MacHardwareInfoExportCode.parsePayload(exportPayload(flag = 0)) + val identifiers = export.identifiers + assertEquals(1640L, export.protocolVersion) + assertEquals("MacBookPro18,3", identifiers.productName) + assertEquals("C02DRYRUNMAC", identifiers.serialNumber) + assertEquals("MLB-DRYRUN-STRING", identifiers.logicBoardSerial) + assertEquals(6, identifiers.macAddress!!.size) + // The five `_enc` blobs ride the export verbatim (C57: the derivation is closed source). + assertContentEquals(byteArrayOf(1, 2, 3), identifiers.serialEnc) + assertContentEquals(byteArrayOf(4, 5), identifiers.platformUuidEnc) + assertContentEquals(byteArrayOf(6), identifiers.rootDiskUuidEnc) + assertContentEquals(ROM, identifiers.rom) + assertContentEquals(byteArrayOf(7, 8, 9, 10), identifiers.romEnc) + assertContentEquals(byteArrayOf(11, 12), identifiers.mlbEnc) + + // ------------------------------------------------------------------ + // 2. ADI provisioning → machine tokens → the real anisette header set. + // ------------------------------------------------------------------ + val provisioner = AdiProvisioner(env) + val start = provisioner.start( + AdiProvisioningRequest(adiUrl("start"), emptyMap(), XmlPlist.encode(mapOf("phase" to "start")), PROVISIONING_CONTENT_TYPE), + ) + val finish = provisioner.finish( + AdiProvisioningRequest(adiUrl("finish"), emptyMap(), XmlPlist.encode(mapOf("phase" to "finish")), PROVISIONING_CONTENT_TYPE), + ) + assertEquals(1, env.adiStartCount) + assertEquals(1, env.adiFinishCount) + // The recorded host fact (§1.1): gsa.apple.com. Paths/body keys are structural (C6). + assertTrue(env.callUrls().containsAll(listOf(env.adiStartUrl, env.adiFinishUrl))) + val machineTokens = requireNotNull(finish.plist) { "ADI finish response is not a plist (structural, C6)" } + val machineIdentifier = machineTokens["machine-identifier"] as ByteArray + assertContentEquals(env.machineIdentifier, machineIdentifier) + + val otpSeam = AnisetteOtpTestDouble("dry-run-otp".toByteArray()) + val anisetteFormats = AnisetteValueFormats( + // The recorded §1.1 form: UTC now truncated to whole seconds, RFC 3339 with `Z`. + clientTime = { ms -> java.time.Instant.ofEpochMilli(ms).truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString() }, + timeZone = "UTC", + locale = "en_US", + rinfo = "17106176", // capture-bound value (C6) — caller-supplied + deviceId = "6a7d4e10-9c2b-4f11-8a33-0d5e1f2a3b4c", // §1.1 recorded form: lowercase hyphenated UUID (caller-supplied value) + ) + val anisetteProvider = AnisetteHeaderProvider(machineIdentifier, identifiers, otpSeam, anisetteFormats) + val anisette = anisetteProvider.headers(now, oneTimeIdentifier = ByteArray(8) { (it + 1).toByte() }) + // The recorded §1.1 header set: the seven base headers + the three macOS hardware headers. + assertEquals( + listOf( + "X-Apple-I-Client-Time", "X-Apple-I-TimeZone", "X-Apple-Locale", "X-Apple-I-MD-RINFO", + "X-Mme-Device-Id", "X-Apple-I-MD", "X-Apple-I-MD-M", "X-Apple-I-MLB", "X-Apple-I-ROM", + "X-Apple-I-SRL-NO", + ), + anisette.keys.toList(), + ) + assertTrue(Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$").matches(anisette["X-Apple-I-Client-Time"]!!)) + assertEquals(Base64.getEncoder().encodeToString("dry-run-otp".toByteArray()), anisette["X-Apple-I-MD"]) + assertEquals(Base64.getEncoder().encodeToString(machineIdentifier), anisette["X-Apple-I-MD-M"]) + assertEquals("MLB-DRYRUN-STRING", anisette["X-Apple-I-MLB"]) + assertEquals(ROM.joinToString("") { "%02x".format(it) }, anisette["X-Apple-I-ROM"]) + assertEquals("C02DRYRUNMAC", anisette["X-Apple-I-SRL-NO"]) + // The OTP seam consumed the machine state from the provisioning response. + assertContentEquals(machineIdentifier, otpSeam.inputs.single().machineIdentifier) + + // ------------------------------------------------------------------ + // 3. The validation-data exchange (§1.1 steps 1–2 wire shapes + step 3 blob). + // ------------------------------------------------------------------ + val bagFetcher = IdsBagFetcher(env) // process-lifetime cache (§1.2) — ONE bag fetch in the journey + val validation = ValidationDataExchange( + http = env, + bags = bagFetcher, + logEvent = {}, + ).generate(identifiers, nowEpochMs = now) + + // Step order and bag-resolved URLs (endpoints never hardcoded, §1.2). + assertEquals( + listOf(IdsBagFetcher.IDS_BAG_URL, env.certUrl, env.initUrl), + env.callUrls().takeLast(3), + ) + assertEquals(1, env.bagFetches) + assertEquals(1, env.validationCertFetches) + assertEquals(1, env.initializeCount) + // The blob is well-framed signed output of the step-3 reconstruction (C60 default). + assertEquals( + ValidationDataFields.SIGNATURE, + ValidationDataFraming.parse(validation.blob).last().tag, + ) + + val store = IdsStore(InMemoryIdsStateDao(), IdentityCachePolicy { _, _, _ -> CacheFreshness.FRESH }) + store.updateAccount { account -> + account.copy( + adiMachineTokensPlist = adiMachineTokensPlist(machineTokens), + adiProvisionedAtEpochMs = now, + ) + } + + // ------------------------------------------------------------------ + // 4. Albert activation (§1.2) — device certificate for the push key. + // ------------------------------------------------------------------ + val pushKey = rsaKey(1024) + val pushCsr = AlbertCsr.create(pushKey) + val activationInfo = AlbertActivationInfo( + activationRandomness = "A1B2C3D4-E5F6-4A1B-8C2D-9E0F1A2B3C4D", + buildVersion = identifiers.osBuildNumber!!, + deviceCertRequestPem = pushCsr.pem, + productType = identifiers.productName!!, + productVersion = export.macOsProductVersion!!, + serialNumber = identifiers.serialNumber, + uniqueDeviceId = export.deviceId!!, + ) + val albert = AlbertActivator(env).activate(activationInfo) // default = the bundled FairPlay signer + assertEquals(1, env.albertActivations) + val deviceCertDer = Pem.decode(requireNotNull(albert.deviceCertificatePem), Pem.CERTIFICATE_TYPE) + assertContentEquals(env.deviceCertDer, deviceCertDer) + + store.updateAccount { account -> + account.copy(pushCertDer = deviceCertDer, pushCertChainJson = pushCertChainJson(listOf(deviceCertDer))) + } + + // ------------------------------------------------------------------ + // 5. GSA login (§1.5) → 2FA → PET → delegate sign-in → IDS credentials. + // ------------------------------------------------------------------ + val deviceConfig = GsaHeaderConfig( + hardwareModel = identifiers.productName!!, + osName = "macOS", + osVersion = export.macOsProductVersion!!, + osBuild = identifiers.osBuildNumber!!, + ) + val cpd = GsaCpd(anisette = anisette, keychainIdentifier = ByteArray(16) { (it + 1).toByte() }) + val chain = GsaLoginChain( + http = env, + cpd = cpd, + credentialStore = IdsStoreGsaCredentials(store), + twoFactorBrowserHeaders = GsaTwoFactorHeaders.of(cpd, device = deviceConfig), + headerConfig = deviceConfig, + clock = { now }, + logEvent = {}, + ) + + val required = chain.login(username, password) as GsaLoginResult.SecondaryAuthRequired + assertEquals(GsaStatus.AU_TRUSTED_DEVICE, required.variant) + assertEquals(gsaIdentityToken(env.adsid, "idms-token-1"), required.identityToken) + assertEquals(1, env.gsaInitCount) + assertEquals(1, env.gsaCompleteCount) + + val authenticated = chain.completeSecondaryAuth( + username = username, + passwordHash = passwordHash, + variant = GsaStatus.AU_TRUSTED_DEVICE, + identityToken = required.identityToken!!, + securityCode = env.securityCode, + ) as GsaLoginResult.Authenticated + // §1.5: the SRP login runs a SECOND time after 2FA, completing as logged-in. + assertEquals(2, env.gsaInitCount) + assertEquals(2, env.gsaCompleteCount) + assertEquals(1, env.trustedDeviceTriggers) + assertEquals(1, env.validateCount) + assertEquals(env.securityCode, env.validateSecurityCode) + assertEquals("pet-3", authenticated.pet!!.token) + + // The PET (+ username, hash, adsid) is persisted through the store adapter. + val accountAfterLogin = store.account()!! + assertEquals(username, accountAfterLogin.gsaUsername) + assertContentEquals(passwordHash, accountAfterLogin.gsaPasswordHash) + assertEquals(env.adsid, accountAfterLogin.gsaAdsId) + assertEquals("pet-3", accountAfterLogin.gsaPetToken) + // No token-header trailer → the recorded 300 s default (§1.5). + assertEquals(now + 300_000, accountAfterLogin.gsaPetExpiresAtEpochMs) + // The session gate's silent-PET path serves the stored token with no network. + assertEquals("pet-3", chain.petToken(now + 100_000)) + assertEquals(2, env.gsaInitCount) + + // The delegate sign-in consumes the step-3 blob — wire it for the server-side assertion. + env.expectedValidationBlob = validation.blob + val credentials = chain.idsDelegateCredentials( + username = username, + pet = authenticated.pet!!.token, + adsid = authenticated.spd!!.adsid!!, + validationData = validation.blob, + anisette = anisette, + ) + assertEquals(1, env.signInCount) + assertEquals(username, env.delegateUsername) + assertEquals("pet-3", env.delegatePet) // Basic username:PET (§1.5) + assertEquals(env.authToken, credentials.authToken) + assertEquals(env.profileId, credentials.profileId) + + store.updateAccount { account -> + account.copy( + delegateAuthToken = credentials.authToken, + delegateProfileId = credentials.profileId, + delegateRefreshedAtEpochMs = now, + ) + } + + // ------------------------------------------------------------------ + // 6. Authenticate (§1.2) → identity certificate → register (§1.4). + // ------------------------------------------------------------------ + val bag = bagFetcher.idsBag() + val authKey = rsaKey(2048) + val realmCn = IdsCsrBuilder.realmUserIdCn(credentials.profileId) + val authCsr = IdsCsrBuilder.create(authKey, realmCn) + env.expectedCsrDer = authCsr.der + + val authResponse = chain.authenticateIds( + credentials = credentials, + bagKey = IdsAuthenticator.BAG_KEY_DS_ID, + csrDer = authCsr.der, + authenticator = IdsAuthenticator(env, bag, protocolVersion = "1640", userAgent = IDS_USER_AGENT), + ) + assertEquals(1, env.authenticateCount) + assertEquals(0, authResponse.status) + assertContentEquals(env.identityCertDer, authResponse.cert) + + val pushToken = ByteArray(32) { (it + 3).toByte() } // courier connect-ack token (§3.3) — structural here + val user = RegisterUser( + userId = credentials.profileId, + uris = listOf(mapOf("uri" to env.registeredUri)), // the server-echoed handle list (§1.4) + tag = null, + authKey = authKey.private, + authCertificateDer = requireNotNull(authResponse.cert), + clientData = mapOf("client-data-version" to 2L), + ktLoggableData = null, + ) + val registerBody = RegisterBody( + deviceName = "Light Phone III", + hardwareVersion = identifiers.productName!!, + language = "en-US", + osVersion = export.macOsProductVersion!!, + softwareVersion = "1.0", + privateDeviceData = PrivateDeviceData( + osBuild = identifiers.osBuildNumber!!, + platformName = identifiers.productName!!, + osVersion = export.macOsProductVersion!!, + deviceUuid = export.deviceId!!, + appleEpochSeconds = now / 1000 - APPLE_EPOCH_OFFSET_SECONDS, + ), + services = listOf(RegisterService(RegisterService.MADRID, users = listOf(user))), + validationData = validation.blob, + ) + val registerResponse = IdsRegistrar( + env, + bag, + RegisterConfig( + pushToken = pushToken, + pushKey = pushKey.private, + pushCertificateDer = deviceCertDer, + protocolVersion = "1640", + userAgent = IDS_USER_AGENT, + ), + ).register(registerBody) + assertEquals(1, env.registerCount) + assertEquals(0, registerResponse.status) + val registeredService = registerResponse.services.single() + assertEquals(RegisterService.MADRID, registeredService.service) + assertEquals(0, registeredService.status) + val registeredUser = registeredService.users.single() + assertEquals(env.profileId, registeredUser.userId) + assertEquals(0, registeredUser.status) + assertEquals(86400L, registeredUser.nextHbi) + assertContentEquals(env.identityCertDer, registeredUser.cert) + assertEquals(listOf(IdsRegisterUriResult(env.registeredUri, 0)), registeredUser.uris) + + // §1.4 register signature sets verify over the recorded tuple (bag key, query, body, push token). + val registerCall = env.registerCalls.single() + val signingFields = listOf( + IdsRegistrar.BAG_KEY.toByteArray(Charsets.UTF_8), + ByteArray(0), + registerCall.body, + pushToken, + ) + verifyRegisterSignature(registerCall, "x-push-nonce", "x-push-sig", pushKey, signingFields) + verifyRegisterSignature(registerCall, "x-auth-nonce-0", "x-auth-sig-0", authKey, signingFields) + assertEquals(Base64.getEncoder().encodeToString(deviceCertDer), registerCall.headers["x-push-cert"]) + assertEquals(Base64.getEncoder().encodeToString(pushToken), registerCall.headers["x-push-token"]) + assertEquals(Base64.getEncoder().encodeToString(authResponse.cert), registerCall.headers["x-auth-cert-0"]) + assertEquals("1640", registerCall.headers["x-protocol-version"]) + + store.recordRegistration( + idsRegisteredUser( + userId = registeredUser.userId!!, + status = registeredUser.status, + identityCertDer = requireNotNull(registeredUser.cert), + uris = registeredUser.uris.map { it.uri }, + registeredAtEpochMs = now, + authCertDer = authResponse.cert, + nextHbiSeconds = registeredUser.nextHbi, + ), + ) + store.updateAccount { account -> + account.copy( + lastRegistrationEpochMs = now, + macSerial = identifiers.serialNumber, + macModel = identifiers.productName, + macBoardId = identifiers.boardId, + macUuid = identifiers.platformUuid, + macMlb = identifiers.logicBoardSerial, + macRom = identifiers.rom.joinToString("") { "%02x".format(it) }, + macBuild = identifiers.osBuildNumber, + ) + } + + // ------------------------------------------------------------------ + // 7. The store's end state — everything the engine's session gate needs. + // ------------------------------------------------------------------ + val finalAccount = store.account()!! + // Provisioned ADI state. + assertEquals(now, finalAccount.adiProvisionedAtEpochMs) + val storedTokens = requireNotNull(adiMachineTokensRaw(finalAccount.adiMachineTokensPlist)) + assertContentEquals(machineIdentifier, storedTokens["machine-identifier"] as ByteArray) + // GSA login state: PET + hash + adsid (never the raw password). + assertEquals("pet-3", finalAccount.gsaPetToken) + assertEquals(now + 300_000, finalAccount.gsaPetExpiresAtEpochMs) + assertEquals(env.adsid, finalAccount.gsaAdsId) + assertContentEquals(passwordHash, finalAccount.gsaPasswordHash) + // Delegate tokens from the sign-in. + assertEquals(env.authToken, finalAccount.delegateAuthToken) + assertEquals(env.profileId, finalAccount.delegateProfileId) + // The device (push) certificate from albert. + assertContentEquals(deviceCertDer, finalAccount.pushCertDer) + // Registered user row: identity cert, auth cert, URIs, next-hbi. + val users = store.registeredUsers() + assertTrue(users.isNotEmpty(), "the session gate needs registered users to exist") + assertEquals(1, users.size) + val userRow = users.single() + assertEquals(env.profileId, userRow.userId) + assertEquals(0, userRow.status) + assertContentEquals(env.identityCertDer, userRow.identityCertDer) + assertContentEquals(authResponse.cert, userRow.authCertDer) + assertEquals(listOf(env.registeredUri), registeredUserUris(userRow)) + assertEquals(86400L, userRow.nextHbiSeconds) + assertEquals(now, userRow.registeredAtEpochMs) + assertTrue(userRow.identityCertNotAfterEpochMs > now, "store parses the identity cert's notAfter") + } + + @Test + fun `the fake environment fails loudly when a hop does not carry the recorded shape`() = runBlocking { + val env = DryRunAppleEnvironment(passwordHash) + val bagFetcher = IdsBagFetcher(env) + val validation = ValidationDataExchange(env, bagFetcher, logEvent = {}) + .generate(exportIdentifiers(), nowEpochMs = 1_770_000_000_000L) + + val deviceConfig = GsaHeaderConfig( + hardwareModel = "MacBookPro18,3", osName = "macOS", osVersion = "13.4", osBuild = "22G74", + ) + val chain = GsaLoginChain( + http = env, + cpd = GsaCpd(anisette = anisetteHeaders(), keychainIdentifier = ByteArray(16)), + twoFactorBrowserHeaders = GsaTwoFactorHeaders.of( + GsaCpd(anisette = anisetteHeaders(), keychainIdentifier = ByteArray(16)), + device = deviceConfig, + ), + headerConfig = deviceConfig, + clock = { 1_770_000_000_000L }, + ) + val required = chain.login(username, password) as GsaLoginResult.SecondaryAuthRequired + chain.completeSecondaryAuth(username, passwordHash, required.variant, required.identityToken!!, env.securityCode) + + // A tampered blob cannot sneak past the server-side X-Mme-Nas-Qualify assertion: + // wire a WRONG blob and the fake fails loudly on the unrecorded-shape hop. + env.expectedValidationBlob = byteArrayOf(9, 9, 9) + val exception = assertFailsWith { + chain.idsDelegateCredentials( + username = username, + pet = "pet-3", + adsid = env.adsid, + validationData = byteArrayOf(1, 2, 3), // not the blob the register/qualify sinks carry + anisette = anisetteHeaders(), + ) + } + assertTrue(exception.message!!.contains("X-Mme-Nas-Qualify")) + Unit + } + + // ---- helpers ---- + + private fun adiUrl(phase: String) = "https://${AdiProvisioner.GSA_HOST}/adi/$phase-provisioning" + + private fun rsaKey(bits: Int): KeyPair = + KeyPairGenerator.getInstance("RSA").apply { initialize(bits) }.generateKeyPair() + + /** §1.4's indexed signature sets: 17-byte nonce, 0x01 0x01-prefixed RSA-SHA1 signature. */ + private fun verifyRegisterSignature( + call: DryRunAppleEnvironment.RegisterCall, + nonceHeader: String, + sigHeader: String, + key: KeyPair, + fields: List, + ) { + val nonce = Base64.getDecoder().decode(call.headers.getValue(nonceHeader)) + assertEquals(17, nonce.size) + assertEquals(0x01, nonce[0].toInt()) + val signature = Base64.getDecoder().decode(call.headers.getValue(sigHeader)) + assertContentEquals(byteArrayOf(0x01, 0x01), signature.copyOfRange(0, 2)) + val verifier = Signature.getInstance("SHA1withRSA") + verifier.initVerify(key.public) + verifier.update(IdsSigning.signingBytes(nonce, fields)) + assertTrue(verifier.verify(signature.copyOfRange(2, signature.size)), "$sigHeader does not verify") + } + + private fun anisetteHeaders() = mapOf( + "X-Apple-I-Client-Time" to "2026-02-04T12:00:00Z", + "X-Apple-I-TimeZone" to "UTC", + "X-Apple-Locale" to "en_US", + "X-Apple-I-MD-RINFO" to "17106176", + "X-Mme-Device-Id" to "6a7d4e10-9c2b-4f11-8a33-0d5e1f2a3b4c", + "X-Apple-I-MD" to Base64.getEncoder().encodeToString("dry-run-otp".toByteArray()), + "X-Apple-I-MD-M" to Base64.getEncoder().encodeToString(ByteArray(32) { (it + 0x11).toByte() }), + "X-Apple-I-MLB" to "MLB-DRYRUN-STRING", + "X-Apple-I-ROM" to ROM.joinToString("") { "%02x".format(it) }, + "X-Apple-I-SRL-NO" to "C02DRYRUNMAC", + ) + + private fun exportIdentifiers() = MacHardwareInfoExportCode.parsePayload(exportPayload(flag = 0)).identifiers + + // ---- hand-built export-code fixture (the MacHardwareInfoExportTest protobuf encoder pattern) ---- + + private object TestProto { + fun varint(value: Long): ByteArray { + var v = value + val out = ArrayList() + while (true) { + if (v and 0x7f.inv() == 0L) { + out.add(v.toByte()) + return out.toByteArray() + } + out.add(((v and 0x7f) or 0x80).toByte()) + v = v ushr 7 + } + } + + fun message(vararg fields: ByteArray): ByteArray { + val size = fields.sumOf { it.size } + return ByteArray(size).also { buffer -> + var at = 0 + fields.forEach { it.copyInto(buffer, at); at += it.size } + } + } + + private fun tag(number: Int, wireType: Int): ByteArray = varint((number.toLong() shl 3) or wireType.toLong()) + + fun stringField(number: Int, value: String): ByteArray = + lenDelimited(number, value.toByteArray(Charsets.UTF_8)) + + fun bytesField(number: Int, value: ByteArray): ByteArray = lenDelimited(number, value) + + fun varintField(number: Int, value: Long): ByteArray = tag(number, 0) + varint(value) + + private fun lenDelimited(number: Int, payload: ByteArray): ByteArray = + tag(number, 2) + varint(payload.size.toLong()) + payload + } + + private fun identifierMessage(): ByteArray = TestProto.message( + TestProto.stringField(1, "MacBookPro18,3"), + TestProto.bytesField(2, MAC_ADDRESS), + TestProto.stringField(3, "C02DRYRUNMAC"), + TestProto.stringField(4, "PLATFORM-UUID-DRYRUN"), + TestProto.stringField(5, "ROOT-DISK-UUID-DRYRUN"), + TestProto.stringField(6, "Mac-BBAA2211FFEE0011"), + TestProto.stringField(7, "22G74"), + TestProto.bytesField(8, byteArrayOf(1, 2, 3)), + TestProto.bytesField(9, byteArrayOf(4, 5)), + TestProto.bytesField(10, byteArrayOf(6)), + TestProto.bytesField(11, ROM), + TestProto.bytesField(12, byteArrayOf(7, 8, 9, 10)), + TestProto.stringField(13, "MLB-DRYRUN-STRING"), + TestProto.bytesField(14, byteArrayOf(11, 12)), + ) + + private fun exportPayload(flag: Int): ByteArray { + val outer = TestProto.message( + TestProto.bytesField(1, identifierMessage()), + TestProto.stringField(2, "13.4"), + TestProto.varintField(3, 1640L), + TestProto.stringField(4, "6a7d4e10-9c2b-4f11-8a33-0d5e1f2a3b4c"), + TestProto.stringField(5, "iCloudHelper/282 (dry-run)"), + TestProto.stringField(6, "AOSKit/282 (dry-run)"), + ) + return "OABS".toByteArray(Charsets.US_ASCII) + byteArrayOf(flag.toByte()) + outer + } + + companion object { + private val MAC_ADDRESS = byteArrayOf(0xA1.toByte(), 0xB2.toByte(), 0xC3.toByte(), 0x01, 0x02, 0x03) + private val ROM = byteArrayOf(0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F) + + /** The recorded §1.5 sign-in/IDS user-agent family (§1.4: `com.apple.invitation-registration […]`). */ + private const val IDS_USER_AGENT = "com.apple.invitation-registration [macOS,13.4,22G74,MacBookPro18,3]" + + /** The ADI provisioning content type is unrecorded (C6) — structural. */ + private const val PROVISIONING_CONTENT_TYPE = "text/x-xml-plist" + + /** Seconds between the 2001-01-01 Apple epoch and the 1970 unix epoch (§1.4 `d`). */ + private const val APPLE_EPOCH_OFFSET_SECONDS = 978_307_200L + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/AdiProvisioningTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/AdiProvisioningTest.kt new file mode 100644 index 0000000..4a8a6e5 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/AdiProvisioningTest.kt @@ -0,0 +1,121 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlinx.coroutines.runBlocking + +/** + * Structural tests only: the spec records the ADI exchange's host, its start/finish shape, and + * that the finish yields machine tokens — no paths, body keys, encodings, or field names + * (TODO(capture) C6). So these pin the plumbing (requests pass through verbatim, responses + * surface raw, failures are loud) against scripted transports, never a guessed wire shape. + */ +class AdiProvisioningTest { + + private fun request(url: String = "https://gsa.apple.com/unrecorded-path") = AdiProvisioningRequest( + url = url, + headers = mapOf("caller-header" to "caller-value"), + body = "caller-assembled".toByteArray(), + contentType = "caller-supplied/type", + ) + + @Test + fun `start posts the caller-assembled request verbatim`() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), "ok".toByteArray()))) + + AdiProvisioner(http).start(request()) + + val call = http.calls.single() + assertEquals("POST", call.method) + assertEquals("https://gsa.apple.com/unrecorded-path", call.url) + assertEquals(mapOf("caller-header" to "caller-value"), call.headers) + assertEquals("caller-assembled".toByteArray().toList(), call.body.toList()) + assertEquals("caller-supplied/type", call.contentType) + } + + @Test + fun `start then finish round-trips in order`() = runBlocking { + val http = ScriptedIdsHttp( + listOf( + IdsHttpResponse(200, mapOf("x-step" to "start"), "start-body".toByteArray()), + IdsHttpResponse(200, mapOf("x-step" to "finish"), "finish-body".toByteArray()), + ), + ) + val provisioner = AdiProvisioner(http) + + val start = provisioner.start(request("https://gsa.apple.com/start")) + val finish = provisioner.finish(request("https://gsa.apple.com/finish")) + + assertEquals(listOf("https://gsa.apple.com/start", "https://gsa.apple.com/finish"), http.calls.map { it.url }) + assertEquals("start", start.headers["x-step"]) + assertEquals("finish", finish.headers["x-step"]) + assertEquals("finish-body".toByteArray().toList(), finish.body.toList()) + } + + @Test + fun `plist response surfaces as a raw dict`() = runBlocking { + val body = "some-fieldvalue".toByteArray() + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), body))) + + val response = AdiProvisioner(http).start(request()) + + assertEquals(mapOf("some-field" to "value"), response.plist) + } + + @Test + fun `unrecorded encoding keeps the raw body with no plist - not a silent guess`() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), "not a plist".toByteArray()))) + + val response = AdiProvisioner(http).finish(request()) + + assertNull(response.plist) + assertEquals("not a plist".toByteArray().toList(), response.body.toList()) + } + + @Test + fun `non-200 fails loudly`() { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(503, emptyMap(), ByteArray(0)))) + + runBlocking { + val exception = assertFailsWith { AdiProvisioner(http).start(request()) } + assertEquals("start-provisioning POST https://gsa.apple.com/unrecorded-path → HTTP 503", exception.message) + } + } + + @Test + fun `the recorded host is pinned as a constant`() { + assertEquals("gsa.apple.com", AdiProvisioner.GSA_HOST) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/AlbertActivationTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/AlbertActivationTest.kt new file mode 100644 index 0000000..78bb17e --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/AlbertActivationTest.kt @@ -0,0 +1,216 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.net.URLDecoder +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.SecureRandom +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class AlbertActivationTest { + + private val fairPlayChain = ByteArray(32) { (it + 1).toByte() } + private val fairPlaySignature = ByteArray(64) { (it + 7).toByte() } + private var signedPlist: ByteArray? = null + + private fun info() = AlbertActivationInfo( + activationRandomness = "A1B2C3D4-E5F6-4A1B-8C2D-9E0F1A2B3C4D", + buildVersion = "23B85", + deviceCertRequestPem = PUSH_CSR.pem, + productType = "Mac15,12", + productVersion = "14.6.1", + serialNumber = "C02TEST123", + uniqueDeviceId = "0123456789abcdef0123456789abcdef01234567", + ) + + private fun signer() = FairPlaySigner { plist -> + signedPlist = plist + FairPlayMaterial(fairPlayChain, fairPlaySignature) + } + + private fun activator(http: IdsHttp, url: String = AlbertActivator.DEFAULT_URL) = + AlbertActivator(http, url) + + @Test + fun requestCarriesTheRecordedFormAndPlistShape() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + activator(http).activate(info(), signer()) + + val call = http.calls.single() + assertEquals(AlbertActivator.DEFAULT_URL, call.url) + assertEquals(AlbertActivator.CONTENT_TYPE, call.contentType) + assertTrue(call.body.decodeToString().startsWith(AlbertActivator.FORM_KEY + "=")) + + // One form key (§1.2); its URL-decoded value is the activation-info XML plist. + val formValue = URLDecoder.decode(call.body.decodeToString().substringAfter('='), Charsets.UTF_8) + val outer = Plist.parse(formValue.toByteArray()) as Map<*, *> + assertEquals( + setOf("ActivationInfoComplete", "ActivationInfoXML", "FairPlayCertChain", "FairPlaySignature"), + outer.keys, + ) + assertEquals(true, outer["ActivationInfoComplete"]) + assertContentEquals(fairPlayChain, outer["FairPlayCertChain"] as ByteArray) + assertContentEquals(fairPlaySignature, outer["FairPlaySignature"] as ByteArray) + + // The nested ActivationInfoXML plist carries exactly the nine recorded keys (§1.2). + val nested = Plist.parse((outer["ActivationInfoXML"] as String).toByteArray()) as Map<*, *> + assertEquals( + setOf( + "ActivationRandomness", "ActivationState", "BuildVersion", "DeviceCertRequest", + "DeviceClass", "ProductType", "ProductVersion", "SerialNumber", "UniqueDeviceID", + ), + nested.keys, + ) + assertEquals("Unactivated", nested["ActivationState"]) + assertEquals("MacOS", nested["DeviceClass"]) + assertEquals("A1B2C3D4-E5F6-4A1B-8C2D-9E0F1A2B3C4D", nested["ActivationRandomness"]) + assertEquals(PUSH_CSR.pem, nested["DeviceCertRequest"]) + } + + @Test + fun fairPlaySignerReceivesTheSerializedActivationInfoPlist() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + activator(http).activate(info(), signer()) + + val signed = signedPlist!! + val nestedBytes = XmlPlist.encode(info().toDict()) + assertContentEquals(nestedBytes, signed, "the FairPlay signature covers the serialized ActivationInfo plist") + } + + @Test + fun responseParsesDeviceCertificateFromTheProtocolBlock() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + val response = activator(http).activate(info(), signer()) + + assertEquals(DEVICE_CERT_PEM, response.deviceCertificatePem) + assertEquals(true, response.ackReceived) + assertEquals(false, response.showSettings) + } + + @Test + fun responseWithoutDeviceActivationParsesWithNullCertificate() = runBlocking { + val response = XmlPlist.encode(mapOf("ack-received" to true)) // activation rejected — no record + val http = ScriptedIdsHttp( + listOf(IdsHttpResponse(200, emptyMap(), protocolWrap(response))), + ) + val parsed = activator(http).activate(info(), signer()) + assertEquals(null, parsed.deviceCertificatePem) + assertEquals(true, parsed.ackReceived) + } + + @Test + fun non200Throws() { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(403, emptyMap(), ByteArray(0)))) + assertFailsWith { + runBlocking { activator(http).activate(info(), signer()) } + } + } + + @Test + fun responseOutsideAProtocolBlockThrows() { + val http = ScriptedIdsHttp( + listOf(IdsHttpResponse(200, emptyMap(), XmlPlist.encode(mapOf("device-activation" to 1L)))), + ) + assertFailsWith { + runBlocking { activator(http).activate(info(), signer()) } + } + } + + @Test + fun activationRandomnessIsAnUppercaseUuidV4() { + val value = AlbertActivationInfo.newActivationRandomness(SecureRandom(byteArrayOf(1, 2, 3))) + assertTrue( + Regex("^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$") + .matches(value), + "not an uppercase UUIDv4: $value", + ) + } + + @Test + fun albertCsrMatchesTheRecordedConstants() { + val csr = AlbertCsr.create(PUSH_KEY) + assertEquals( + listOf("Client Push Certificate", "iPhone", "Apple Inc."), + csrSubjectValues(csr.der), + ) + assertEquals(1024, (PUSH_KEY.public as java.security.interfaces.RSAPublicKey).modulus.bitLength()) + // §1.2: "SHA-1WithRSA" — recorded, not a C57 parameter here. + val top = derChildren(csr.der, readTlv(csr.der, 0)) + val verifier = java.security.Signature.getInstance("SHA1withRSA") + verifier.initVerify(PUSH_KEY.public) + verifier.update(derFullBytes(csr.der, top[0])) + assertTrue(verifier.verify(csr.der.copyOfRange(top[2].bodyStart + 1, top[2].bodyEnd))) + } + + @Test + fun thePrivateKeyIsNeverSent() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + activator(http).activate(info(), signer()) + val wire = http.calls.single().body.decodeToString() + assertFalse(wire.contains("PRIVATE KEY"), "private key material must never ride the wire") + assertFalse(wire.contains("BEGIN RSA"), "no private-material armor on the wire") + } + + private fun protocolWrap(plist: ByteArray): ByteArray { + // The inner plist rides inside the Protocol element, so it carries no XML declaration of its own. + val inner = plist.decodeToString().substring(plist.decodeToString().indexOf(" + +$inner""".toByteArray() + } + + private fun okResponse(): ByteArray = protocolWrap( + XmlPlist.encode( + mapOf( + "device-activation" to mapOf( + "activation-record" to mapOf("DeviceCertificate" to DEVICE_CERT_PEM), + ), + "ack-received" to true, + "show-settings" to false, + ), + ), + ) + + private companion object { + val DEVICE_CERT_PEM = + "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n" + + val PUSH_KEY: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + val PUSH_CSR: IdsCsr = AlbertCsr.create(PUSH_KEY) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/AnisetteHeadersTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/AnisetteHeadersTest.kt new file mode 100644 index 0000000..3808fd6 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/AnisetteHeadersTest.kt @@ -0,0 +1,146 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The recorded §1.1 header set is pinned by name and by recorded value form (base64 OTP, + * base64 machine ID, lowercase-hex ROM). Everything the spec leaves unrecorded — client-time + * format, timezone/locale/rinfo/device-id values, the OTP derivation, the one-time identifier + * — flows through caller-supplied seams and is tested structurally only. + */ +class AnisetteHeadersTest { + + private val machineId = byteArrayOf(1, 2, 3, 4) + private val rom = byteArrayOf(0xAB.toByte(), 0x0C, 0x7F) + + private fun formats() = AnisetteValueFormats( + clientTime = { ms -> "formatted-$ms" }, + timeZone = "America/Los_Angeles", + locale = "en_US", + rinfo = "17106197", + deviceId = "device-id-value", + ) + + private fun otpRecording(output: ByteArray = "otp-bytes".toByteArray()) = + AnisetteOtpTestDouble(output) + + private fun provider( + otp: AnisetteOtp = otpRecording(), + macHardware: MacHardwareIdentifiers? = MacHardwareIdentifiers("SERIAL123", "MLB-VALUE", rom), + ) = AnisetteHeaderProvider(machineId, macHardware, otp, formats()) + + @Test + fun `full header set carries exactly the recorded names in mac hardware mode`() { + val headers = provider().headers(nowEpochMs = 1_000L, oneTimeIdentifier = byteArrayOf(9)) + + assertEquals( + listOf( + "X-Apple-I-Client-Time", + "X-Apple-I-TimeZone", + "X-Apple-Locale", + "X-Apple-I-MD-RINFO", + "X-Mme-Device-Id", + "X-Apple-I-MD", + "X-Apple-I-MD-M", + "X-Apple-I-MLB", + "X-Apple-I-ROM", + "X-Apple-I-SRL-NO", + ), + headers.keys.toList(), + ) + } + + @Test + fun `hardware headers are omitted without mac identifiers`() { + val headers = provider(macHardware = null).headers(1_000L, byteArrayOf(9)) + + assertEquals(7, headers.size) + assertTrue(headers.keys.none { it.startsWith("X-Apple-I-MLB") || it == "X-Apple-I-ROM" || it == "X-Apple-I-SRL-NO" }) + } + + @Test + fun `recorded value forms - base64 otp and machine id, lowercase hex rom`() { + val headers = provider().headers(1_000L, byteArrayOf(9)) + + assertEquals(Base64.getEncoder().encodeToString("otp-bytes".toByteArray()), headers["X-Apple-I-MD"]) + assertEquals(Base64.getEncoder().encodeToString(machineId), headers["X-Apple-I-MD-M"]) + assertEquals("ab0c7f", headers["X-Apple-I-ROM"]) + assertEquals("MLB-VALUE", headers["X-Apple-I-MLB"]) + assertEquals("SERIAL123", headers["X-Apple-I-SRL-NO"]) + } + + @Test + fun `capture-bound values flow through the caller-supplied formats`() { + val headers = provider().headers(nowEpochMs = 1_234_567L, oneTimeIdentifier = byteArrayOf(9)) + + assertEquals("formatted-1234567", headers["X-Apple-I-Client-Time"]) + assertEquals("America/Los_Angeles", headers["X-Apple-I-TimeZone"]) + assertEquals("en_US", headers["X-Apple-Locale"]) + assertEquals("17106197", headers["X-Apple-I-MD-RINFO"]) + assertEquals("device-id-value", headers["X-Mme-Device-Id"]) + } + + @Test + fun `otp seam receives machine state and the one-time identifier verbatim`() { + val otp = otpRecording() + val oneTime = byteArrayOf(0x55, 0x66) + + provider(otp = otp).headers(1_000L, oneTime) + + assertEquals(1, otp.inputs.size) + assertTrue(otp.inputs[0].machineIdentifier.contentEquals(machineId)) + assertTrue(otp.inputs[0].oneTimeIdentifier.contentEquals(oneTime)) + } + + @Test + fun `empty otp output fails loudly`() { + val exception = assertFailsWith { + provider(otp = otpRecording(ByteArray(0))).headers(1_000L, byteArrayOf(9)) + } + assertEquals("OTP seam returned no bytes for X-Apple-I-MD", exception.message) + } +} + +/** OTP test double: records every input, returns a fixed output. */ +class AnisetteOtpTestDouble(private val output: ByteArray) : AnisetteOtp { + val inputs = mutableListOf() + + override fun otp(input: AnisetteOtpInput): ByteArray { + inputs.add(input) + return output + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaCpdTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaCpdTest.kt new file mode 100644 index 0000000..4190192 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaCpdTest.kt @@ -0,0 +1,212 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The §1.5 cpd dictionary: anisette values as keys (from chunk A's provider output), the + * derived `X-Apple-I-MD-LU` (§1.1), and the recorded fixed-value set — pinned wholesale, since + * the server compares this dict byte-for-byte. + */ +class GsaCpdTest { + + private val anisette = mapOf( + "X-Apple-I-Client-Time" to "2026-09-11T12:00:00Z", + "X-Apple-I-TimeZone" to "UTC", + "X-Apple-Locale" to "en_US", + "X-Apple-I-MD-RINFO" to "17106176", + "X-Mme-Device-Id" to "1234ABCD-5678-90EF-1234-ABCD567890EF", + "X-Apple-I-MD" to "an-otp", + "X-Apple-I-MD-M" to "a-machine-id", + "X-Apple-I-MLB" to "C02X12345678", + "X-Apple-I-ROM" to "aabbccddeeff", + "X-Apple-I-SRL-NO" to "C02X1Y2ZLVDL", + ) + private val keychainIdentifier = ByteArray(16) { (it + 1).toByte() } + + private fun cpd(apnsToken: String? = null, flavor: GsaFlavor = GsaFlavor.MESSAGES) = GsaCpd( + anisette = anisette, + keychainIdentifier = keychainIdentifier, + flavor = flavor, + apnsToken = apnsToken, + ) + + private fun dict(apnsToken: String? = null) = cpd(apnsToken).dictFor("A1B2C3D4-E5F6-4789-ABCD-0123456789AB") + + @Test + fun `the anisette values ride as keys, hardware headers excluded`() { + val dict = dict() + assertEquals("2026-09-11T12:00:00Z", dict["X-Apple-I-Client-Time"]) + assertEquals("an-otp", dict["X-Apple-I-MD"]) + assertEquals("a-machine-id", dict["X-Apple-I-MD-M"]) + assertEquals("17106176", dict["X-Apple-I-MD-RINFO"]) + assertEquals("1234ABCD-5678-90EF-1234-ABCD567890EF", dict["X-Mme-Device-Id"]) + // §1.5 cpd keys are the per-request anisette set; the §1.1 hardware headers are not in it. + assertNull(dict["X-Apple-I-MLB"]) + assertNull(dict["X-Apple-I-ROM"]) + assertNull(dict["X-Apple-I-SRL-NO"]) + assertNull(dict["X-Apple-I-TimeZone"]) + assertNull(dict["X-Apple-Locale"]) + } + + @Test + fun `md-lu is lowercase hex sha-256 of the keychain identifier`() { + val expected = MessageDigest.getInstance("SHA-256").digest(keychainIdentifier) + .joinToString("") { "%02x".format(it) } + assertEquals(expected, dict()["X-Apple-I-MD-LU"]) + } + + @Test + fun `recorded fixed values are pinned`() { + val dict = dict() + assertEquals("0", dict["X-Apple-I-Device-Configuration-Mode"]) + assertEquals("A1B2C3D4-E5F6-4789-ABCD-0123456789AB", dict["X-Apple-I-Request-UUID"]) + assertEquals("0", dict["X-Apple-Requested-Partition"]) + assertEquals("com.apple.authkit.generic", dict["X-Apple-Security-Upgrade-Context"]) + assertEquals("US", dict["cou"]) + assertEquals("en_US", dict["loc"]) + } + + @Test + fun `the recorded per-flavor capp cbid svct values`() { + val messages = dict()["capp"] + assertEquals("Messages", messages) + assertEquals("com.apple.MobileSMS", dict()["cbid"]) + assertEquals("imessage", dict()["svct"]) + + val icloud = cpd(flavor = GsaFlavor.APPLE_ID_SETTINGS).dictFor("A1B2C3D4-E5F6-4789-ABCD-0123456789AB") + assertEquals("icloud", icloud["capp"]) + assertEquals("com.apple.systempreferences.AppleIDSettings", icloud["cbid"]) + assertEquals("icloud", icloud["svct"]) + } + + @Test + fun `recorded booleans are pinned`() { + val dict = dict() + assertEquals(true, dict["X-Apple-Offer-Security-Upgrade"]) + assertEquals(false, dict["at"]) + assertEquals(true, dict["bootstrap"]) + assertEquals(true, dict["ckgen"]) + assertEquals(true, dict["fcd"]) + assertEquals(false, dict["icdrsDisabled"]) + assertEquals(true, dict["icscrec"]) + assertEquals(false, dict["pbe"]) + assertEquals(true, dict["prkgen"]) + assertEquals(false, dict["webAccessEnabled"]) + } + + @Test + fun `ptkn is optional`() { + assertNull(dict()["ptkn"]) + assertEquals("apns-token", dict(apnsToken = "apns-token")["ptkn"]) + } + + @Test + fun `the filtered anisette header set rides the POSTs with the derived md-lu`() { + val headers = cpd().headersFor() + assertEquals("an-otp", headers["X-Apple-I-MD"]) + assertEquals("a-machine-id", headers["X-Apple-I-MD-M"]) + assertEquals("17106176", headers["X-Apple-I-MD-RINFO"]) + assertEquals("1234ABCD-5678-90EF-1234-ABCD567890EF", headers["X-Mme-Device-Id"]) + assertEquals(dict()["X-Apple-I-MD-LU"], headers["X-Apple-I-MD-LU"]) + // The filtered set: client-time is a cpd key only, hardware headers appear on neither. + assertNull(headers["X-Apple-I-Client-Time"]) + assertNull(headers["X-Apple-I-MLB"]) + } + + @Test + fun `akd-variant headers carry the recorded values`() { + val headers = GsaHeaderConfig( + flavor = GsaFlavor.MESSAGES, + hardwareModel = "iMac13,1", + osName = "macOS", + osVersion = "13.6.4", + osBuild = "22G513", + ).toHeaders() + assertEquals( + mapOf( + "X-MMe-Client-Info" to " ", + "User-Agent" to "akd/1.0 CFNetwork/1494.0.7 Darwin/23.4.0", + "X-Apple-AK-Context-Type" to "imessage", + "X-Apple-Client-App-Name" to "Messages", + "X-Apple-I-Client-Bundle-Id" to "com.apple.MobileSMS", + ), + headers, + ) + val icloud = GsaHeaderConfig( + flavor = GsaFlavor.APPLE_ID_SETTINGS, + hardwareModel = "iPhone7,2", + osName = "iPhone OS", + osVersion = "12.5.5", + osBuild = "16H62", + ).toHeaders() + assertEquals("icloud", icloud["X-Apple-AK-Context-Type"]) + assertEquals("icloud", icloud["X-Apple-Client-App-Name"]) + assertEquals("com.apple.systempreferences.AppleIDSettings", icloud["X-Apple-I-Client-Bundle-Id"]) + } + + @Test + fun `the full key set is exactly the recorded one`() { + val expected = setOf( + "X-Apple-I-Client-Time", + "X-Apple-I-MD", + "X-Apple-I-MD-LU", + "X-Apple-I-MD-M", + "X-Apple-I-MD-RINFO", + "X-Mme-Device-Id", + "X-Apple-I-Device-Configuration-Mode", + "X-Apple-I-Request-UUID", + "X-Apple-Requested-Partition", + "X-Apple-Security-Upgrade-Context", + "capp", + "cbid", + "cou", + "loc", + "svct", + "X-Apple-Offer-Security-Upgrade", + "at", + "bootstrap", + "ckgen", + "fcd", + "icdrsDisabled", + "icscrec", + "pbe", + "prkgen", + "webAccessEnabled", + ) + assertTrue(dict().keys == expected, "cpd keys ${dict().keys} != the §1.5 recorded set") + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt new file mode 100644 index 0000000..2ea05bc --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt @@ -0,0 +1,216 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** + * The §1.5 delegate login: POST to `setup.icloud.com/setup/signin/v2/login` with the recorded + * body (`delegates`, `protocolVersion`, `userInfo`) and header set (Basic `username:PET`, + * `X-Mme-Nas-Qualify`, `X-Apple-ADSID`, `X-Mme-Client-Info`, `User-Agent`, anisette), and the + * response parse yielding the IDS delegate's `auth-token`/`profile-id` — kebab-case + * serviceData, loud on every deviation. + */ +class GsaDelegateLoginTest { + + private fun client(http: IdsHttp) = + GsaDelegateLoginClient(http, endpoint = "https://setup.example.com/setup/signin/v2/login") + + private fun request() = IcloudSignInRequest( + username = "user@example.com", + pet = "pet-token", + adsid = "adsid-1", + validationData = byteArrayOf(1, 2, 3), + clientId = "A1B2C3D4-E5F6-4789-ABCD-0123456789AB", + timezone = "Europe/Berlin", + clientInfo = "macOS;13.5;22G74", + anisette = mapOf("X-Apple-I-MD" to "otp"), + ) + + private fun okResponse(): ByteArray = XmlPlist.encode( + mapOf( + "status" to 0L, + "delegates" to linkedMapOf( + "com.apple.private.ids" to linkedMapOf( + "status" to 0L, + "statusMessage" to "", + "serviceData" to linkedMapOf( + "auth-token" to "ids-auth-token", + "profile-id" to "profile-1", + ), + ), + "com.apple.mobileme" to linkedMapOf( + "status" to 0L, + "serviceData" to linkedMapOf("mmeAuthToken" to "mme-token"), + ), + ), + ), + ) + + @Test + fun `recorded sign-in values are the defaults`() { + val request = IcloudSignInRequest( + username = "user@example.com", + pet = "pet-token", + adsid = "adsid-1", + validationData = byteArrayOf(1), + clientId = "A1B2C3D4-E5F6-4789-ABCD-0123456789AB", + anisette = emptyMap(), + ) + assertEquals("America/New_York", request.timezone) + assertEquals("com.apple.AOSKit/282 (com.apple.accountsd/113)", request.clientInfo) + assertEquals( + "com.apple.iCloudHelper/282 com.apple.iCloudHelper/282", + GsaDelegateLoginClient.SIGNIN_USER_AGENT, + ) + } + + @Test + fun `posts the recorded body and headers`() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + client(http).signIn(request()) + + val call = http.calls.single() + assertEquals("POST", call.method) + assertEquals("https://setup.example.com/setup/signin/v2/login", call.url) + assertEquals("application/x-apple-plist", call.contentType) + assertEquals("Basic " + Base64.getEncoder().encodeToString("user@example.com:pet-token".toByteArray()), + call.headers["Authorization"]) + assertEquals(Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3)), call.headers["X-Mme-Nas-Qualify"]) + assertEquals("adsid-1", call.headers["X-Apple-ADSID"]) + assertEquals("macOS;13.5;22G74", call.headers["X-Mme-Client-Info"]) + assertEquals(GsaDelegateLoginClient.SIGNIN_USER_AGENT, call.headers["User-Agent"]) + assertEquals("otp", call.headers["X-Apple-I-MD"]) + + val body = XmlPlist.decode(call.body) as Map<*, *> + assertEquals("1.0", body["protocolVersion"]) + val delegates = body["delegates"] as Map<*, *> + assertEquals(mapOf("protocol-version" to "4"), delegates["com.apple.private.ids"]) + assertEquals(emptyMap(), delegates["com.apple.mobileme"]) + val userInfo = body["userInfo"] as Map<*, *> + assertEquals( + mapOf( + "clientId" to "A1B2C3D4-E5F6-4789-ABCD-0123456789AB", + "language" to "en-US", + "timezone" to "Europe/Berlin", + ), + userInfo, + ) + } + + @Test + fun `parses the ids delegate credentials`() = runBlocking { + val response = client(ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse())))) + .signIn(request()) + assertEquals(0, response.status) + assertNull(response.localizedError) + assertEquals(2, response.delegates.size) + val credentials = response.idsDelegateCredentials() + assertEquals("ids-auth-token", credentials.authToken) + assertEquals("profile-1", credentials.profileId) + } + + @Test + fun `account-level failure surfaces loudly`() = runBlocking { + val bad = XmlPlist.encode( + linkedMapOf( + "status" to 2L, + "localizedError" to "already-signed-in", + "description" to "another sign-in is active", + ), + ) + val exception = assertFailsWith { + runBlocking { + client(ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), bad)))).signIn(request()) + .idsDelegateCredentials() + } + } + assertTrue(exception.message!!.contains("already-signed-in")) + } + + @Test + fun `delegate-level failure surfaces loudly`() = runBlocking { + val bad = XmlPlist.encode( + mapOf( + "status" to 0L, + "delegates" to mapOf( + "com.apple.private.ids" to linkedMapOf("status" to 500L, "statusMessage" to "no ids"), + ), + ), + ) + val exception = assertFailsWith { + runBlocking { + client(ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), bad)))).signIn(request()) + .idsDelegateCredentials() + } + } + assertTrue(exception.message!!.contains("no ids")) + } + + @Test + fun `missing ids delegate or serviceData keys fail loudly`() = runBlocking { + val noIds = XmlPlist.encode(mapOf("status" to 0L, "delegates" to emptyMap())) + val noToken = XmlPlist.encode( + mapOf( + "status" to 0L, + "delegates" to mapOf( + "com.apple.private.ids" to mapOf( + "status" to 0L, + "serviceData" to linkedMapOf("profile-id" to "p"), + ), + ), + ), + ) + val client = client(ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), noIds)))) + val missingDelegate = assertFailsWith { + runBlocking { client.signIn(request()).idsDelegateCredentials() } + } + assertTrue(missingDelegate.message!!.contains("com.apple.private.ids")) + val client2 = client(ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), noToken)))) + val missingToken = assertFailsWith { + runBlocking { client2.signIn(request()).idsDelegateCredentials() } + } + assertTrue(missingToken.message!!.contains("auth-token")) + } + + @Test + fun `non-200 fails loudly`() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(401, emptyMap(), ByteArray(0)))) + val exception = assertFailsWith { client(http).signIn(request()) } + assertTrue(exception.message!!.contains("HTTP 401")) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt new file mode 100644 index 0000000..9a369f2 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt @@ -0,0 +1,475 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.math.BigInteger +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** + * The §1.5 chain end to end against an in-test scripted GSA server (a second independent + * implementation of the spec's server-side formulas): login → M2 verification → spd → PET + * persistence, both 2FA variants with the recorded "run the SRP login a second time" behavior, + * re-login-on-expiry, and the delegate-login handoff to the §1.2 authenticate path. + */ +class GsaLoginChainTest { + + private val password = "correct horse battery staple" + private val passwordHash = MessageDigest.getInstance("SHA-256").digest(password.toByteArray()) + private val username = "user@example.com" + + private val cpd = GsaCpd( + anisette = mapOf( + "X-Apple-I-Client-Time" to "2026-09-11T12:00:00Z", + "X-Apple-I-MD" to "otp", + "X-Apple-I-MD-RINFO" to "17106176", + "X-Apple-I-MD-M" to "machine-id", + "X-Mme-Device-Id" to "device-id", + ), + keychainIdentifier = ByteArray(16) { it.toByte() }, + ) + + private fun chain( + http: IdsHttp, + store: GsaCredentialStore? = null, + now: () -> Long = { 1_770_000_000_000L }, + ) = GsaLoginChain( + http = http, + cpd = cpd, + credentialStore = store, + twoFactorBrowserHeaders = GsaTwoFactorHeaders.of( + cpd, + device = GsaHeaderConfig( + hardwareModel = "iMac13,1", + osName = "macOS", + osVersion = "13.6.4", + osBuild = "22G513", + ), + userAgent = "ua", + ), + clock = now, + ) + + @Test + fun `login runs init and complete, decrypts spd, persists the credentials`() = runBlocking { + val server = FakeGsaServer(passwordHash, secondaryAuth = null) + val store = RecordingStore() + val result = chain(server, store).login(username, password) + + val authenticated = result as GsaLoginResult.Authenticated + assertEquals("adsid-1", authenticated.spd?.adsid) + assertEquals("idms-token-1", authenticated.spd!!.gsIdmsToken) + assertEquals("pet-1", authenticated.pet?.token) + assertEquals(1, server.initCount) + assertEquals(1, server.completeCount) + assertEquals(username, store.saved!!.username) + assertTrue(store.saved!!.passwordHash.contentEquals(passwordHash)) + assertEquals("adsid-1", store.saved!!.adsid) + assertEquals("pet-1", store.saved!!.petToken) + // No PET trailer → the recorded 300 s default (§1.5). + assertEquals(1_770_000_000_000L + 300_000, store.saved!!.petExpiresAtEpochMs) + } + + @Test + fun `server M2 proof mismatch fails loudly`() = runBlocking { + val server = FakeGsaServer(passwordHash, secondaryAuth = null, corruptM2 = true) + assertFailsWith { chain(server).login(username, password) } + assertTrue(server.completeCount == 1) + } + + @Test + fun `sms secondary auth resolves then re-runs the SRP login`() = runBlocking { + val server = FakeGsaServer(passwordHash, secondaryAuth = GsaStatus.AU_SECONDARY_AUTH) + val store = RecordingStore() + val chain = chain(server, store) + + val required = chain.login(username, password) as GsaLoginResult.SecondaryAuthRequired + assertEquals(GsaStatus.AU_SECONDARY_AUTH, required.variant) + // The identity token comes from the spd that arrived with the 2FA-required response. + assertEquals(gsaIdentityToken("adsid-1", "idms-token-1"), required.identityToken) + assertNull(store.saved, "nothing persisted before 2FA completes") + + server.secondaryAuth = null + val result = chain.completeSecondaryAuth( + username = username, + passwordHash = passwordHash, + variant = GsaStatus.AU_SECONDARY_AUTH, + identityToken = required.identityToken!!, + securityCode = "123456", + phoneNumberId = "7", + ) + assertTrue(result is GsaLoginResult.Authenticated) + assertEquals(2, server.initCount, "§1.5: the SRP login runs a second time after 2FA") + assertTrue(server.smsRequested && server.smsVerified) + // pet-1 rode the 2FA-required complete, pet-2 the SMS verification response, pet-3 the re-login. + assertEquals("pet-3", store.saved!!.petToken) + } + + @Test + fun `trusted-device secondary auth triggers then validates with the security-code header`() = runBlocking { + val server = FakeGsaServer(passwordHash, secondaryAuth = GsaStatus.AU_TRUSTED_DEVICE) + val chain = chain(server) + + val required = chain.login(username, password) as GsaLoginResult.SecondaryAuthRequired + assertEquals(GsaStatus.AU_TRUSTED_DEVICE, required.variant) + + server.secondaryAuth = null + val result = chain.completeSecondaryAuth( + username = username, + passwordHash = passwordHash, + variant = GsaStatus.AU_TRUSTED_DEVICE, + identityToken = required.identityToken!!, + securityCode = "987654", + ) + assertTrue(result is GsaLoginResult.Authenticated) + assertEquals("987654", server.validateSecurityCode) + assertTrue(server.trustedDeviceTriggered) + } + + @Test + fun `unknown secondary-auth variant fails loudly`() { + val server = FakeGsaServer(passwordHash, secondaryAuth = null) + val exception = assertFailsWith { + runBlocking { + chain(server).completeSecondaryAuth( + username, passwordHash, variant = "circlePake", identityToken = "x", securityCode = "1", + ) + } + } + assertTrue(exception.message!!.contains("circlePake")) + } + + @Test + fun `petToken serves the stored pet while fresh and re-logins once expired`() = runBlocking { + val server = FakeGsaServer(passwordHash, secondaryAuth = null) + val store = RecordingStore() + val chain = chain(server, store) + + // Nothing persisted yet. + assertNull(chain.petToken()) + + chain.login(username, password) + val fresh = 1_770_000_000_000L + 100_000 + assertEquals("pet-1", chain.petToken(fresh)) + assertEquals(1, server.initCount, "fresh PET → no network") + + // §1.5: expired → silently re-run the SRP login with the persisted password hash. + assertEquals("pet-2", chain.petToken(fresh + 300_000)) + assertEquals(2, server.initCount) + } + + @Test + fun `delegate login yields the ids credentials and hands off to the authenticate path`() = runBlocking { + val server = FakeGsaServer(passwordHash, secondaryAuth = null) + val chain = chain(server) + val authenticated = chain.login(username, password) as GsaLoginResult.Authenticated + + val credentials = chain.idsDelegateCredentials( + username = username, + pet = authenticated.pet!!.token, + adsid = authenticated.spd!!.adsid!!, + validationData = byteArrayOf(1, 2, 3), + timezone = "Europe/Berlin", + clientInfo = "macOS;13.5;22G74", + anisette = emptyMap(), + ) + assertEquals("ids-auth-token", credentials.authToken) + assertEquals("profile-1", credentials.profileId) + assertTrue(server.delegateSignInSeen) + assertEquals("pet-1", server.delegatePet) // Basic username:PET rode the sign-in + assertEquals(username, server.delegateUsername) + + // The natural next step: the §1.2 authenticate exchange consumes them verbatim. + val authHttp = ScriptedIdsHttp( + listOf( + IdsHttpResponse( + 200, + emptyMap(), + XmlPlist.encode(mapOf("status" to 0L, "cert" to byteArrayOf(1, 1))), + ), + ), + ) + val bag = Bag(mapOf("id-authenticate-ds-id" to "https://identity.example.com/authenticateDS")) + val response = chain.authenticateIds( + credentials = credentials, + bagKey = IdsAuthenticator.BAG_KEY_DS_ID, + csrDer = byteArrayOf(9), + authenticator = IdsAuthenticator(authHttp, bag, protocolVersion = "1640", userAgent = "ua"), + ) + assertEquals(0, response.status) + val requestBody = gunzip(authHttp.calls.single().body) + val body = XmlPlist.decode(requestBody) as Map<*, *> + assertEquals("profile-1", body["realm-user-id"]) + assertEquals( + mapOf("auth-token" to "ids-auth-token"), + body["authentication-data"], + ) + } + + /** The §1.2 authenticate path gzips its request body — undo it for the assertion. */ + private fun gunzip(bytes: ByteArray): ByteArray = + java.util.zip.GZIPInputStream(bytes.inputStream()).use { it.readBytes() } +} + +/** Records what the orchestrator persists (stand-in for the Room-backed adapter). */ +private class RecordingStore : GsaCredentialStore { + var saved: GsaPersistedCredentials? = null + override suspend fun load(): GsaPersistedCredentials? = saved + override suspend fun save(credentials: GsaPersistedCredentials) { + saved = credentials + } +} + +/** + * A scripted GSA server: an independent test-side implementation of the §1.5 server half + * (init/complete SRP with the identity-hash quirk, spd encryption, 2FA endpoints, delegate + * sign-in). Its K is derived from the client's A exactly as the spec's formulas dictate. + */ +private class FakeGsaServer( + private val passwordHash: ByteArray, + var secondaryAuth: String?, + private val corruptM2: Boolean = false, +) : IdsHttp { + + private val group = GsaSrpGroup + + var initCount = 0 + var completeCount = 0 + var smsRequested = false + var smsVerified = false + var trustedDeviceTriggered = false + var validateSecurityCode: String? = null + var delegateSignInSeen = false + var delegatePet: String? = null + var delegateUsername: String? = null + + private val salt = ByteArray(16) { it.toByte() } + private val random = SecureRandom() + private var session: Session? = null + private var petCounter = 0 + + private class Session( + val aPublic: ByteArray, + val serverPublic: ByteArray, + val verifier: BigInteger, + val serverPrivate: BigInteger, + ) + + override suspend fun post( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse = when { + url.endsWith("/GsService2") -> grandslam(body) + url.endsWith("/setup/signin/v2/login") -> delegateSignIn(headers) + url.endsWith("/verify/phone/securitycode") -> { + smsVerified = true + tokensResponse() + } + else -> throw IllegalStateException("unexpected POST $url") + } + + override suspend fun get(url: String, headers: Map): IdsHttpResponse = when { + url.endsWith("/auth") && !url.contains("verify") -> + IdsHttpResponse( + 200, + emptyMap(), + """{"trustedPhoneNumbers":[{"numberWithDialCode":"+15550001111","lastTwoDigits":"11", + "pushMode":false,"id":7}]}""".toByteArray(), + ) + url.endsWith("/verify/trusteddevice") -> { + trustedDeviceTriggered = true + IdsHttpResponse(200, emptyMap(), ByteArray(0)) + } + url.endsWith("/GsService2/validate") -> { + validateSecurityCode = headers["security-code"] + tokensResponse() + } + else -> throw IllegalStateException("unexpected GET $url") + } + + override suspend fun put( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse { + require(url.endsWith("/verify/phone")) { "unexpected PUT $url" } + smsRequested = true + return IdsHttpResponse(200, emptyMap(), ByteArray(0)) + } + + private fun grandslam(body: ByteArray): IdsHttpResponse { + val request = (XmlPlist.decode(body) as Map<*, *>)["Request"] as Map<*, *> + return if (request["o"] == "init") init(request) else complete(request) + } + + private fun init(request: Map<*, *>): IdsHttpResponse { + initCount++ + val aPublic = request["A2k"] as ByteArray + val passwordKey = GsaSrpPasswordKey.derive(passwordHash, salt, 1000, "s2k") + val x = BigInteger(1, sha256(salt + sha256(byteArrayOf(':'.code.toByte()) + passwordKey))) + val verifier = group.generator.modPow(x, group.n) + val serverPrivate = BigInteger(1, ByteArray(group.byteLength - 1).also { random.nextBytes(it) }) + val k = BigInteger(1, sha256(group.prime + padGenerator())) + val serverPublic = k.multiply(verifier).add(group.generator.modPow(serverPrivate, group.n)).mod(group.n) + session = Session(aPublic, minimalBytes(serverPublic), verifier, serverPrivate) + return plistResponse( + linkedMapOf( + "s" to salt, + "B" to minimalBytes(serverPublic), + "i" to 1000L, + "c" to "challenge", + "sp" to "s2k", + "Status" to linkedMapOf("ec" to 0L, "em" to ""), + ), + ) + } + + private fun complete(request: Map<*, *>): IdsHttpResponse { + completeCount++ + val s = session ?: throw IllegalStateException("complete before init") + val m1 = request["M1"] as ByteArray + val u = BigInteger(1, sha256(s.aPublic + s.serverPublic)) + val premaster = BigInteger(1, s.aPublic).multiply(s.verifier.modPow(u, group.n)) + .mod(group.n).modPow(s.serverPrivate, group.n) + val serverK = sha256(minimalBytes(premaster)) + val expectedM1 = sha256( + xor(sha256(group.prime), sha256(padGenerator())) + + sha256((request["u"] as String).toByteArray()) + salt + s.aPublic + s.serverPublic + serverK, + ) + require(expectedM1.contentEquals(m1)) { "scripted server: client M1 did not verify" } + val computedM2 = sha256(s.aPublic + m1 + serverK) + val m2 = if (corruptM2) { + computedM2.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() } + } else { + computedM2 + } + val response = linkedMapOf( + "M2" to m2, + "spd" to encryptSpd(serverK), + "Status" to linkedMapOf("ec" to 0L), + ) + secondaryAuth?.let { response["au"] = it } + petCounter++ + return IdsHttpResponse( + 200, + mapOf( + "X-Apple-PE-Token" to java.util.Base64.getEncoder() + .encodeToString("com.apple.gs.idms.pet:pet-$petCounter".toByteArray()), + ), + XmlPlist.encode(mapOf("Response" to response)), + ) + } + + private fun encryptSpd(serverK: ByteArray): ByteArray { + val plain = XmlPlist.encode( + linkedMapOf( + "adsid" to "adsid-1", + "DsPrsId" to 123456789L, + "GsIdmsToken" to "idms-token-1", + "t" to emptyMap(), + ), + ) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(GsaSpdDecryptor.key(serverK), "AES"), + IvParameterSpec(GsaSpdDecryptor.iv(serverK)), + ) + return cipher.doFinal(plain) + } + + private fun tokensResponse(): IdsHttpResponse { + petCounter++ + return IdsHttpResponse( + 200, + mapOf( + "X-Apple-PE-Token" to java.util.Base64.getEncoder() + .encodeToString("com.apple.gs.idms.pet:pet-$petCounter".toByteArray()), + ), + ByteArray(0), + ) + } + + private fun delegateSignIn(headers: Map): IdsHttpResponse { + delegateSignInSeen = true + val basic = String( + java.util.Base64.getDecoder().decode(headers["Authorization"]!!.removePrefix("Basic ")), + ) + delegateUsername = basic.substringBefore(":") + delegatePet = basic.substringAfter(":") + return IdsHttpResponse( + 200, + emptyMap(), + XmlPlist.encode( + mapOf( + "status" to 0L, + "delegates" to linkedMapOf( + "com.apple.private.ids" to linkedMapOf( + "status" to 0L, + "serviceData" to linkedMapOf( + "auth-token" to "ids-auth-token", + "profile-id" to "profile-1", + ), + ), + ), + ), + ), + ) + } + + private fun plistResponse(response: Map) = + IdsHttpResponse(200, emptyMap(), XmlPlist.encode(mapOf("Response" to response))) + + private fun sha256(bytes: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(bytes) + + private fun xor(a: ByteArray, b: ByteArray): ByteArray = + ByteArray(a.size) { (a[it].toInt() xor b[it].toInt()).toByte() } + + private fun padGenerator(): ByteArray = ByteArray(group.byteLength - 1) + 0x02.toByte() + + private fun minimalBytes(value: BigInteger): ByteArray { + val signed = value.toByteArray() + return if (signed.size > 1 && signed[0] == 0.toByte()) signed.copyOfRange(1, signed.size) else signed + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginTest.kt new file mode 100644 index 0000000..39ac972 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginTest.kt @@ -0,0 +1,227 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** + * The §1.5 GSA SRP POSTs: endpoint, content type, the `Header`/`Request` XML-plist wrapper, + * request key sets, response parsing (`s`/`B`/`i`/`c`/`sp`/`Status`; `M2`/`spd`/`au`), the + * `sp` default, and Status.ec error surfacing — all against scripted transports. + */ +class GsaLoginTest { + + private val okInit = XmlPlist.encode( + mapOf( + "Response" to linkedMapOf( + "s" to byteArrayOf(1, 2, 3), + "B" to byteArrayOf(4, 5, 6), + "i" to 1000L, + "c" to "challenge-string", + "sp" to "s2k_fo", + "Status" to linkedMapOf("ec" to 0L, "em" to ""), + ), + ), + ) + + private fun client(responses: List) = + GsaLoginClient(ScriptedIdsHttp(responses), endpoint = "https://gsa.example/GsService2") + + private fun initRequest() = GsaInitRequest( + username = "user@example.com", + a2k = byteArrayOf(9), + cpd = mapOf("cou" to "US"), + headers = mapOf("X-Apple-I-MD" to "otp"), + ) + + @Test + fun `init posts the recorded wrapper and key set`() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okInit))) + GsaLoginClient(http, endpoint = "https://gsa.example/GsService2").init(initRequest()) + + val call = http.calls.single() + assertEquals("POST", call.method) + assertEquals("https://gsa.example/GsService2", call.url) + assertEquals("text/x-xml-plist", call.contentType) + assertEquals(mapOf("X-Apple-I-MD" to "otp"), call.headers) + val body = XmlPlist.decode(call.body) as Map<*, *> + assertEquals(mapOf("Version" to "1.0.1"), body["Header"]) + val request = body["Request"] as Map<*, *> + assertEquals("user@example.com", request["u"]) + assertEquals("init", request["o"]) + assertEquals(listOf("s2k", "s2k_fo"), request["ps"]) + assertEquals(mapOf("cou" to "US"), request["cpd"]) + assertTrue(request["A2k"] is ByteArray) + assertEquals(setOf("A2k", "cpd", "o", "ps", "u"), request.keys) + } + + @Test + fun `init response parses the recorded fields`() = runBlocking { + val response = client(listOf(IdsHttpResponse(200, emptyMap(), okInit))).init(initRequest()) + assertEquals(byteArrayOf(1, 2, 3).toList(), response.salt.toList()) + assertEquals(byteArrayOf(4, 5, 6).toList(), response.serverPublic.toList()) + assertEquals(1000, response.iterations) + assertEquals("challenge-string", response.challenge) + assertEquals("s2k_fo", response.protocol) + assertEquals(0, response.status.ec) + } + + @Test + fun `missing sp defaults to s2k`() = runBlocking { + val withoutSp = XmlPlist.encode( + mapOf( + "Response" to linkedMapOf( + "s" to byteArrayOf(1), + "B" to byteArrayOf(2), + "i" to 2000L, + "c" to "c", + "Status" to linkedMapOf("ec" to 0L), + ), + ), + ) + val response = client(listOf(IdsHttpResponse(200, emptyMap(), withoutSp))).init(initRequest()) + assertEquals("s2k", response.protocol) + } + + @Test + fun `non-zero ec surfaces loudly with the recorded message`() = runBlocking { + val rejected = XmlPlist.encode( + mapOf( + "Response" to mapOf( + "Status" to linkedMapOf("ec" to -20001L, "em" to "bad credentials"), + ), + ), + ) + val exception = assertFailsWith { + runBlocking { client(listOf(IdsHttpResponse(200, emptyMap(), rejected))).init(initRequest()) } + } + assertTrue(exception.message!!.contains("ec=-20001"), exception.message) + assertTrue(exception.message!!.contains("bad credentials"), exception.message) + } + + @Test + fun `non-200 fails loudly`() = runBlocking { + val exception = assertFailsWith { + client(listOf(IdsHttpResponse(503, emptyMap(), ByteArray(0)))).init(initRequest()) + } + assertTrue(exception.message!!.contains("HTTP 503")) + } + + @Test + fun `missing recorded fields fail loudly`() = runBlocking { + val noSalt = XmlPlist.encode( + mapOf( + "Response" to mapOf( + "B" to byteArrayOf(2), + "i" to 1000L, + "c" to "c", + "Status" to linkedMapOf("ec" to 0L), + ), + ), + ) + val exception = assertFailsWith { + client(listOf(IdsHttpResponse(200, emptyMap(), noSalt))).init(initRequest()) + } + assertTrue(exception.message!!.contains("'s'")) + } + + @Test + fun `complete posts the recorded key set and parses the response`() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okComplete()))) + val response = GsaLoginClient(http, endpoint = "https://gsa.example/GsService2") + .complete( + GsaCompleteRequest( + username = "user@example.com", + m1 = byteArrayOf(7), + challenge = "challenge-string", + cpd = mapOf("cou" to "US"), + ), + nowEpochMs = 1_000_000L, + ) + + val call = http.calls.single() + assertEquals("text/x-xml-plist", call.contentType) + val body = XmlPlist.decode(call.body) as Map<*, *> + val request = body["Request"] as Map<*, *> + assertEquals("complete", request["o"]) + assertEquals("user@example.com", request["u"]) + assertEquals("challenge-string", request["c"]) + assertEquals(byteArrayOf(7).toList(), (request["M1"] as ByteArray).toList()) + assertEquals(setOf("M1", "c", "cpd", "o", "u"), request.keys) + + assertEquals(byteArrayOf(8).toList(), response.m2!!.toList()) + assertEquals(byteArrayOf(9).toList(), response.spd!!.toList()) + assertNull(response.secondaryAuth) + assertNull(response.pet) + } + + @Test + fun `complete parses the PET from the response headers`() = runBlocking { + val petValue = Base64.getEncoder() + .encodeToString("com.apple.gs.idms.pet:pet-token:1800000000000".toByteArray()) + val response = client( + listOf( + IdsHttpResponse( + 200, + mapOf("X-Apple-PE-Token" to petValue), + okComplete(au = null), + ), + ), + ).complete(completeRequest(), nowEpochMs = 1_000_000L) + assertEquals("pet-token", response.pet?.token) + assertEquals(1_800_000_000_000L, response.pet?.expiresAtEpochMs) + } + + @Test + fun `au without an error code surfaces as secondary auth`() = runBlocking { + val response = client(listOf(IdsHttpResponse(200, emptyMap(), okComplete(au = "secondaryAuth")))) + .complete(completeRequest(), nowEpochMs = 1_000_000L) + assertEquals("secondaryAuth", response.secondaryAuth) + } + + private fun completeRequest() = GsaCompleteRequest("user@example.com", byteArrayOf(7), "c", emptyMap()) + + private fun okComplete(au: String? = null): ByteArray { + val response = linkedMapOf( + "M2" to byteArrayOf(8), + "spd" to byteArrayOf(9), + "Status" to linkedMapOf("ec" to 0L), + ) + au?.let { response["au"] = it } + return XmlPlist.encode(mapOf("Response" to response)) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaSpdTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaSpdTest.kt new file mode 100644 index 0000000..27bd6af --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaSpdTest.kt @@ -0,0 +1,138 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * §1.5 spd decryption. Key/IV labels and a full decrypt sample are known answers from the + * independent dev script; the sample plist exercises the recorded field reads (`t`, `DsPrsId`, + * `adsid`, `acname`) and the structural `t`-entry read. + */ +class GsaSpdTest { + + /** HMAC-SHA256(K, "extra data key:") for the s2k KAT session key K (dev-script KAT). */ + private val katK = "92f6de387c217ae9dc61911d532234e6a1fdbc343c18081b55b4dd1d33f84700".fromHex() + + @Test + fun `key and iv labels known answers`() { + assertEquals("3b6513fa04855fd56c396dffaec2fb549e50fd1a1b67c12553042994389521b3", GsaSpdDecryptor.key(katK).toHex()) + assertEquals("7752dd0de4323335fe328fe70b7471d2", GsaSpdDecryptor.iv(katK).toHex()) + } + + @Test + fun `decrypts the sample spd and reads the recorded fields`() { + val raw = GsaSpdDecryptor.decrypt(SAMPLE_SPD, katK) + val spd = GsaSpd.parse(raw) + assertEquals(123456789L, spd.dsPrsId) + assertEquals("001234.abcdef.987", spd.adsid) + assertEquals("user@example.com", spd.accountName) + assertEquals(setOf("GsIdmsToken"), spd.tokens.keys) + val token = spd.serviceToken("GsIdmsToken") + assertEquals("the-idms-token", token.token) + assertEquals(1_800_000_000_000L, token.expiresAtEpochMs) // `expiry` = epoch-ms + assertEquals(null, token.durationSeconds) + } + + @Test + fun `top-level GsIdmsToken and fn-ln are read per the recorded keys`() { + val raw = linkedMapOf( + "t" to linkedMapOf( + "com.apple.gs.idms.pet" to linkedMapOf( + "token" to "pet-token", + "duration" to 600L, + ), + ), + "GsIdmsToken" to "idms-token", + "fn" to "Ada", + "ln" to "Lovelace", + ) + val spd = GsaSpd.parse(raw) + assertEquals("idms-token", spd.gsIdmsToken) + assertEquals("Ada", spd.firstName) + assertEquals("Lovelace", spd.lastName) + val pet = spd.serviceToken("com.apple.gs.idms.pet") + assertEquals("pet-token", pet.token) + assertEquals(null, pet.expiresAtEpochMs) + assertEquals(600L, pet.durationSeconds) + } + + @Test + fun `identity token is base64 of adsid colon idms token`() { + assertEquals( + "MDAxMjM0LmFiY2RlZi45ODc6dGhlLWlkbXMtdG9rZW4=", + gsaIdentityToken("001234.abcdef.987", "the-idms-token"), + ) + } + + @Test + fun `wrong session key fails loudly`() { + val other = katK.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() } + assertFailsWith { GsaSpdDecryptor.decrypt(SAMPLE_SPD, other) } + } + + @Test + fun `token map missing or non-dict fails loudly`() { + val raw = GsaSpdDecryptor.decrypt(SAMPLE_SPD, katK) - "t" + assertFailsWith { GsaSpd.parse(raw) } + } + + @Test + fun `t entries without the recorded keys fail loudly`() { + val noToken = linkedMapOf( + "t" to linkedMapOf("svc" to linkedMapOf("expiry" to 1L)), + ) + val noExpiry = linkedMapOf( + "t" to linkedMapOf("svc" to linkedMapOf("token" to "t")), + ) + assertFailsWith { GsaSpd.parse(noToken) } + assertFailsWith { GsaSpd.parse(noExpiry) } + } + + private fun String.fromHex(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } + + companion object { + /** Dev-script-encrypted sample spd (AES-256-CBC/PKCS7 under the s2k KAT session key). */ + val SAMPLE_SPD: ByteArray = Base64.getDecoder().decode( + "jv+SLJj1p8uywkkSXwYzBnOSWwDHKTb2CPqomQQ3JwDmey1di35tjbIFU4EnRbkGCHgEHrALs0KWpF1Ni+N5VCVhkEPm5zHFv" + + "H4A5BeX7JBln/hilrKErWRFMofWlgVQWcINpKErTN+hhv6gMQtVtv9rmH3LiOOs2aD/I/1K0dbOw7T601GRBoRsOzsddXwX" + + "fThSNbgYbfksF11U6SFnSvKFgSqADsRldpEVj3qZQnFbRM/U2MaS7fTJqgPkinKfeKvpK+wlzqOCY9XomUj+Y8Yb14paGBX" + + "1IDCH+pPx5pqrtsnFNsIluzt2MZcOVGmRePtUhLrw6Yb9MMN5JbtFT8Z9RtW1TDhudfMnvPT1tRcVHJ1EF6aF/ZYdtvPnKq" + + "6nWgyB0IqB65ie+0JEXcPRRBe4ojK4zKNVYeDrjhjz+Sq/rHamYf39qUkYXZNhAYjtCABUBoQbg/ArO1rLAJ7w8NGe1ivfl" + + "krw3gYD6F1/cuGhe/LOO4HKajw3t+sjaIDW", + ) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaSrpTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaSrpTest.kt new file mode 100644 index 0000000..77a0139 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaSrpTest.kt @@ -0,0 +1,253 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.math.BigInteger +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** Test-local AES-256-CBC/PKCS7 encryptor (the production side only decrypts). */ +private object TestAesCbc { + fun encrypt(plain: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(plain) + } +} + +/** + * The §1.5 SRP-6a GSA variant, pinned two ways: + * + * 1. Known answers computed with an independent script straight from the spec text during + * development (the KAT encodes the GSA identity-hash quirk — an EMPTY username inside the + * identity hash — and the real username in M1's H(username) term; any deviation from the + * recorded derivation shifts M1/M2). Recomputed for the recorded RFC 5054 2048-bit group + * (C59) with the same script, re-validated first against the previous test-prime vectors. + * 2. A scripted in-test SRP server implementing the same §1.5 formulas, so init→complete + * round-trips with random values and the session key K decrypts a server-encrypted `spd`. + */ +class GsaSrpTest { + + private fun fixedClient(aBytes: ByteArray) = GsaSrpClient(FixedRandom(aBytes)) + + @Test + fun `password key s2k known answer`() { + assertEquals( + "6b7cc6edb94620dcf9811c616742ca428fe81b5bede8478a876a895345ded185", + GsaSrpPasswordKey.derive(PASSWORD_HASH, SALT, ITERATIONS, GsaSrpPasswordKey.PROTOCOL_S2K).toHex(), + ) + } + + @Test + fun `password key s2k_fo known answer - hex-encoded digest feeds pbkdf2`() { + assertEquals( + "5df3f8614930d6e2cf855fc8ddec13b51431c6c08e3375a1ef346686965d049e", + GsaSrpPasswordKey.derive(PASSWORD_HASH, SALT, ITERATIONS, GsaSrpPasswordKey.PROTOCOL_S2K_FO).toHex(), + ) + } + + @Test + fun `unknown password protocol fails loudly`() { + assertFailsWith { + GsaSrpPasswordKey.derive(PASSWORD_HASH, SALT, ITERATIONS, "s2k_foo") + } + } + + @Test + fun `the recorded group is the RFC 5054 2048-bit prime with g 2`() { + assertEquals(256, GsaSrpGroup.byteLength) + assertEquals(BigInteger.TWO, GsaSrpGroup.generator) + // Full-length big-endian modulus: top bit set (no leading zero byte, no short form). + assertTrue(GsaSrpGroup.prime[0].toInt() and 0x80 != 0) + assertEquals("ac6bdb41324a9a9b", GsaSrpGroup.prime.take(8).joinToString("") { "%02x".format(it) }) + assertEquals("9e4aff73", GsaSrpGroup.prime.takeLast(4).joinToString("") { "%02x".format(it) }) + } + + @Test + fun `srp run known answer for both protocols`() { + // The identity-hash quirk (§1.5: SHA-256 of empty username, ':' and the password buffer, + // while M1 hashes the REAL username) is what these vectors were computed with. + GSA_KAT.forEach { (protocol, kat) -> + val client = fixedClient(FIXED_A) + assertEquals(kat["A"], client.begin().toHex(), "A ($protocol)") + val proof = client.proof( + username = USERNAME.toByteArray(), + passwordKey = GsaSrpPasswordKey.derive(PASSWORD_HASH, SALT, ITERATIONS, protocol), + salt = SALT, + serverPublic = FIXED_B, + ) + assertEquals(kat["M1"], proof.m1.toHex(), "M1 ($protocol)") + assertEquals(kat["M2"], proof.m2.toHex(), "M2 ($protocol)") + } + } + + @Test + fun `a different real username shifts M1 (the H(username) term) but not A`() { + val passwordKey = GsaSrpPasswordKey.derive(PASSWORD_HASH, SALT, ITERATIONS, GsaSrpPasswordKey.PROTOCOL_S2K) + val a = fixedClient(FIXED_A) + val aPublic = a.begin() + val m1Other = a.proof( + "someone.else@example.com".toByteArray(), passwordKey, SALT, FIXED_B, + ).m1 + val m1Kat = GSA_KAT.getValue(GsaSrpPasswordKey.PROTOCOL_S2K).getValue("M1") + assertTrue(!m1Other.toHex().equals(m1Kat), "M1 must depend on the real username") + assertEquals( + GSA_KAT.getValue(GsaSrpPasswordKey.PROTOCOL_S2K).getValue("A"), + aPublic.toHex(), + ) + } + + @Test + fun `round-trips against a scripted server and decrypts its spd`() = repeat(RUNS) { + val group = GsaSrpGroup + val passwordKey = GsaSrpPasswordKey.derive(PASSWORD_HASH, SALT, ITERATIONS, GsaSrpPasswordKey.PROTOCOL_S2K) + + // Server side, per the same §1.5 formulas: v = g^x with the quirk identity hash. + val serverPrivate = BigInteger(1, randomBytes(group.byteLength - 1)) + val x = BigInteger( + 1, + sha256(SALT + sha256(byteArrayOf(':'.code.toByte()) + passwordKey)), + ) + val verifier = group.generator.modPow(x, group.n) + val k = BigInteger(1, sha256(group.prime + ByteArray(group.byteLength - 1) + 0x02.toByte())) + val serverPublic = k.multiply(verifier).add(group.generator.modPow(serverPrivate, group.n)).mod(group.n) + + val client = GsaSrpClient() + val aPublic = client.begin() + val proof = client.proof(USERNAME.toByteArray(), passwordKey, SALT, minimalBytes(serverPublic)) + + // The server verifies M1 and produces its own K from the same premaster. + val u = BigInteger(1, sha256(aPublic + minimalBytes(serverPublic))) + val premaster = aPublic.toBigInteger().multiply( + verifier.modPow(u, group.n), + ).mod(group.n).modPow(serverPrivate, group.n) + val serverK = sha256(minimalBytes(premaster)) + val m1 = sha256( + xor(sha256(group.prime), sha256(ByteArray(group.byteLength - 1) + 0x02.toByte())) + + sha256(USERNAME.toByteArray()) + SALT + aPublic + minimalBytes(serverPublic) + serverK, + ) + assertTrue(m1.contentEquals(proof.m1), "server verifies M1") + val m2 = sha256(aPublic + m1 + serverK) + assertTrue(m2.contentEquals(proof.m2), "client's expected M2 matches the server's") + + // The session key K decrypts a server-encrypted spd (§1.5 labels). + val spdPlain = XmlPlist.encode(mapOf("adsid" to "an-adsid", "t" to emptyMap())) + val spdCipher = TestAesCbc.encrypt(spdPlain, GsaSpdDecryptor.key(serverK), GsaSpdDecryptor.iv(serverK)) + val decrypted = GsaSpdDecryptor.decrypt(spdCipher, proof.k) + assertEquals("an-adsid", decrypted["adsid"]) + } + + @Test + fun `server public value at or above N fails loudly`() { + val group = GsaSrpGroup + val client = fixedClient(FIXED_A) + client.begin() + val passwordKey = GsaSrpPasswordKey.derive(PASSWORD_HASH, SALT, ITERATIONS, GsaSrpPasswordKey.PROTOCOL_S2K) + assertFailsWith { + client.proof(USERNAME.toByteArray(), passwordKey, SALT, group.prime) + } + assertFailsWith { + client.proof(USERNAME.toByteArray(), passwordKey, SALT, ByteArray(group.byteLength)) + } + } + + @Test + fun `proof before begin fails loudly`() { + assertFailsWith { + GsaSrpClient().proof( + USERNAME.toByteArray(), + GsaSrpPasswordKey.derive(PASSWORD_HASH, SALT, ITERATIONS, GsaSrpPasswordKey.PROTOCOL_S2K), + SALT, + FIXED_B, + ) + } + } + + // ---- scripted server helpers (test-local implementations of the §1.5 formulas) ---- + + private fun sha256(bytes: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(bytes) + + private fun xor(a: ByteArray, b: ByteArray): ByteArray = + ByteArray(a.size) { (a[it].toInt() xor b[it].toInt()).toByte() } + + private fun minimalBytes(value: BigInteger): ByteArray { + val signed = value.toByteArray() + return if (signed.size > 1 && signed[0] == 0.toByte()) signed.copyOfRange(1, signed.size) else signed + } + + private fun randomBytes(count: Int): ByteArray = ByteArray(count).also { SecureRandom().nextBytes(it) } + + private fun ByteArray.toBigInteger() = BigInteger(1, this) + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } + + private class FixedRandom(private val source: ByteArray) : SecureRandom() { + override fun nextBytes(bytes: ByteArray) { + source.copyInto(bytes, 0, 0, minOf(source.size, bytes.size)) + require(source.size >= bytes.size) { "fixed random source too short" } + } + } + + companion object { + const val USERNAME = "user@example.com" + val PASSWORD_HASH = sha256Static("correct horse battery staple".toByteArray()) + val SALT = ByteArray(16) { it.toByte() } + const val ITERATIONS = 1000 + val FIXED_A = ByteArray(GsaSrpGroup.byteLength - 1) { 0x11.toByte() } + val FIXED_B = ByteArray(GsaSrpGroup.byteLength) { 0x21.toByte() } + const val RUNS = 8 + + /** KATs from the independent dev script (values only — nothing copied from any source). */ + val GSA_KAT = mapOf( + GsaSrpPasswordKey.PROTOCOL_S2K to mapOf( + "A" to "2ef21a178863a757ceb9c9e542e3f6e015500ebcde624d0945a9b6497844f2dc2a1ea26e8914ec725bdcec39d578e28da273c2a1c0a448f655f3e0e2f68056df51f3124ff949ee86586e729f7a379ae1099183509fd53c6116ac12b7da2f2ea076ce6d179f0792068ceea98059caec903e4a797b4950337834af9c94f067cdd74014d75406e01ac5beae49bba88f550b63f677bb7c5ea2c5de82881eb4e6be58ee765d24f8acdb359d130f4f1a25728476cc979672216e4ee038d395d7958d6d3f0cdcf5541d33d195744b3fe5b064e8c3c86ea93190e089239d0f4052efdac2375933440187f91a1e4bf3442b029f0d3361b3779102afc76f827560c88ac3f2", + "M1" to "7ade59813b9b44e530c79aca6a1818ec407d3a678f40f3c4ed96873c420469bc", + "M2" to "cb2e2fbb28040c4b2775952e11428ef7407ae310a7211c6ad590f8c975f3d9b4", + ), + GsaSrpPasswordKey.PROTOCOL_S2K_FO to mapOf( + "A" to "2ef21a178863a757ceb9c9e542e3f6e015500ebcde624d0945a9b6497844f2dc2a1ea26e8914ec725bdcec39d578e28da273c2a1c0a448f655f3e0e2f68056df51f3124ff949ee86586e729f7a379ae1099183509fd53c6116ac12b7da2f2ea076ce6d179f0792068ceea98059caec903e4a797b4950337834af9c94f067cdd74014d75406e01ac5beae49bba88f550b63f677bb7c5ea2c5de82881eb4e6be58ee765d24f8acdb359d130f4f1a25728476cc979672216e4ee038d395d7958d6d3f0cdcf5541d33d195744b3fe5b064e8c3c86ea93190e089239d0f4052efdac2375933440187f91a1e4bf3442b029f0d3361b3779102afc76f827560c88ac3f2", + "M1" to "be0d7ab2fbc3c74e8e4a59c0cb962e6cfd5c332f3d347e68f1153b2ecfdd48ba", + "M2" to "08859fbf824b58270e4be07007e2690491e0452db3c08fc0e6e8052274455466", + ), + ) + + private fun sha256Static(bytes: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(bytes) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTokensTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTokensTest.kt new file mode 100644 index 0000000..794cb91 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTokensTest.kt @@ -0,0 +1,135 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +/** + * §1.5 response-header token parsing (`X-Apple-GS-Token` / `X-Apple-HB-Token` / + * `X-Apple-PE-Token`): base64 of colon-separated `ID:TOKEN[:DURATION][:EXP]` with inconsistent + * trailer forms — the recorded heuristic is pinned here, including the edge forms. + */ +class GsaTokensTest { + + private val now = 1_770_000_000_000L + + private fun token(vararg fields: String): String = + Base64.getEncoder().encodeToString(fields.joinToString(":").toByteArray()) + + @Test + fun `bare ID-TOKEN has no expiry`() { + val parsed = GsaTokenHeaders.parse("x-apple-gs-token", token("svc.one", "tok1"), now) + assertEquals(1, parsed.size) + assertEquals("svc.one", parsed[0].serviceId) + assertEquals("tok1", parsed[0].token) + assertNull(parsed[0].durationSeconds) + assertNull(parsed[0].expiresAtEpochMs) + } + + @Test + fun `single trailer reads as expiry - epoch-ms passes through, epoch-seconds is scaled`() { + val ms = GsaTokenHeaders.parse("x-apple-gs-token", token("svc", "tok", "1800000000000"), now).single() + assertEquals(1_800_000_000_000L, ms.expiresAtEpochMs) + + val s = GsaTokenHeaders.parse("x-apple-gs-token", token("svc", "tok", "1800000000"), now).single() + assertEquals(1_800_000_000_000L, s.expiresAtEpochMs) + } + + @Test + fun `two trailers are DURATION then EXP`() { + val parsed = GsaTokenHeaders.parse( + "x-apple-gs-token", + token("svc", "tok", "300", "1800000000000"), + now, + ).single() + assertEquals(300L, parsed.durationSeconds) + assertEquals(1_800_000_000_000L, parsed.expiresAtEpochMs) + } + + @Test + fun `comma-joined repeats parse per occurrence`() { + val value = token("svc.one", "tok1") + ", " + token("svc.two", "tok2", "1800000000000") + val parsed = GsaTokenHeaders.parse("x-apple-gs-token", value, now) + assertEquals(listOf("svc.one", "svc.two"), parsed.map { it.serviceId }) + assertEquals("tok1", parsed[0].token) + assertEquals(1_800_000_000_000L, parsed[1].expiresAtEpochMs) + } + + @Test + fun `non-numeric trailer surfaces as null rather than failing - the forms are inconsistent`() { + val parsed = GsaTokenHeaders.parse("x-apple-gs-token", token("svc", "tok", "tomorrow"), now).single() + assertNull(parsed.expiresAtEpochMs) + } + + @Test + fun `payload without the ID-TOKEN shape fails loudly`() { + val bad = Base64.getEncoder().encodeToString("just-a-token".toByteArray()) + assertFailsWith { GsaTokenHeaders.parse("x-apple-gs-token", bad, now) } + } + + @Test + fun `undecodable base64 fails loudly`() { + assertFailsWith { GsaTokenHeaders.parse("x-apple-gs-token", "!!!not-base64!!!", now) } + } + + @Test + fun `gs tokens key by service id`() { + val value = token("svc.one", "tok1") + "," + token("svc.two", "tok2") + val tokens = GsaTokenHeaders.gsTokens(value, now) + assertEquals("tok1", tokens.getValue("svc.one").token) + assertEquals("tok2", tokens.getValue("svc.two").token) + assertEquals(emptyMap(), GsaTokenHeaders.gsTokens(null, now)) + } + + @Test + fun `pet uses the trailer expiry when present`() { + val pet = GsaTokenHeaders.pet(token("com.apple.gs.idms.pet", "pet-token", "1800000000000"), now) + assertEquals("pet-token", pet?.token) + assertEquals(1_800_000_000_000L, pet?.expiresAtEpochMs) + } + + @Test + fun `pet defaults to the recorded 300 s when the header carries no trailer`() { + val pet = GsaTokenHeaders.pet(token("com.apple.gs.idms.pet", "pet-token"), now) + assertEquals("pet-token", pet?.token) + assertEquals(now + 300_000, pet?.expiresAtEpochMs) + } + + @Test + fun `pet absent header gives no pet`() { + assertNull(GsaTokenHeaders.pet(null, now)) + assertNull(GsaTokenHeaders.pet("", now)) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTwoFactorTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTwoFactorTest.kt new file mode 100644 index 0000000..6696958 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTwoFactorTest.kt @@ -0,0 +1,274 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * The §1.5 2FA paths against scripted transports: the `secondaryAuth` SMS sequence (extras → + * verify/phone → verify/phone/securitycode), the `trustedDeviceSecondaryAuth` sequence + * (trusteddevice trigger → `validate` with the `security-code` header), the + * `X-Apple-Identity-Token` header, and the response-header token parsing. + */ +class GsaTwoFactorTest { + + private val identityToken = gsaIdentityToken("adsid-1", "idms-token-1") + + private fun client(http: IdsHttp) = GsaTwoFactorClient( + http = http, + browserHeaders = browserHeaders(), + authBase = "https://gsa.example/auth", + validateUrl = "https://gsa.example/GsService2/validate", + trustedDeviceUrl = "https://gsa.example/auth/verify/trusteddevice", + ) + + /** The recorded §1.5 set; [device] feeds the recorded non-akd client-info template. */ + private fun browserHeaders( + device: GsaHeaderConfig? = null, + userAgent: String = GsaTwoFactorHeaders.BROWSER_USER_AGENT, + ) = GsaTwoFactorHeaders.of( + GsaCpd( + anisette = mapOf( + "X-Apple-I-MD" to "otp", + "X-Apple-I-MD-RINFO" to "17106176", + "X-Apple-I-MD-M" to "machine-id", + "X-Mme-Device-Id" to "device-id", + ), + keychainIdentifier = ByteArray(16) { it.toByte() }, + ), + device = device, + userAgent = userAgent, + ) + + private fun petHeader(value: String) = mapOf( + "X-Apple-PE-Token" to Base64.getEncoder().encodeToString(value.toByteArray()), + ) + + @Test + fun `authentication extras parse the recorded JSON fields`() = runBlocking { + val json = """ + {"trustedPhoneNumbers":[{"numberWithDialCode":"+1 555 000 1111","lastTwoDigits":"11", + "pushMode":false,"id":7}],"other":"field"} + """.trimIndent() + val extras = client(ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), json.toByteArray())))) + .trustedPhoneNumbers(identityToken) + + val phone = extras.trustedPhoneNumbers.single() + assertEquals("+1 555 000 1111", phone.numberWithDialCode) + assertEquals("11", phone.lastTwoDigits) + assertEquals(false, phone.pushMode) + assertEquals(7L, phone.id) + } + + @Test + fun `http 201 means the SMS was already sent and still parses`() = runBlocking { + val client = client(ScriptedIdsHttp(listOf(IdsHttpResponse(201, emptyMap(), "{}".toByteArray())))) + assertEquals(emptyList(), client.trustedPhoneNumbers(identityToken).trustedPhoneNumbers) + } + + @Test + fun `other statuses fail loudly`() = runBlocking { + val client = client(ScriptedIdsHttp(listOf(IdsHttpResponse(401, emptyMap(), ByteArray(0))))) + assertFailsWith { client.trustedPhoneNumbers(identityToken) } + Unit + } + + @Test + fun `sms request is a PUT with the recorded JSON body`() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), ByteArray(0)))) + client(http).requestSmsCode(identityToken, "7") + + val call = http.calls.single() + assertEquals("PUT", call.method) + assertEquals("https://gsa.example/auth/verify/phone", call.url) + assertEquals("application/json", call.headers["Content-Type"]) + assertEquals("application/json", call.contentType) + val body = Json.parseToJsonElement(call.body.decodeToString()).jsonObject + assertEquals("7", body["phoneNumber"]!!.jsonObject["id"]!!.jsonPrimitive.content) + assertEquals("sms", body["mode"]!!.jsonPrimitive.content) + assertEquals(identityToken, call.headers["X-Apple-Identity-Token"]) + assertEquals("same-origin", call.headers["Sec-Fetch-Site"]) + } + + @Test + fun `the recorded 2FA browser header set carries the literal values`() { + val device = GsaHeaderConfig( + hardwareModel = "iMac13,1", + osName = "macOS", + osVersion = "13.6.4", + osBuild = "22G513", + ) + val headers = browserHeaders(device = device).toHeaders() + val expected = mapOf( + "X-Apple-I-MD" to "otp", + "X-Apple-I-MD-RINFO" to "17106176", + "X-Apple-I-MD-M" to "machine-id", + "X-Mme-Device-Id" to "device-id", + "X-Apple-I-MD-LU" to + java.security.MessageDigest.getInstance("SHA-256") + .digest(ByteArray(16) { it.toByte() }) + .joinToString("") { "%02x".format(it) }, + "X-Apple-Client-App-Name" to "Messages", + "X-Apple-I-Client-Bundle-Id" to "com.apple.MobileSMS", + // Spec §1.5 rev-15 closure: the device-bracket form with the fixed + // `com.apple.akd/1.0 (com.apple.akd/1.0)` inner component. + "X-MMe-Client-Info" to " ", + "X-Apple-I-CDP-Circle-Status" to "false", + "X-Apple-I-ICSCREC" to "true", + // Spec §1.5 rev-15 closure: the browser UA literal — NO Safari/Version token. + "User-Agent" to + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)", + "Sec-Fetch-Site" to "same-origin", + "X-Apple-Requested-Partition" to "0", + "X-Apple-I-DeviceUserMode" to "0", + "X-Apple-I-Locale" to "en_US", + "X-Apple-Security-Upgrade-Context" to "com.apple.authkit.generic", + "Accept-Language" to "en-US,en;q=0.9", + "X-Apple-I-PRK-Gen" to "true", + "Sec-Fetch-Mode" to "cors", + "X-Apple-I-TimeZone" to "UTC", + "X-Apple-I-OT-Status" to "false", + "X-Apple-I-TimeZone-Offset" to "0", + "X-MMe-Country" to "US", + "X-Apple-I-CDP-Status" to "false", + "X-Apple-I-Device-Configuration-Mode" to "0", + "Sec-Fetch-Dest" to "empty", + "X-Apple-AK-Context-Type" to "imessage", + "X-Apple-I-CFU-State" to GsaTwoFactorHeaders.CFU_STATE, + ) + assertEquals(expected, headers) + } + + @Test + fun `without device slots the non-akd client-info is omitted`() { + val headers = browserHeaders().toHeaders() + assertEquals(false, headers.containsKey("X-MMe-Client-Info")) + assertEquals(GsaTwoFactorHeaders.BROWSER_USER_AGENT, headers["User-Agent"]) + } + + @Test + fun `cfu state is base64 of the full 182-byte plist wrapper`() { + val decoded = Base64.getDecoder().decode(GsaTwoFactorHeaders.CFU_STATE) + // Spec-pinned: the decoded shape (prolog + Apple DOCTYPE + plist open + empty array + + // plist close + trailing newline) and its exact 182-byte decoded length (§1.5). + assertEquals(182, decoded.size) + assertEquals(GsaTwoFactorHeaders.CFU_STATE_PLIST, String(decoded, Charsets.US_ASCII)) + val text = String(decoded, Charsets.UTF_8) + assertTrue(text.startsWith("\n")) + assertTrue(text.contains("\n\n\n")) + // NOT the previously-assumed bare ``. + assertTrue(decoded.size != "".length) + // And it parses as a plist whose value is an empty array. + val parsed = XmlPlist.decode(decoded) + assertTrue(parsed is List<*> && parsed.isEmpty()) + } + + @Test + fun `sms verification posts the security code and parses the token headers`() = runBlocking { + val http = ScriptedIdsHttp( + listOf( + IdsHttpResponse( + 200, + petHeader("com.apple.gs.idms.pet:pet-token:1800000000000") + + mapOf( + "X-Apple-GS-Token" to listOf( + Base64.getEncoder().encodeToString("svc.a:tok-a".toByteArray()), + Base64.getEncoder().encodeToString("svc.b:tok-b".toByteArray()), + ).joinToString(","), + "X-Apple-HB-Token" to Base64.getEncoder().encodeToString("hb:hv".toByteArray()), + ), + ByteArray(0), + ), + ), + ) + val tokens = client(http).submitSmsCode(identityToken, "7", "123456", nowEpochMs = 5_000L) + + val call = http.calls.single() + assertEquals("POST", call.method) + assertEquals("https://gsa.example/auth/verify/phone/securitycode", call.url) + val body = Json.parseToJsonElement(call.body.decodeToString()).jsonObject + assertEquals("123456", body["securityCode"]!!.jsonObject["code"]!!.jsonPrimitive.content) + assertEquals(identityToken, call.headers["X-Apple-Identity-Token"]) + + assertEquals("pet-token", tokens.pet?.token) + assertEquals("tok-a", tokens.gsTokens.getValue("svc.a").token) + assertEquals("tok-b", tokens.gsTokens.getValue("svc.b").token) + assertEquals("hv", tokens.hbTokens.single().token) + } + + @Test + fun `trusted device flow triggers then validates with the security-code header`() = runBlocking { + val http = ScriptedIdsHttp( + listOf( + IdsHttpResponse(200, emptyMap(), ByteArray(0)), + IdsHttpResponse(200, petHeader("com.apple.gs.idms.pet:pet-2"), ByteArray(0)), + ), + ) + val twoFactor = client(http) + twoFactor.triggerTrustedDevice(identityToken) + val tokens = twoFactor.submitTrustedDeviceCode(identityToken, "987654", nowEpochMs = 5_000L) + + assertEquals("GET", http.calls[0].method) + assertEquals("https://gsa.example/auth/verify/trusteddevice", http.calls[0].url) + assertEquals("GET", http.calls[1].method) + assertEquals("https://gsa.example/GsService2/validate", http.calls[1].url) + assertEquals("987654", http.calls[1].headers["security-code"]) + assertEquals(identityToken, http.calls[1].headers["X-Apple-Identity-Token"]) + assertEquals("pet-2", tokens.pet?.token) + assertEquals(5_000L + 300_000, tokens.pet?.expiresAtEpochMs) // 300 s default, §1.5 + } + + @Test + fun `failed verification fails loudly`() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(403, emptyMap(), ByteArray(0)))) + val client = client(http) + assertFailsWith { + client.submitSmsCode(identityToken, "7", "000000", nowEpochMs = 5_000L) + } + Unit + } + + @Test + fun `identity token value is base64 of adsid colon idms token`() { + val decoded = String(Base64.getDecoder().decode(identityToken)) + assertEquals("adsid-1:idms-token-1", decoded) + assertTrue(!decoded.startsWith(":") && !decoded.endsWith(":")) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsActivationTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsActivationTest.kt new file mode 100644 index 0000000..eaa2ca8 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsActivationTest.kt @@ -0,0 +1,489 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.gunzipIfNeeded +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class IdsActivationTest { + + private val registerUrl = "https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register" + private val authUrl = "https://profile.ess.apple.com/WebObjects/VCProfileService.woa/wa/authenticateDS" + private val phoneAuthUrl = + "https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/authenticatePhoneNumber" + private val pushToken = ByteArray(32) { it.toByte() } + private val pushCert = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00) + private val authCert = byteArrayOf(0x30, 0x82.toByte(), 0x03, 0x00) + + private fun config() = RegisterConfig( + pushToken = pushToken, + pushKey = PUSH.private, + pushCertificateDer = pushCert, + protocolVersion = "1660", + userAgent = "com.apple.invitation-registration [Test,1,1,Test]", + ) + + private fun user(userId: String = "user-1") = RegisterUser( + userId = userId, + uris = listOf(mapOf("uri" to "tel:+15557654321")), + tag = null, + authKey = AUTH.private, + authCertificateDer = authCert, + clientData = mapOf("client-data-version" to 2L), + ktLoggableData = null, + ) + + private fun body(user: RegisterUser = user()) = RegisterBody( + deviceName = "LP3", + hardwareVersion = "h1", + language = "en", + osVersion = "14.6.1", + softwareVersion = "1.0", + privateDeviceData = PrivateDeviceData( + osBuild = "24G84", + platformName = "Mac15,12", + osVersion = "14.6.1", + deviceUuid = "abcd-ef", + appleEpochSeconds = 800_000_000L, + ), + services = listOf(RegisterService(RegisterService.MADRID, users = listOf(user))), + validationData = byteArrayOf(1, 2, 3), + ) + + private fun registrar(http: IdsHttp, refreshMarker: String? = null) = + IdsRegistrar(http, Bag(mapOf("id-register" to registerUrl)), config(), refreshMarker) + + @Test + fun registerIsSignedForThePushKeyAndEachUser() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + registrar(http).register(body()) + + val call = http.calls.single() + assertEquals(registerUrl, call.url) + assertEquals(IdsRegistrar.CONTENT_TYPE, call.contentType) + + // §1.4 tuple: (bag key "id-register", empty query, exact body, raw push token). + val fields = listOf( + "id-register".toByteArray(), + ByteArray(0), + call.body, + pushToken, + ) + verifySignature(call, "x-push-nonce", "x-push-sig", PUSH, fields) + verifySignature(call, "x-auth-nonce-0", "x-auth-sig-0", AUTH, fields) + + assertEquals(Base64.getEncoder().encodeToString(pushCert), call.headers.getValue("x-push-cert")) + assertEquals(Base64.getEncoder().encodeToString(pushToken), call.headers.getValue("x-push-token")) + assertEquals("user-1", call.headers.getValue("x-auth-user-id-0")) + assertEquals(Base64.getEncoder().encodeToString(authCert), call.headers.getValue("x-auth-cert-0")) + assertEquals("1660", call.headers.getValue("x-protocol-version")) + assertEquals( + "com.apple.invitation-registration [Test,1,1,Test]", + call.headers.getValue("user-agent"), + ) + } + + @Test + fun phoneUserIdHeaderCarriesTheRecordedPFormVerbatim() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + registrar(http).register(body(user("P:+15557654321"))) + assertEquals("P:+15557654321", http.calls.single().headers.getValue("x-auth-user-id-0")) + } + + private fun verifySignature( + call: ScriptedIdsHttp.Call, + nonceHeader: String, + sigHeader: String, + key: KeyPair, + fields: List, + ) { + val nonce = Base64.getDecoder().decode(call.headers.getValue(nonceHeader)) + assertEquals(17, nonce.size) + assertEquals(0x01, nonce[0].toInt()) + val signature = Base64.getDecoder().decode(call.headers.getValue(sigHeader)) + assertContentEquals(byteArrayOf(0x01, 0x01), signature.copyOfRange(0, 2)) + val verifier = Signature.getInstance("SHA1withRSA") + verifier.initVerify(key.public) + verifier.update(IdsSigning.signingBytes(nonce, fields)) + assertTrue(verifier.verify(signature.copyOfRange(2, signature.size)), "$sigHeader does not verify") + } + + @Test + fun registerBodyCarriesExactlyTheRecordedTopLevelKeys() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + registrar(http).register(body()) + + val decoded = Plist.parse(http.calls.single().body) as Map<*, *> + assertEquals( + setOf( + "device-name", "hardware-version", "language", "os-version", "software-version", + "private-device-data", "services", "validation-data", + ), + decoded.keys, + ) + assertEquals("LP3", decoded["device-name"]) + assertEquals("h1", decoded["hardware-version"]) + assertEquals("en", decoded["language"]) + assertEquals("14.6.1", decoded["os-version"]) + assertEquals("1.0", decoded["software-version"]) + assertContentEquals(byteArrayOf(1, 2, 3), decoded["validation-data"] as ByteArray) + } + + @Test + fun serviceEntryCarriesTheRecordedKeys() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + registrar(http).register(body()) + + val decoded = Plist.parse(http.calls.single().body) as Map<*, *> + val service = (decoded["services"] as List<*>).single() as Map<*, *> + assertEquals(setOf("capabilities", "service", "sub-services", "users"), service.keys) + assertEquals("com.apple.madrid", service["service"]) + assertEquals(RegisterService.SUB_SERVICES, service["sub-services"]) + val capability = (service["capabilities"] as List<*>).single() as Map<*, *> + // C3 disagreement: 17 (first-recorded) is the default; record keys are settled (§1.4). + assertEquals(mapOf("flags" to 17L, "name" to "Messenger", "version" to 1L), capability) + val user = (service["users"] as List<*>).single() as Map<*, *> + assertEquals("user-1", user["user-id"]) + assertEquals(listOf(mapOf("uri" to "tel:+15557654321")), user["uris"]) + assertEquals(mapOf("client-data-version" to 2L), user["client-data"]) + assertTrue(!user.containsKey("tag"), "null tag must be omitted") + } + + @Test + fun sevenTopicSubServiceListIsAvailableForTheC3Disagreement() { + assertEquals(RegisterService.SUB_SERVICES.size + 3, RegisterService.SUB_SERVICES_SEVEN.size) + assertTrue(RegisterService.SUB_SERVICES_SEVEN.containsAll(RegisterService.SUB_SERVICES)) + } + + @Test + fun privateDeviceDataCarriesTheRecordedKeys() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + registrar(http).register(body()) + + val decoded = Plist.parse(http.calls.single().body) as Map<*, *> + assertEquals( + mapOf( + "ap" to "0", + "d" to "800000000", + "dt" to 1L, + "gt" to "0", + "h" to "1", + "m" to "0", + "p" to "0", + "pb" to "24G84", + "pn" to "Mac15,12", + "pv" to "14.6.1", + "s" to "0", + "t" to "0", + "u" to "ABCD-EF", + "v" to "1", + ), + decoded["private-device-data"], + ) + } + + @Test + fun relayProfileFlipsMAndS() { + val dict = PrivateDeviceData("b", "p", "v", "u", 1L, relayProfile = true).toDict() + assertEquals("1", dict["m"]) + assertEquals("1", dict["s"]) + } + + @Test + fun responseParsesTheRecordedResponseKeys() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + val response = registrar(http).register(body()) + + assertEquals(0, response.status) + assertEquals("ok", response.message) + assertEquals(600L, response.retryInterval) + val service = response.services.single() + assertEquals("com.apple.madrid", service.service) + assertEquals(0, service.status) + val user = service.users.single() + assertEquals("user-1", user.userId) + assertEquals(0, user.status) + assertEquals(86400L, user.nextHbi) + assertContentEquals(authCert, user.cert) + assertEquals(listOf(IdsRegisterUriResult("tel:+15557654321", 0)), user.uris) + assertEquals("Test title", user.alert?.title) + assertEquals("Test body", user.alert?.body) + assertEquals("OK", user.alert?.button) + assertEquals("https://example/alert", user.alert?.action?.url) + assertEquals("button", user.alert?.action?.button) + assertEquals("url", user.alert?.action?.type) + } + + @Test + fun responseUsersRideInsideServices() = runBlocking { + // §1.4: the response nests users per service; a top-level users array is not recorded. + val response = XmlPlist.encode( + mapOf( + "status" to 0L, + "services" to listOf( + mapOf("service" to "com.apple.madrid", "status" to 0L, "users" to listOf(mapOf("status" to 0L))), + ), + ), + ) + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), response))) + val parsed = registrar(http).register(body()) + + assertEquals(1, parsed.services.single().users.size) + assertEquals(null, parsed.services.single().users[0].nextHbi) + assertEquals(null, parsed.retryInterval) + } + + @Test + fun refreshCredentialsMarkerMarksTheAuthPairWhenConfigured() = runBlocking { + val response = XmlPlist.encode( + mapOf( + "status" to 0L, + "services" to listOf( + mapOf( + "service" to "com.apple.madrid", + "status" to 0L, + "users" to listOf(mapOf("status" to "refresh credentials", "user-id" to "user-1")), + ), + ), + ), + ) + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), response))) + + val parsed = registrar(http, refreshMarker = "refresh credentials").register(body()) + + assertEquals(true, parsed.services.single().users.single().refreshCredentials) + assertEquals(null, parsed.services.single().users.single().status) + } + + @Test + fun unconfiguredOrUnknownStringStatusesFailLoudly() { + // §6.1's "refresh credentials" marker is capture-bound — never guessed. + val response = XmlPlist.encode( + mapOf( + "status" to 0L, + "services" to listOf( + mapOf("service" to "s", "status" to 0L, "users" to listOf(mapOf("status" to "refresh credentials"))), + ), + ), + ) + val unconfigured = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), response))) + assertFailsWith { + runBlocking { registrar(unconfigured).register(body()) } + } + + val mismatched = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), response))) + assertFailsWith { + runBlocking { registrar(mismatched, refreshMarker = "other marker").register(body()) } + } + } + + @Test + fun missingStatusThrows() { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), XmlPlist.encode(mapOf("message" to "ok"))))) + assertFailsWith { + runBlocking { registrar(http).register(body()) } + } + } + + @Test + fun non200Throws() { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(429, emptyMap(), ByteArray(0)))) + assertFailsWith { + runBlocking { registrar(http).register(body()) } + } + } + + @Test + fun unparseableResponseThrows() { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), "not a plist".toByteArray()))) + assertFailsWith { + runBlocking { registrar(http).register(body()) } + } + } + + @Test + fun missingRegisterBagKeyIsTheBagError() { + val http = ScriptedIdsHttp(emptyList()) + val client = IdsRegistrar(http, Bag(emptyMap()), config()) + assertFailsWith { + runBlocking { client.register(body()) } + } + assertEquals(0, http.calls.size) + } + + @Test + fun authenticateSendsTheRecordedBodyKeysAndHeaderSet() = runBlocking { + val response = XmlPlist.encode(mapOf("status" to 0L, "cert" to authCert)) + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), response))) + val authenticator = IdsAuthenticator(http, Bag(mapOf(IdsAuthenticator.BAG_KEY_DS_ID to authUrl)), "1660", "ua") + + val parsed = authenticator.authenticate( + IdsAuthenticator.BAG_KEY_DS_ID, + realmUserId = "user@example.com", + csrDer = AUTH_CSR.der, + authenticationData = AuthenticationData.AuthToken("auth-token-1"), + ) + + val call = http.calls.single() + assertEquals(authUrl, call.url) + // §1.2: ONLY user-agent and x-protocol-version, plus content-encoding: gzip. No §1.4 signature headers. + assertEquals(setOf("user-agent", "x-protocol-version", "content-encoding"), call.headers.keys) + assertEquals("gzip", call.headers.getValue("content-encoding")) + assertEquals("1660", call.headers.getValue("x-protocol-version")) + assertEquals("ua", call.headers.getValue("user-agent")) + + // The body is gzipped (content-encoding) XML plist with exactly the recorded keys. + assertEquals(0x1f, call.body[0].toInt() and 0xff) + assertEquals(0x8b, call.body[1].toInt() and 0xff) + val decoded = Plist.parse(gunzipIfNeeded(call.body)) as Map<*, *> + assertEquals(setOf("authentication-data", "csr", "realm-user-id"), decoded.keys) + assertEquals(mapOf("auth-token" to "auth-token-1"), decoded["authentication-data"]) + assertEquals("user@example.com", decoded["realm-user-id"]) + assertContentEquals(AUTH_CSR.der, decoded["csr"] as ByteArray) + + // §1.2: flat status + cert response, no per-user nesting. + assertEquals(0, parsed.status) + assertContentEquals(authCert, parsed.cert) + } + + @Test + fun phoneFlowUsesThePhoneNumberBagKeyAndPushAuthenticationData() = runBlocking { + val response = XmlPlist.encode(mapOf("status" to 0L, "cert" to authCert)) + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), response))) + val authenticator = IdsAuthenticator( + http, + Bag(mapOf(IdsAuthenticator.BAG_KEY_PHONE_NUMBER to phoneAuthUrl)), + "1660", + "ua", + ) + + authenticator.authenticate( + IdsAuthenticator.BAG_KEY_PHONE_NUMBER, + realmUserId = "P:+15557654321", + csrDer = AUTH_CSR.der, + authenticationData = AuthenticationData.Push( + pushToken = "push-token-1", + sigs = listOf("sig-1"), + ), + ) + + val call = http.calls.single() + assertEquals(phoneAuthUrl, call.url) + val decoded = Plist.parse(gunzipIfNeeded(call.body)) as Map<*, *> + assertEquals("P:+15557654321", decoded["realm-user-id"]) + assertEquals( + mapOf("push-token" to "push-token-1", "sigs" to listOf("sig-1")), + decoded["authentication-data"], + ) + } + + @Test + fun authenticateCnIsTheHexSha1RealmUserIdWithTheDefaultCase() { + // The full §1.2 path: 2048-bit RSA key, CN = hex SHA-1 of the realm user id (§1.2, C57 case parameter). + val cn = IdsCsrBuilder.realmUserIdCn("P:+15557654321") + assertEquals("99F8BAD26E4592AEC848DB65117BD551A9955163", cn) + val csr = IdsCsrBuilder.create(AUTH, cn) + assertEquals(listOf(cn), csrSubjectValues(csr.der)) + } + + @Test + fun authenticateRefusesBagKeysThatAreNotRecordedAuthenticateEndpoints() { + val http = ScriptedIdsHttp(emptyList()) + val authenticator = IdsAuthenticator(http, Bag(mapOf("id-query" to "https://other")), "1660", "ua") + assertFailsWith { + runBlocking { + authenticator.authenticate("id-query", "user", AUTH_CSR.der, AuthenticationData.AuthToken("t")) + } + } + assertEquals(0, http.calls.size) + } + + @Test + fun authenticateMissingBagKeyIsTheBagError() { + val http = ScriptedIdsHttp(emptyList()) + val authenticator = IdsAuthenticator(http, Bag(emptyMap()), "1660", "ua") + assertFailsWith { + runBlocking { + authenticator.authenticate( + IdsAuthenticator.BAG_KEY_DS_ID, + "user", + AUTH_CSR.der, + AuthenticationData.AuthToken("t"), + ) + } + } + } + + private fun okResponse(): ByteArray = XmlPlist.encode( + mapOf( + "status" to 0L, + "message" to "ok", + "retry-interval" to 600L, + "services" to listOf( + mapOf( + "service" to "com.apple.madrid", + "status" to 0L, + "users" to listOf( + mapOf( + "user-id" to "user-1", + "status" to 0L, + "next-hbi" to 86400L, + "cert" to authCert, + "uris" to listOf(mapOf("uri" to "tel:+15557654321", "status" to 0L)), + "alert" to mapOf( + "title" to "Test title", + "body" to "Test body", + "button" to "OK", + "action" to mapOf("button" to "button", "type" to "url", "url" to "https://example/alert"), + ), + ), + ), + ), + ), + ), + ) + + private companion object { + // 1024-bit test keys, per the IdsLookupTest precedent — never hardcoded key material. + val PUSH: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + val AUTH: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + val AUTH_CSR: IdsCsr = IdsCsrBuilder.create(AUTH, "df88e39ca8b5cfdfd8da5d9b927d0535374f6fd7") + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsCsrTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsCsrTest.kt new file mode 100644 index 0000000..98288a4 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsCsrTest.kt @@ -0,0 +1,186 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.interfaces.RSAPublicKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** Minimal DER walk for structural CSR assertions (tag, total start, body start, body end). */ +internal data class Tlv(val tag: Int, val start: Int, val bodyStart: Int, val bodyEnd: Int) + +internal fun readTlv(der: ByteArray, at: Int): Tlv { + val tag = der[at].toInt() and 0xff + var length = der[at + 1].toInt() and 0xff + var bodyStart = at + 2 + if (length and 0x80 != 0) { + val byteCount = length and 0x7f + length = 0 + repeat(byteCount) { + length = (length shl 8) or (der[bodyStart].toInt() and 0xff) + bodyStart++ + } + } + return Tlv(tag, at, bodyStart, bodyStart + length) +} + +internal fun derChildren(der: ByteArray, tlv: Tlv): List { + val out = ArrayList() + var at = tlv.bodyStart + while (at < tlv.bodyEnd) { + val child = readTlv(der, at) + out.add(child) + at = child.bodyEnd + } + return out +} + +internal fun derFullBytes(der: ByteArray, tlv: Tlv): ByteArray = der.copyOfRange(tlv.start, tlv.bodyEnd) + +/** The CSR's subject attribute values (the UTF8 strings of the RDN sequence). */ +internal fun csrSubjectValues(csrDer: ByteArray): List { + val requestInfo = derChildren(csrDer, readTlv(csrDer, 0)).first() + val subject = derChildren(csrDer, requestInfo)[1] + return derChildren(csrDer, subject).flatMap { set -> + derChildren(csrDer, set).flatMap { rdn -> + derChildren(csrDer, rdn).filter { it.tag == 0x0c } + .map { String(csrDer, it.bodyStart, it.bodyEnd - it.bodyStart, Charsets.UTF_8) } + } + } +} + +class IdsCsrTest { + + @Test + fun realmUserIdCnIsTheHexSha1OfTheRealmUserId() { + // Known answer: SHA-1("P:+15551234567") = df88e39c…f6fd7 (spec §1.2). + assertEquals( + "DF88E39CA8B5CFDFD8DA5D9B927D0535374F6FD7", + IdsCsrBuilder.realmUserIdCn("P:+15551234567"), + ) + } + + @Test + fun realmUserIdCnHexCaseIsTheC57Parameter() { + val lower = IdsCsrBuilder.realmUserIdCn("P:+15551234567", HexCase.LOWER) + assertEquals("df88e39ca8b5cfdfd8da5d9b927d0535374f6fd7", lower) + val upper = IdsCsrBuilder.realmUserIdCn("P:+15551234567", HexCase.UPPER) + assertEquals(upper, upper.uppercase()) + } + + @Test + fun csrSelfSignatureVerifiesOverTheRequestInfo() { + val csr = IdsCsrBuilder.create(KEY, "test-cn", "test-ou", "test-o") + + val top = derChildren(csr.der, readTlv(csr.der, 0)) + assertEquals(3, top.size, "CertificationRequest must hold requestInfo, algId, signature") + assertEquals(0x30, top[0].tag) + assertEquals(0x30, top[1].tag) + assertEquals(0x03, top[2].tag, "signature must be a BIT STRING") + + // The self-signature covers the exact requestInfo TLV bytes (§1.2, C57 parameter). + val verifier = Signature.getInstance("SHA1withRSA") + verifier.initVerify(KEY.public) + verifier.update(derFullBytes(csr.der, top[0])) + val signature = csr.der.copyOfRange(top[2].bodyStart + 1, top[2].bodyEnd) + assertTrue(verifier.verify(signature), "CSR self-signature does not verify") + + // Default is the Rust reading of the C57 disagreement: SHA-1 with RSA/PKCS1 (§1.2). + val algorithm = derChildren(csr.der, top[1]).first() + assertEquals(0x06, algorithm.tag) + assertContentEquals(Der.oid(CsrSignatureHash.SHA1.oid), csr.der.copyOfRange(algorithm.start, algorithm.bodyEnd)) + } + + @Test + fun sha256SelfSignatureVariantVerifies() { + val csr = IdsCsrBuilder.create(KEY, "test-cn", selfSignatureHash = CsrSignatureHash.SHA256) + val top = derChildren(csr.der, readTlv(csr.der, 0)) + val verifier = Signature.getInstance("SHA256withRSA") + verifier.initVerify(KEY.public) + verifier.update(derFullBytes(csr.der, top[0])) + assertTrue(verifier.verify(csr.der.copyOfRange(top[2].bodyStart + 1, top[2].bodyEnd))) + } + + @Test + fun csrCarriesTheSubjectPublicKeyAndSubject() { + val csr = IdsCsrBuilder.create(KEY, "Client Push Certificate", "iPhone", "Apple Inc.") + assertEquals( + listOf("Client Push Certificate", "iPhone", "Apple Inc."), + csrSubjectValues(csr.der), + ) + val public = KEY.public as RSAPublicKey + assertEquals(1024, public.modulus.bitLength()) + val requestInfo = derChildren(csr.der, readTlv(csr.der, 0)).first() + val spki = derChildren(csr.der, requestInfo)[2] + val spkiChildren = derChildren(csr.der, spki) + assertEquals(0x03, spkiChildren[1].tag, "SPKI must carry the key as a BIT STRING") + // Version INTEGER 0 and empty [0] attributes open/close the request info. + val infoChildren = derChildren(csr.der, requestInfo) + assertEquals(0x02, infoChildren[0].tag) + assertEquals(BigInteger.ZERO, BigInteger(csr.der, infoChildren[0].bodyStart, infoChildren[0].bodyEnd - infoChildren[0].bodyStart)) + assertEquals(0xa0, infoChildren[3].tag) + assertEquals(0, infoChildren[3].bodyEnd - infoChildren[3].bodyStart, "attributes must be empty") + } + + @Test + fun pemEncodesAndDecodesSixtyFourColumnBase64() { + val der = ByteArray(200) { it.toByte() } + val pem = Pem.encode(Pem.CSR_TYPE, der) + assertTrue(pem.startsWith("-----BEGIN CERTIFICATE REQUEST-----\n")) + assertTrue(pem.trimEnd().endsWith("-----END CERTIFICATE REQUEST-----")) + assertContentEquals(der, Pem.decode(pem, Pem.CSR_TYPE)) + } + + @Test + fun pemDecodeRejectsMissingOrUnterminatedBlocks() { + assertFailsWith { Pem.decode("nothing here", Pem.CSR_TYPE) } + assertFailsWith { + Pem.decode("-----BEGIN CERTIFICATE REQUEST-----\nAAAA", Pem.CSR_TYPE) + } + } + + @Test + fun csrRefusesNonRsaKeys() { + val ec = KeyPairGenerator.getInstance("EC").apply { initialize(256) }.generateKeyPair() + assertFailsWith { IdsCsrBuilder.create(ec, "cn") } + } + + private companion object { + val KEY: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsKeysTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsKeysTest.kt new file mode 100644 index 0000000..454d046 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsKeysTest.kt @@ -0,0 +1,105 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.interfaces.RSAPublicKey +import java.security.spec.ECGenParameterSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * JVM software stand-in for [AndroidKeystoreKeyPairs] — the AndroidKeyStore provider does not + * exist on the JVM, so the seam's contract (get-or-create per alias) is what is tested here. + */ +class SoftwareKeyPairs : IdsKeyPairs { + private val cache = HashMap() + override fun getOrCreate(alias: String, spec: IdsKeySpec): KeyPair = cache.getOrPut(alias) { + when (spec) { + is IdsKeySpec.Rsa -> KeyPairGenerator.getInstance("RSA").apply { initialize(spec.bits) }.generateKeyPair() + IdsKeySpec.Ec -> KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + } + } +} + +class IdsKeysTest { + + @Test + fun createMintsTheRecordedKeySet() { + val keys = IdsKeys.create(SoftwareKeyPairs(), "user-1") + + assertEquals(2048, (keys.push.public as RSAPublicKey).modulus.bitLength()) + assertEquals(2048, (keys.auth.public as RSAPublicKey).modulus.bitLength()) + assertEquals(1280, (keys.identityEncryption.public as RSAPublicKey).modulus.bitLength()) + assertEquals("EC", keys.identitySigning.public.algorithm) + assertEquals("EC", keys.device.public.algorithm) + assertEquals("EC", keys.prekey.public.algorithm) + for (ec in listOf(keys.identitySigning, keys.device, keys.prekey)) { + assertEquals("EC", ec.public.algorithm) + } + } + + @Test + fun getOrCreateIsIdempotentPerAlias() { + val keyPairs = SoftwareKeyPairs() + val first = keyPairs.getOrCreate(IdsKeys.PUSH_ALIAS, IdsKeySpec.Rsa(bits = 2048)) + val second = keyPairs.getOrCreate(IdsKeys.PUSH_ALIAS, IdsKeySpec.Rsa(bits = 2048)) + assertSame(first, second) + } + + @Test + fun authAliasIsStablePerUserAndDistinctAcrossUsers() { + assertEquals(IdsKeys.authAlias("user-1"), IdsKeys.authAlias("user-1")) + assertTrue(IdsKeys.authAlias("user-1") != IdsKeys.authAlias("user-2")) + assertTrue(IdsKeys.authAlias("user-1").startsWith("ids-auth-")) + assertTrue(!IdsKeys.authAlias("+15551234567").contains("+")) + } + + @Test + fun activationKeysSignThroughIdsSigning() { + val keys = IdsKeys.create(SoftwareKeyPairs(), "user-1") + val nonce = IdsSigning.nonce() + val fields = listOf(byteArrayOf(1), ByteArray(0), byteArrayOf(2), ByteArray(3)) + + val signature = IdsSigning.sign(keys.push.private, nonce, fields) + + val verifier = Signature.getInstance("SHA1withRSA") + verifier.initVerify(keys.push.public) + verifier.update(IdsSigning.signingBytes(nonce, fields)) + assertTrue(verifier.verify(signature.copyOfRange(2, signature.size))) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsRenewalTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsRenewalTest.kt new file mode 100644 index 0000000..da2e483 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsRenewalTest.kt @@ -0,0 +1,162 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** + * [IdsRenewal] end to end on the JVM — the registration itself is injected, so + * the scheduler's full trigger/backoff logic runs without a device. Entities + * are plain data classes, constructed directly. [TieredIdentityCachePolicy]'s + * verdict boundaries are covered too (it is pure). + */ +class IdsRenewalTest { + + private class Harness(var now: Long = 1_000_000_000_000L) { + val attempts = mutableListOf() + var fail = false + val renewal = IdsRenewal(nowMs = { now }) { reason -> + attempts.add(reason) + if (fail) Result.failure(IllegalStateException("registration failed")) + else Result.success(Unit) + } + } + + private fun user(registeredAt: Long, hbi: Long?, notAfter: Long) = IdsRegisteredUserEntity( + userId = "u", + status = 0, + identityCertDer = byteArrayOf(1), + identityCertNotAfterEpochMs = notAfter, + urisJson = "[\"tel:+15551234567\"]", + nextHbiSeconds = hbi, + registeredAtEpochMs = registeredAt, + ) + + @Test + fun `deadline is the earliest per-user bound capped at 45 days since last registration`() { + val registeredAt = 1_000_000_000_000L + val farFuture = registeredAt + 100L * 24 * 60 * 60 * 1000 // beyond the cap + val a = user(registeredAt, hbi = 90L * 24 * 60 * 60, notAfter = farFuture) + val b = user(registeredAt, hbi = 80L * 24 * 60 * 60, notAfter = farFuture) + // Earliest hbi bound wins across users. + assertEquals(registeredAt + 80L * 24 * 60 * 60 * 1000, renewalDeadlineEpochMs(listOf(a, b), null)) + // 45-day cap applies (C41 client policy, caller-layered). + assertEquals( + registeredAt + IdsRenewal.CAP_MS, + renewalDeadlineEpochMs(listOf(a), registeredAt), + ) + // Nothing registered → null. + assertEquals(null, renewalDeadlineEpochMs(emptyList(), null)) + } + + @Test + fun `timer fires only past the deadline and respects failure backoff`() = Harness().let { h -> runBlocking { + val registeredAt = h.now + val users = listOf(user(registeredAt, hbi = 60_000, notAfter = registeredAt + 100L * 24 * 60 * 60 * 1000)) + // Not due. + assertEquals(false, h.renewal.onTimer(users, registeredAt).attempted) + h.now += 59_999 * 1000 + assertEquals(false, h.renewal.onTimer(users, registeredAt).attempted) + // Due → attempt, success clears state. + h.now += 1_000 + val due = h.renewal.onTimer(users, registeredAt) + assertTrue(due.attempted && due.success == true) + // A re-registered schedule (registration time moves to now) is not due again. + h.now += 60_000 * 1000 + assertEquals( + false, + h.renewal.onTimer(listOf(user(h.now, hbi = 60_000, notAfter = h.now + 100L * 24 * 60 * 60 * 1000)), h.now).attempted, + ) + } } + + @Test + fun `timer backs off exponentially after failures, capped at 1 day`() = Harness().let { h -> runBlocking { + h.fail = true + val registeredAt = h.now + val users = listOf(user(registeredAt, hbi = 0, notAfter = registeredAt + 100L * 24 * 60 * 60 * 1000)) + assertTrue(h.renewal.onTimer(users, registeredAt).attempted) + // Backoff: still inside each window → not attempted. + repeat(6) { + h.now += 1 + assertEquals(false, h.renewal.onTimer(users, registeredAt).attempted, "retry $it must back off") + } + // Jump past the first backoff window → retry fires; failure count increments. + h.now = registeredAt + IdsRenewal.BACKOFF_MIN_MS + 1 + assertTrue(h.renewal.onTimer(users, h.now).attempted) + h.now = h.now + 2 * IdsRenewal.BACKOFF_MIN_MS + 1 + assertTrue(h.renewal.onTimer(users, h.now).attempted) + // Success resets everything (clock past every backoff + dedup window). + h.fail = false + h.now += IdsRenewal.BACKOFF_MAX_MS + assertTrue(h.renewal.onTimer(listOf(user(h.now, 0, h.now + 1)), h.now).attempted) + assertTrue(h.attempts.last() == "timer") + } } + + @Test + fun `c32 forces re-registration and c66 re-registers only on a handle change`() = Harness().let { h -> runBlocking { + val registered = listOf("tel:+15551234567") + // c=32 → attempt. + assertTrue(h.renewal.onPrivateIdsCommand(32).attempted) + h.now += IdsRenewal.DEDUP_MS // past the 15-second dedup window + // c=66 unchanged → no attempt. + assertFalse(h.renewal.onPrivateIdsCommand(66, registered) { registered }.attempted) + // c=66 with no fetcher → no attempt. + assertFalse(h.renewal.onPrivateIdsCommand(66, registered).attempted) + // c=66 changed → attempt. + assertTrue(h.renewal.onPrivateIdsCommand(66, registered) { listOf("tel:+15550000000") }.attempted) + // c=34 and unknown commands are not re-register triggers (c=34 → the caller clears the cache). + assertFalse(h.renewal.onPrivateIdsCommand(34).attempted) + assertFalse(h.renewal.onPrivateIdsCommand(130).attempted) + assertEquals(listOf("c=32", "c=66"), h.attempts) + } } + + @Test + fun `c66 compares handle sets ignoring order`() = Harness().let { h -> runBlocking { + val registered = listOf("tel:+15551234567", "mailto:a@example.com") + assertFalse(h.renewal.onPrivateIdsCommand(66, registered) { listOf("mailto:a@example.com", "tel:+15551234567") }.attempted) + assertFalse(h.attempts.contains("c=66")) + } } + + @Test + fun `6005 is rate-limited to once per hour`() = Harness().let { h -> runBlocking { + assertTrue(h.renewal.onLookupError6005().attempted) + h.now += IdsRenewal.RATE_6005_MS - 1 + assertFalse(h.renewal.onLookupError6005().attempted) + h.now += 1 + assertTrue(h.renewal.onLookupError6005().attempted) + assertEquals(listOf("6005", "6005"), h.attempts) + } } + + @Test + fun `a trigger within 15 seconds of the last attempt is a no-op`() = Harness().let { h -> runBlocking { + assertTrue(h.renewal.onPrivateIdsCommand(32).attempted) + h.now += IdsRenewal.DEDUP_MS - 1 + val deduped = h.renewal.onPrivateIdsCommand(32) + assertFalse(deduped.attempted) + assertEquals("deduped within 15 s", deduped.reason) + h.now += 1 + assertTrue(h.renewal.onPrivateIdsCommand(32).attempted) + } } + + @Test + fun `tiered cache policy verdicts`() { + val now = 1_000_000_000_000L + val positive = IdsLookupResult( + status = 200, + identities = listOf(IdsIdentity(3600, 60, emptyMap())), + raw = emptyMap(), + ) + val negative = IdsLookupResult(status = 404, identities = emptyList(), raw = emptyMap()) + // Fresh inside the tier; REFRESH past it; STALE past the hard bound. + assertEquals(CacheFreshness.FRESH, TieredIdentityCachePolicy.freshness(positive, now - 1, now)) + assertEquals(CacheFreshness.REFRESH, TieredIdentityCachePolicy.freshness(positive, now - TieredIdentityCachePolicy.POSITIVE_TTL_MS, now)) + // A negative result is past its 1-hour tier long before the positive tier. + assertEquals(CacheFreshness.REFRESH, TieredIdentityCachePolicy.freshness(negative, now - TieredIdentityCachePolicy.POSITIVE_TTL_MS, now)) + assertEquals(CacheFreshness.REFRESH, TieredIdentityCachePolicy.freshness(negative, now - TieredIdentityCachePolicy.NEGATIVE_TTL_MS, now)) + assertEquals(CacheFreshness.STALE, TieredIdentityCachePolicy.freshness(positive, now - TieredIdentityCachePolicy.HARD_TTL_MS, now)) + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/MacHardwareInfoExportTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/MacHardwareInfoExportTest.kt new file mode 100644 index 0000000..da77d42 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/MacHardwareInfoExportTest.kt @@ -0,0 +1,300 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.security.MessageDigest +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The §1.1 Mac-Hardware-Info export-code parser (C58). Fixtures are hand-built with a + * test-local protobuf encoder over the recorded field map; the restricted mode is encrypted + * test-side with an independent EVP_BytesToKey implementation. + */ +class MacHardwareInfoExportTest { + + @Test + fun `parses the raw payload bytes with every recorded field`() { + val export = MacHardwareInfoExportCode.parsePayload(FULL_PAYLOAD(flag = 0)) + + assertTrue(!export.preventSharing) + assertEquals("13.4", export.macOsProductVersion) + assertEquals(1640L, export.protocolVersion) + assertEquals("DEVICE-UUID", export.deviceId) + assertEquals("iCloudHelper/282 (test)", export.icloudHelperUserAgent) + assertEquals("AOSKit/282 (test)", export.aosKitVersion) + + val id = export.identifiers + assertEquals("MacBookPro18,3", id.productName) + assertEquals(MAC_ADDRESS.toList(), id.macAddress!!.toList()) + assertEquals("C02SERIALVALUE", id.serialNumber) + assertEquals("PLATFORM-UUID", id.platformUuid) + assertEquals("ROOT-DISK-UUID", id.rootDiskUuid) + assertEquals("Mac-BBAA2211FFEE0011", id.boardId) + assertEquals("21G217", id.osBuildNumber) + assertEquals(listOf(1, 2, 3), id.serialEnc!!.toList()) + assertEquals(listOf(4, 5), id.platformUuidEnc!!.toList()) + assertEquals(listOf(6), id.rootDiskUuidEnc!!.toList()) + assertEquals(ROM.toList(), id.rom.toList()) + assertEquals(listOf(7, 8, 9, 10), id.romEnc!!.toList()) + assertEquals("MLB-STRING-VALUE", id.logicBoardSerial) + assertEquals(listOf(11, 12), id.mlbEnc!!.toList()) + } + + @Test + fun `parses plain base64 text`() { + val export = MacHardwareInfoExportCode.parse( + Base64.getEncoder().encodeToString(FULL_PAYLOAD(flag = 0)), + ) + assertEquals("C02SERIALVALUE", export.identifiers.serialNumber) + } + + @Test + fun `the flag byte selects prevent-sharing mode`() { + assertTrue(MacHardwareInfoExportCode.parsePayload(FULL_PAYLOAD(flag = 1)).preventSharing) + assertTrue(!MacHardwareInfoExportCode.parsePayload(FULL_PAYLOAD(flag = 0)).preventSharing) + } + + @Test + fun `unknown fields are skipped`() { + val identifiers = TestProto.message( + TestProto.stringField(1, "MacBookPro18,3"), + TestProto.bytesField(2, MAC_ADDRESS), + TestProto.stringField(3, "C02SERIALVALUE"), + TestProto.stringField(13, "MLB-STRING-VALUE"), + TestProto.bytesField(11, ROM), + TestProto.varintField(20, 12345L), // unknown + TestProto.bytesField(21, byteArrayOf(9, 9)), // unknown + ) + val outer = TestProto.message( + TestProto.bytesField(1, identifiers), + TestProto.varintField(15, 7L), // unknown varint + TestProto.bytesField(16, "unknown".toByteArray()), // unknown length-delimited + ) + val export = MacHardwareInfoExportCode.parsePayload("OABS".toByteArray() + byteArrayOf(0) + outer) + assertEquals("C02SERIALVALUE", export.identifiers.serialNumber) + assertNull(export.macOsProductVersion) + } + + @Test + fun `wrong magic fails loudly`() { + val payload = "XABS".toByteArray() + byteArrayOf(0) + TestProto.message() + val exception = assertFailsWith { + MacHardwareInfoExportCode.parsePayload(payload) + } + assertTrue(exception.message!!.contains("magic")) + assertFailsWith { + MacHardwareInfoExportCode.parse(Base64.getEncoder().encodeToString(payload)) + } + } + + @Test + fun `truncated payload, missing identifier message, and bad flag fail loudly`() { + assertFailsWith { MacHardwareInfoExportCode.parsePayload("OA".toByteArray()) } + assertFailsWith { + MacHardwareInfoExportCode.parsePayload("OABS".toByteArray() + byteArrayOf(0)) + } + val noIdentifiers = TestProto.message(TestProto.stringField(2, "13.4")) + assertFailsWith { + MacHardwareInfoExportCode.parsePayload("OABS".toByteArray() + byteArrayOf(0) + noIdentifiers) + } + assertFailsWith { + MacHardwareInfoExportCode.parsePayload("OABS".toByteArray() + byteArrayOf(2) + noIdentifiers) + } + } + + @Test + fun `identifier message without the required fields fails loudly`() { + val identifiers = TestProto.message(TestProto.stringField(1, "MacBookPro18,3")) + val outer = TestProto.message(TestProto.bytesField(1, identifiers)) + assertFailsWith { + MacHardwareInfoExportCode.parsePayload("OABS".toByteArray() + byteArrayOf(0) + outer) + } + } + + @Test + fun `the hardcoded protocol version is the recorded 1640`() { + assertEquals(1640L, MacHardwareInfoExportCode.PROTOCOL_VERSION) + } + + @Test + fun `restricted mode decrypts with the activation code and parses`() { + val code = "MBABCDEFGH23456789ABCDEFGH234567" + val container = restrictedContainer(FULL_PAYLOAD(flag = 1), code) + + val export = MacHardwareInfoExportCode.parse( + Base64.getEncoder().encodeToString(container), + activationCode = code, + ) + assertTrue(export.preventSharing) + assertEquals("C02SERIALVALUE", export.identifiers.serialNumber) + assertEquals("MLB-STRING-VALUE", export.identifiers.logicBoardSerial) + } + + @Test + fun `restricted mode without or with the wrong activation code fails loudly`() { + val code = "MBABCDEFGH23456789ABCDEFGH234567" + val container = Base64.getEncoder().encodeToString(restrictedContainer(FULL_PAYLOAD(flag = 0), code)) + assertFailsWith { MacHardwareInfoExportCode.parse(container) } + val exception = assertFailsWith { + MacHardwareInfoExportCode.parse(container, activationCode = "MBZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") + } + assertTrue(exception.message!!.contains("activation code")) + } + + @Test + fun `plain text without a container prefix ignores the activation code`() { + val export = MacHardwareInfoExportCode.parse( + Base64.getEncoder().encodeToString(FULL_PAYLOAD(flag = 0)), + activationCode = "not-a-code-for-plain-mode", + ) + assertEquals("C02SERIALVALUE", export.identifiers.serialNumber) + } + + @Test + fun `parsed identifiers feed the anisette hardware headers`() { + val export = MacHardwareInfoExportCode.parsePayload(FULL_PAYLOAD(flag = 0)) + val headers = AnisetteHeaderProvider( + machineId, + export.identifiers, + AnisetteOtpTestDouble("otp-bytes".toByteArray()), + AnisetteValueFormats({ "t-$it" }, "UTC", "en_US", "17106176", "device-id"), + ).headers(nowEpochMs = 1_000L, oneTimeIdentifier = byteArrayOf(9)) + + assertEquals("C02SERIALVALUE", headers["X-Apple-I-SRL-NO"]) + assertEquals("MLB-STRING-VALUE", headers["X-Apple-I-MLB"]) + assertEquals(ROM.joinToString("") { "%02x".format(it) }, headers["X-Apple-I-ROM"]) + } + + // ---- hand-built protobuf encoder + restricted-mode encryptor (test-side only) ---- + + /** Minimal protobuf wire writer for the fixtures (main side only decodes). */ + private object TestProto { + fun varint(value: Long): ByteArray { + var v = value + val out = ArrayList() + while (true) { + if (v and 0x7f.inv() == 0L) { + out.add(v.toByte()) + return out.toByteArray() + } + out.add(((v and 0x7f) or 0x80).toByte()) + v = v ushr 7 + } + } + + fun message(vararg fields: ByteArray): ByteArray { + val size = fields.sumOf { it.size } + return ByteArray(size).also { buffer -> + var at = 0 + fields.forEach { it.copyInto(buffer, at); at += it.size } + } + } + + private fun tag(number: Int, wireType: Int): ByteArray = varint((number.toLong() shl 3) or wireType.toLong()) + + fun stringField(number: Int, value: String): ByteArray = + lenDelimited(number, value.toByteArray(Charsets.UTF_8)) + + fun bytesField(number: Int, value: ByteArray): ByteArray = lenDelimited(number, value) + + fun varintField(number: Int, value: Long): ByteArray = tag(number, 0) + varint(value) + + private fun lenDelimited(number: Int, payload: ByteArray): ByteArray = + tag(number, 2) + varint(payload.size.toLong()) + payload + } + + /** Independent EVP_BytesToKey (§1.1): repeated MD5 of prev ‖ password ‖ salt → 32-byte key + 16-byte IV. */ + private fun evpKeyIv(password: ByteArray, salt: ByteArray): Pair { + val md5 = MessageDigest.getInstance("MD5") + var prev = ByteArray(0) + val derived = ByteArray(48) + var at = 0 + while (at < 48) { + prev = md5.digest(prev + password + salt) + val chunk = minOf(prev.size, 48 - at) + System.arraycopy(prev, 0, derived, at, chunk) + at += chunk + } + return derived.copyOfRange(0, 32) to derived.copyOfRange(32, 48) + } + + /** Builds the §1.1 restricted container: `Salted__` ‖ 8-byte salt ‖ AES-256-CBC/PKCS7 payload. */ + private fun restrictedContainer(payload: ByteArray, activationCode: String): ByteArray { + val salt = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) + val (key, iv) = evpKeyIv(activationCode.toByteArray(Charsets.UTF_8), salt) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return "Salted__".toByteArray(Charsets.US_ASCII) + salt + cipher.doFinal(payload) + } + + companion object { + private val MAC_ADDRESS = byteArrayOf(0xA1.toByte(), 0xB2.toByte(), 0xC3.toByte(), 0x01, 0x02, 0x03) + private val ROM = byteArrayOf(0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F) + private val machineId = byteArrayOf(1, 2, 3, 4) + + private fun identifierMessage() = TestProto.message( + TestProto.stringField(1, "MacBookPro18,3"), + TestProto.bytesField(2, MAC_ADDRESS), + TestProto.stringField(3, "C02SERIALVALUE"), + TestProto.stringField(4, "PLATFORM-UUID"), + TestProto.stringField(5, "ROOT-DISK-UUID"), + TestProto.stringField(6, "Mac-BBAA2211FFEE0011"), + TestProto.stringField(7, "21G217"), + TestProto.bytesField(8, byteArrayOf(1, 2, 3)), + TestProto.bytesField(9, byteArrayOf(4, 5)), + TestProto.bytesField(10, byteArrayOf(6)), + TestProto.bytesField(11, ROM), + TestProto.bytesField(12, byteArrayOf(7, 8, 9, 10)), + TestProto.stringField(13, "MLB-STRING-VALUE"), + TestProto.bytesField(14, byteArrayOf(11, 12)), + ) + + private fun FULL_PAYLOAD(flag: Int): ByteArray { + val outer = TestProto.message( + TestProto.bytesField(1, identifierMessage()), + TestProto.stringField(2, "13.4"), + TestProto.varintField(3, 1640L), + TestProto.stringField(4, "DEVICE-UUID"), + TestProto.stringField(5, "iCloudHelper/282 (test)"), + TestProto.stringField(6, "AOSKit/282 (test)"), + ) + return "OABS".toByteArray(Charsets.US_ASCII) + byteArrayOf(flag.toByte()) + outer + } + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/TestTransports.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/TestTransports.kt new file mode 100644 index 0000000..866dddf --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/TestTransports.kt @@ -0,0 +1,73 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.LookupTransport +import dev.fenn.imessage.ids.TunnelReply + +// Port note: these two scripted doubles lived beside the :ids tests in the originating repo +// (one source tree, one package); module boundaries here split them out. They are verbatim +// copies of the :ids test doubles, retargeted at the same interfaces. + +/** + * Scripted [IdsHttp] test double: hands back [responses] in order and records every call. + * One response per expected call — an unscripted extra call throws. + */ +class ScriptedIdsHttp(private val responses: List) : IdsHttp { + + class Call( + val method: String, + val url: String, + val headers: Map, + val body: ByteArray, + val contentType: String, + ) + + val calls = mutableListOf() + private var next = 0 + + override suspend fun get(url: String, headers: Map): IdsHttpResponse { + calls.add(Call("GET", url, headers, ByteArray(0), "")) + return responses[next++] + } + + override suspend fun put( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse { + calls.add(Call("PUT", url, headers, body, contentType)) + return responses[next++] + } + + override suspend fun post( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): IdsHttpResponse { + calls.add(Call("POST", url, headers, body, contentType)) + return responses[next++] + } +} + +/** Scripted [LookupTransport] — the tunnel is faked, never the network. */ +class ScriptedLookupTransport(replies: List) : LookupTransport { + + class Call(val url: String, val headers: Map, val body: ByteArray, val contentType: String) + + val calls = mutableListOf() + private val replies = replies.toList() + private var next = 0 + + override suspend fun exchange( + url: String, + headers: Map, + body: ByteArray, + contentType: String, + ): TunnelReply { + calls.add(Call(url, headers, body, contentType)) + return replies[next++] + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ValidationDataExchangeTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ValidationDataExchangeTest.kt new file mode 100644 index 0000000..466db59 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ValidationDataExchangeTest.kt @@ -0,0 +1,177 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** + * The §1.1 three-step exchange against a scripted transport. + * + * - **Spec-pinned**: the step-1 GET (bag URL → single `cert` data field), the step-2 POST + * (single-key `session-info-request` plist body, NO anisette/auth headers → `session-info` + * data), the bag-key names, and the blob hand-off forms (`validation-data` bytes / + * `X-Mme-Nas-Qualify` base64). + * - **Reconstruction-shape**: that the step-3 context's output is a well-framed signed blob + * (the strategy hypothesis; the live server arbitrates — C5/C6 remaining). + */ +class ValidationDataExchangeTest { + + private val cert = byteArrayOf(1, 2, 3, 4) + private val bagUrl = IdsBagFetcher.IDS_BAG_URL + private val certUrl = "https://identity.example.com/validation-cert" + private val initUrl = "https://identity.example.com/initialize-validation" + + private fun identifiers() = MacHardwareIdentifiers( + serialNumber = "C02SERIAL", + logicBoardSerial = "MLB-STRING", + rom = byteArrayOf(9, 8, 7), + productName = "Macmini9,1", + macAddress = byteArrayOf(1, 2, 3, 4, 5, 6), + platformUuid = "PLATFORM-UUID", + rootDiskUuid = "ROOT-DISK-UUID", + boardId = "Mac-AAAA", + osBuildNumber = "22G513", + serialEnc = byteArrayOf(10), + platformUuidEnc = byteArrayOf(11), + rootDiskUuidEnc = byteArrayOf(12), + romEnc = byteArrayOf(13), + mlbEnc = byteArrayOf(14), + ) + + private fun bagResponse(): ByteArray = XmlPlist.encode( + mapOf( + ValidationBagKeys.CERT to certUrl, + ValidationBagKeys.INITIALIZE to initUrl, + ), + ) + + private fun serverSessionInfo(): ByteArray { + // A real P-256 point: the RAW_PUBLIC_POINT reconstruction parses it as the server key. + val server = EcKeys.p256(java.security.SecureRandom()) + return EcCrypto.compress(server.public) + } + + private fun exchange(http: IdsHttp) = ValidationDataExchange( + http = http, + bags = IdsBagFetcher(http), + logEvent = {}, + ) + + @Test + fun `the three steps run in the recorded order and the blob feeds both sinks`() = runBlocking { + val http = ScriptedIdsHttp( + listOf( + IdsHttpResponse(200, emptyMap(), bagResponse()), + IdsHttpResponse(200, emptyMap(), XmlPlist.encode(mapOf("cert" to cert))), + IdsHttpResponse(200, emptyMap(), XmlPlist.encode(mapOf("session-info" to serverSessionInfo()))), + ), + ) + val result = exchange(http).generate(identifiers(), nowEpochMs = 1_770_000_000_000L) + + // Step order and URLs: bag first (endpoints never hardcoded), then the two bag keys. + assertEquals(3, http.calls.size) + assertEquals("GET", http.calls[0].method) + assertEquals(bagUrl, http.calls[0].url) + assertEquals("GET", http.calls[1].method) + assertEquals(certUrl, http.calls[1].url) + assertEquals("POST", http.calls[2].method) + assertEquals(initUrl, http.calls[2].url) + + // Step 2 is body-only: NO anisette, NO authentication headers (spec §1.1). + assertTrue(http.calls[2].headers.isEmpty()) + assertEquals(ValidationCertClient.CONTENT_TYPE, http.calls[2].contentType) + val requestBody = XmlPlist.decode(http.calls[2].body) as Map<*, *> + val requestKeys = requestBody.keys.toList() + assertEquals(listOf("session-info-request"), requestKeys) + val requestBlob = requestBody["session-info-request"] as ByteArray + assertEquals( + listOf(ValidationDataFields.KA_PUBLIC_KEY, ValidationDataFields.SIGNING_PUBLIC_KEY), + ValidationDataFraming.parse(requestBlob).map { it.tag }.takeLast(2), + ) + + // The final blob is the step-3 output: framed fields + the SIGNATURE field (last). + val fields = ValidationDataFraming.parse(result.blob) + assertEquals(ValidationDataFields.SIGNATURE, fields.last().tag) + assertEquals(1_770_000_000_000L, result.generatedAtEpochMs) + + // Sink 1 — the IDS register body's `validation-data` field takes the blob verbatim (§1.4). + val registerBody = XmlPlist.encode(mapOf("validation-data" to result.blob)) + assertTrue((XmlPlist.decode(registerBody) as Map<*, *>)["validation-data"] is ByteArray) + + // Sink 2 — the §1.5 X-Mme-Nas-Qualify header is the blob base64. + assertEquals(result.blob.toList(), Base64.getDecoder().decode(result.nasQualify()).toList()) + } + + @Test + fun `a step-1 response without a cert data field fails loudly`() = runBlocking { + val http = ScriptedIdsHttp( + listOf( + IdsHttpResponse(200, emptyMap(), bagResponse()), + IdsHttpResponse(200, emptyMap(), XmlPlist.encode(mapOf("wrong" to "shape"))), + ), + ) + assertFailsWith { + exchange(http).generate(identifiers(), nowEpochMs = 0) + } + Unit + } + + @Test + fun `a step-2 failure status fails loudly`() = runBlocking { + val http = ScriptedIdsHttp( + listOf( + IdsHttpResponse(200, emptyMap(), bagResponse()), + IdsHttpResponse(200, emptyMap(), XmlPlist.encode(mapOf("cert" to cert))), + IdsHttpResponse(503, emptyMap(), ByteArray(0)), + ), + ) + assertFailsWith { + exchange(http).generate(identifiers(), nowEpochMs = 0) + } + Unit + } + + @Test + fun `a missing validation bag key fails loudly`() = runBlocking { + val http = ScriptedIdsHttp( + listOf(IdsHttpResponse(200, emptyMap(), XmlPlist.encode(mapOf("unrelated" to "key")))), + ) + assertFailsWith { + exchange(http).generate(identifiers(), nowEpochMs = 0) + } + Unit + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ValidationDataGeneratorTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ValidationDataGeneratorTest.kt new file mode 100644 index 0000000..bb6a79b --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ValidationDataGeneratorTest.kt @@ -0,0 +1,322 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.NativePlist +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistDate +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.longOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.courier.CourierClient +import dev.fenn.imessage.courier.CourierCommands +import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.ids.AppleTrust +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.BagKeyMissingException +import dev.fenn.imessage.ids.IdsBagFetcher +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsIdentity +import dev.fenn.imessage.ids.IdsLookupClient +import dev.fenn.imessage.ids.IdsLookupException +import dev.fenn.imessage.ids.IdsLookupResult +import dev.fenn.imessage.crypto.Hkdf +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.TunnelReply +import dev.fenn.imessage.ids.TunnelStatus +import dev.fenn.imessage.crypto.EcCrypto +import dev.fenn.imessage.crypto.MessageBody +import dev.fenn.imessage.crypto.PairEcEnvelope +import dev.fenn.imessage.crypto.PairEnvelope +import dev.fenn.imessage.crypto.PayloadCommands +import java.security.PrivateKey +import java.security.Signature +import javax.crypto.KeyAgreement +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The §1.1 step-3 context. Two assertion kinds, named per the task split: + * + * - **Spec-pinned**: the hardware-configuration consumption (field set, the five `_enc` + * blobs' label mapping and verbatim pass-through) and the recorded contract shape + * (create → establish → sign order, all state in the context). + * - **Reconstruction-shape**: everything the framing/KDF/signature strategies decide — these + * assertions pin the hypothesis's structure and plumbing, NOT Apple's protocol. The live + * server is the arbiter (C5/C6 remaining). + */ +class ValidationDataGeneratorTest { + + private val certificate = byteArrayOf(1, 2, 3, 4) + + private val hardware = HardwareConfiguration.from( + productName = "Macmini9,1", + macAddress = byteArrayOf(1, 2, 3, 4, 5, 6), + platformSerial = "C02SERIAL", + platformUuid = "PLATFORM-UUID", + rootDiskUuid = "ROOT-DISK-UUID", + boardId = "Mac-AAAA", + osBuildNumber = "22G513", + rom = byteArrayOf(9, 8, 7), + mlb = "MLB-STRING", + serialEnc = byteArrayOf(10), + platformUuidEnc = byteArrayOf(11), + rootDiskUuidEnc = byteArrayOf(12), + romEnc = byteArrayOf(13), + mlbEnc = byteArrayOf(14), + ) + + private fun generator( + strategies: ValidationDataStrategies = ValidationDataStrategies(), + ) = ValidationDataGenerator( + certificateChain = listOf(certificate), + hardware = hardware, + strategies = strategies, + random = java.security.SecureRandom(), + ) + + private fun fieldsOf(blob: ByteArray) = ValidationDataFraming.parse(blob) + + private fun fieldValue(blob: ByteArray, tag: Int): ByteArray = + fieldsOf(blob).first { it.tag == tag }.value + + // ---- spec-pinned: the hardware-configuration consumption ---- + + @Test + fun `hardware configuration consumes the recorded field set from the export identifiers`() { + val config = HardwareConfiguration.from( + MacHardwareIdentifiers( + serialNumber = "C02SERIAL", + logicBoardSerial = "MLB-STRING", + rom = byteArrayOf(9, 8, 7), + productName = "Macmini9,1", + macAddress = byteArrayOf(1, 2, 3, 4, 5, 6), + platformUuid = "PLATFORM-UUID", + rootDiskUuid = "ROOT-DISK-UUID", + boardId = "Mac-AAAA", + osBuildNumber = "22G513", + serialEnc = byteArrayOf(10), + platformUuidEnc = byteArrayOf(11), + rootDiskUuidEnc = byteArrayOf(12), + romEnc = byteArrayOf(13), + mlbEnc = byteArrayOf(14), + ), + ) + assertEquals("Macmini9,1", config.productName) + assertEquals("22G513", config.osBuildNumber) + assertEquals("C02SERIAL", config.platformSerial) + assertEquals("PLATFORM-UUID", config.platformUuid) + assertEquals("ROOT-DISK-UUID", config.rootDiskUuid) + assertEquals("Mac-AAAA", config.boardId) + assertEquals("MLB-STRING", config.mlb) + assertEquals(byteArrayOf(1, 2, 3, 4, 5, 6).toList(), config.macAddress.toList()) + assertEquals(byteArrayOf(9, 8, 7).toList(), config.rom.toList()) + } + + @Test + fun `the five _enc blobs pass through under the recorded label mapping`() { + val labels = HardwareConfiguration.EncLabel + assertEquals( + mapOf( + labels.SERIAL to byteArrayOf(10).toList(), + labels.PLATFORM_UUID to byteArrayOf(11).toList(), + labels.ROOT_DISK_UUID to byteArrayOf(12).toList(), + labels.ROM to byteArrayOf(13).toList(), + labels.MLB to byteArrayOf(14).toList(), + ), + hardware.encVariants.mapValues { it.value.toList() }, + ) + } + + @Test + fun `missing _enc variants or a malformed mac address fail loudly`() { + val identifiers = MacHardwareIdentifiers( + serialNumber = "S", + logicBoardSerial = "M", + rom = byteArrayOf(1), + productName = "Macmini9,1", + macAddress = byteArrayOf(1, 2, 3, 4, 5, 6), + platformUuid = "PU", + rootDiskUuid = "RU", + boardId = "Mac-AAAA", + osBuildNumber = "22G513", + serialEnc = byteArrayOf(10), + platformUuidEnc = byteArrayOf(11), + rootDiskUuidEnc = byteArrayOf(12), + romEnc = byteArrayOf(13), + mlbEnc = null, // §1.1 requires all five _enc variants — C57 derivation, C58 delivery + ) + assertFailsWith { HardwareConfiguration.from(identifiers) } + + // §1.1: exactly 6 raw MAC bytes. + assertFailsWith { + HardwareConfiguration.from( + productName = "Macmini9,1", + macAddress = byteArrayOf(1, 2, 3), + platformSerial = "C02SERIAL", + platformUuid = "PLATFORM-UUID", + rootDiskUuid = "ROOT-DISK-UUID", + boardId = "Mac-AAAA", + osBuildNumber = "22G513", + rom = byteArrayOf(9), + mlb = "MLB-STRING", + serialEnc = byteArrayOf(10), + platformUuidEnc = byteArrayOf(11), + rootDiskUuidEnc = byteArrayOf(12), + romEnc = byteArrayOf(13), + mlbEnc = byteArrayOf(14), + ) + } + } + + // ---- reconstruction-shape: the contract order and the framing/KDF/signature plumbing ---- + + @Test + fun `session-info-request frames the chain, the hardware configuration and the context public keys`() { + val request = generator().createSessionInfoRequest() + val tags = fieldsOf(request).map { it.tag } + // Reconstruction shape (TLV_V1): every §1.1 hardware field, the step-1 cert, both pubs. + assertEquals( + listOf( + ValidationDataFields.PRODUCT_NAME, + ValidationDataFields.OS_BUILD_NUMBER, + ValidationDataFields.PLATFORM_SERIAL, + ValidationDataFields.PLATFORM_UUID, + ValidationDataFields.ROOT_DISK_UUID, + ValidationDataFields.BOARD_ID, + ValidationDataFields.MAC_ADDRESS, + ValidationDataFields.ROM, + ValidationDataFields.MLB, + ValidationDataFields.SERIAL_ENC, + ValidationDataFields.PLATFORM_UUID_ENC, + ValidationDataFields.ROOT_DISK_UUID_ENC, + ValidationDataFields.ROM_ENC, + ValidationDataFields.MLB_ENC, + ValidationDataFields.CERTIFICATE, + ValidationDataFields.KA_PUBLIC_KEY, + ValidationDataFields.SIGNING_PUBLIC_KEY, + ), + tags, + ) + assertEquals(certificate.toList(), fieldValue(request, ValidationDataFields.CERTIFICATE).toList()) + assertEquals(33, fieldValue(request, ValidationDataFields.KA_PUBLIC_KEY).size) + assertEquals(33, fieldValue(request, ValidationDataFields.SIGNING_PUBLIC_KEY).size) + } + + @Test + fun `the key-agreement keypair is fresh per context`() { + val first = fieldValue(generator().createSessionInfoRequest(), ValidationDataFields.KA_PUBLIC_KEY) + val second = fieldValue(generator().createSessionInfoRequest(), ValidationDataFields.KA_PUBLIC_KEY) + assertTrue(!first.contentEquals(second)) + } + + @Test + fun `sign before establishKeys fails loudly`() { + assertFailsWith { generator().sign() } + } + + @Test + fun `hmac strategy - sign frames the context fields plus a session-key mac over them`() { + val server = EcKeys.p256(java.security.SecureRandom()) + val context = generator( + ValidationDataStrategies(signature = ValidationDataStrategies.BlobSignature.HMAC_SHA256), + ) + val request = context.createSessionInfoRequest() + context.establishKeys(EcCrypto.compress(server.public)) + + val blob = context.sign() + val fields = fieldsOf(blob) + val signatureField = fields.last() + val body = ValidationDataFraming.frame(fields.dropLast(1)) + + // The blob = the framed fields (same field list as the request) + the SIGNATURE field. + assertEquals(ValidationDataFields.SIGNATURE, signatureField.tag) + assertEquals( + fieldsOf(request).map { it.tag to it.value.toList() }, + fields.dropLast(1).map { it.tag to it.value.toList() }, + ) + // The MAC verifies against the session key the SERVER-side agreement derives. + val serverShared = agree("ECDH", server.private, EcCrypto.decompress( + fieldValue(request, ValidationDataFields.KA_PUBLIC_KEY), + )) + val serverSessionKey = Hkdf.expand( + Hkdf.extract(ByteArray(0), serverShared), ByteArray(0), 32, + ) + assertTrue(Hkdf.hmac(serverSessionKey, body).contentEquals(signatureField.value)) + } + + @Test + fun `ecdsa strategy - the signature verifies against the context signing public key`() { + val server = EcKeys.p256(java.security.SecureRandom()) + val context = generator( + ValidationDataStrategies( + signature = ValidationDataStrategies.BlobSignature.ECDSA_P256_SHA256, + ), + ) + val request = context.createSessionInfoRequest() + context.establishKeys(EcCrypto.compress(server.public)) + val blob = context.sign() + + val fields = fieldsOf(blob) + assertEquals(ValidationDataFields.SIGNATURE, fields.last().tag) + val body = ValidationDataFraming.frame(fields.dropLast(1)) + val verifier = Signature.getInstance("SHA256withECDSA") + verifier.initVerify(EcCrypto.decompress(fieldValue(request, ValidationDataFields.SIGNING_PUBLIC_KEY))) + verifier.update(body) + assertTrue(verifier.verify(fields.last().value)) + } + + @Test + fun `x25519 scheme is plumbed end to end`() { + val server = EcKeys.x25519(java.security.SecureRandom()) + val context = generator( + ValidationDataStrategies(keyAgreement = ValidationDataStrategies.KeyAgreementScheme.X25519), + ) + val request = context.createSessionInfoRequest() + assertEquals(32, fieldValue(request, ValidationDataFields.KA_PUBLIC_KEY).size) + context.establishKeys(EcKeys.x25519Raw(server.public)) + val blob = context.sign() + assertEquals(ValidationDataFields.SIGNATURE, fieldsOf(blob).last().tag) + } + + @Test + fun `an unparsable session-info fails loudly under the raw-public-point strategy`() { + val context = generator() + assertFailsWith { context.establishKeys(byteArrayOf(1, 2, 3)) } + } + + @Test + fun `a 65-byte uncompressed server point is accepted as session-info`() { + val server = EcKeys.p256(java.security.SecureRandom()) + val context = generator() + context.establishKeys(uncompressed(server.public)) + context.sign() + } + + private fun uncompressed(public: java.security.PublicKey): ByteArray { + val w = (public as java.security.interfaces.ECPublicKey).w + val out = ByteArray(65) + out[0] = 0x04 + fixed32(w.affineX).copyInto(out, 1) + fixed32(w.affineY).copyInto(out, 33) + return out + } + + private fun fixed32(v: java.math.BigInteger): ByteArray { + val raw = v.toByteArray() + val out = ByteArray(32) + raw.copyInto(out, maxOf(0, 32 - raw.size), maxOf(0, raw.size - 32)) + return out + } + + private fun agree(algorithm: String, privateKey: PrivateKey, public: java.security.PublicKey): ByteArray { + val agreement = KeyAgreement.getInstance(algorithm) + agreement.init(privateKey) + agreement.doPhase(public, true) + return agreement.generateSecret() + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ValidationRequestStructureTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ValidationRequestStructureTest.kt new file mode 100644 index 0000000..6c91c19 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ValidationRequestStructureTest.kt @@ -0,0 +1,119 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.XmlPlist +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The step-2 validation request's STRUCTURE, pinned against the real capture + * `validation-request.bin` (imessage-identifiers/, 2026-09-11 live run) — WITHOUT committing + * it: the blob is device-derived material (it opens with the machine's product/model + * identifiers and carries per-device key fields), so the privacy rule keeps the bytes out of + * the repo. Recorded from the capture (assert nothing below against network state; these are + * offline constants): + * + * - file: 3898 bytes, an XML plist with a single top-level key `session-info-request` + * whose value is a single `` element (base64) — SHA-256 of the whole file + * 7a692fad21cd998dedcec6df9558604843eee40b4f0b36f04be9523b6c6ab84e; + * - inner blob: 2729 bytes — SHA-256 + * 8c7b4bc7a1c2a815069673055fcd2a33d1c15d04970cb8a3a39356ebfa4c0ac9; + * - blob framing (the invented TLV v1): a flat walk of (1-byte tag, 2-byte big-endian + * length, value) fields, tags 0x01..0x11 in ascending order, consuming the blob exactly; + * field lengths [14, 5, 10, 36, 36, 12, 6, 6, 17, 17, 17, 17, 17, 17, 2385, 33, 33] — + * the 36-byte fields are EC points, the 17-byte fields device-key blobs, the 33-byte + * fields compressed P-256 points, and tag 0x0F carries the 2385-byte Apple certificate + * container (byte-identical, verified offline, to the public `cert` blob of the + * validation-cert-1.0.plist CDN object — committed as a test resource here). + * + * The test rebuilds the wrapper with SYNTHETIC payloads in the recorded layout (only the + * public 2385-byte container is real) and pins the whole walk — sizes, tag order, plist + * shape and container identity are the capture's, no private byte is. + */ +class ValidationRequestStructureTest { + + private companion object { + /** The capture's TLV layout: tag → field length (see class KDoc). */ + val CAPTURED_FIELD_LENGTHS = intArrayOf( + 14, 5, 10, 36, 36, 12, 6, 6, 17, 17, 17, 17, 17, 17, 2385, 33, 33, + ) + const val BLOB_BYTES = 2729 + const val TAG_0F_INDEX = 14 + } + + private fun certContainer(): ByteArray { + val outer = XmlPlist.decode( + javaClass.getResourceAsStream("/imessage/validation-cert-1.0.plist")!!.readBytes(), + ) as Map<*, *> + return outer["cert"] as ByteArray + } + + /** Synthetic blob in the captured layout; tag 0x0F carries the real public container. */ + private fun syntheticBlob(): ByteArray { + val out = java.io.ByteArrayOutputStream(BLOB_BYTES) + CAPTURED_FIELD_LENGTHS.forEachIndexed { index, length -> + out.write(index + 1) // tags 0x01..0x11 ascending + out.write((length ushr 8) and 0xFF) + out.write(length and 0xFF) + if (index == TAG_0F_INDEX) { + out.write(certContainer()) + } else { + out.write(ByteArray(length) { (it + index).toByte() }) // synthetic filler + } + } + return out.toByteArray() + } + + @Test + fun `synthetic wrapper reproduces the captured plist shape`() { + val wrapper = XmlPlist.encode(mapOf("session-info-request" to syntheticBlob())) + // Same wire shape as the capture: XML plist, one key, one value. + assertTrue(wrapper.decodeToString().startsWith(" + assertEquals(setOf("session-info-request"), parsed.keys) + val blob = assertIs(parsed["session-info-request"]) + assertEquals(BLOB_BYTES, blob.size) + } + + @Test + fun `the captured tlv walk consumes the blob exactly with the recorded layout`() { + val blob = syntheticBlob() + assertEquals(BLOB_BYTES, blob.size) // 2385 of the bytes are the real container + var pos = 0 + val tags = ArrayList() + val lengths = ArrayList() + while (pos < blob.size) { + val tag = blob[pos].toInt() and 0xFF + val length = ((blob[pos + 1].toInt() and 0xFF) shl 8) or (blob[pos + 2].toInt() and 0xFF) + pos += 3 + length + tags.add(tag) + lengths.add(length) + } + // Tags 0x01..0x11 ascending, the captured lengths, and the walk ends exactly + // (rev 22's "walks the invented TLV v1 to the last byte with ZERO slack"). + assertEquals((1..17).toList(), tags) + assertContentEquals(CAPTURED_FIELD_LENGTHS, lengths.toIntArray()) + assertEquals(BLOB_BYTES, pos) + } + + @Test + fun `tag 0f carries the apple certificate container byte-identically`() { + val blob = syntheticBlob() + var pos = 0 + var payload: ByteArray? = null + while (pos < blob.size) { + val length = ((blob[pos + 1].toInt() and 0xFF) shl 8) or (blob[pos + 2].toInt() and 0xFF) + if ((blob[pos].toInt() and 0xFF) == 0x0F) payload = blob.copyOfRange(pos + 3, pos + 3 + length) + pos += 3 + length + } + // Verified offline against the capture: the blob's tag-0x0F field is byte-identical + // to the public CDN validation-certificate container (2385 B, 01 02 + two DERs). + assertContentEquals(certContainer(), payload) + assertEquals(2385, payload!!.size) + assertEquals(0x01, payload[0].toInt()) + assertEquals(0x02, payload[1].toInt()) + } +} diff --git a/registration/src/test/resources/imessage/AppleServerAuthenticationCA-2010.pem b/registration/src/test/resources/imessage/AppleServerAuthenticationCA-2010.pem new file mode 100644 index 0000000..d24695d --- /dev/null +++ b/registration/src/test/resources/imessage/AppleServerAuthenticationCA-2010.pem @@ -0,0 +1,47 @@ +-----BEGIN CERTIFICATE----- +MIID+DCCAuCgAwIBAgIII2l0BK3LgxQwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UE +BhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsTHUFwcGxlIENlcnRp +ZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBSb290IENBMB4XDTE0 +MDMwODAxNTMwNFoXDTI5MDMwODAxNTMwNFowbTEnMCUGA1UEAwweQXBwbGUgU2Vy +dmVyIEF1dGhlbnRpY2F0aW9uIENBMSAwHgYDVQQLDBdDZXJ0aWZpY2F0aW9uIEF1 +dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5Jhawy4ercRWSjt+qPuGA11O6pGDM +fIVy9zB8CU9XDUr/4V7JS1ATAmSxvTk10dcEUcEY+iL6rt+YGNa/Tk1DEPoliJ/T +QIV25SKBtlRFc5qL45xIGoZ6w1Hi2pX4pH3bMN5sDsTF9WyY56b6VyAdGXN6Ds1j +D7cniC7hmmiCuEBsYxYkZivnsuJUfeeIOaIbgT4C0znYl3dKMgzWCgqzBJvxcm9j +qBUebDfoD9tTkNYpXLxqV5tGeAo+JOqaP6HYP/XbbqhsgrXdmTjsklaUpsVzJtGu +CLLGUueOdkuJuFQPbuDZQtsqZYdGFLuWuFe7UeaEE/cNobaJrHzRIXSrAgMBAAGj +gaYwgaMwHQYDVR0OBBYEFCzFbVLdMe+M7AiB7d/cykMARQHQMA8GA1UdEwEB/wQF +MAMBAf8wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wLgYDVR0fBCcw +JTAjoCGgH4YdaHR0cDovL2NybC5hcHBsZS5jb20vcm9vdC5jcmwwDgYDVR0PAQH/ +BAQDAgEGMBAGCiqGSIb3Y2QGAgwEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQAj8QZ+ +UEGBol7TcKRJka/YzGeMoSV9xJqTOS/YafsbQVtE19lryzslCRry9OPHnOiwW/Df +3SIlERWTuUle2gxmel7Xb/Bj1GWMxHpUfVZPZZr92sSyyLC4oct94EeoQBW4Fhnt +W2GO36rQzdI6wH46nyJO39/0ThrNk//Q8EVVZDM+1OXaaKATinYwJ9S/+B529vnD +AO+xg+pTbVw1xw0HAbr4Ybn+xZprQ2GBA+u6X3Cd6G+UJEvczpKoLqI1PONJ4BZ3 +otxruY0YQrk2lkMyxst2mTU22FbGmF3Db6V+lcLVegoCIGZ4kvJnpCMN6Am9zCEx +EKC9vrXdTN1GA5mZ +-----END CERTIFICATE----- +--- +Server certificate +subject=CN = query.ess.apple.com, O = Apple Inc., ST = California, C = US +issuer=CN = Apple Server Authentication CA, OU = Certification Authority, O = Apple Inc., C = US +--- +No client certificate CA names sent +Peer signing digest: SHA256 +Peer signature type: RSA-PSS +Server Temp Key: X25519, 253 bits +--- +SSL handshake has read 3091 bytes and written 401 bytes +Verification error: unable to get local issuer certificate +--- +New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 +Server public key is 2048 bit +Secure Renegotiation IS NOT supported +Compression: NONE +Expansion: NONE +No ALPN negotiated +Early data was not sent +Verify return code: 20 (unable to get local issuer certificate) +--- +DONE diff --git a/registration/src/test/resources/imessage/AppleServerAuthenticationCA2-G3.pem b/registration/src/test/resources/imessage/AppleServerAuthenticationCA2-G3.pem new file mode 100644 index 0000000..445a002 --- /dev/null +++ b/registration/src/test/resources/imessage/AppleServerAuthenticationCA2-G3.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIIC7DCCAnKgAwIBAgIIW0OkxHdBzOwwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTcwMTA1MjAzOTU4WhcNMzExMDIxMDAwMDAwWjB1MSkwJwYDVQQDDCBBcHBsZSBT +ZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgMjEmMCQGA1UECwwdQXBwbGUgQ2VydGlm +aWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYT +AlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEz+Muhn4KiyKLzXgfmvcuESbE +MfG3t2AtNnjT94GFUYSbuwCon24UZOZN+8wZn89WAGrZUZx34uIHxna+7hD1LqOB ++TCB9jAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFLuw3qFYM4iapIqZ3r69 +66/ayySrMEgGCCsGAQUFBwEBBDwwOjA4BggrBgEFBQcwAYYsaHR0cDovL29jc3Au +YXBwbGUuY29tL29jc3AwMy1hcHBsZXJvb3RjYWczMDQwNwYDVR0fBDAwLjAsoCqg +KIYmaHR0cDovL2NybC5hcHBsZS5jb20vYXBwbGVyb290Y2FnMy5jcmwwHQYDVR0O +BBYEFNEx0cpREQ/NyZu1M9q3zr5WhDU7MA4GA1UdDwEB/wQEAwIBBjAQBgoqhkiG +92NkBgIMBAIFADAKBggqhkjOPQQDAwNoADBlAjEAieK078D03E4+GF1Oz0+Oy2WV +K8fp836ut88rHrmsro+H/cMEwAATlJlxipamyoYfAjALSohKmzrVZ3ksmz6LVd5Q +HixZymHQoE+q0ernZle4nWGXRBSCVY1ZDxz//PafOuk= +-----END CERTIFICATE----- +--- +Server certificate +subject=CN = init.ess.apple.com, O = Apple Inc., ST = California, C = US +issuer=CN = Apple Server Authentication CA 2, OU = Apple Certification Authority, O = Apple Inc., C = US +--- +No client certificate CA names sent +Peer signing digest: SHA256 +Peer signature type: ECDSA +Server Temp Key: X25519, 253 bits +--- +SSL handshake has read 1909 bytes and written 400 bytes +Verification error: unable to get local issuer certificate +--- +New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 +Server public key is 256 bit +Secure Renegotiation IS NOT supported +Compression: NONE +Expansion: NONE +No ALPN negotiated +Early data was not sent +Verify return code: 20 (unable to get local issuer certificate) +--- +DONE diff --git a/registration/src/test/resources/imessage/validation-cert-1.0.plist b/registration/src/test/resources/imessage/validation-cert-1.0.plist new file mode 100644 index 0000000..26fd3b9 --- /dev/null +++ b/registration/src/test/resources/imessage/validation-cert-1.0.plist @@ -0,0 +1,9 @@ + + + + + + cert + AQIAAAQWMIIEEjCCAvqgAwIBAgIBHDANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMTEwMTI2MTkwMTM0WhcNMTkwMTI2MTkwMTM0WjCBhTELMAkGA1UEBhMCVVMxEzARBgNVBAoMCkFwcGxlIEluYy4xJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MTkwNwYDVQQDDDBBcHBsZSBTeXN0ZW0gSW50ZWdyYXRpb24gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDa4A+Yl8tYKYYqC7ieGVoxwy0OaixSAe4dA/uCQWnNUCY2ercMbw45A7jUGFajCLI8w/s2QeTXyGdgMgtOMn2H9/3NU7AauvwfbMlFB62COPOofMROwrFW2T6ybW0EQRrBmkfArBV8LXiRqweiZbF6g92YS3dA2O5Q68drWAgGl1dVfSf4Cua1Feenk/nxgOZCeT8W0zKdEXZBKQoxCe8PW/jzp6n3Ug27+C10rKZJHx/OewWnhT2+z6KnqiOFZv7FFhJ+W+Ixd5ECCd9+fuSK4OxBrBcsBOC8eaSJeEQGiztLoLyE4rCCtTK+BBwD7YI+dTcUz3WfgjFtzwkUhtEnAgMBAAGjga4wgaswDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFPAwc2Py7x2szOYJMsH6eXqxaVBoMB8GA1UdIwQYMBaAFCvQaUeUdgn+9GuNLkCm90dNfwheMDYGA1UdHwQvMC0wK6ApoCeGJWh0dHA6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2Evcm9vdC5jcmwwEAYKKoZIhvdjZAYCBAQCBQAwDQYJKoZIhvcNAQEFBQADggEBAD17j60fDCKKm0ujz/grsB9o4Qz3nCSDFgMt07Ko0EPorzyXJsit1SzETFVTAUnQ4rT75tty0Zi7/JvITrePzGWGf0S52icqTt/L39N930Fx+LPAHaIKM7nsK8Vzcvvhyl2OLzT0a8RPD8iKrA/7byVut66Ox+QCuCBOXVZMSZexJHR+yZOTNIyZ0afAHNPUwq5p65+fV+Jox8rVxSKCZEFY/njRysH5NmprRPezhnJ6ZEAXMZ28rHXw+jNR5b0Balg/8ACumVwKwsnpXhyHAuygCFVBKpuMZIWOUAPN4BGvznIZ61Lzr5Ktky6Undav/8Am8d6UkhzZvD02zFX6ONsAAAUxMIIFLTCCBBWgAwIBAgIISyyRSB2bfaAwDQYJKoZIhvcNAQEFBQAwgYUxCzAJBgNVBAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTE5MDcGA1UEAwwwQXBwbGUgU3lzdGVtIEludGVncmF0aW9uIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTExMDMyNTAxMTMzMloXDTE0MDMyNDAxMTMzMlowaTEdMBsGA1UEAwwURFJNIFRlY2hub2xvZ2llcyBBMDExJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALQGbX5lc5fhv0mx+poiLqfTcYEga0lBFcLbYHrGordNei+OwWMHHATMk9jgDci48lvObfpCyxBAwiQKp+QdJoKKPjCGXe0XOO6Hq73oSEpJd4Uut5GEmyl9QQWgefWtjMEL2J1p55yyqUbQS/4JGFAkillHKyJVR+1RIp1C6Z3ugcNHzeRvCio/TyvSBNC4jOhkmN/OYFObiBrP1MINdGW/84WHX0uHEKKHim0+QFUO+Z+ZzDKTg1GIybn4XskZXxfna5t8Ot3/aN/U0TRVdOz3S+gckHWF8vxD/6VEI1I/+/Uh44MWP74KdPk8dJlq/j/SWqFQ4y6LSA0iJjvVnkkCAwEAAaOCAbowggG2MB0GA1UdDgQWBBTSJCP76+iOj3GchO5icz3pXiQJLzAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFPAwc2Py7x2szOYJMsH6eXqxaVBoMIIBDgYDVR0gBIIBBTCCAQEwgf4GCSqGSIb3Y2QFATCB8DAoBggrBgEFBQcCARYcaHR0cDovL3d3dy5hcHBsZS5jb20vYXBwbGVjYTCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlzIGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2YgdGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9ucyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBwcmFjdGljZSBzdGF0ZW1lbnRzLjAvBgNVHR8EKDAmMCSgIqAghh5odHRwOi8vY3JsLmFwcGxlLmNvbS9hc2ljYS5jcmwwDgYDVR0PAQH/BAQDAgWgMBMGCiqGSIb3Y2QGDAEBAf8EAgUAMA0GCSqGSIb3DQEBBQUAA4IBAQB9eadjbkE7vsHOsYz6bTAguLpJMJI9HVXOucItS2LFykD2t7yx9tKm+q0Ba08czK7ORiD/wrPALHdP0BNETIfHYS0Px8xDLjc6N/2umJoStkmwqnfTU4GWgM2E23OqR6ggVjbC2aXpDDwiHXnv57BPCX1e+7Iio7b3IyUJg3moNFaE5kWtIqEcVZyiLx+2Ibn/2A/Jcwl28AMXGY/po/zmQstfZIaWjGg/wqBYQtSfdm2Vv8D32xR0/Fqogsem/FaKN7fIcpy8m0TRRuKNJNl/J3nxdLnFsrDC4SYG5P+vpQvZox6V20SRzOlLAjID5lL2pypaIzTQHRfy6+rCeQrp + + diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..555be36 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,28 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { + name = "JitPack" + url = uri("https://jitpack.io") + } + } +} + +rootProject.name = "open-madrid" + +include(":codec") +include(":courier") +include(":ids") +include(":crypto") +include(":registration") +include(":engine")