diff --git a/codec/src/main/kotlin/dev/fenn/imessage/codec/XmlPlist.kt b/codec/src/main/kotlin/dev/fenn/imessage/codec/XmlPlist.kt index 03075bc..f6d8afa 100644 --- a/codec/src/main/kotlin/dev/fenn/imessage/codec/XmlPlist.kt +++ b/codec/src/main/kotlin/dev/fenn/imessage/codec/XmlPlist.kt @@ -149,10 +149,17 @@ object XmlPlist { /** * The Albert activation response (§1.2): an XML document whose outer `` block * wraps a plist document. Parses the plist inside the Protocol element. + * + * Apple sometimes wraps the `` inside a larger response document (rev 26, + * live 2026-10-02: the activation answered `……`, + * whose surrounding UI XML is not well-formed enough for the parser) — the Protocol block + * is extracted by span first and parsed alone; a bare `` document parses as before. */ fun decodeProtocol(bytes: ByteArray): Any? { + val text = PROTOCOL_SPAN.find(String(bytes, Charsets.UTF_8))?.value + ?: String(bytes, Charsets.UTF_8) val document = try { - builder().parse(ByteArrayInputStream(bytes)) + builder().parse(ByteArrayInputStream(text.toByteArray(Charsets.UTF_8))) } catch (e: SAXParseException) { throw PlistFormatException("malformed XML at line ${e.lineNumber}: ${e.message}") } catch (e: Exception) { @@ -241,4 +248,7 @@ object XmlPlist { } return out } + + /** The §1.2 activation response's `` span, extracted from larger documents (rev 26). */ + private val PROTOCOL_SPAN = Regex(".*", RegexOption.DOT_MATCHES_ALL) } diff --git a/codec/src/test/kotlin/dev/fenn/imessage/codec/XmlPlistTest.kt b/codec/src/test/kotlin/dev/fenn/imessage/codec/XmlPlistTest.kt index c6ff893..e4ff516 100644 --- a/codec/src/test/kotlin/dev/fenn/imessage/codec/XmlPlistTest.kt +++ b/codec/src/test/kotlin/dev/fenn/imessage/codec/XmlPlistTest.kt @@ -205,4 +205,28 @@ class XmlPlistTest { assertFailsWith { XmlPlist.encode(mapOf("a" to BpUid(1uL))) } assertFailsWith { XmlPlist.encode(mapOf("a" to null)) } } + + @Test + fun decodeProtocolParsesTheBareProtocolDocument() { + val document = "a1" + assertEquals(mapOf("a" to 1L), XmlPlist.decodeProtocol(document.toByteArray())) + } + + @Test + fun decodeProtocolExtractsTheSpanFromALargerResponseDocument() { + // rev 26 (live 2026-10-02): the activation answered … + // … — the surrounding UI XML is not well-formed enough for the parser, so + // the Protocol span is extracted and parsed alone. + val wrapped = """ + + + device-activation + ()), + XmlPlist.decodeProtocol(wrapped.toByteArray()), + ) + } } diff --git a/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierClient.kt b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierClient.kt index bc26d8b..7fe75e7 100644 --- a/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierClient.kt +++ b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierClient.kt @@ -17,7 +17,9 @@ 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. + * to the bare hostname, legacy or packed frame format by the negotiated ALPN (§3.2, C19 — + * `apns-pack-v1` selects the packed codec; legacy stays the default path), 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 @@ -28,8 +30,10 @@ import kotlinx.coroutines.withContext * 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. + * Client certificate: on the packed `apns-pack-v1` path no TLS client certificate is + * demanded (rev 26 — the push certificate rides only in the connect frame); the C18 + * post-handshake client-cert alert belongs to the no-ALPN legacy path. No client + * certificate is configured here either way. */ class CourierClient( private val config: Config, @@ -48,8 +52,72 @@ class CourierClient( ) interface Handler { + /** The negotiated codec; build and send the connect through it (§3.2, C19). */ + fun onConnected(codec: Codec) {} + fun onFrame(frame: CourierFrame.Frame) {} - fun onConnected() {} + + /** Packed-format frame (§3.2, C19) — delivered instead of [onFrame] on a packed connection. */ + fun onPackedFrame(frame: CourierPacked.Frame) {} + } + + /** The wire codec chosen by the negotiated ALPN (spec §3.2, C19). */ + interface Codec { + fun sendConnect(token: ByteArray?, certDer: ByteArray, nonce: ByteArray, signature: ByteArray) + + fun sendSetState() + + fun sendFilter(token: ByteArray, enabledTopics: List) + + fun sendPing() + } + + private inner class PackedCodec : Codec { + override fun sendConnect(token: ByteArray?, certDer: ByteArray, nonce: ByteArray, signature: ByteArray) { + sendFrame(CourierPacked.connectFrame(token, certDer, nonce, signature)) + } + + override fun sendSetState() { + sendFrame(CourierPacked.setStateFrame()) + } + + override fun sendFilter(token: ByteArray, enabledTopics: List) { + sendFrame(CourierPacked.filterFrame(token, enabledTopics)) + } + + override fun sendPing() { + sendFrame(CourierPacked.ping()) + } + } + + private inner class LegacyCodec : Codec { + override fun sendConnect(token: ByteArray?, certDer: ByteArray, nonce: ByteArray, signature: ByteArray) { + val frame = CourierConnect.connectFrame( + deviceToken = token ?: ByteArray(0), + pushCertificateDer = certDer, + nonce = nonce, + signature = signature, + ) + sendFrame(CourierFrame.encode(frame.command, frame.fields.map { it.id to it.value })) + } + + override fun sendSetState() { + val frame = CourierCommands.setStateFrame(CourierCommands.CONNECT_STATE) + sendFrame(CourierFrame.encode(frame.command, frame.fields.map { it.id to it.value })) + } + + override fun sendFilter(token: ByteArray, enabledTopics: List) { + val frame = CourierCommands.filterFrame( + token, + enabled = enabledTopics.map(CourierCommands::topicHash), + shape = CourierCommands.FilterShape.TOPIC_LIST, + ) + sendFrame(CourierFrame.encode(frame.command, frame.fields.map { it.id to it.value })) + } + + override fun sendPing() { + sendFrame(CourierFrame.encode(CourierFrame.KEEPALIVE)) + } } private val random = SecureRandom() @@ -70,8 +138,9 @@ class CourierClient( } /** - * Runs the connect/read/keepalive/reconnect loop until cancelled. Each connection - * calls [handler.onConnected], then every inbound frame goes to [handler.onFrame]. + * Runs the connect/read/keepalive/reconnect loop until cancelled. Each connection calls + * [handler.onConnected] with the ALPN-negotiated codec, then every inbound frame goes to + * [handler.onFrame] (legacy framing) or [handler.onPackedFrame] (packed framing, C19). */ suspend fun run(handler: Handler) { while (coroutineContext.isActive) { @@ -82,6 +151,14 @@ class CourierClient( tcp.connect(java.net.InetSocketAddress(tcpHost, config.port), CONNECT_TIMEOUT_MS) val ssl = tlsFactory(tcp, config.hostname) tcp.soTimeout = CONNECT_TIMEOUT_MS + // Offer the packed-format ALPN the courier speaks (spec §3.2, C19, rev 26): + // the live server ignores the offer and answers `apns-pack-v1::` + // (observed 4096:4096 on every host) — OpenJDK rejects a selection outside + // the offer, so the exact suffixed form must be offered alongside the bare + // names; the legacy path stays the default when no pack protocol is selected. + ssl.sslParameters = (ssl.sslParameters ?: javax.net.ssl.SSLParameters()).apply { + applicationProtocols = arrayOf("apns-pack-v1:4096:4096", "apns-pack-v1", "apns-security-v3") + } try { ssl.startHandshake() } finally { @@ -90,11 +167,12 @@ class CourierClient( ssl } backoffAttempt.set(0) - handler.onConnected() + val codec = codecFor(socket!!) + handler.onConnected(codec) val connection = socket!! coroutineScope { - val reader = launch(Dispatchers.IO) { readLoop(connection, handler) } - launch(Dispatchers.IO) { keepaliveLoop(connection) } + val reader = launch(Dispatchers.IO) { readLoop(codec, connection, handler) } + launch(Dispatchers.IO) { keepaliveLoop(codec, connection) } reader.join() closeSocket() // ends the keepalive ticker with the reader } @@ -111,8 +189,20 @@ class CourierClient( } } - private fun readLoop(connection: SSLSocket, handler: Handler) { + /** The ALPN-negotiated codec: `apns-pack-v1*` speaks the packed format (§3.2, C19), else legacy. */ + private fun codecFor(ssl: SSLSocket): Codec = + if (ssl.applicationProtocol?.startsWith("apns-pack-v1") == true) PackedCodec() else LegacyCodec() + + private fun readLoop(codec: Codec, connection: SSLSocket, handler: Handler) { val input = connection.getInputStream() + if (codec is PackedCodec) { + val packed = CourierPacked() + while (true) { + val frame = packed.decode(input) ?: return // clean EOF + if (frame.command == CourierFrame.KEEPALIVE_ACK) lastPongMillis = System.currentTimeMillis() + handler.onPackedFrame(frame) + } + } while (true) { val frame = CourierFrame.decode(input) ?: return // clean EOF if (frame.command == CourierFrame.KEEPALIVE_ACK) lastPongMillis = System.currentTimeMillis() @@ -123,13 +213,13 @@ class CourierClient( @Volatile private var lastPongMillis: Long = 0 - private fun keepaliveLoop(connection: SSLSocket) { + private fun keepaliveLoop(codec: Codec, connection: SSLSocket) { val intervalMs = config.keepaliveSecs * 1000 val timeoutMs = config.pongTimeoutSecs * 1000 lastPongMillis = System.currentTimeMillis() while (!connection.isClosed) { try { - sendFrame(CourierFrame.encode(CourierFrame.KEEPALIVE)) + codec.sendPing() } catch (e: IOException) { return // reader will notice too; reconnect loop takes over } diff --git a/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierPacked.kt b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierPacked.kt new file mode 100644 index 0000000..cdc4748 --- /dev/null +++ b/courier/src/main/kotlin/dev/fenn/imessage/courier/CourierPacked.kt @@ -0,0 +1,347 @@ +package dev.fenn.imessage.courier + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.EOFException +import java.io.IOException +import java.io.InputStream + +/** + * The packed courier codec (spec §3.2, C19 closed in rev 26 — the server negotiated ALPN + * `apns-pack-v1` live and this format flowed): the tag-based alternative to the §3.2 legacy + * frames. A frame is a 1-byte command, the attribute-block length as a tagged value (width 8), + * then the attributes. Tagged value: a 1-byte tag whose low `width` bits carry an inline value + * while they are not all ones; otherwise the all-ones marker followed by a 7-bit continuation + * varint of the remainder. Attribute: a 1-byte key tag — a single-byte key k (< 0x10) sent as + * k|flags, longer keys through a rolling key cache (0x20|flags|index reference; 0x10|flags + + * key-length−1 as a tagged width-4 value, then the key) — then the value: inline integer + * (|0x80, ≤ 0x1f), wide integer ((byte-length−1)|0xa0, +0x08 negative, big-endian magnitude), + * data (tagged width 6), string (width 5, |0x40). Cache flags: 0x40 = first occurrence (full + * value, enters the rolling value cache), 0x80 = reference (tagged width-8 index). Both caches + * index from the newest entry; the key cache caps at 32 entries, the value cache at ~4 KiB of + * entries. + */ +class CourierPacked { + + sealed class Value { + data class Int(val v: Long) : Value() + class Data(val bytes: ByteArray) : Value() + } + + class Frame(val command: Int, val fields: List) { + data class Field(val id: Int, val value: Value) + + fun intField(id: Int): Long? = + fields.firstOrNull { it.id == id }?.let { it.value as? Value.Int }?.v + + fun dataField(id: Int): ByteArray? = + fields.firstOrNull { it.id == id }?.let { it.value as? Value.Data }?.bytes + } + + class Attr(val id: Int, val value: Value, val valueCached: Boolean = false) + + private val keyTable = ArrayList() + private val valueTable = ArrayList() + private var valueTableBytes = 0 + + // ---- encode ---- + + fun encode(command: Int, attrs: List): ByteArray { + val body = ByteArrayOutputStream() + for (attr in attrs) writeAttr(attr, body) + val payload = body.toByteArray() + if (payload.size + 2 > CourierFrame.MAX_FRAME_BYTES) throw IllegalArgumentException("frame too large") + val out = ByteArrayOutputStream() + out.write(command) + writeTagged(out, 8, payload.size.toLong(), 0) + out.write(payload) + return out.toByteArray() + } + + private fun writeAttr(attr: Attr, out: ByteArrayOutputStream) { + var flags = 0 + var cachedBuf: ByteArray? = null + var cachedIndex: Int? = null + if (attr.valueCached) { + val buf = ByteArrayOutputStream() + writeValue(attr.value, buf) + val bytes = buf.toByteArray() + val existing = indexOf(valueTable, bytes) + if (existing == null) { + valueAdd(bytes) + flags = 0x40 + cachedBuf = bytes + } else { + flags = 0x80 + cachedIndex = existing + } + } + val single = attr.id in 0 until 0x10 + if (single) { + out.write(attr.id or flags) + } else { + val existing = indexOf(keyTable, byteArrayOf(attr.id.toByte())) + if (existing != null) { + out.write(0x20 or flags or existing) + } else { + keyAdd(byteArrayOf(attr.id.toByte())) + writeTagged(out, 4, 0, 0x10 or flags) // key length − 1: the key is one byte + out.write(attr.id) + } + } + when { + cachedIndex != null -> writeTagged(out, 8, cachedIndex.toLong(), 0) + cachedBuf != null -> out.write(cachedBuf) + else -> writeValue(attr.value, out) + } + } + + private fun writeValue(value: Value, out: ByteArrayOutputStream) { + when (value) { + is Value.Data -> { + writeTagged(out, 6, value.bytes.size.toLong(), 0) + out.write(value.bytes) + } + is Value.Int -> { + val v = value.v + if (v in 0..0x1f) { + out.write(v.toInt() or 0x80) + } else { + val negative = v < 0 + val bytes = java.math.BigInteger.valueOf(v).abs().toByteArray() + // BigInteger is sign-prefixed two's-complement; the wire carries the magnitude + val magnitude = if (bytes[0] == 0.toByte()) bytes.copyOfRange(1, bytes.size) else bytes + val tag = (magnitude.size - 1) or 0xa0 or (if (negative) 0x08 else 0) + out.write(tag) + out.write(magnitude) + } + } + } + } + + private fun writeTagged(out: ByteArrayOutputStream, width: Int, value: Long, mask: Int) { + val max = (1L shl width) - 1 + if (value < max) { + out.write((value.toInt() and 0xff) or mask) + return + } + out.write(max.toInt() or mask) + var remainder = value - max + if (remainder == 0L) { + out.write(0) // the varint form always carries at least one continuation byte + } + while (remainder != 0L) { + var byte = (remainder and 0x7f).toInt() + remainder = remainder shr 7 + if (remainder != 0L) byte = byte or 0x80 + out.write(byte) + } + } + + /** Both caches index 0 = newest entry. */ + private fun indexOf(table: List, item: ByteArray): Int? { + for (i in table.indices.reversed()) { + if (table[i].contentEquals(item)) return table.size - 1 - i + } + return null + } + + private fun keyAdd(item: ByteArray) { + if (keyTable.size >= 0x20) keyTable.removeAt(0) + keyTable.add(item) + } + + private fun valueAdd(item: ByteArray) { + valueTableBytes += item.size + 0x20 + while (valueTableBytes > 0x1000 && valueTable.isNotEmpty()) { + val removed = valueTable.removeAt(0) + valueTableBytes -= removed.size + 0x20 + } + valueTable.add(item) + } + + // ---- decode ---- + + /** Returns null on clean EOF at a frame boundary; throws on malformed or truncated data. */ + fun decode(input: InputStream): Frame? { + val command = try { + input.read() + } catch (e: EOFException) { + return null + } + if (command == -1) return null + val lengthTag = try { + input.read() + } catch (e: EOFException) { + return null + } + if (lengthTag == -1) return null + val length = readTagged(input, 8, lengthTag) + if (length > CourierFrame.MAX_FRAME_BYTES) throw IOException("frame length $length exceeds cap") + val bytes = readN(input, length.toInt()) + val cursor = ByteArrayInputStream(bytes) + val fields = ArrayList() + while (cursor.available() > 0) { + fields.add(readAttr(cursor)) + } + return Frame(command, fields) + } + + private fun readAttr(input: InputStream): Frame.Field { + val tag = readByte(input) + val id: Int = when { + tag and 0x20 != 0 -> { + val idx = tag and 0x1f + keyTable.getOrNull(keyTable.size - 1 - idx)?.let { it[0].toInt() and 0xff } + ?: throw IOException("courier packed: key cache miss for index $idx") + } + tag and 0x10 != 0 -> { + val len = readTagged(input, 4, tag).toInt() + val key = readN(input, len + 1) + keyAdd(key) + key[0].toInt() and 0xff + } + else -> tag and 0x0f + } + val value: Value = when { + tag and 0x80 != 0 -> { + val idx = readTagged(input, 8, readByte(input)).toInt() + val bytes = valueTable.getOrNull(valueTable.size - 1 - idx) + ?: throw IOException("courier packed: value cache miss for index $idx") + parseValue(ByteArrayInputStream(bytes)) + } + tag and 0x40 != 0 -> { + val captured = CapturingStream(input) + val value = parseValue(captured) + valueAdd(captured.captured.toByteArray()) + value + } + else -> parseValue(input) + } + return Frame.Field(id, value) + } + + private fun parseValue(input: InputStream): Value { + val tag = readByte(input) + return when (tag shr 6) { + 0 -> Value.Data(readN(input, readTagged(input, 6, tag).toInt())) + 1 -> Value.Data(readN(input, readTagged(input, 5, tag).toInt())) // string as data + 2 -> if (tag and 0x20 != 0) { + val length = (tag and 0x07) + 1 + val negative = tag and 0x08 != 0 + val bytes = readN(input, length) + var v = 0L + for (b in bytes) v = (v shl 8) or (b.toLong() and 0xff) + Value.Int(if (negative) -v else v) + } else { + Value.Int((tag and 0x1f).toLong()) + } + else -> if (tag and 0x20 != 0) { + Value.Int(if (readTagged(input, 4, tag) != 0L) 1 else 0) + } else { + throw IOException("courier packed: unsupported value class ${tag shr 6}") + } + } + } + + private fun readTagged(input: InputStream, width: Int, tag: Int): Long { + val mask = (1 shl width) - 1 + if (tag and mask != mask) return (tag and mask).toLong() + var number = 0L + var shift = 0 + while (true) { + val byte = readByte(input) + number = number or ((byte and 0x7f).toLong() shl shift) + if (byte and 0x80 == 0) break + shift += 7 + } + return number + mask + } + + private fun readByte(input: InputStream): Int { + val b = input.read() + if (b == -1) throw EOFException("courier packed: truncated frame") + return b + } + + private fun readN(input: InputStream, n: Int): ByteArray { + val out = ByteArray(n) + var read = 0 + while (read < n) { + val r = input.read(out, read, n - read) + if (r == -1) throw EOFException("courier packed: truncated frame") + read += r + } + return out + } + + private class CapturingStream(val inner: InputStream) : InputStream() { + val captured = ByteArrayOutputStream() + override fun read(): Int { + val b = inner.read() + if (b != -1) captured.write(b) + return b + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + val r = inner.read(b, off, len) + if (r != -1) captured.write(b, off, r) + return r + } + } + + companion object { + + /** + * The connect frame (§3.3 field ids): state 1, flags, push certificate (12), nonce (13), + * signature (14), the Rust-only version field 0x10 = 9, and the device token (1) — + * ABSENT when [token] is null (the first connect of a connection: C19/§3.3, rev 26 — + * an empty token attribute is the wrong form). + */ + fun connectFrame( + token: ByteArray?, + pushCertificateDer: ByteArray, + nonce: ByteArray, + signature: ByteArray, + ): ByteArray = CourierPacked().encode( + CourierFrame.CONNECT, + buildList { + if (token != null) add(Attr(CourierCommands.CONNECT_FIELD_TOKEN, Value.Data(token), valueCached = true)) + add(Attr(CourierCommands.CONNECT_FIELD_STATE, Value.Int(CourierCommands.CONNECT_STATE.toLong()))) + add(Attr(CourierCommands.CONNECT_FIELD_FLAGS, Value.Int(CourierCommands.CONNECT_FLAGS_BASE.toLong()))) + add(Attr(CourierCommands.CONNECT_FIELD_CERTIFICATE, Value.Data(pushCertificateDer))) + add(Attr(CourierCommands.CONNECT_FIELD_NONCE, Value.Data(nonce))) + add(Attr(CourierCommands.CONNECT_FIELD_SIGNATURE, Value.Data(signature))) + add(Attr(CourierCommands.CONNECT_FIELD_RUST_VERSION, Value.Int(CourierCommands.CONNECT_RUST_VERSION_VALUE.toLong()))) + }, + ) + + /** Set state (20): the state byte + the fixed interval constant (§3.3) — sent right after connect. */ + fun setStateFrame(): ByteArray = CourierPacked().encode( + CourierFrame.SET_STATE, + listOf( + Attr(CourierCommands.SET_STATE_FIELD_STATE, Value.Int(CourierCommands.CONNECT_STATE.toLong())), + Attr(CourierCommands.SET_STATE_FIELD_INTERVAL, Value.Int(CourierCommands.SET_STATE_INTERVAL.toLong())), + ), + ) + + /** + * Filter (9): the connect-response token (value-cached) plus the SHA-1 topic hashes as + * enabled topics (§3.3 field 2, the single-topic-field reading of C57). Sent right after + * the set state — unfiltered connections are dropped within seconds, which invalidates + * the connect token before the login can use it (rev 26, live 2026-10-03). + */ + fun filterFrame(token: ByteArray, enabledTopics: List): ByteArray = CourierPacked().encode( + CourierFrame.FILTER, + buildList { + add(Attr(CourierCommands.CONNECT_FIELD_TOKEN, Value.Data(token), valueCached = true)) + for (topic in enabledTopics) { + add(Attr(2, Value.Data(CourierCommands.topicHash(topic)), valueCached = true)) + } + }, + ) + + /** Ping/keepalive (12): no attributes (§3.3). */ + fun ping(): ByteArray = CourierPacked().encode(CourierFrame.KEEPALIVE, emptyList()) + } +} diff --git a/courier/src/test/kotlin/dev/fenn/imessage/courier/CourierPackedTest.kt b/courier/src/test/kotlin/dev/fenn/imessage/courier/CourierPackedTest.kt new file mode 100644 index 0000000..d688e55 --- /dev/null +++ b/courier/src/test/kotlin/dev/fenn/imessage/courier/CourierPackedTest.kt @@ -0,0 +1,156 @@ +package dev.fenn.imessage.courier + +import java.io.ByteArrayInputStream +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The packed courier codec (§3.2, C19): encode/decode round trips over the tagged-value forms + * (inline and wide integers, data, the width-escape varint), the rolling key/value caches, and + * the frame builders' §3.3 field ids. + */ +class CourierPackedTest { + + private fun decoded(bytes: ByteArray): CourierPacked.Frame { + val frame = CourierPacked().decode(ByteArrayInputStream(bytes)) + assertTrue(frame != null, "frame decoded") + return frame + } + + @Test + fun dataValuesRoundTrip() { + val bytes = CourierPacked().encode( + 7, + listOf( + CourierPacked.Attr(1, CourierPacked.Value.Data(byteArrayOf(1, 2, 3))), + CourierPacked.Attr(0x12, CourierPacked.Value.Data(ByteArray(100) { it.toByte() })), + ), + ) + val frame = decoded(bytes) + assertEquals(7, frame.command) + assertContentEquals(byteArrayOf(1, 2, 3), frame.dataField(1)) + val wide = frame.dataField(0x12)!! + assertEquals(100, wide.size) + assertEquals(99, wide[99].toInt()) + } + + @Test + fun integerEncodingsRoundTrip() { + val bytes = CourierPacked().encode( + 7, + listOf( + CourierPacked.Attr(1, CourierPacked.Value.Int(5)), // inline (≤ 0x1f) + CourierPacked.Attr(2, CourierPacked.Value.Int(0x20)), // wide single byte + CourierPacked.Attr(3, CourierPacked.Value.Int(0x010203040506)), // wide multi-byte + CourierPacked.Attr(4, CourierPacked.Value.Int(-3)), // negative (0x08 bit) + CourierPacked.Attr(5, CourierPacked.Value.Int(CourierCommands.SET_STATE_INTERVAL.toLong())), + ), + ) + val frame = decoded(bytes) + assertEquals(5L, frame.intField(1)) + assertEquals(0x20L, frame.intField(2)) + assertEquals(0x010203040506L, frame.intField(3)) + assertEquals(-3L, frame.intField(4)) + assertEquals(CourierCommands.SET_STATE_INTERVAL.toLong(), frame.intField(5)) + } + + @Test + fun valueCacheReferenceDecodesToTheFirstValue() { + val encoder = CourierPacked() + val token = ByteArray(32) { (it + 1).toByte() } + val bytes = encoder.encode( + 7, + listOf( + CourierPacked.Attr(1, CourierPacked.Value.Data(token), valueCached = true), + CourierPacked.Attr(2, CourierPacked.Value.Data(token), valueCached = true), + ), + ) + val frame = decoded(bytes) + assertContentEquals(token, frame.dataField(1)) + assertContentEquals(token, frame.dataField(2)) + } + + @Test + fun keyCacheReferenceDecodesToTheSameId() { + val encoder = CourierPacked() + val bytes = encoder.encode( + 7, + listOf( + CourierPacked.Attr(0x20, CourierPacked.Value.Int(1)), + CourierPacked.Attr(0x20, CourierPacked.Value.Int(2)), + ), + ) + val frame = decoded(bytes) + val values = frame.fields.filter { it.id == 0x20 }.map { (it.value as CourierPacked.Value.Int).v } + assertEquals(listOf(1L, 2L), values) + } + + @Test + fun cleanEofAtFrameBoundaryReturnsNull() { + assertNull(CourierPacked().decode(ByteArrayInputStream(ByteArray(0)))) + } + + @Test + fun connectFrameCarriesTheRecordedFieldsWithoutTheToken() { + val cert = ByteArray(16) { 1 } + val nonce = ByteArray(17) { 2 } + val signature = ByteArray(8) { 3 } + val frame = decoded(CourierPacked.connectFrame(null, cert, nonce, signature)) + assertEquals(CourierFrame.CONNECT, frame.command) + assertEquals(CourierCommands.CONNECT_STATE.toLong(), frame.intField(CourierCommands.CONNECT_FIELD_STATE)) + assertEquals( + CourierCommands.CONNECT_FLAGS_BASE.toLong(), + frame.intField(CourierCommands.CONNECT_FIELD_FLAGS), + ) + assertContentEquals(cert, frame.dataField(CourierCommands.CONNECT_FIELD_CERTIFICATE)) + assertContentEquals(nonce, frame.dataField(CourierCommands.CONNECT_FIELD_NONCE)) + assertContentEquals(signature, frame.dataField(CourierCommands.CONNECT_FIELD_SIGNATURE)) + assertEquals( + CourierCommands.CONNECT_RUST_VERSION_VALUE.toLong(), + frame.intField(CourierCommands.CONNECT_FIELD_RUST_VERSION), + ) + // First connect: the token attribute is ABSENT, not empty (C19, rev 26). + assertEquals(null, frame.dataField(CourierCommands.CONNECT_FIELD_TOKEN)) + } + + @Test + fun connectFrameCarriesTheTokenWhenPresent() { + val token = ByteArray(32) { 4 } + val frame = decoded(CourierPacked.connectFrame(token, ByteArray(0), ByteArray(0), ByteArray(0))) + assertContentEquals(token, frame.dataField(CourierCommands.CONNECT_FIELD_TOKEN)) + } + + @Test + fun setStateFrameCarriesStateAndInterval() { + val frame = decoded(CourierPacked.setStateFrame()) + assertEquals(CourierFrame.SET_STATE, frame.command) + assertEquals(1L, frame.intField(CourierCommands.SET_STATE_FIELD_STATE)) + assertEquals(CourierCommands.SET_STATE_INTERVAL.toLong(), frame.intField(CourierCommands.SET_STATE_FIELD_INTERVAL)) + } + + @Test + fun filterFrameCarriesTokenAndSha1TopicHashes() { + val token = ByteArray(32) { 5 } + val frame = decoded(CourierPacked.filterFrame(token, listOf("com.apple.madrid", "com.apple.private.alloy.sms"))) + assertEquals(CourierFrame.FILTER, frame.command) + assertContentEquals(token, frame.dataField(1)) + val topicHashes = frame.fields.filter { it.id == 2 }.map { (it.value as CourierPacked.Value.Data).bytes } + assertEquals(2, topicHashes.size) + assertContentEquals(sha1("com.apple.madrid"), topicHashes[0]) + assertContentEquals(sha1("com.apple.private.alloy.sms"), topicHashes[1]) + } + + @Test + fun pingCarriesNoAttributes() { + val frame = decoded(CourierPacked.ping()) + assertEquals(CourierFrame.KEEPALIVE, frame.command) + assertEquals(0, frame.fields.size) + } + + private fun sha1(text: String): ByteArray = + MessageDigest.getInstance("SHA-1").digest(text.toByteArray(Charsets.UTF_8)) +} diff --git a/docs/imessage-protocol-spec.md b/docs/imessage-protocol-spec.md index 58aa531..0d605f2 100644 --- a/docs/imessage-protocol-spec.md +++ b/docs/imessage-protocol-spec.md @@ -1,6 +1,6 @@ # iMessage protocol spec (clean-room) -**Status:** draft — revision 25. The full revision history is at the bottom ([Revision history](#revision-history)); the sections below state the facts as of rev 25. +**Status:** draft — revision 26. The full revision history is at the bottom ([Revision history](#revision-history)); the sections below state the facts as of rev 26. **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. @@ -20,7 +20,7 @@ - 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. +- 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. — 2026-10-04 (rev 26, live): **REPLAY GUARD — one mint = one device record.** Apple keys a validation blob's consumption server-side: re-submitting an already-consumed blob at the sign-in/register pair answers HTTP 409, which is a REPLAY refusal, NOT a blob rejection — a fresh blob under the same device identity succeeds where the reused one failed, and a captured blob of external provenance succeeds outright (session 17: a captured stamp replaced the local mint and the 409 vanished). Client consequences: mint or fetch a fresh blob per registration attempt (the 15-minute validity above makes stale reuse doubly fatal), and mint the sign-in's blob AT SIGN-IN TIME — never a login-start blob carried across the 2FA wait. A caller-injected blob from any source (local mint, relay Mac, staged capture) is a legitimate stamp; the injection points are the register body's `validation-data` and the sign-in's `X-Mme-Nas-Qualify` header. - 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 @@ -30,7 +30,7 @@ - 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 on file as `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 committed as `ids/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). +- 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). — 2026-10-02/03 (rev 26, live): three request/response refinements. (1) `DeviceCertRequest` rides as a plist `` whose content is base64 of the PEM CSR TEXT bytes — a DER-encoded CSR is rejected server-side (the SIGNATURE_VERIFICATION_FAILED page; the earlier "DER ``" reading was dead, session 12). (2) The accepted `FairPlayCertChain` is THREE DER certificates concatenated — leaf + FairPlay CA + Apple Root CA (the two CA links captured in the accepted Android request); leaf-only and leaf+CA were refused. (3) The activation response may wrap the `` block inside a larger document (`……` whose UI XML breaks a whole-document parse, live 2026-10-02) — a client extracts the `` span and parses it alone; a bare `` document parses as before. Refusals (no DeviceCertificate) carry the reason in wrapped-UI page text, not in a structured field. - 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]. @@ -61,10 +61,11 @@ - 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]. — 2026-09-29 (rev 25, live): the trusted-device trigger is what makes the code APPEAR on the devices — it must run BEFORE prompting the user, and a SECOND trigger before the same validate invalidates the outstanding code and re-prompts every device (both observed live); `au` is returned inside the complete response's `Status` dict, not at the `Response` level. +- 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]. — 2026-10-04 (rev 26, live): anisette is fresh PER SRP RUN, not per login flow — GSA rejects an SRP init whose `X-Apple-I-MD` was minted minutes earlier (error `ec` −22421), including the post-2FA re-run of the same login; the reference client mints a new set for every login attempt. +- 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]. — 2026-09-29 (rev 25, live): the trusted-device trigger is what makes the code APPEAR on the devices — it must run BEFORE prompting the user, and a SECOND trigger before the same validate invalidates the outstanding code and re-prompts every device (both observed live); `au` is returned inside the complete response's `Status` dict, not at the `Response` level. — 2026-10-04 (rev 26, live): the anisette riding the 2FA calls is fresh PER CALL (trigger and validate each re-mint; a set frozen at login start answers HTTP 434 — the frozen-OTP wall of session 16), the trusted-device trigger and the validate GET both carry `Content-Type: text/x-xml-plist` + `Accept: text/x-xml-plist`, and a failed verify's body is a plist whose `Status.ec`/`em` (or top-level `ec`/`em`) name the real failure — the HTTP status alone is opaque. - 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]. — 2026-09-29 (rev 25, live): the post-2FA SRP re-login's decrypted `spd` token map `t` is an ALTERNATIVE delivery channel carrying the PET plus the full per-service token set under the same `ID` keys (`com.apple.gs.idms.pet`, `com.apple.gs.icloud.auth`, `com.apple.gs.idms.hb`, `com.apple.gs.pb.auth`, `com.apple.gs.gpk.auth`, …21 observed in total); a no-2FA password login's `spd` carries only `com.apple.gs.appleid.auth` and NO PET — the header route on the 2FA-verification response and the re-login `spd` map are the two live-confirmed PET paths. -- 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]. — 2026-09-29 (rev 25, live): the anisette headers minted at login start are SPENT by the GSA hops — the sign-in POST needs a FRESH set (a reused set was refused; the reference client mints per request); and the endpoint answered HTTP 409 with `ErrorID=ICLOUD_UNSUPPORTED_DEVICE` ("This Apple Account is valid but is not an iCloud account" / "Cannot create another iCloud account with this device") — a device-identity-bound iCloud-activation refusal (response plist carries `protocolVersion` 2, `ErrorID`, `Header`, `Body`, `DialogType`, `DefaultButton*`, `AlternateButton*`, `HelpLink`), consistent with the per-Mac-identity account cap the upstream docs put at ~20 accounts. +- 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]. — 2026-09-29 (rev 25, live): the anisette headers minted at login start are SPENT by the GSA hops — the sign-in POST needs a FRESH set (a reused set was refused; the reference client mints per request); and the endpoint answered HTTP 409 with `ErrorID=ICLOUD_UNSUPPORTED_DEVICE` ("This Apple Account is valid but is not an iCloud account" / "Cannot create another iCloud account with this device") — a device-identity-bound iCloud-activation refusal (response plist carries `protocolVersion` 2, `ErrorID`, `Header`, `Body`, `DialogType`, `DefaultButton*`, `AlternateButton*`, `HelpLink`), consistent with the per-Mac-identity account cap the upstream docs put at ~20 accounts. — 2026-10-04 (rev 26, live): **the 409's true root cause is the validation-stamp REPLAY guard (§1.1), not the request shape or a blob rejection** — a captured stamp minted elsewhere passed the sign-in with HTTP 200 and CREATED the iCloud device record, and the full chain then completed: §1.2 authenticate + §1.4 register both green against live Apple, with a `mailto:` handle coming back ACTIVE (§1.3/§1.4). Two response/request refinements: (1) Apple's live sign-in response is KEBAB-CASE — the delegate entries carry `status`/`service-data`/`account-exists` (delegate keys observed live), i.e. `service-data` and `status-message`, NOT the extraction's camelCase `serviceData`/`statusMessage` (which survive as a fallback reading of the recording); (2) the sign-in body's client id rides the kebab-case wire key `client-id` in `userInfo` — the one body delta that survived until the live capture. +- The GSA `postdata` liveness event (rev 26, live 2026-10-03; `gsas.apple.com` per the host split above): a `POST .../grandslam/GsService2/postdata` with `Content-Type: text/x-xml-plist` and an XML-plist body of two top-level dictionaries — `Header` (empty) and `Request` — the `liveness` event declaring the device's services `icloud`, `imessage`, `facetime` (with recovery-key generation on), and two account-binding headers: `X-Apple-I-UrlSwitch-Info` = base64(`adsid:postdata`) and `X-Apple-HB-Token` = base64(`adsid:hb-token`) with the hb token from the post-2FA spd token map's `com.apple.gs.idms.hb` entry (§1.5 PET). The anisette set is the postdata-filtered machine/device subset (serial-bearing headers, locale and the request uuid are dropped); the akd-variant fixed headers ride. The APNs push token rides the body as `ptkn` (uppercase hex) when minted — a dead or absent token answers `ec` −800012 "Push token is invalid", so the Albert device certificate + courier connect token must be minted BEFORE the event, and the courier connection must stay OPEN across the whole registration: the connect-response token identifies the CONNECTION, and closing the socket between the mint and the postdata hop drew −800012 on an otherwise valid token. ORDER (correcting the earlier session note): the reference program runs the delegate sign-in FIRST and postdata AFTER it (the sign-in mints its own Nas-Qualify blob at sign-in time, §1.1) — the hop is a post-sign-in liveness declaration, not a sign-in precondition. - 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]. @@ -109,7 +110,7 @@ - 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].) +- 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].) — 2026-10-03 (rev 26, live, instrumented client): **C19 CLOSED.** Offering `apns-pack-v1:4096:4096` (the suffixed form), `apns-pack-v1`, and `apns-security-v3`, the server selects `apns-pack-v1::` — observed `apns-pack-v1:4096:4096` on every courier host — and the packed frame format flows; the offer must include the exact suffixed form (a TLS stack that rejects a selection outside the offer otherwise refuses the handshake). With the pack-v1 ALPN the server's chain is leaf ← `Apple Server Authentication CA` ← Apple Root CA (the RSA chain; the working client pins the intermediate itself, distinct from the no-ALPN ECC chain of [CAP-COURIERTLS] anchored at `AAA Certificate Services`), and NO TLS client certificate is demanded on this path — the push certificate rides only in the connect frame (§3.3 field 12); the C18 `certificate_required` alert belongs to the no-ALPN legacy path. - 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.) @@ -118,7 +119,7 @@ ### 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. +- 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. — 2026-10-03 (rev 26, live): **C19 CLOSED — the packed format's full wire shape is now recorded from the live connection** (it flowed with the negotiated `apns-pack-v1` ALPN, §3.1). A frame is a 1-byte command, the attribute-block length as a tagged value of width 8, then the attributes. Tagged value: a 1-byte tag whose low `width` bits carry an inline value while they are not all ones; at the all-ones marker the remainder follows as a 7-bit continuation varint (always present — even a zero remainder writes one 0x00 byte). Attribute: a 1-byte key tag — a single-byte key (< 0x10) rides inline as key|flags; longer keys go through the rolling key cache (0x20|flags|index reference; 0x10|flags + key-length−1 as a tagged width-4 value, then the key bytes) — then the value: inline integer (|0x80, ≤ 0x1f), wide integer ((byte-length−1)|0xa0, +0x08 negative, big-endian magnitude), data (tagged width 6), string (width 5, |0x40). Cache flags: 0x40 = first occurrence (full value, enters the rolling value cache), 0x80 = reference (tagged width-8 index into it). Both caches index from the newest entry; the key cache holds 32 entries, the value cache ~4 KiB of entries (the ALPN's `4096:4096` suffixes match the cache sizing). Post-connect sequence (live): connect → connect-response (token) → set-state (20) → filter (9) with the connect-response token plus the SHA-1 topic hashes as enabled topics — unfiltered connections are dropped within seconds, which invalidates the connect token before the login can use it. ### 3.3 Commands @@ -267,7 +268,7 @@ 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 on file as `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] +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 on file as `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] — 2026-10-03 (rev 26): C19 CLOSED — with `apns-pack-v1` offered the server selects `apns-pack-v1:4096:4096` and the packed frame format flows; the packed chain anchors at the `Apple Server Authentication CA` intermediate (§3.1/§3.2), and no TLS client certificate is demanded on the packed path (refining C18: the post-handshake client-cert alert belongs to the no-ALPN legacy path only). 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. @@ -330,6 +331,7 @@ Ordered by value. IDs match the inline `TODO(capture):` markers above. ## Revision history +- Rev 26 (2026-10-02→04, live registration of this protocol family against real Apple endpoints — Albert activation, APNs courier (packed ALPN), the full GSA chain through 2FA, and IDS authenticate + register all green; user-gated with a real Apple ID, `mailto:` handle ACTIVE): six live-verified fact groups beyond the extraction record. (1) **The 409's root cause is the validation-stamp REPLAY guard (§1.1)** — one mint = one device record: a consumed blob re-submitted answers HTTP 409 `ICLOUD_UNSUPPORTED_DEVICE`, a fresh (or captured-elsewhere) blob succeeds; the earlier readings (request shape, blob provenance) are dead. §1.2 authenticate + §1.4 register completed green after the stamp route was fixed, with the `mailto:` handle active (§1.3/§1.4). (2) **The courier's packed format is live (§3.1/§3.2, C19 CLOSED)** — the server selects `apns-pack-v1:4096:4096` and speaks the tag-based packed codec (full wire shape recorded from the wire); its chain anchors at the `Apple Server Authentication CA` intermediate, no TLS client certificate is demanded on that path, and the post-connect set-state + filter sequence is mandatory (unfiltered connections are dropped within seconds, invalidating the connect token). (3) **Apple's live GSA sign-in response is kebab-case** — delegate `service-data`/`status-message` (camelCase recordings become the fallback), and the sign-in body's client id rides `client-id` (§1.5). (4) **Anisette freshness is per-run and per-call** — fresh per SRP run incl. the post-2FA re-run (stale → `ec` −22421), fresh per 2FA call (frozen set → HTTP 434); the trigger/validate GETs carry the plist content type, and failed verifies name the real failure in the body plist's `Status.ec`/`em` (§1.5). (5) **The GSA `postdata` liveness event is recorded in full (§1.5)** — services `icloud`/`imessage`/`facetime`, `X-Apple-I-UrlSwitch-Info`/`X-Apple-HB-Token` binding, `ptkn` (−800012 with a dead push token — the courier connection must stay open across the registration), and the order sign-in FIRST, postdata AFTER (correcting the earlier reversed note). (6) **Albert activation refinements (§1.2)** — `DeviceCertRequest` is ``(base64(PEM)), DER rejected; the accepted `FairPlayCertChain` is leaf + FairPlay CA + Apple Root CA concatenated; the response's `` block may arrive wrapped in a larger `` (parse the span); refusals state their reason in wrapped-UI page text. Code side: the packed courier codec + ALPN negotiation, `GsaPostdata`, the kebab-case parsing, the per-run/per-call anisette providers, the ``-span extraction, and the three-cert FairPlay chain all landed; the validation stamp stays caller-injected with the replay guard documented. - Rev 25 (2026-09-29, first LIVE GSA login of this protocol family — SRP init/complete, both 2FA steps, and the PET-bearing re-login against `gsa.apple.com` with a real Apple ID, user-gated; the delegate sign-in POST was attempted and refused): the §1.5 chain is live-confirmed end-to-end up to the delegate sign-in, with five behavioral facts beyond the extraction record. (1) `au` (`trustedDeviceSecondaryAuth` observed) rides INSIDE the complete response's `Status` dict — reading it at the `Response` level silently turns a 2FA login into a "logged in, no PET" dead end (§1.5 2FA). (2) The PET's second live-confirmed delivery channel: the post-2FA re-login's decrypted `spd` token map `t` (21 service tokens observed incl. `com.apple.gs.idms.pet`), while a no-2FA login's `spd` carries only `com.apple.gs.appleid.auth` and no PET — an earlier implementation inference that a no-2FA login suffices for the PET is dead; 2FA is mandatory for the PET on this account class (§1.5 PET). (3) The trusted-device trigger creates the code on the devices and must precede the prompt; triggering twice before one validate invalidates the outstanding code and re-prompts (§1.5 2FA). (4) Anisette is one-time per request: headers minted at flow start are spent by the GSA hops, and the delegate sign-in with a reused set is refused — mint fresh per POST, matching the reference client's per-request provider (§1.5 delegate logins). Post-login ADI behavior confirmed live: after an account login the machine re-provisions for the personal DSID (GetLoginCode → not-provisioned → full exchange succeeds), which is the personal-`X-Apple-I-MD-M` path, not a defect. (5) The delegate sign-in answered HTTP 409 `ICLOUD_UNSUPPORTED_DEVICE` with the recorded error-plist shape (§1.5 delegate logins) — a device-identity-bound refusal, the current open blocker. Velocity note: a persisted-password-hash re-login was rejected once with -22406 ("enter the correct password") after four full logins in 30 minutes, with the identical hash derivation succeeding twice earlier in the same window — treat as server-side velocity throttling, not a derivation defect. - 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). diff --git a/engine/src/main/kotlin/dev/fenn/imessage/engine/CourierConnection.kt b/engine/src/main/kotlin/dev/fenn/imessage/engine/CourierConnection.kt index e08599f..fc7fde8 100644 --- a/engine/src/main/kotlin/dev/fenn/imessage/engine/CourierConnection.kt +++ b/engine/src/main/kotlin/dev/fenn/imessage/engine/CourierConnection.kt @@ -2,6 +2,7 @@ package dev.fenn.imessage.engine import dev.fenn.imessage.courier.CourierClient import dev.fenn.imessage.courier.CourierFrame +import dev.fenn.imessage.courier.CourierPacked import java.io.Closeable import java.io.IOException import javax.net.ssl.KeyManager @@ -77,11 +78,13 @@ class CourierConnection( private inner class ForwardingHandler( private val delegate: CourierClient.Handler, ) : CourierClient.Handler { - override fun onConnected() { + override fun onConnected(codec: CourierClient.Codec) { _state.value = State.CONNECTED - delegate.onConnected() + delegate.onConnected(codec) } override fun onFrame(frame: CourierFrame.Frame) = delegate.onFrame(frame) + + override fun onPackedFrame(frame: CourierPacked.Frame) = delegate.onPackedFrame(frame) } } diff --git a/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsHttp.kt b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsHttp.kt index c448457..843425d 100644 --- a/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsHttp.kt +++ b/ids/src/main/kotlin/dev/fenn/imessage/ids/IdsHttp.kt @@ -58,11 +58,26 @@ object AppleTrust { */ const val COURIER_ROOT_RESOURCE = "/imessage/AAACertificateServices.cer" + /** + * The packed-format courier chain's intermediate anchor (spec §3.2, C19 closed in rev 26): + * with the `apns-pack-v1` ALPN the server serves leaf ← `Apple Server Authentication CA` + * ← Apple Root CA, and the working client pins the INTERMEDIATE itself (observed on the + * wire 2026-10-03; the same CA the albert activation pool's chain passes through). + */ + const val COURIER_PACKED_ANCHOR_RESOURCE = "/imessage/AppleServerAuthenticationCA.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)) + /** + * The courier pool: the `AAA Certificate Services` root (§3.1 [CAP-COURIERTLS] — the + * no-ALPN ECC chain) plus the `Apple Server Authentication CA` intermediate (the pack-v1 + * RSA chain the ALPN selects; §3.2 C19, rev 26). + */ + fun courierTrustManager(): X509TrustManager = trustManager( + embeddedRoot(COURIER_ROOT_RESOURCE), + embeddedRoot(COURIER_PACKED_ANCHOR_RESOURCE), + ) /** A socket factory whose only trust anchor is [trustManager]. */ fun sslSocketFactory(trustManager: X509TrustManager): SSLSocketFactory = diff --git a/ids/src/main/resources/imessage/AppleServerAuthenticationCA.cer b/ids/src/main/resources/imessage/AppleServerAuthenticationCA.cer new file mode 100644 index 0000000..258f963 --- /dev/null +++ b/ids/src/main/resources/imessage/AppleServerAuthenticationCA.cer @@ -0,0 +1,28 @@ +-----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----- + 2 s:C = US, O = Apple Inc., OU = Apple Certification Authority, CN = Apple Root CA + i:C = US, O = Apple Inc., OU = Apple Certification Authority, CN = Apple Root CA + a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA1 + v:NotBefore: Apr 25 21:40:36 2006 GMT; NotAfter: Feb 9 21:40:36 2035 GMT diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/AlbertActivation.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/AlbertActivation.kt index 77ce688..30146cf 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/AlbertActivation.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/AlbertActivation.kt @@ -53,13 +53,14 @@ fun interface FairPlaySigner { /** * The nested ActivationInfoXML plist of spec §1.2 — keys `ActivationRandomness` (uppercase - * UUIDv4), `ActivationState` ("Unactivated"), `BuildVersion`, `DeviceCertRequest` (PEM CSR), - * `DeviceClass`, `ProductType`, `ProductVersion`, `SerialNumber`, `UniqueDeviceID`. + * UUIDv4), `ActivationState` ("Unactivated"), `BuildVersion`, `DeviceCertRequest` (the PEM + * CSR as plist ``), `DeviceClass`, `ProductType`, `ProductVersion`, `SerialNumber`, + * `UniqueDeviceID`. */ data class AlbertActivationInfo( val activationRandomness: String, val buildVersion: String, - /** `DeviceCertRequest` — the PEM CSR of §1.2 ([AlbertCsr.create]). */ + /** `DeviceCertRequest` — the PEM CSR of §1.2 ([AlbertCsr.create]), sent as ``(PEM). */ val deviceCertRequestPem: String, val deviceClass: String = DEVICE_CLASS_MACOS, val productType: String, @@ -71,7 +72,10 @@ data class AlbertActivationInfo( "ActivationRandomness" to activationRandomness, "ActivationState" to ACTIVATION_STATE, "BuildVersion" to buildVersion, - "DeviceCertRequest" to deviceCertRequestPem, + // rev 26 (live 2026-10-03): the accepted request carries base64(PEM) — the PEM CSR TEXT + // bytes in the ``; a DER-encoded CSR is rejected server-side (the + // SIGNATURE_VERIFICATION_FAILED page). + "DeviceCertRequest" to deviceCertRequestPem.toByteArray(), "DeviceClass" to deviceClass, "ProductType" to productType, "ProductVersion" to productVersion, @@ -136,6 +140,11 @@ data class AlbertActivationResponse( val ackReceived: Any?, val showSettings: Any?, val raw: Map, + /** + * Redacted body excerpt carried when Apple refused (no DeviceCertificate) — the wrapped-UI + * refusal states its reason in page text (rev 26, live 2026-10-02). Diagnostic only. + */ + val refusalBody: String? = null, ) /** @@ -193,12 +202,29 @@ class AlbertActivator( "activation 'device-activation' is ${typeName(deviceActivation)}, expected a dict", ) } - val certificate = activationRecord?.get("DeviceCertificate") as? String + // Apple wraps the PEM text in plist `` (rev 26, live 2026-10-03: base64(PEM) + // inside the activation record) — the XML decode yields bytes; the plain-string form + // (§1.2's original recording) is accepted too. + val certificate = when (val v = activationRecord?.get("DeviceCertificate")) { + is String -> v + is ByteArray -> String(v, Charsets.UTF_8) + else -> null + } + val refusalBody = if (certificate == null) { + // Apple's refusal answers carry no certificate; a wrapped-UI one (rev 26, live + // 2026-10-02: empty record + a `` page) states the reason in its text. + // Carry a redacted excerpt — data blobs stripped, whitespace collapsed. + String(bytes, Charsets.UTF_8) + .replace(Regex("<[a-zA-Z0-9]*Data>[^<]*"), "") + .replace(Regex("\\s+"), " ") + .take(4000) + } else null return AlbertActivationResponse( deviceCertificatePem = certificate, ackReceived = root["ack-received"], showSettings = root["show-settings"], raw = root, + refusalBody = refusalBody, ) } diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt index 5dfaba1..3a046f8 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt @@ -40,11 +40,14 @@ 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 bundled FairPlay identity of spec §1.2: the staged classpath resources + * `imessage/fairplay/leaf.crt` (DER leaf), `imessage/fairplay/fairplay-ca.crt` + + * `imessage/fairplay/apple-root-ca.crt` (the FairPlay CA and Apple Root CA links of the + * accepted activation request, rev 26), 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 three DER certificates concatenated — + * leaf + FairPlay CA + root (rev 26, live 2026-10-03: the accepted form; leaf-only and + * leaf+CA were refused). Apple accepts this expired-leaf identity as-is (§1.2 rev-17 fact). * * The default [FairPlaySigner] of [AlbertActivator]; tests inject a scripted signer instead. * Only status text reaches [logEvent] — no key or certificate material ever does. @@ -53,7 +56,9 @@ class BundledFairPlaySigner( private val logEvent: (String) -> Unit = {}, ) : FairPlaySigner { - private val leafChain: ByteArray by lazy { resource(LEAF_CERT_RESOURCE) } + private val leafChain: ByteArray by lazy { + resource(LEAF_CERT_RESOURCE) + resource(FAIRPLAY_CA_RESOURCE) + resource(ROOT_CA_RESOURCE) + } private val identityKey: RSAPrivateKey by lazy { loadPrivateKey() } @@ -97,6 +102,8 @@ class BundledFairPlaySigner( companion object { const val LEAF_CERT_RESOURCE = "/imessage/fairplay/leaf.crt" + const val FAIRPLAY_CA_RESOURCE = "/imessage/fairplay/fairplay-ca.crt" + const val ROOT_CA_RESOURCE = "/imessage/fairplay/apple-root-ca.crt" const val IDENTITY_KEY_RESOURCE = "/imessage/fairplay/identity.key" /** §1.2: the FairPlay signature is SHA-1 RSA PKCS#1v15. */ diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt index 1ec4144..e143691 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt @@ -54,7 +54,7 @@ data class IcloudSignInRequest( val adsid: String, /** The §1.1 validation blob, base64'd into `X-Mme-Nas-Qualify`. */ val validationData: ByteArray, - /** `userInfo.clientId` — uppercase UUIDv4 (§1.5). */ + /** `userInfo`'s client id — sent as the kebab-case wire key `client-id`, uppercase UUIDv4 (§1.5, rev 26). */ val clientId: String, /** `userInfo.timezone` — the recorded hardcoded-IANA-name default (§1.5 C59). */ val timezone: String = GsaDelegateLoginClient.SIGNIN_TIMEZONE, @@ -104,7 +104,10 @@ data class IcloudSignInResponse( } val serviceData = delegate.serviceData ?: throw GsaLoginException( - "${GsaDelegateLoginClient.IDS_DELEGATE_BUNDLE_ID} delegate carries no serviceData (§1.5)", + "${GsaDelegateLoginClient.IDS_DELEGATE_BUNDLE_ID} delegate carries no serviceData (§1.5) — " + + "delegate keys=${delegate.raw.keys}, top-level keys=${raw.keys}, delegate scalars=" + + delegate.raw.entries.filter { it.value !is Map<*, *> } + .joinToString { "${it.key}=${it.value}" }, ) val authToken = serviceData["auth-token"] as? String ?: throw GsaLoginException( @@ -156,7 +159,10 @@ class GsaDelegateLoginClient( ), "protocolVersion" to PROTOCOL_VERSION, "userInfo" to linkedMapOf( - "clientId" to request.clientId, + // rev 26 (live 2026-10-04): the sign-in body's client id rides kebab-case + // (`client-id` — the recorded upstream identity struct is kebab-case on the + // wire); "clientId" was the one body delta vs the live request capture. + "client-id" to request.clientId, "language" to "en-US", "timezone" to request.timezone, ), @@ -219,12 +225,15 @@ class GsaDelegateLoginClient( ) } + // Apple's live sign-in response is kebab-case (rev 26, live 2026-10-04: delegate keys = + // [status, service-data, account-exists]); the camelCase fallback keeps the §1.5 + // extraction recording parseable. private fun parseDelegate(dict: Map) = IcloudDelegateResult( status = integerOrNull(dict["status"]), - statusMessage = dict["statusMessage"] as? String, - serviceData = dict["serviceData"]?.let { + statusMessage = (dict["status-message"] ?: dict["statusMessage"]) as? String, + serviceData = (dict["service-data"] ?: dict["serviceData"])?.let { stringKeyedDictOrNull(it) - ?: throw GsaLoginException("delegate serviceData is ${typeName(it)}, expected a dict (§1.5)") + ?: throw GsaLoginException("delegate service-data is ${typeName(it)}, expected a dict (§1.5)") }, raw = dict, ) diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt index 0916811..496ca93 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt @@ -119,10 +119,19 @@ class IdsStoreGsaCredentials(private val store: IdsStore) : GsaCredentialStore { */ class GsaLoginChain( private val http: IdsHttp, - private val cpd: GsaCpd, + /** + * Fresh per login run: GSA rejects an SRP init whose `X-Apple-I-MD` was minted minutes + * earlier (rev 26, live: the one-time anisette OTP is spent per request, §1.5 rev-25 fact) + * — upstream mints a new set for every login attempt, including the post-2FA re-run. + */ + private val cpd: suspend () -> GsaCpd, private val credentialStore: GsaCredentialStore? = null, - /** The recorded §1.5 browser-style 2FA header set (C59); null omits it. */ - private val twoFactorBrowserHeaders: GsaTwoFactorHeaders? = null, + /** + * Fresh per 2FA call (trigger, validate): GSA ties the validate to the anisette OTP's age + * — a frozen set from login time answers HTTP 434 (rev 26, live: the frozen-OTP wall). + * Null-returning provider omits the browser-style set. + */ + private val twoFactorHeaders: suspend () -> 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(), @@ -134,8 +143,8 @@ class GsaLoginChain( 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() + private suspend 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 = @@ -149,7 +158,7 @@ class GsaLoginChain( */ suspend fun loginWithPasswordHash(username: String, passwordHash: ByteArray): GsaLoginResult { val requestUuid = newRequestUuid() - val cpdDict = cpd.dictFor(requestUuid) + val cpdDict = cpd().dictFor(requestUuid) val gsaHeaders = gsaHeaders() val srp = GsaSrpClient(random) val a2k = srp.begin() @@ -293,7 +302,10 @@ class GsaLoginChain( * form when the device config is wired ([GsaHeaderConfig.signInClientInfo]), falling back to * the bare recorded AOSKit inner component. [anisette] must be FRESHLY minted for this * call — the one-time OTP headers spent on the GSA hops are refused here (observed live; - * the reference client mints per request). + * the reference client mints per request). [validationData] must be a FRESH mint per + * sign-in (minted at sign-in time, never a login-start blob carried across the 2FA wait) — + * a consumed blob answers HTTP 409, the stamp REPLAY refusal, not a blob rejection + * (rev 26, §1.4's replay guard). */ suspend fun idsDelegateCredentials( username: String, @@ -336,7 +348,7 @@ class GsaLoginChain( private fun twoFactorClient() = GsaTwoFactorClient( http = http, - browserHeaders = twoFactorBrowserHeaders, + browserHeaders = twoFactorHeaders, logEvent = logEvent, ) diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaPostdata.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaPostdata.kt new file mode 100644 index 0000000..4668c67 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaPostdata.kt @@ -0,0 +1,164 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.ids.IdsHttp +import java.util.Base64 + +/** + * The GSA `postdata` liveness event of spec §1.5 (rev 26, live 2026-10-03): a `liveness` event + * declaring the device's services (`icloud`, `imessage`, `facetime`) against the account, + * declared against `gsas.apple.com` (the §1.5 host split) — the hop that marks the device an + * iCloud-capable device before the delegate sign-in can bind one (the §1.5 rev-25 409 + * `ICLOUD_UNSUPPORTED_DEVICE` blocker's precondition). + * + * Request shape (recorded): a `POST` of an XML-plist body with two top-level dictionaries, + * `Header` (empty) and `Request` (the liveness event below), `Content-Type: + * text/x-xml-plist`, the postdata-filtered anisette set (machine/device headers only — locale, + * serial-bearing headers and the request uuid are dropped), fixed akd-variant headers, and two + * account-binding headers: `X-Apple-I-UrlSwitch-Info` = base64(`:postdata`) and + * `X-Apple-HB-Token` = base64(`:`) — the `com.apple.gs.idms.hb` entry of the + * post-2FA spd token map (§1.5 PET). The APNs push token rides the body as `ptkn` (uppercase + * hex, §1.5 C59) when one has been minted; it is absent on a first login. + */ +data class GsaPostdataRequest( + /** The decrypted spd `adsid` — pairs in both base64 headers. */ + val adsid: String, + /** The `com.apple.gs.idms.hb` spd token — never logged. */ + val hbToken: String, + /** A FRESH anisette set (one-time OTP — mint one per request, §1.5 rev-25 fact). */ + val anisette: Map, + /** The akd-form `X-Mme-Client-Info` ([GsaHeaderConfig.clientInfo]). */ + val clientInfo: String, + /** The APNs push token hex — optional; absent on a first login (registration follows it). */ + val pushToken: String? = null, +) + +/** + * The GSA postdata client: one [post] per login, AFTER the delegate sign-in (rev 26: the + * reference program order is sign-in first, postdata after — the delegate sign-in mints its + * own Nas-Qualify blob at sign-in time, and the liveness event rides the post-sign-in + * credentials; the earlier reverse-order note was wrong). + */ +class GsaPostdataClient( + private val http: IdsHttp, + private val logEvent: (String) -> Unit = {}, +) { + + /** + * Fires the liveness event; loud on a non-200 (the body carries no usable payload beyond + * success). + */ + suspend fun post(request: GsaPostdataRequest) { + logEvent("gsa: postdata liveness (services=${SERVICES.joinToString(",")})") + val headers = buildMap { + putAll(filteredAnisette(request.anisette)) + put("Accept", "*/*") + put("Accept-Language", "en-US,en;q=0.9") + put("User-Agent", GsaHeaderConfig.AKD_USER_AGENT) + put("X-Mme-Client-Info", request.clientInfo) + put("X-Apple-I-Device-Configuration-Mode", "0") + put("X-Apple-I-CDP-Status", "true") + put("X-Apple-I-OT-Status", "true") + put("X-Apple-I-CK-Presence", "true") + put("X-Apple-I-DeviceUserMode", "0") + put("X-Apple-AK-DataRecoveryService-Status", "1") + put("X-Apple-I-TimeZone-Offset", "0") + put("X-Apple-I-Service-Type", "itunesstore") + put("X-Apple-I-Device-Type", "1") + put("X-Apple-Requested-Partition", "0") + put("X-Apple-I-UrlSwitch-Info", base64("${request.adsid}:postdata")) + put("X-Apple-HB-Token", base64("${request.adsid}:${request.hbToken}")) + } + val body = linkedMapOf( + "Header" to linkedMapOf(), + "Request" to requestBody(request), + ) + val response = http.post(ENDPOINT, headers, XmlPlist.encode(body), CONTENT_TYPE) + if (response.status != 200) { + throw GsaLoginException( + "GSA postdata POST $ENDPOINT → HTTP ${response.status}${errorDetail(response.body)}", + ) + } + } + + /** Top-level error-shape facts only (keys, integer codes, error strings) — never tokens. */ + private fun errorDetail(bytes: ByteArray): String { + if (bytes.isEmpty()) return "" + val dict = stringKeyedDictOrNull(try { Plist.parse(bytes) } catch (_: Exception) { return "" }) + ?: return " (body ${bytes.size}B)" + val scalars = dict.entries.mapNotNull { (k, v) -> + when { + v is Int || v is Long -> "$k=$v" + v is String && k !in setOf("dsid", "auth-token", "adsid") -> "$k=$v" + v is Map<*, *> -> "$k keys=${stringKeyedDictOrNull(v)?.keys ?: v.keys}" + else -> null + } + }.joinToString(", ") + return " — body: $scalars" + } + + /** + * The liveness-event dict: the device announces the services [SERVICES] with recovery-key + * generation on (`prkgen`). + */ + private fun requestBody(request: GsaPostdataRequest) = linkedMapOf( + "cdpStatus" to true, + "cfuids" to emptyList(), + "circleStatus" to true, + "denyICloudWebAccess" to true, + "dn" to DEVICE_NAME, + "event" to EVENT_LIVENESS, + "icloudMailEnabled" to false, + "icscStatus" to true, + "isLegacyContactAssignee" to 1, + "loc" to "en_US", + "otStatus" to true, + "pkc" to "1", + "prkgen" to true, + "reason" to 5, + "rep" to 1, + "services" to SERVICES.toList(), + "signinPartition" to 1, + "stingrayDisabledIndicator" to false, + "usrt" to 4, + "ut" to 1, + ).apply { request.pushToken?.let { put("ptkn", it) } } + + /** + * The postdata anisette filter: only the machine/device anisette headers ride this + * endpoint — locale, serial-bearing headers and the request uuid are dropped; the fixed + * headers above replace them. + */ + private fun filteredAnisette(anisette: Map): Map = + anisette.filterKeys { key -> + key.equals(ANISETTE_MD_LU, true) || key.equals(ANISETTE_RINFO, true) || + key.equals(ANISETTE_MD_M, true) || key.equals(ANISETTE_MD, true) || + key.equals(ANISETTE_DEVICE_ID, true) || key.equals(ANISETTE_CLIENT_TIME, true) || + key.equals(ANISETTE_TIME_ZONE, true) + } + + private fun base64(value: String): String = + Base64.getEncoder().encodeToString(value.toByteArray(Charsets.UTF_8)) + + companion object { + /** The postdata endpoint (§1.5: `gsas.apple.com`, with the S — not gsa). */ + const val ENDPOINT = "https://gsas.apple.com/grandslam/GsService2/postdata" + + const val CONTENT_TYPE = "text/x-xml-plist" + const val EVENT_LIVENESS = "liveness" + const val DEVICE_NAME = "Apple Device" + + /** The declared device services — the point of the whole hop. */ + val SERVICES = listOf("icloud", "imessage", "facetime") + + private const val ANISETTE_MD_LU = "X-Apple-I-MD-LU" + private const val ANISETTE_RINFO = "X-Apple-I-MD-RINFO" + private const val ANISETTE_MD_M = "X-Apple-I-MD-M" + private const val ANISETTE_MD = "X-Apple-I-MD" + private const val ANISETTE_DEVICE_ID = "X-Mme-Device-Id" + private const val ANISETTE_CLIENT_TIME = "X-Apple-I-Client-Time" + private const val ANISETTE_TIME_ZONE = "X-Apple-I-TimeZone" + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSpd.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSpd.kt index 50a61b6..8380113 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSpd.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaSpd.kt @@ -124,6 +124,12 @@ data class GsaSpd( /** The recorded TOP-LEVEL spd key whose value pairs with `adsid` in the identity token (§1.5). */ const val SERVICE_IDMS_TOKEN = "GsIdmsToken" + /** + * The spd token-map service id whose token pairs with `adsid` in the §1.5 postdata + * liveness event's `X-Apple-HB-Token` header (rev 26; observed in the live 21-service map). + */ + const val SERVICE_IDMS_HB = "com.apple.gs.idms.hb" + fun parse(raw: Map): GsaSpd { val tokensRaw = raw["t"] ?: throw GsaLoginException("spd has no 't' token-map dict (§1.5, got null)") diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt index ea55182..ddfa866 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt @@ -173,7 +173,13 @@ class GsaTwoFactorHeaders( */ class GsaTwoFactorClient( private val http: IdsHttp, - private val browserHeaders: GsaTwoFactorHeaders? = null, + /** + * Fresh per phase (trigger, validate): GSA ties the validate to the anisette OTP's age — + * a frozen set from login time answers HTTP 434 (rev 26, live: the frozen-OTP wall; + * upstream re-fetches anisette before every 2FA call, 60 s provider cache). Null omits + * the browser-style set. + */ + private val browserHeaders: suspend () -> 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", @@ -247,7 +253,13 @@ class GsaTwoFactorClient( /** `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)) + val response = http.get( + trustedDeviceUrl, + headers(identityToken) + mapOf( + CONTENT_TYPE_HEADER to PLIST_CONTENT_TYPE, + "Accept" to PLIST_CONTENT_TYPE, + ), + ) if (response.status != 200 && response.status != 201) { throw GsaLoginException( "GSA trusted-device trigger GET $trustedDeviceUrl → HTTP ${response.status} (§1.5)", @@ -262,13 +274,22 @@ class GsaTwoFactorClient( nowEpochMs: Long, ): GsaSecondaryAuthTokens { logEvent("gsa 2fa: submit trusted device code") - val response = http.get(validateUrl, headers(identityToken) + mapOf("security-code" to securityCode)) + val response = http.get( + validateUrl, + headers(identityToken) + mapOf( + "security-code" to securityCode, + CONTENT_TYPE_HEADER to PLIST_CONTENT_TYPE, + "Accept" to PLIST_CONTENT_TYPE, + ), + ) 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)") + // The HTTP status alone is opaque — the body's plist `Status.ec`/`em` names the + // real failure (rev 26, live: 434 with an empty-looking body). Surface it. + throw GsaLoginException("GSA 2FA verification → HTTP ${response.status} (§1.5)${statusDetail(response)}") } return GsaSecondaryAuthTokens( gsTokens = GsaTokenHeaders.gsTokens(response.headers.header(HEADER_GS), nowEpochMs), @@ -278,6 +299,29 @@ class GsaTwoFactorClient( ) } + /** + * The 2FA error body is a plist whose `Status.ec`/`em` (or top-level `ec`/`em`) name the + * real failure; when nothing parses, a redacted preview (any long token-like run masked) + * so the failure is visible without exposing credential material. + */ + private fun statusDetail(response: IdsHttpResponse): String { + val body = response.body + if (body.isEmpty()) return " — empty body" + val parsed = try { + val dict = XmlPlist.decode(body) as? Map<*, *> + val status = dict?.get("Status") as? Map<*, *> ?: dict + val ec = status?.get("ec")?.toString() + val em = status?.get("em")?.toString() + if (ec != null && ec != "0") " — ec=$ec em=$em" else null + } catch (e: Exception) { + null + } + val preview = String(body, Charsets.ISO_8859_1) + .replace(Regex("[A-Za-z0-9+/=]{40,}"), "") + .take(400) + return (parsed ?: " — body(${body.size}B): $preview") + } + private fun phoneBody(phoneNumberId: String) = buildJsonObject { putJsonObject("phoneNumber") { put("id", phoneNumberId) } put("mode", "sms") @@ -287,8 +331,8 @@ class GsaTwoFactorClient( (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 suspend 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 @@ -297,6 +341,8 @@ class GsaTwoFactorClient( const val IDENTITY_TOKEN_HEADER = "X-Apple-Identity-Token" const val CONTENT_TYPE_HEADER = "Content-Type" const val JSON_CONTENT_TYPE = "application/json" + /** The GSA plist POSTs' content type (§1.5) — the trigger/validate GETs carry it too (rev 26). */ + const val PLIST_CONTENT_TYPE = "text/x-xml-plist" 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 index 5b93edc..93a87ac 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt @@ -185,7 +185,19 @@ data class RegisterBody( val softwareVersion: String, val privateDeviceData: PrivateDeviceData, val services: List, - /** The `validation-data` blob (§1.1) — carried verbatim; minting it is C5/C56-blocked. */ + /** + * The `validation-data` blob (§1.1) — carried verbatim; minting it is C5/C56-blocked, so + * the caller injects it here (the caller-injectable validation stamp: a blob minted by + * this library, fetched from a relay Mac, or a captured stamp — the source is the + * caller's, the injection point is this field). + * + * REPLAY GUARD (rev 26, live): one mint = one device record. Apple keys the blob's + * consumption server-side — re-submitting an already-consumed blob at the delegate + * sign-in/§1.4 register pair answers HTTP 409, which is the REPLAY refusal, not a blob + * rejection (a fresh blob under the same device identity succeeds). Mint or fetch a + * fresh blob per registration attempt (the §1.1 15-minute validity window makes stale + * reuse doubly fatal), and never reuse a login-time blob across the 2FA wait. + */ val validationData: ByteArray, ) { fun toDict(): Map = linkedMapOf( diff --git a/registration/src/main/resources/imessage/fairplay/apple-root-ca.crt b/registration/src/main/resources/imessage/fairplay/apple-root-ca.crt new file mode 100644 index 0000000..8a9ff24 Binary files /dev/null and b/registration/src/main/resources/imessage/fairplay/apple-root-ca.crt differ diff --git a/registration/src/main/resources/imessage/fairplay/fairplay-ca.crt b/registration/src/main/resources/imessage/fairplay/fairplay-ca.crt new file mode 100644 index 0000000..f25fe39 Binary files /dev/null and b/registration/src/main/resources/imessage/fairplay/fairplay-ca.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 index 9793dcf..3f4c438 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt @@ -176,6 +176,7 @@ internal class DryRunAppleEnvironment( var trustedDeviceTriggers = 0 var validateCount = 0 var signInCount = 0 + var postdataCount = 0 var authenticateCount = 0 var registerCount = 0 @@ -231,6 +232,7 @@ internal class DryRunAppleEnvironment( AlbertActivator.DEFAULT_URL -> albert(headers, body, contentType) "https://gsa.apple.com/grandslam/GsService2" -> grandslam(body) GsaDelegateLoginClient.SIGNIN_ENDPOINT -> delegateSignIn(headers, body) + GsaPostdataClient.ENDPOINT -> postdata(headers, body, contentType) authenticateUrl -> authenticate(headers, body, contentType) registerUrl -> register(headers, body, contentType) else -> throw IllegalStateException("dry-run fake: unexpected POST $url") @@ -326,7 +328,13 @@ internal class DryRunAppleEnvironment( )) { "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 + // rev 26 (live 2026-10-03): DeviceCertRequest rides as plist `` of the PEM text + // (base64(PEM)) — DER is rejected server-side. + val csrPem = when (val request = nested["DeviceCertRequest"]) { + is ByteArray -> request.decodeToString() + is String -> request + else -> throw IllegalStateException("DeviceCertRequest is neither data nor string (§1.2)") + } 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)) { @@ -432,7 +440,12 @@ $inner""".toByteArray() "adsid" to adsid, "DsPrsId" to 123456789L, "GsIdmsToken" to "idms-token-1", - "t" to emptyMap(), + "t" to linkedMapOf( + "com.apple.gs.idms.hb" to linkedMapOf( + "token" to "hb-token-1", + "duration" to 3600L, + ), + ), ), ) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") @@ -508,12 +521,16 @@ $inner""".toByteArray() 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")) { + // rev 26 (live 2026-10-04): the client id rides the kebab-case wire key `client-id`. + require(userInfo != null && userInfo.keys == setOf("client-id", "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++ + // rev 26 (live 2026-10-04): Apple's live sign-in response is kebab-case — the ids + // delegate answers `status-message`/`service-data` (the parser keeps a camelCase + // fallback for the §1.5 extraction recording). return plist( 200, linkedMapOf( @@ -521,8 +538,8 @@ $inner""".toByteArray() "delegates" to linkedMapOf( "com.apple.private.ids" to linkedMapOf( "status" to 0L, - "statusMessage" to "OK", - "serviceData" to linkedMapOf( + "status-message" to "OK", + "service-data" to linkedMapOf( "auth-token" to authToken, "profile-id" to profileId, ), @@ -536,6 +553,37 @@ $inner""".toByteArray() ) } + // ---- GSA postdata liveness (§1.5, rev 26) ---- + + private fun postdata(headers: Map, body: ByteArray, contentType: String): IdsHttpResponse { + postdataCount++ + require(contentType == GsaPostdataClient.CONTENT_TYPE) { "postdata is not text/x-xml-plist (§1.5)" } + require(headers["X-Apple-I-UrlSwitch-Info"] == base64("$adsid:postdata")) { + "postdata X-Apple-I-UrlSwitch-Info is not base64(:postdata) (§1.5)" + } + require(headers["X-Apple-HB-Token"] == base64("$adsid:hb-token-1")) { + "postdata X-Apple-HB-Token is not base64(:) (§1.5)" + } + require(headers["User-Agent"] == GsaHeaderConfig.AKD_USER_AGENT) { "postdata UA is not the akd literal (§1.5)" } + // The anisette filter: machine/device headers ride, serial-bearing and locale ones don't. + require(headers.containsKey("X-Apple-I-MD") && headers.containsKey("X-Apple-I-MD-M")) { + "postdata carries no machine anisette headers (§1.5)" + } + require(!headers.containsKey("X-Apple-I-SRL-NO") && !headers.containsKey("X-Apple-I-MLB")) { + "postdata must drop the serial-bearing anisette headers (§1.5)" + } + val dict = Plist.parse(body) as? Map<*, *> ?: throw IllegalStateException("postdata body is not a plist (§1.5)") + require(dict.keys == setOf("Header", "Request")) { "postdata body top-level keys are ${dict.keys} (§1.5)" } + require((dict["Header"] as? Map<*, *>)?.isEmpty() == true) { "postdata Header must be empty (§1.5)" } + val request = dict["Request"] as? Map<*, *> ?: throw IllegalStateException("postdata Request is not a dict (§1.5)") + require(request["event"] == GsaPostdataClient.EVENT_LIVENESS) { "postdata event is not liveness (§1.5)" } + require(request["services"] == GsaPostdataClient.SERVICES) { + "postdata services are ${request["services"]} (§1.5)" + } + require(request["prkgen"] == true) { "postdata prkgen is not true (§1.5)" } + return plist(200, linkedMapOf("Status" to linkedMapOf("ec" to 0L, "em" to ""))) + } + // ---- authenticate (§1.2) ---- private fun authenticate(headers: Map, body: ByteArray, contentType: String): IdsHttpResponse { diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt index 2193126..1850dae 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt @@ -225,9 +225,9 @@ class ActivationDryRunTest { val cpd = GsaCpd(anisette = anisette, keychainIdentifier = ByteArray(16) { (it + 1).toByte() }) val chain = GsaLoginChain( http = env, - cpd = cpd, + cpd = { cpd }, credentialStore = IdsStoreGsaCredentials(store), - twoFactorBrowserHeaders = GsaTwoFactorHeaders.of(cpd, device = deviceConfig), + twoFactorHeaders = { GsaTwoFactorHeaders.of(cpd, device = deviceConfig) }, headerConfig = deviceConfig, clock = { now }, logEvent = {}, @@ -285,6 +285,22 @@ class ActivationDryRunTest { assertEquals(env.authToken, credentials.authToken) assertEquals(env.profileId, credentials.profileId) + // The GSA postdata liveness event fires AFTER the delegate sign-in (rev 26 order — + // sign-in first, postdata after; the sign-in mints its own Nas-Qualify blob), with a + // FRESH anisette set and the spd's com.apple.gs.idms.hb token. + GsaPostdataClient(env).post( + GsaPostdataRequest( + adsid = env.adsid, + hbToken = authenticated.spd!!.tokens[GsaSpd.SERVICE_IDMS_HB]!!.token, + anisette = anisette, + clientInfo = deviceConfig.clientInfo, + ), + ) + assertEquals(1, env.postdataCount) + val signInIndex = env.callUrls().indexOfFirst { it == GsaDelegateLoginClient.SIGNIN_ENDPOINT } + val postdataIndex = env.callUrls().indexOfFirst { it == GsaPostdataClient.ENDPOINT } + assertTrue(signInIndex in 0 until postdataIndex, "postdata must come after the delegate sign-in (rev 26)") + store.updateAccount { account -> account.copy( delegateAuthToken = credentials.authToken, @@ -445,11 +461,13 @@ class ActivationDryRunTest { ) val chain = GsaLoginChain( http = env, - cpd = GsaCpd(anisette = anisetteHeaders(), keychainIdentifier = ByteArray(16)), - twoFactorBrowserHeaders = GsaTwoFactorHeaders.of( - GsaCpd(anisette = anisetteHeaders(), keychainIdentifier = ByteArray(16)), - device = deviceConfig, - ), + cpd = { GsaCpd(anisette = anisetteHeaders(), keychainIdentifier = ByteArray(16)) }, + twoFactorHeaders = { + GsaTwoFactorHeaders.of( + GsaCpd(anisette = anisetteHeaders(), keychainIdentifier = ByteArray(16)), + device = deviceConfig, + ) + }, headerConfig = deviceConfig, clock = { 1_770_000_000_000L }, ) diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/AlbertActivationTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/AlbertActivationTest.kt index 78bb17e..ae197a2 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/AlbertActivationTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/AlbertActivationTest.kt @@ -99,7 +99,8 @@ class AlbertActivationTest { assertEquals("Unactivated", nested["ActivationState"]) assertEquals("MacOS", nested["DeviceClass"]) assertEquals("A1B2C3D4-E5F6-4A1B-8C2D-9E0F1A2B3C4D", nested["ActivationRandomness"]) - assertEquals(PUSH_CSR.pem, nested["DeviceCertRequest"]) + // rev 26 (live 2026-10-03): DeviceCertRequest rides ``(base64 of the PEM text). + assertEquals(PUSH_CSR.pem, (nested["DeviceCertRequest"] as ByteArray).decodeToString()) } @Test diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt index 2ea05bc..f14dda9 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt @@ -122,9 +122,10 @@ class GsaDelegateLoginTest { assertEquals(mapOf("protocol-version" to "4"), delegates["com.apple.private.ids"]) assertEquals(emptyMap(), delegates["com.apple.mobileme"]) val userInfo = body["userInfo"] as Map<*, *> + // rev 26 (live 2026-10-04): the client id rides the kebab-case wire key `client-id`. assertEquals( mapOf( - "clientId" to "A1B2C3D4-E5F6-4789-ABCD-0123456789AB", + "client-id" to "A1B2C3D4-E5F6-4789-ABCD-0123456789AB", "language" to "en-US", "timezone" to "Europe/Berlin", ), @@ -144,6 +145,33 @@ class GsaDelegateLoginTest { assertEquals("profile-1", credentials.profileId) } + @Test + fun `parses Apple's live kebab-case delegate shape with the camelCase fallback`() = runBlocking { + // rev 26 (live 2026-10-04): the ids delegate answers `status-message`/`service-data` + // (kebab-case); the MobileMe delegate keeps the extraction's camelCase recording. + val kebab = XmlPlist.encode( + mapOf( + "status" to 0L, + "delegates" to linkedMapOf( + "com.apple.private.ids" to linkedMapOf( + "status" to 0L, + "status-message" to "OK", + "service-data" to linkedMapOf( + "auth-token" to "ids-auth-token", + "profile-id" to "profile-1", + ), + ), + ), + ), + ) + val response = client(ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), kebab)))) + .signIn(request()) + assertEquals("OK", response.delegates["com.apple.private.ids"]!!.statusMessage) + 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( diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt index b4fc425..1b7625c 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt @@ -72,18 +72,20 @@ class GsaLoginChainTest { now: () -> Long = { 1_770_000_000_000L }, ) = GsaLoginChain( http = http, - cpd = cpd, + cpd = { cpd }, credentialStore = store, - twoFactorBrowserHeaders = GsaTwoFactorHeaders.of( - cpd, - device = GsaHeaderConfig( - hardwareModel = "iMac13,1", - osName = "macOS", - osVersion = "13.6.4", - osBuild = "22G513", - ), - userAgent = "ua", - ), + twoFactorHeaders = { + GsaTwoFactorHeaders.of( + cpd, + device = GsaHeaderConfig( + hardwareModel = "iMac13,1", + osName = "macOS", + osVersion = "13.6.4", + osBuild = "22G513", + ), + userAgent = "ua", + ) + }, clock = 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 index 6696958..db420aa 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTwoFactorTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTwoFactorTest.kt @@ -52,7 +52,7 @@ class GsaTwoFactorTest { private fun client(http: IdsHttp) = GsaTwoFactorClient( http = http, - browserHeaders = browserHeaders(), + browserHeaders = { browserHeaders() }, authBase = "https://gsa.example/auth", validateUrl = "https://gsa.example/GsService2/validate", trustedDeviceUrl = "https://gsa.example/auth/verify/trusteddevice",