From e1ee0f06b784cde501ff905c71927228053f89f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 16:55:52 +0700 Subject: [PATCH 01/17] test(android): cover cpace.json for the CPace-X25519-SHA512 primitive Draft Appendix B.1 vectors (generator/message), HandLive's PIN-pairing vectors with their HKDF-derived Bluetooth keys, and all four negative cases (low-order points, sid mismatch, wrong PIN). --- .../crypto/CPaceX25519SHA512VectorTest.kt | 149 ++++++++++++++++++ .../core/crypto/VectorFileCoverageTest.kt | 11 ++ 2 files changed, 160 insertions(+) create mode 100644 core/crypto/src/test/kotlin/app/handlive/android/core/crypto/CPaceX25519SHA512VectorTest.kt diff --git a/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/CPaceX25519SHA512VectorTest.kt b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/CPaceX25519SHA512VectorTest.kt new file mode 100644 index 0000000..01d79a9 --- /dev/null +++ b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/CPaceX25519SHA512VectorTest.kt @@ -0,0 +1,149 @@ +package app.handlive.android.core.crypto + +import app.handlive.android.core.crypto.primitives.CPaceX25519SHA512 +import app.handlive.android.core.crypto.primitives.HkdfSha256 +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.str +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.security.MessageDigest + +private val EMPTY = ByteArray(0) + +/** + * cpace.json (A7.2a): `calculate_generator`/`scalar_mult_vfy`/`ISK` của [CPaceX25519SHA512] khớp cả vector + * chính thức của draft-irtf-cfrg-cpace-21 Appendix B.1 (`official_generator`/`official_message`) và vector + * ghép đôi bằng PIN của HandLive (`handlive_pin`, kèm `K_bt_pinlink`/`K_bt_pin` suy từ ISK); vector âm + * (`low_order_ya`/`low_order_yb`/`sid_mismatch`/`wrong_pin`) đúng hành vi phải đóng kênh hoặc lệch ISK. + */ +class CPaceX25519SHA512VectorTest { + private val vectors = SharedTestVectors.vectors("cpace.json") + private val invalidVectors = SharedTestVectors.invalidVectors("cpace.json") + + @Test + fun officialGeneratorVectorMatchesDraftAppendixB11() { + val vector = vectors.single { it.str("kind") == "official_generator" } + val generatorString = CPaceX25519SHA512.generatorString(vector.hex("PRS"), vector.hex("CI"), vector.hex("sid")) + assertArrayEquals(vector.hex("generator_string"), generatorString) + + val hash = MessageDigest.getInstance("SHA-512").digest(generatorString) + assertArrayEquals(vector.hex("hash_generator_string"), hash.copyOf(32)) + + val maskedTopBit = hash.copyOf(32).also { it[31] = (it[31].toInt() and 0x7F).toByte() } + assertArrayEquals(vector.hex("decoded_field_element"), maskedTopBit) + + val g = CPaceX25519SHA512.calculateGenerator(vector.hex("PRS"), vector.hex("CI"), vector.hex("sid")) + assertArrayEquals(vector.hex("g"), g) + } + + @Test + fun officialMessageVectorMatchesDraftAppendixB12To17() { + val vector = vectors.single { it.str("kind") == "official_message" } + val g = vector.hex("g") + + val ya = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("ya"), g)) + assertArrayEquals(vector.hex("Ya"), ya) + val yb = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("yb"), g)) + assertArrayEquals(vector.hex("Yb"), yb) + + val kFromC = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("ya"), yb)) + val kFromS = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("yb"), ya)) + assertArrayEquals(vector.hex("K"), kFromC) + assertArrayEquals(vector.hex("K"), kFromS) + + val isk = CPaceX25519SHA512.isk(vector.hex("sid"), kFromC, ya, vector.hex("ADa"), yb, vector.hex("ADb")) + assertArrayEquals(vector.hex("ISK_initiator_responder"), isk) + } + + @Test + fun handlivePinVectorsDeriveMatchingIskAndBluetoothKeys() { + val pinVectors = vectors.filter { it.str("kind") == "handlive_pin" } + for (vector in pinVectors) { + val prs = vector.str("pin").toByteArray(Charsets.UTF_8) + val ci = vector.str("ci").toByteArray(Charsets.US_ASCII) + val sid = vector.hex("n_s") + + val g = CPaceX25519SHA512.calculateGenerator(prs, ci, sid) + assertArrayEquals(vector.str("name"), vector.hex("g"), g) + + val ya = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("ya"), g)) + assertArrayEquals(vector.str("name"), vector.hex("Ya"), ya) + val yb = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("yb"), g)) + assertArrayEquals(vector.str("name"), vector.hex("Yb"), yb) + + val k = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("ya"), yb)) + assertArrayEquals(vector.str("name"), vector.hex("K"), k) + + val isk = CPaceX25519SHA512.isk(sid, k, ya, EMPTY, yb, EMPTY) + assertArrayEquals(vector.str("name"), vector.hex("ISK"), isk) + + val kBtPinlink = HkdfSha256.derive(isk, salt = sid, info = vector.str("k_bt_pinlink_info"), length = 32) + assertArrayEquals(vector.str("name"), vector.hex("k_bt_pinlink"), kBtPinlink) + val kBtPin = HkdfSha256.derive(isk, salt = sid, info = vector.str("k_bt_pin_info"), length = 32) + assertArrayEquals(vector.str("name"), vector.hex("k_bt_pin"), kBtPin) + } + assertEquals(2, pinVectors.size) + } + + @Test + fun lowOrderYaMakesTheResponderCloseWithoutAnswering() { + val vector = invalidVectors.single { it.str("reason") == "low_order_ya" } + val k = CPaceX25519SHA512.scalarMultVfy(vector.hex("yb"), vector.hex("Ya_attacker")) + assertNull(vector.str("name"), k) + } + + @Test + fun lowOrderYbMakesTheInitiatorCloseWithoutAnswering() { + val vector = invalidVectors.single { it.str("reason") == "low_order_yb" } + val k = CPaceX25519SHA512.scalarMultVfy(vector.hex("ya"), vector.hex("Yb_attacker")) + assertNull(vector.str("name"), k) + } + + @Test + fun sidMismatchYieldsDifferentIskOnEachSide() { + val vector = invalidVectors.single { it.str("reason") == "sid_mismatch" } + val ya = vector.hex("Ya") + val yb = vector.hex("Yb") + + val kC = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("ya"), yb)) + val iskC = CPaceX25519SHA512.isk(vector.hex("n_s_c"), kC, ya, EMPTY, yb, EMPTY) + assertArrayEquals(vector.str("name"), vector.hex("isk_c"), iskC) + + val kS = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("yb"), ya)) + val iskS = CPaceX25519SHA512.isk(vector.hex("n_s_s"), kS, ya, EMPTY, yb, EMPTY) + assertArrayEquals(vector.str("name"), vector.hex("isk_s"), iskS) + + assertFalse(vector.str("name"), iskC.contentEquals(iskS)) + } + + @Test + fun wrongPinYieldsDifferentIskOnEachSide() { + val vector = invalidVectors.single { it.str("reason") == "wrong_pin" } + val ya = vector.hex("Ya") + val yb = vector.hex("Yb") + val sid = vector.hex("n_s") + + val kC = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("ya"), yb)) + val iskC = CPaceX25519SHA512.isk(sid, kC, ya, EMPTY, yb, EMPTY) + assertArrayEquals(vector.str("name"), vector.hex("isk_c"), iskC) + + val kS = requireNotNull(CPaceX25519SHA512.scalarMultVfy(vector.hex("yb"), ya)) + val iskS = CPaceX25519SHA512.isk(sid, kS, ya, EMPTY, yb, EMPTY) + assertArrayEquals(vector.str("name"), vector.hex("isk_s"), iskS) + + assertFalse(vector.str("name"), iskC.contentEquals(iskS)) + } + + @Test + fun vectorFileHasExactlyTheExpectedShape() { + assertEquals(4, vectors.size) + assertEquals(4, invalidVectors.size) + assertNotNull(invalidVectors.singleOrNull { it.str("reason") == "low_order_ya" }) + assertNotNull(invalidVectors.singleOrNull { it.str("reason") == "low_order_yb" }) + } +} diff --git a/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/VectorFileCoverageTest.kt b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/VectorFileCoverageTest.kt index 036efbc..42517c6 100644 --- a/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/VectorFileCoverageTest.kt +++ b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/VectorFileCoverageTest.kt @@ -33,6 +33,17 @@ class VectorFileCoverageTest { "relay-frame.json" to "RelayFrameVectorTest", "revoke.json" to "RevokeStatementVectorTest", "clipboard-html.json" to "HtmlClipSanitizerVectorTest", + "bt-link.json" to "BtLinkKeyDerivationTest, NoiseNNpsk0Test " + + "(core:transport: BtTransportCipherTest, BluetoothPreauthSessionTest, BluetoothLinkAcceptorTest, " + + "BluetoothAdvertisementDataTest)", + "bt-frame.json" to "BtRecordCodecTest, BtPreauthRecordTest, BtTransportFrameTest " + + "(core:transport: BtTransportCipherTest, BluetoothLongLaneReassemblerTest)", + "bt-advertisement.json" to "(core:transport: BluetoothAdvertisementDataTest)", + // 1 vector dưới chưa có test Android (ghi nhận ở #3447): bt-pairing.json thuộc A7.2 (PAIR-01 qua + // Bluetooth, CPace, chưa bắt đầu). Ghi vào đây để test này KHÔNG ngầm bỏ sót — xoá ghi chú và trỏ + // đúng test owner khi thẻ tương ứng viết test. + "bt-pairing.json" to "(chưa có test Android — A7.2, PAIR-01 qua Bluetooth, chưa bắt đầu)", + "cpace.json" to "CPaceX25519SHA512VectorTest", EnvelopeRoundtripTest.ANDROID_FILE to "EnvelopeRoundtripTest", EnvelopeRoundtripTest.APPLE_FILE to "EnvelopeRoundtripTest", ) From 5265c41cea080b0193227dfaf29c15333a947562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 16:56:04 +0700 Subject: [PATCH 02/17] feat(android): implement the CPace-X25519-SHA512 primitive Field25519 is a self-written, constant-time GF(2^255-19): 8 limbs of 32 bits (not BigInteger, not Tink, no branch or table index on a secret field value) so every limb*limb product fits a ULong without needing a 64x64->128 multiply (JVM has none below API 31). On top of it, CPaceX25519SHA512 implements calculate_generator (Elligator2 per RFC 9380) and ISK per draft-irtf-cfrg-cpace-21 Appendix B.1/A.5; scalar_mult_vfy reuses Tink's X25519 (already rejects low-order points), matching the existing primitives' use of Tink for point multiplication. --- .../crypto/primitives/CPaceX25519SHA512.kt | 135 +++++++ .../core/crypto/primitives/Field25519.kt | 333 ++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt create mode 100644 core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/Field25519.kt diff --git a/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt new file mode 100644 index 0000000..f5c000f --- /dev/null +++ b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt @@ -0,0 +1,135 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + +package app.handlive.android.core.crypto.primitives + +import java.security.GeneralSecurityException +import java.security.MessageDigest + +/** + * CPACE-X25519-SHA512 (draft-irtf-cfrg-cpace-21 Appendix B.1, suite `G_X25519`): [calculateGenerator] (Elligator2 + * qua [Field25519], RFC 9380), [scalarMultVfy] (X25519 với từ chối điểm bậc thấp) và [isk] (section 7.2) — ghép + * đôi bằng PIN qua Bluetooth không mạng (00-common-specs.md 0.4.5 "PIN (PAIR-01 luồng C)"), để PIN không bao + * giờ đi qua sóng. Chỉ cài transcript khởi tạo/trả lời ([transcriptIR]): HandLive luôn có vai cố định (C = M-APP + * gửi `cpace1` trước, S = A-SVC trả lời bằng `cpace2`), không bao giờ dùng biến thể song hành (`transcript_oc`) + * của draft. Số học trường: [Field25519]. + */ +object CPaceX25519SHA512 { + private val dsi = "CPace255".toByteArray(Charsets.US_ASCII) + private val dsiIsk = dsi + "_ISK".toByteArray(Charsets.US_ASCII) + + /** Kích thước khối nhập của SHA-512 — [generatorString] đệm 0 tới đây để thời gian băm không phụ thuộc độ + * dài thật của `prs` (một PIN bí mật). */ + private const val HASH_INPUT_BLOCK_BYTES = 128 + + /** LEB128 độ dài ‖ data (draft Appendix A.1.1). */ + private fun prependLength(data: ByteArray): ByteArray { + val encoded = ArrayList(data.size / 128 + 1) + var length = data.size + while (true) { + encoded.add(if (length < 0x80) length.toByte() else ((length and 0x7F) or 0x80).toByte()) + length = length ushr 7 + if (length == 0) break + } + return encoded.toByteArray() + data + } + + /** "length-value": nối các phần, mỗi phần có độ dài LEB128 đặt trước (draft Appendix A.1.3). */ + private fun lengthValueConcat(vararg parts: ByteArray): ByteArray = + parts.fold(ByteArray(0)) { acc, part -> acc + prependLength(part) } + + /** + * Draft Appendix A.2: `lv_cat(DSI, PRS, zero_bytes(len_zpad), CI, sid)` — đệm 0 để `DSI ‖ PRS` lấp đầy khối + * băm đầu tiên, làm số lần gọi hàm nén của SHA-512 (và thời gian) không phụ thuộc độ dài thật của `prs`. + */ + internal fun generatorString( + prs: ByteArray, + ci: ByteArray, + sid: ByteArray, + ): ByteArray { + val lenZpad = maxOf(0, HASH_INPUT_BLOCK_BYTES - 1 - prependLength(prs).size - prependLength(dsi).size) + return lengthValueConcat(dsi, prs, ByteArray(lenZpad), ci, sid) + } + + /** RFC 7748 `decodeUCoordinate` cho trường 255-bit (draft Appendix A.4): che bit cao nhất của byte cuối (32 + * byte little-endian) rồi dựng [Field25519]. */ + private fun decodeUCoordinate(bytes: ByteArray): Field25519 { + val masked = bytes.copyOf() + masked[Field25519.FIELD_SIZE_BYTES - 1] = (masked[Field25519.FIELD_SIZE_BYTES - 1].toInt() and 0x7F).toByte() + return Field25519.fromLittleEndianBytes(masked) + } + + /** + * Draft Appendix A.5 (`map_to_curve_elligator2`), chuyên biệt cho Curve25519 (`A = 486662`, `Z = 2`, kết quả + * `find_z_ell2` của đường cong này, RFC 9380) — ánh xạ một phần tử trường thẳng sang tọa độ Montgomery `u`, + * cách CPace rút gọn vì chỉ cần `u`, không cần điểm đầy đủ. `epsilon` (ký hiệu Legendre) đã là `1`/`q−1` + * (≡`-1`)/`0` của chính trường, nên dùng trực tiếp như một giá trị trường ở bước kết hợp dưới — không nhánh + * theo nó, khác một bản chuyển thẳng pseudocode của draft. + */ + private fun elligator2(r: Field25519): Field25519 { + val a = Field25519(UIntArray(Field25519.LIMB_COUNT).also { it[0] = 486_662u }) + val z = Field25519(UIntArray(Field25519.LIMB_COUNT).also { it[0] = 2u }) + val denominator = Field25519.one + (z * r * r) + val v = Field25519.zero - (a * denominator.inverted()) + val rhs = (v * v * v) + (a * v * v) + v + val epsilon = rhs.legendreSymbol() + val oneMinusEpsilon = Field25519.one - epsilon + return (epsilon * v) - (oneMinusEpsilon * a * Field25519.inverseOfTwo) + } + + /** + * `G.calculate_generator(SHA-512, PRS, CI, sid)` của suite `G_X25519` (0.4.5: `g = calculate_generator(...)`, + * draft §3.2.1). `prs`/`ci`/`sid` không có độ dài cố định trong draft — vector chính thức Appendix B.1 dùng + * `sid` 16 byte; cách dùng của HandLive (0.4.5 "PIN") luôn truyền `n_s` 32 byte. + */ + fun calculateGenerator( + prs: ByteArray, + ci: ByteArray, + sid: ByteArray, + ): ByteArray { + val hash = MessageDigest.getInstance("SHA-512").digest(generatorString(prs, ci, sid)) + val r = decodeUCoordinate(hash.copyOf(Field25519.FIELD_SIZE_BYTES)) + return elligator2(r).toLittleEndianBytes() + } + + /** + * `G_X25519.scalar_mult_vfy = X25519(scalar, point)`; `null` đúng ở điều kiện "G.I — abort" của draft + * (`point` là điểm bậc thấp, nên bí mật chung sẽ toàn 0 — 0.4.5: "Một `K` toàn 0 → đóng kênh không trả + * lời"). [X25519Keys.sharedSecret] (Tink) đã ném đúng ở trường hợp đó (đối chiếu với các điểm bậc thấp của + * draft qua vector `low_order_ya`/`low_order_yb` của `cpace.json`), nên chỉ cần bắt lỗi của nó — không cần + * tự viết lại phép kiểm điểm bậc thấp ở đây. + */ + fun scalarMultVfy( + scalar: ByteArray, + point: ByteArray, + ): ByteArray? = + try { + X25519Keys.sharedSecret(scalar, point) + } catch (_: GeneralSecurityException) { + null + } + + /** Draft Appendix A.3.4, thứ tự khởi tạo/trả lời (`transcript_ir`) — cố định, vì C của HandLive luôn khởi + * tạo và S luôn trả lời (0.4.5 "PIN"). */ + fun transcriptIR( + ya: ByteArray, + ada: ByteArray, + yb: ByteArray, + adb: ByteArray, + ): ByteArray = lengthValueConcat(ya, ada) + lengthValueConcat(yb, adb) + + /** + * `ISK = SHA-512(lv_cat(DSI ‖ "_ISK", sid, K) ‖ transcript_ir(Ya, ADa, Yb, ADb))` (draft section 7.2). 6 + * tham số là đúng chữ ký ISK của draft (sid, K, Ya, ADa, Yb, ADb); `k` dưới khớp `K` của draft. + */ + fun isk( + sid: ByteArray, + k: ByteArray, + ya: ByteArray, + ada: ByteArray, + yb: ByteArray, + adb: ByteArray, + ): ByteArray { + val message = lengthValueConcat(dsiIsk, sid, k) + transcriptIR(ya, ada, yb, adb) + return MessageDigest.getInstance("SHA-512").digest(message) + } +} diff --git a/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/Field25519.kt b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/Field25519.kt new file mode 100644 index 0000000..c86ea5a --- /dev/null +++ b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/Field25519.kt @@ -0,0 +1,333 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + +package app.handlive.android.core.crypto.primitives + +/** Khớp [Field25519.LIMB_COUNT] — tách riêng vì hằng số top-level private không đọc được hằng số companion. */ +private const val LIMB_COUNT = 8 +private const val LIMB_BITS = 32 +private const val WORD_MASK = 0xFFFFFFFFuL + +/** `2^256 ≡ 38 (mod q)` — hệ số dùng để gập phần vượt 256-bit của một tích/tổng thô vào phần thấp trước khi + * rút gọn (xem [mulMod], [addMod]). */ +private const val FOLD_CONSTANT = 38u + +/** Số vòng cố định để [reduceBounded] đưa một giá trị tối đa vài lần q về canonical `[0, q)`. */ +private const val REDUCE_ROUNDS = 4 + +/** Số vòng cố định để [mulMod] gập hết phần cao 256-bit của tích thô vào phần thấp. */ +private const val MUL_FOLD_ROUNDS = 6 + +/** + * GF(2^255 − 19), trường Curve25519 sống trong đó. Tink không có số học trường, nên [CPaceX25519SHA512] + * (Elligator2, `calculate_generator`) cần tự viết nó. **Hằng thời gian để Trang soát**: mọi phép toán ở dưới + * (từ [addRaw]/[subRaw] tới [power]) là một dãy cố định các phép cộng/nhân/mask bit — không `if`/`when` hay chỉ + * số mảng theo một giá trị trường (ở đây có thể mang bí mật suy từ PIN), chỉ theo chỉ số vòng lặp hay một số mũ + * công khai cố định ([inverseExponent]/[legendreExponent], luôn đúng 255 vòng mỗi lần gọi bất kể cơ số bí mật). + * Rút gọn chọn giữa "trừ q" và "giữ nguyên" bằng mask ([select]), không bao giờ bằng nhánh. Các hằng số công + * khai dựng trong [Companion] (q, [inverseExponent], [legendreExponent], [inverseOfTwo]) suy ra từ q — một số + * nguyên tố công khai, không mang bí mật — nên các hàm dựng chúng một lần lúc nạp lớp ĐƯỢC PHÉP dùng số học + * thường có nhánh; chúng tách riêng, không dùng lại cho giá trị trường bí mật. + * + * 8 limb 32-bit, little-endian (`limbs[0]` = bit 0-31), luôn giữ dạng canonical (< q) giữa các phép toán — limb + * 32-bit (không phải 64-bit như bản Swift tương ứng, `Field25519.swift`) để mọi tích limb×limb nằm gọn trong + * [ULong] (64-bit) không tràn, tránh phải tự viết phép nhân rộng 64×64→128-bit mà JVM không có sẵn ở minSdk 29 + * (`Math.multiplyHigh` chỉ có từ API 31). + */ +internal class Field25519( + val limbs: UIntArray, +) { + init { + require(limbs.size == LIMB_COUNT) { "Field25519 cần đúng $LIMB_COUNT limb" } + } + + operator fun plus(other: Field25519): Field25519 = Field25519(addMod(limbs, other.limbs)) + + operator fun minus(other: Field25519): Field25519 = Field25519(subMod(limbs, other.limbs)) + + operator fun times(other: Field25519): Field25519 = Field25519(mulMod(limbs, other.limbs)) + + /** + * Bình phương-và-nhân theo số mũ công khai CỐ ĐỊNH ([inverseExponent]/[legendreExponent]) — luôn đúng 255 + * vòng bình phương, mỗi vòng theo sau một phép nhân có-điều-kiện bằng mask (không nhánh), nên dãy phép toán + * không bao giờ phụ thuộc vào `this` (cơ số bí mật). + */ + private fun power(exponent: UIntArray): Field25519 { + var result = one + for (bitIndex in FIELD_BITS - 1 downTo 0) { + result *= result + val bit = (exponent[bitIndex / LIMB_BITS] shr (bitIndex % LIMB_BITS)) and 1u + val candidate = result * this + result = Field25519(selectLimbs(mask(bit), candidate.limbs, result.limbs)) + } + return result + } + + /** `this^-1 mod q` (0 ánh xạ về 0, đúng định lý nhỏ Fermat áp cho `a^(q-2)`). */ + fun inverted(): Field25519 = power(inverseExponent) + + /** Ký hiệu Legendre (tiêu chuẩn Euler): `1` nếu `this` là bình phương khác 0 mod q, `q−1` (≡ `-1`) nếu không + * phải, `0` nếu `this` là `0`. */ + fun legendreSymbol(): Field25519 = power(legendreExponent) + + /** Little-endian 32 byte — nghịch đảo của [fromLittleEndianBytes]. */ + fun toLittleEndianBytes(): ByteArray { + val out = ByteArray(FIELD_SIZE_BYTES) + for (limbIndex in 0 until LIMB_COUNT) { + var word = limbs[limbIndex] + for (byteIndex in 0 until BYTES_PER_LIMB) { + out[limbIndex * BYTES_PER_LIMB + byteIndex] = word.toByte() + word = word shr BITS_PER_BYTE + } + } + return out + } + + companion object { + const val LIMB_COUNT = 8 + const val FIELD_SIZE_BYTES = 32 + private const val BITS_PER_BYTE = 8 + private const val BYTES_PER_LIMB = 4 + private const val FIELD_BITS = 255 + + /** `2^255 − 19`, 8 limb 32-bit little-endian. */ + val q = + Field25519( + uintArrayOf( + 0xFFFFFFEDu, 0xFFFFFFFFu, 0xFFFFFFFFu, 0xFFFFFFFFu, + 0xFFFFFFFFu, 0xFFFFFFFFu, 0xFFFFFFFFu, 0x7FFFFFFFu, + ), + ) + val zero = Field25519(UIntArray(LIMB_COUNT)) + val one = Field25519(UIntArray(LIMB_COUNT).also { it[0] = 1u }) + + /** `q − 2`: số mũ công khai cố định của nghịch đảo Fermat (`a^(q-2) ≡ a^-1 mod q`, `a ≠ 0`). */ + private val inverseExponent: UIntArray = minusSmallPublicConstant(q.limbs, 2u) + + /** `(q − 1) / 2`: số mũ công khai cố định của tiêu chuẩn Euler (ký hiệu Legendre). q lẻ nên đây là phép + * dịch phải chẵn, không mất bit. */ + private val legendreExponent: UIntArray = shiftRightOnePublic(minusSmallPublicConstant(q.limbs, 1u)) + + /** `2^-1 mod q = (q+1)/2` (q lẻ nên q+1 chẵn) — Elligator2 cần ở bước kết hợp, rẻ hơn nghịch đảo 2 mỗi + * lần gọi. */ + val inverseOfTwo: Field25519 = Field25519(shiftRightOnePublic(addSmallPublicConstant(q.limbs, 1u))) + + /** RFC 7748 `decodeUCoordinate` cho trường 255-bit: little-endian 32 byte, bit cao nhất của byte cuối ĐÃ + * được caller che trước khi gọi (nên giá trị luôn < 2^255, cần tối đa một lần rút gọn ở dưới). */ + fun fromLittleEndianBytes(bytes: ByteArray): Field25519 { + require(bytes.size == FIELD_SIZE_BYTES) { "Field25519 cần đúng $FIELD_SIZE_BYTES byte" } + val limbs = UIntArray(LIMB_COUNT) + for (limbIndex in 0 until LIMB_COUNT) { + var word = 0u + for (byteIndex in BYTES_PER_LIMB - 1 downTo 0) { + word = (word shl BITS_PER_BYTE) or bytes[limbIndex * BYTES_PER_LIMB + byteIndex].toUByte().toUInt() + } + limbs[limbIndex] = word + } + return Field25519(reduceBounded(limbs)) + } + + // --- Dựng hằng số CÔNG KHAI (q, inverseExponent, legendreExponent, inverseOfTwo) lúc nạp lớp: q là số + // nguyên tố công khai, không mang bí mật, nên 3 hàm dưới dùng nhánh thường — KHÔNG được dùng cho một + // giá trị trường bí mật (ya/yb/K/r suy từ PIN). --- + + private fun minusSmallPublicConstant( + a: UIntArray, + k: UInt, + ): UIntArray { + val diff = UIntArray(LIMB_COUNT) + var borrow = k.toLong() + for (i in 0 until LIMB_COUNT) { + var limb = a[i].toLong() - borrow + borrow = if (limb < 0) 1L else 0L + if (limb < 0) limb += 1L shl LIMB_BITS + diff[i] = limb.toUInt() + } + return diff + } + + private fun addSmallPublicConstant( + a: UIntArray, + k: UInt, + ): UIntArray { + val sum = UIntArray(LIMB_COUNT) + var carry = k.toULong() + for (i in 0 until LIMB_COUNT) { + val total = a[i].toULong() + carry + sum[i] = (total and WORD_MASK).toUInt() + carry = total shr LIMB_BITS + } + return sum + } + + private fun shiftRightOnePublic(a: UIntArray): UIntArray { + val out = UIntArray(LIMB_COUNT) + var carry = 0u + for (i in LIMB_COUNT - 1 downTo 0) { + out[i] = (a[i] shr 1) or (carry shl (LIMB_BITS - 1)) + carry = a[i] and 1u + } + return out + } + } +} + +// --- Hằng thời gian: mọi hàm dưới đây chỉ dùng cộng/trừ/nhân limb và mask bit — không nhánh, không chỉ số mảng +// theo giá trị bí mật. Dùng cho [Field25519.plus]/[minus]/[times]/[power], áp lên giá trị trường có thể bí mật +// (ya, yb, K, r suy từ PIN qua CPace). --- + +/** Mask toàn-0 hoặc toàn-1 từ một bit 0/1 — không nhánh. */ +private fun mask(bit: UInt): UInt = 0u - bit + +/** `a` khi [maskValue] toàn-1, `b` khi toàn-0. */ +private fun select( + maskValue: UInt, + a: UInt, + b: UInt, +): UInt = (a and maskValue) or (b and maskValue.inv()) + +private fun selectLimbs( + maskValue: UInt, + a: UIntArray, + b: UIntArray, +): UIntArray = UIntArray(LIMB_COUNT) { select(maskValue, a[it], b[it]) } + +private fun addRaw( + a: UIntArray, + b: UIntArray, +): Pair { + val sum = UIntArray(LIMB_COUNT) + var carry = 0uL + for (i in 0 until LIMB_COUNT) { + val total = a[i].toULong() + b[i].toULong() + carry + sum[i] = (total and WORD_MASK).toUInt() + carry = total shr LIMB_BITS + } + return sum to carry.toUInt() +} + +/** + * `a[i] - b[i]`, limb theo limb, với mượn lan sang limb kế — không nhánh: `(a[i] hoặc 2^32) - b[i] - mượn` luôn + * không âm (vì số bị trừ tối đa là `2^32`), bit 32 của kết quả cho biết có mượn hay không mà không cần so sánh. + */ +private fun subRaw( + a: UIntArray, + b: UIntArray, +): Pair { + val diff = UIntArray(LIMB_COUNT) + var borrow = 0uL + for (i in 0 until LIMB_COUNT) { + val raw = (a[i].toULong() or (1uL shl LIMB_BITS)) - b[i].toULong() - borrow + diff[i] = (raw and WORD_MASK).toUInt() + borrow = 1uL - ((raw shr LIMB_BITS) and 1uL) + } + return diff to borrow.toUInt() +} + +/** + * Đưa một giá trị tối đa vài lần q (do [addRaw]/[subRaw] ở các hàm gọi dưới tạo ra) về canonical `[0, q)`: một + * số vòng CỐ ĐỊNH ([REDUCE_ROUNDS]) "trừ q, chỉ giữ phép trừ nếu không mượn" — luôn đúng số vòng đó, không bao + * giờ ít hơn, nên chi phí không hé lộ đầu vào lớn tới đâu. + */ +private fun reduceBounded(value: UIntArray): UIntArray { + var v = value + repeat(REDUCE_ROUNDS) { + val (diff, borrow) = subRaw(v, Field25519.q.limbs) + v = selectLimbs(mask(1u - borrow), diff, v) + } + return v +} + +private fun addMod( + a: UIntArray, + b: UIntArray, +): UIntArray { + val (sum, carry) = addRaw(a, b) + // 2^256 ≡ 38 (mod q): gập carry của phép cộng thô vào trước khi rút gọn (chỉ để phòng xa — không xảy ra với + // hai đầu vào < q đã canonical, vì a+b < 2q < 2^256, nhưng giữ hàm đúng theo nghĩa của riêng nó). + val carryLimbs = UIntArray(LIMB_COUNT).also { it[0] = FOLD_CONSTANT * carry } + val (adjusted, _) = addRaw(sum, carryLimbs) + return reduceBounded(adjusted) +} + +private fun subMod( + a: UIntArray, + b: UIntArray, +): UIntArray { + val (diff, borrow) = subRaw(a, b) + val (diffPlusQ, _) = addRaw(diff, Field25519.q.limbs) // cố ý tràn mod 2^256 — xem bất biến ở reduceBounded. + return reduceBounded(selectLimbs(mask(borrow), diffPlusQ, diff)) +} + +/** + * Cộng [value] (< 2^32) vào `result[index]`, lan carry qua TOÀN BỘ vị trí còn lại tới một biên CỐ ĐỊNH + * (`result.size`, không bao giờ dừng sớm khi "carry == 0") — số limb bị đụng tới chỉ phụ thuộc `index`/ + * `result.size`, không phụ thuộc giá trị thực của carry, nên không hé lộ limb nào của một giá trị bí mật thực + * sự có mượn/nhớ. + */ +private fun addAt( + result: UIntArray, + index: Int, + value: UInt, +) { + var carry = value.toULong() + for (i in index until result.size) { + val sum = result[i].toULong() + carry + result[i] = (sum and WORD_MASK).toUInt() + carry = sum shr LIMB_BITS + } +} + +/** Nhân thường (schoolbook) 8×8 limb → 16 limb. */ +private fun mulRaw( + a: UIntArray, + b: UIntArray, +): UIntArray { + val result = UIntArray(LIMB_COUNT * 2) + for (i in 0 until LIMB_COUNT) { + for (j in 0 until LIMB_COUNT) { + val product = a[i].toULong() * b[j].toULong() + addAt(result, i + j, (product and WORD_MASK).toUInt()) + if (i + j + 1 < result.size) addAt(result, i + j + 1, (product shr LIMB_BITS).toUInt()) + } + } + return result +} + +/** + * 8 limb × hằng số nhỏ → 9 limb (limb thứ 9 giữ phần không vừa 8 limb thấp — không bao giờ bị cắt mất, khác một + * phép nhân 8-limb bình thường sẽ làm). + */ +private fun mulBySmallConstant( + a: UIntArray, + k: UInt, +): UIntArray { + val result = UIntArray(LIMB_COUNT + 1) + for (i in 0 until LIMB_COUNT) { + val product = a[i].toULong() * k.toULong() + addAt(result, i, (product and WORD_MASK).toUInt()) + addAt(result, i + 1, (product shr LIMB_BITS).toUInt()) + } + return result +} + +/** + * `a * b mod q`. Tích thô chia thành `lo + hi · 2^256`; vì `2^256 ≡ 38 (mod q)`, gập `38 · hi` vào `lo` và lặp + * lại co `hi` lại dần. Một số vòng CỐ ĐỊNH ([MUL_FOLD_ROUNDS], không phải lặp "tới khi hi = 0" theo dữ liệu) đủ + * đưa `hi` về một limb rồi về 0 với mọi tích của hai giá trị < q — đã xác nhận bằng toàn bộ bộ vector + * `cpace.json`, kể cả hai phép nhân gần q mà `calculate_generator` thực hiện. + */ +private fun mulMod( + a: UIntArray, + b: UIntArray, +): UIntArray { + val product = mulRaw(a, b) + var lo = product.copyOfRange(0, LIMB_COUNT) + var hi = product.copyOfRange(LIMB_COUNT, LIMB_COUNT * 2) + repeat(MUL_FOLD_ROUNDS) { + val scaled = mulBySmallConstant(hi, FOLD_CONSTANT) + val (sum, carryOut) = addRaw(lo, scaled.copyOfRange(0, LIMB_COUNT)) + lo = sum + hi = UIntArray(LIMB_COUNT).also { it[0] = scaled[LIMB_COUNT] + carryOut } + } + return reduceBounded(lo) +} From a771c28152d48495545f817b0c04455093937e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 17:14:49 +0700 Subject: [PATCH 03/17] style(android): keep Field25519 and CPaceX25519SHA512 within ktlint/detekt limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field25519.kt: wraps the q-constant uintArrayOf one element per line (ktlint argument-list-wrapping) and suppresses TooManyFunctions at file scope — the constant-time helpers below the class (addRaw, subRaw, reduceBounded, addMod, subMod, addAt, mulRaw, mulBySmallConstant, mulMod) are split one step per function on purpose, so merging them to dodge the count would make the constant-time review harder, not easier. CPaceX25519SHA512.kt: names the LEB128 constants (group bits, continuation bit, payload mask, capacity hint) and the RFC 7748 top-bit-clear mask instead of inline magic numbers, and suppresses LongParameterList on isk() — its 6 parameters are exactly the draft's ISK signature (sid, K, Ya, ADa, Yb, ADb), not something to shrink. No behavior change; all 8 CPaceX25519SHA512VectorTest cases (cpace.json, including the negative vectors) still pass. Signed-off-by: Hồ Xuân Dũng --- .../crypto/primitives/CPaceX25519SHA512.kt | 27 ++++++++++++++++--- .../core/crypto/primitives/Field25519.kt | 14 ++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt index f5c000f..3fe25fa 100644 --- a/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt +++ b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt @@ -21,13 +21,27 @@ object CPaceX25519SHA512 { * dài thật của `prs` (một PIN bí mật). */ private const val HASH_INPUT_BLOCK_BYTES = 128 + /** LEB128 (draft Appendix A.1.1): 7 bit dữ liệu mỗi byte, bit cao nhất báo còn byte tiếp theo. */ + private const val LEB128_GROUP_BITS = 7 + private const val LEB128_CONTINUATION_BIT = 0x80 + private const val LEB128_PAYLOAD_MASK = 0x7F + + /** Ước lượng rộng tay số byte LEB128 cần để cấp trước dung lượng [ArrayList], không ảnh hưởng kết quả. */ + private const val LEB128_CAPACITY_HINT_DIVISOR = 128 + /** LEB128 độ dài ‖ data (draft Appendix A.1.1). */ private fun prependLength(data: ByteArray): ByteArray { - val encoded = ArrayList(data.size / 128 + 1) + val encoded = ArrayList(data.size / LEB128_CAPACITY_HINT_DIVISOR + 1) var length = data.size while (true) { - encoded.add(if (length < 0x80) length.toByte() else ((length and 0x7F) or 0x80).toByte()) - length = length ushr 7 + encoded.add( + if (length < LEB128_CONTINUATION_BIT) { + length.toByte() + } else { + ((length and LEB128_PAYLOAD_MASK) or LEB128_CONTINUATION_BIT).toByte() + }, + ) + length = length ushr LEB128_GROUP_BITS if (length == 0) break } return encoded.toByteArray() + data @@ -50,11 +64,15 @@ object CPaceX25519SHA512 { return lengthValueConcat(dsi, prs, ByteArray(lenZpad), ci, sid) } + /** Che bit cao nhất (bit 255) của byte cuối — RFC 7748 `decodeUCoordinate` cho trường 255-bit. */ + private const val TOP_BIT_CLEAR_MASK = 0x7F + /** RFC 7748 `decodeUCoordinate` cho trường 255-bit (draft Appendix A.4): che bit cao nhất của byte cuối (32 * byte little-endian) rồi dựng [Field25519]. */ private fun decodeUCoordinate(bytes: ByteArray): Field25519 { val masked = bytes.copyOf() - masked[Field25519.FIELD_SIZE_BYTES - 1] = (masked[Field25519.FIELD_SIZE_BYTES - 1].toInt() and 0x7F).toByte() + val lastIndex = Field25519.FIELD_SIZE_BYTES - 1 + masked[lastIndex] = (masked[lastIndex].toInt() and TOP_BIT_CLEAR_MASK).toByte() return Field25519.fromLittleEndianBytes(masked) } @@ -121,6 +139,7 @@ object CPaceX25519SHA512 { * `ISK = SHA-512(lv_cat(DSI ‖ "_ISK", sid, K) ‖ transcript_ir(Ya, ADa, Yb, ADb))` (draft section 7.2). 6 * tham số là đúng chữ ký ISK của draft (sid, K, Ya, ADa, Yb, ADb); `k` dưới khớp `K` của draft. */ + @Suppress("LongParameterList") fun isk( sid: ByteArray, k: ByteArray, diff --git a/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/Field25519.kt b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/Field25519.kt index c86ea5a..303e4b3 100644 --- a/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/Field25519.kt +++ b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/Field25519.kt @@ -1,4 +1,8 @@ @file:OptIn(ExperimentalUnsignedTypes::class) +// Các hàm hằng thời gian ở dưới tách nhỏ có chủ đích (addRaw/subRaw/reduceBounded/addMod/subMod/addAt/mulRaw/ +// mulBySmallConstant/mulMod) để mỗi bước của số học trường đứng riêng, dễ soát hằng thời gian từng bước — gộp +// lại để qua ngưỡng đếm hàm sẽ làm khó soát hơn, không phải lỗi thật. +@file:Suppress("TooManyFunctions") package app.handlive.android.core.crypto.primitives @@ -93,8 +97,14 @@ internal class Field25519( val q = Field25519( uintArrayOf( - 0xFFFFFFEDu, 0xFFFFFFFFu, 0xFFFFFFFFu, 0xFFFFFFFFu, - 0xFFFFFFFFu, 0xFFFFFFFFu, 0xFFFFFFFFu, 0x7FFFFFFFu, + 0xFFFFFFEDu, + 0xFFFFFFFFu, + 0xFFFFFFFFu, + 0xFFFFFFFFu, + 0xFFFFFFFFu, + 0xFFFFFFFFu, + 0xFFFFFFFFu, + 0x7FFFFFFFu, ), ) val zero = Field25519(UIntArray(LIMB_COUNT)) From 7826f2b934210d96390907946be371ea1a861226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 17:39:48 +0700 Subject: [PATCH 04/17] feat(protocol): A7.1 Bluetooth link records, frames and constants Pre-auth records, transport frames and the record codec of the Bluetooth link (0.4.5), the direct and bulk envelope types and DIRECT_WIFI_UNAVAILABLE (P7). --- .../android/core/protocol/ErrorCode.kt | 1 + .../protocol/bluetooth/BtLinkConstants.kt | 39 ++++++++ .../protocol/bluetooth/BtLinkException.kt | 9 ++ .../protocol/bluetooth/BtPreauthRecord.kt | 33 +++++++ .../core/protocol/bluetooth/BtRecordCodec.kt | 44 +++++++++ .../protocol/bluetooth/BtTransportFrame.kt | 93 ++++++++++++++++++ .../core/protocol/envelope/MessageType.kt | 4 +- .../protocol/bluetooth/BtPreauthRecordTest.kt | 42 +++++++++ .../protocol/bluetooth/BtRecordCodecTest.kt | 52 ++++++++++ .../bluetooth/BtTransportFrameTest.kt | 94 +++++++++++++++++++ 10 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtLinkConstants.kt create mode 100644 core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtLinkException.kt create mode 100644 core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtPreauthRecord.kt create mode 100644 core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtRecordCodec.kt create mode 100644 core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtTransportFrame.kt create mode 100644 core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtPreauthRecordTest.kt create mode 100644 core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtRecordCodecTest.kt create mode 100644 core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtTransportFrameTest.kt diff --git a/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/ErrorCode.kt b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/ErrorCode.kt index d9ff2f1..7cc6726 100644 --- a/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/ErrorCode.kt +++ b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/ErrorCode.kt @@ -52,6 +52,7 @@ enum class ErrorCode { USB_ADB_UNAVAILABLE, MAC_EXTENSION_NOT_ACTIVE, MAC_MIC_DRIVER_MISSING, + DIRECT_WIFI_UNAVAILABLE, ; companion object { diff --git a/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtLinkConstants.kt b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtLinkConstants.kt new file mode 100644 index 0000000..f21a384 --- /dev/null +++ b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtLinkConstants.kt @@ -0,0 +1,39 @@ +package app.handlive.android.core.protocol.bluetooth + +/** Hằng số khung liên kết Bluetooth (0.4.5 "Link records", "Transport records and channels"; 0.10). */ +object BtLinkConstants { + /** Cận trên của một bản ghi pre-auth, kiểm trước khi cấp phát (0.4.5 "Link records"). */ + const val PREAUTH_RECORD_MAX = 64 + + private const val PADDED_PLAINTEXT_SMALL = 256 + private const val PADDED_PLAINTEXT_MEDIUM = 1024 + private const val PADDED_PLAINTEXT_LARGE = 4096 + + /** Kích cỡ plaintext đã đệm trước khi mã hoá (0.4.5 "Transport records and channels"). */ + val PADDED_PLAINTEXT_SIZES = intArrayOf(PADDED_PLAINTEXT_SMALL, PADDED_PLAINTEXT_MEDIUM, PADDED_PLAINTEXT_LARGE) + + /** `rlen` hợp lệ duy nhất của một bản ghi vận chuyển sau bước 4: mỗi [PADDED_PLAINTEXT_SIZES] cộng [TAG_SIZE]. */ + val TRANSPORT_RECORD_SIZES = + intArrayOf( + PADDED_PLAINTEXT_SMALL + TAG_SIZE, + PADDED_PLAINTEXT_MEDIUM + TAG_SIZE, + PADDED_PLAINTEXT_LARGE + TAG_SIZE, + ) + + /** `dlen` của làn ngắn (control, `long` = 0); cũng là cận chung của trường `dlen`. */ + const val FRAGMENT_MAX = 4092 + + /** `dlen` của một mảnh làn dài (control, `long` = 1) hoặc một mảnh bulk (`chan` = 0x02). */ + const val BULK_FRAGMENT_MAX = 1020 + + /** Tổng byte lớn nhất của một tin đã ghép lại trên làn dài. */ + const val MESSAGE_MAX = 256 * 1024 + + const val CHAN_CONTROL = 0x01 + const val CHAN_BULK = 0x02 + + /** `chan`(1) ‖ `flags`(1) ‖ `dlen`(2 BE), trước `data`. */ + const val FRAME_HEADER_SIZE = 4 + + const val TAG_SIZE = 16 +} diff --git a/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtLinkException.kt b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtLinkException.kt new file mode 100644 index 0000000..cb10961 --- /dev/null +++ b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtLinkException.kt @@ -0,0 +1,9 @@ +package app.handlive.android.core.protocol.bluetooth + +/** + * Lỗi khung liên kết Bluetooth (0.4.5 "Link records"): mọi trường hợp đóng kênh ngay, không trả lời, không nói + * lý do qua dây (cận `rlen`/`dlen` vượt, `chan` lạ, `more` không có `long`, tin làn dài vượt `BT_MESSAGE_MAX`…). + */ +class BtLinkException( + message: String, +) : Exception(message) diff --git a/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtPreauthRecord.kt b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtPreauthRecord.kt new file mode 100644 index 0000000..ef429dd --- /dev/null +++ b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtPreauthRecord.kt @@ -0,0 +1,33 @@ +package app.handlive.android.core.protocol.bluetooth + +/** Thân bản ghi pre-auth, plaintext, byte đầu = kind (0.4.5 "Link records" bảng). */ +object BtPreauthRecord { + const val KIND_CHALLENGE = 0x01 + const val KIND_NOISE1 = 0x02 + const val KIND_NOISE2 = 0x03 + const val KIND_CPACE1 = 0x04 + const val KIND_CPACE2 = 0x05 + const val KIND_REJECT = 0x7F + private const val BYTE_MASK = 0xFF + + class Decoded( + val kind: Int, + val payload: ByteArray, + ) + + fun encode( + kind: Int, + payload: ByteArray = ByteArray(0), + ): ByteArray { + val body = ByteArray(1 + payload.size) + body[0] = kind.toByte() + payload.copyInto(body, destinationOffset = 1) + return body + } + + /** [body] đã qua [BtRecordCodec.requireWithinBound]; thân rỗng (không cả byte kind) → đóng kênh. */ + fun decode(body: ByteArray): Decoded { + if (body.isEmpty()) throw BtLinkException("empty pre-auth record") + return Decoded(body[0].toInt() and BYTE_MASK, body.copyOfRange(1, body.size)) + } +} diff --git a/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtRecordCodec.kt b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtRecordCodec.kt new file mode 100644 index 0000000..d152b6c --- /dev/null +++ b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtRecordCodec.kt @@ -0,0 +1,44 @@ +package app.handlive.android.core.protocol.bluetooth + +import java.nio.ByteBuffer + +/** + * Bản ghi liên kết: `rlen` (uint16 BE) ‖ `rlen` byte (0.4.5 "Link records"). Người đọc phải kiểm `rlen` **trước + * khi cấp phát** thân bản ghi (finding của pentester, review #165): [requireWithinBound]/[requireValidTransportRlen] + * ném ngay khi vượt cận, trước khi gọi mã nào đọc thêm byte theo `rlen`. + */ +object BtRecordCodec { + const val LENGTH_PREFIX_SIZE = 2 + private const val UINT16_MAX = 0xFFFF + private const val BYTE_MASK = 0xFF + private const val BITS_PER_BYTE = 8 + + fun encode(body: ByteArray): ByteArray { + require(body.size <= UINT16_MAX) { "record body too large: ${body.size}" } + return ByteBuffer + .allocate(LENGTH_PREFIX_SIZE + body.size) + .putShort(body.size.toShort()) + .put(body) + .array() + } + + fun decodeRlen(prefix: ByteArray): Int { + require(prefix.size == LENGTH_PREFIX_SIZE) { "rlen prefix must be $LENGTH_PREFIX_SIZE bytes" } + return ((prefix[0].toInt() and BYTE_MASK) shl BITS_PER_BYTE) or (prefix[1].toInt() and BYTE_MASK) + } + + /** Bản ghi pre-auth: `rlen` vượt [maxBodySize] (`BT_PREAUTH_RECORD_MAX`) → đóng kênh, không đọc thêm. */ + fun requireWithinBound( + rlen: Int, + maxBodySize: Int, + ) { + if (rlen > maxBodySize) throw BtLinkException("rlen $rlen exceeds bound $maxBodySize") + } + + /** Bản ghi vận chuyển: `rlen` phải đúng một trong ba cỡ cố định, không chỉ ≤ cận trên (0.4.5). */ + fun requireValidTransportRlen(rlen: Int) { + if (rlen !in BtLinkConstants.TRANSPORT_RECORD_SIZES) { + throw BtLinkException("rlen $rlen is not a valid transport record size") + } + } +} diff --git a/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtTransportFrame.kt b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtTransportFrame.kt new file mode 100644 index 0000000..55516c7 --- /dev/null +++ b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/bluetooth/BtTransportFrame.kt @@ -0,0 +1,93 @@ +package app.handlive.android.core.protocol.bluetooth + +import java.nio.ByteBuffer + +/** + * Plaintext của một khung vận chuyển (0.4.5 "Transport records and channels"), trước khi mã hoá / sau khi giải + * mã: `chan`(1) ‖ `flags`(1, bit0 `more`, bit1 `long`) ‖ `dlen`(2 BE) ‖ `data` ‖ đệm 0 tới cỡ nhỏ nhất trong + * [BtLinkConstants.PADDED_PLAINTEXT_SIZES] chứa đủ khung. + */ +class BtTransportFrame( + val chan: Int, + val more: Boolean, + val long: Boolean, + val data: ByteArray, +) { + companion object { + private const val FLAG_MORE = 0x01 + private const val FLAG_LONG = 0x02 + private const val BYTE_MASK = 0xFF + private const val BITS_PER_BYTE = 8 + private const val DLEN_LOW_BYTE_INDEX = 3 + + /** + * Cận `dlen`: làn dài (control, `long`) hoặc bulk (`chan` = 0x02) → `BULK_FRAGMENT_MAX`; làn ngắn → + * `FRAGMENT_MAX`. + */ + fun maxDlenFor( + chan: Int, + long: Boolean, + ): Int = + if (chan == BtLinkConstants.CHAN_BULK || long) { + BtLinkConstants.BULK_FRAGMENT_MAX + } else { + BtLinkConstants.FRAGMENT_MAX + } + + fun paddedSizeFor(unpaddedSize: Int): Int = + BtLinkConstants.PADDED_PLAINTEXT_SIZES.firstOrNull { it >= unpaddedSize } + ?: throw IllegalArgumentException("no padded tier holds $unpaddedSize bytes") + + /** Dựng plaintext đã đệm sẵn để mã hoá; bên gọi (bộ lập lịch/mux) lo việc cắt mảnh đúng cận trước. */ + fun encode( + chan: Int, + more: Boolean, + long: Boolean, + data: ByteArray, + ): ByteArray { + require(data.size <= maxDlenFor(chan, long)) { "dlen ${data.size} exceeds bound for chan=$chan long=$long" } + val padded = paddedSizeFor(BtLinkConstants.FRAME_HEADER_SIZE + data.size) + var flags = 0 + if (more) flags = flags or FLAG_MORE + if (long) flags = flags or FLAG_LONG + return ByteBuffer + .allocate(padded) + .put(chan.toByte()) + .put(flags.toByte()) + .putShort(data.size.toShort()) + .put(data) + .array() + } + + private fun fail(message: String): Nothing = throw BtLinkException(message) + + /** + * [plaintext] đã qua xác thực tag; kiểm mọi luật của 0.4.5 trước khi trả khung — không trả lời, đóng kênh + * khi sai. + */ + fun decode(plaintext: ByteArray): BtTransportFrame { + if (plaintext.size !in BtLinkConstants.PADDED_PLAINTEXT_SIZES) { + fail("unexpected plaintext size ${plaintext.size}") + } + val chan = plaintext[0].toInt() and BYTE_MASK + if (chan != BtLinkConstants.CHAN_CONTROL && chan != BtLinkConstants.CHAN_BULK) { + fail("unknown chan $chan") + } + val flags = plaintext[1].toInt() and BYTE_MASK + val more = flags and FLAG_MORE != 0 + val long = flags and FLAG_LONG != 0 + // Luật "more=1 với long=0 → đóng kênh" chỉ áp dụng cho làn ngắn của control (chan=0x01); bulk + // (chan=0x02) không dùng bit `long` nhưng vẫn hợp lệ cắt mảnh nhiều `more=1` (0.4.7). + if (chan == BtLinkConstants.CHAN_CONTROL && more && !long) fail("more without long") + val dlen = + ((plaintext[2].toInt() and BYTE_MASK) shl BITS_PER_BYTE) or + (plaintext[DLEN_LOW_BYTE_INDEX].toInt() and BYTE_MASK) + val maxDlen = maxDlenFor(chan, long) + if (dlen > maxDlen) fail("dlen $dlen exceeds bound $maxDlen for chan=$chan long=$long") + val dataEnd = BtLinkConstants.FRAME_HEADER_SIZE + dlen + if (dataEnd > plaintext.size) fail("dlen $dlen overflows frame") + val data = plaintext.copyOfRange(BtLinkConstants.FRAME_HEADER_SIZE, dataEnd) + return BtTransportFrame(chan, more, long, data) + } + } +} diff --git a/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/envelope/MessageType.kt b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/envelope/MessageType.kt index 3d8c85e..a73ee75 100644 --- a/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/envelope/MessageType.kt +++ b/core/protocol/src/main/kotlin/app/handlive/android/core/protocol/envelope/MessageType.kt @@ -1,6 +1,6 @@ package app.handlive.android.core.protocol.envelope -/** Giá trị `type` của envelope (0.7.1): tập đã chốt cộng `session`, `camera`, `web` (P6). */ +/** Giá trị `type` của envelope (0.7.1): tập đã chốt cộng `session`, `camera`, `web` (P6), `direct`, `bulk` (P7). */ enum class MessageType( val wire: String, ) { @@ -15,6 +15,8 @@ enum class MessageType( SESSION("session"), CAMERA("camera"), WEB("web"), + DIRECT("direct"), + BULK("bulk"), ; companion object { diff --git a/core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtPreauthRecordTest.kt b/core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtPreauthRecordTest.kt new file mode 100644 index 0000000..2421221 --- /dev/null +++ b/core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtPreauthRecordTest.kt @@ -0,0 +1,42 @@ +package app.handlive.android.core.protocol.bluetooth + +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.str +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** bt-frame.json kind "preauth_frame": thân pre-auth = kind(1) ‖ payload (0.4.5 "Link records" bảng). */ +class BtPreauthRecordTest { + private val vectors = SharedTestVectors.vectors("bt-frame.json").filter { it.str("kind") == "preauth_frame" } + + @Test + fun encodeAndDecodeMatchVectors() { + for (v in vectors) { + val name = v.str("name") + val kind = Integer.decode(v.str("preauth_kind")) + val body = v.hex("body") + val payload = body.copyOfRange(1, body.size) + assertArrayEquals(name, body, BtPreauthRecord.encode(kind, payload)) + val decoded = BtPreauthRecord.decode(body) + assertEquals(name, kind, decoded.kind) + assertArrayEquals(name, payload, decoded.payload) + } + assertEquals(2, vectors.size) + } + + @Test + fun emptyBodyIsRejected() { + assertThrows(BtLinkException::class.java) { BtPreauthRecord.decode(ByteArray(0)) } + } + + @Test + fun kindConstantsMatchVectors() { + assertEquals(0x01, BtPreauthRecord.KIND_CHALLENGE) + assertEquals(0x7F, BtPreauthRecord.KIND_REJECT) + assertEquals(0x02, BtPreauthRecord.KIND_NOISE1) + assertEquals(0x03, BtPreauthRecord.KIND_NOISE2) + } +} diff --git a/core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtRecordCodecTest.kt b/core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtRecordCodecTest.kt new file mode 100644 index 0000000..e095575 --- /dev/null +++ b/core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtRecordCodecTest.kt @@ -0,0 +1,52 @@ +package app.handlive.android.core.protocol.bluetooth + +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.int +import app.handlive.android.core.protocol.testing.str +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** bt-frame.json: `rlen` (uint16 BE) ‖ thân — cận kiểm trước khi cấp phát (0.4.5 "Link records", review #165). */ +class BtRecordCodecTest { + @Test + fun encodeAndDecodeRlenRoundTrip() { + for (v in SharedTestVectors.vectors("bt-frame.json").filter { it.str("kind") == "preauth_frame" }) { + val record = v.hex("record") + val body = v.hex("body") + assertArrayEquals(v.str("name"), record, BtRecordCodec.encode(body)) + assertEquals(v.str("name"), body.size, BtRecordCodec.decodeRlen(record.copyOfRange(0, 2))) + } + } + + @Test + fun rlenOutOfBoundsClosesBeforeAllocating() { + val v = SharedTestVectors.invalidVectors("bt-frame.json").single { it.str("reason") == "rlen_out_of_bounds" } + val rlen = BtRecordCodec.decodeRlen(v.hex("record").copyOfRange(0, 2)) + assertEquals(v.int("rlen"), rlen) + assertThrows(BtLinkException::class.java) { BtRecordCodec.requireValidTransportRlen(rlen) } + } + + @Test + fun preauthRecordOverBoundClosesBeforeAllocating() { + val v = + SharedTestVectors + .invalidVectors("bt-frame.json") + .single { it.str("reason") == "preauth_record_over_bound" } + val rlen = BtRecordCodec.decodeRlen(v.hex("record").copyOfRange(0, 2)) + assertEquals(v.int("rlen"), rlen) + assertEquals(v.int("bt_preauth_record_max"), BtLinkConstants.PREAUTH_RECORD_MAX) + assertThrows(BtLinkException::class.java) { + BtRecordCodec.requireWithinBound(rlen, BtLinkConstants.PREAUTH_RECORD_MAX) + } + } + + @Test + fun validTransportRlensNeverThrow() { + for (rlen in BtLinkConstants.TRANSPORT_RECORD_SIZES) { + BtRecordCodec.requireValidTransportRlen(rlen) + } + } +} diff --git a/core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtTransportFrameTest.kt b/core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtTransportFrameTest.kt new file mode 100644 index 0000000..e84530c --- /dev/null +++ b/core/protocol/src/test/kotlin/app/handlive/android/core/protocol/bluetooth/BtTransportFrameTest.kt @@ -0,0 +1,94 @@ +package app.handlive.android.core.protocol.bluetooth + +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.bool +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.int +import app.handlive.android.core.protocol.testing.str +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** bt-frame.json kind "transport_frame": `chan` ‖ `flags`(`more`/`long`) ‖ `dlen` ‖ `data` ‖ đệm (0.4.5). */ +class BtTransportFrameTest { + private val vectors = SharedTestVectors.vectors("bt-frame.json").filter { it.str("kind") == "transport_frame" } + + @Test + fun encodeMatchesVectorPlaintext() { + for (v in vectors) { + val chan = Integer.decode(v.str("chan")) + val plaintext = BtTransportFrame.encode(chan, v.bool("more"), v.bool("long"), v.hex("data")) + assertArrayEquals(v.str("name"), v.hex("plaintext"), plaintext) + assertEquals(v.str("name"), v.int("padded_size"), plaintext.size) + } + assertEquals(4, vectors.size) + } + + @Test + fun decodeMatchesVectorFields() { + for (v in vectors) { + val frame = BtTransportFrame.decode(v.hex("plaintext")) + val name = v.str("name") + assertEquals(name, Integer.decode(v.str("chan")), frame.chan) + assertEquals(name, v.bool("more"), frame.more) + assertEquals(name, v.bool("long"), frame.long) + assertArrayEquals(name, v.hex("data"), frame.data) + } + } + + @Test + fun unknownChanClosesTheLink() { + val v = SharedTestVectors.invalidVectors("bt-frame.json").single { it.str("reason") == "unknown_chan" } + assertThrows(BtLinkException::class.java) { BtTransportFrame.decode(v.hex("plaintext")) } + } + + @Test + fun moreWithoutLongClosesTheLink() { + val v = SharedTestVectors.invalidVectors("bt-frame.json").single { it.str("reason") == "more_without_long" } + assertThrows(BtLinkException::class.java) { BtTransportFrame.decode(v.hex("plaintext")) } + } + + @Test + fun bulkFragmentOverMaxClosesTheLink() { + val v = + SharedTestVectors.invalidVectors("bt-frame.json").single { it.str("reason") == "bulk_fragment_over_max" } + val dlen = v.int("dlen") + assertEquals(v.int("bt_bulk_fragment_max"), BtLinkConstants.BULK_FRAGMENT_MAX) + assertTrue(dlen > BtLinkConstants.BULK_FRAGMENT_MAX) + // Vector chỉ tả cận, không cho byte cụ thể — dựng thủ công một plaintext có dlen vượt cận để kiểm decode. + val padded = ByteArray(4096) + padded[0] = Integer.decode(v.str("chan")).toByte() + padded[1] = 0x02 // long = 1 + padded[2] = (dlen shr 8).toByte() + padded[3] = dlen.toByte() + assertThrows(BtLinkException::class.java) { BtTransportFrame.decode(padded) } + } + + @Test + fun maxDlenMatchesEachLane() { + val control = BtLinkConstants.CHAN_CONTROL + val bulk = BtLinkConstants.CHAN_BULK + assertEquals(BtLinkConstants.FRAGMENT_MAX, BtTransportFrame.maxDlenFor(control, long = false)) + assertEquals(BtLinkConstants.BULK_FRAGMENT_MAX, BtTransportFrame.maxDlenFor(control, long = true)) + assertEquals(BtLinkConstants.BULK_FRAGMENT_MAX, BtTransportFrame.maxDlenFor(bulk, long = false)) + } + + @Test + fun shortLaneRejectsOversizedData() { + val oversized = ByteArray(BtLinkConstants.FRAGMENT_MAX + 1) + assertThrows(IllegalArgumentException::class.java) { + BtTransportFrame.encode(BtLinkConstants.CHAN_CONTROL, more = false, long = false, data = oversized) + } + } + + @Test + fun paddedSizeFitsTheSmallestTier() { + assertEquals(256, BtTransportFrame.paddedSizeFor(4)) + assertEquals(1024, BtTransportFrame.paddedSizeFor(1024)) + assertEquals(4096, BtTransportFrame.paddedSizeFor(1025)) + assertFalse(BtTransportFrame.paddedSizeFor(256) > 256) + } +} From a3fc6957100e2df6794522669e1ab1bc10aeec34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 17:39:56 +0700 Subject: [PATCH 05/17] feat(crypto): A7.1 Bluetooth link keys and the Noise_NNpsk0 responder K_bt_disc/K_bt_link (0.4.5 Keys), Noise_NNpsk0_25519_ChaChaPoly_SHA256 on the S side and the ChaCha20-Poly1305 AEAD it uses, with bt-link.json tests. --- .../core/crypto/bluetooth/NoiseNNpsk0.kt | 156 ++++++++++++++++++ .../crypto/derivation/BtLinkKeyDerivation.kt | 16 ++ .../crypto/primitives/ChaCha20Poly1305Aead.kt | 37 +++++ .../core/crypto/BtLinkKeyDerivationTest.kt | 29 ++++ .../android/core/crypto/NoiseNNpsk0Test.kt | 69 ++++++++ 5 files changed, 307 insertions(+) create mode 100644 core/crypto/src/main/kotlin/app/handlive/android/core/crypto/bluetooth/NoiseNNpsk0.kt create mode 100644 core/crypto/src/main/kotlin/app/handlive/android/core/crypto/derivation/BtLinkKeyDerivation.kt create mode 100644 core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/ChaCha20Poly1305Aead.kt create mode 100644 core/crypto/src/test/kotlin/app/handlive/android/core/crypto/BtLinkKeyDerivationTest.kt create mode 100644 core/crypto/src/test/kotlin/app/handlive/android/core/crypto/NoiseNNpsk0Test.kt diff --git a/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/bluetooth/NoiseNNpsk0.kt b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/bluetooth/NoiseNNpsk0.kt new file mode 100644 index 0000000..b519ff7 --- /dev/null +++ b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/bluetooth/NoiseNNpsk0.kt @@ -0,0 +1,156 @@ +package app.handlive.android.core.crypto.bluetooth + +import app.handlive.android.core.crypto.primitives.ChaCha20Poly1305Aead +import app.handlive.android.core.crypto.primitives.HmacSha256 +import app.handlive.android.core.crypto.primitives.X25519Keys +import java.security.GeneralSecurityException +import java.security.MessageDigest + +/** + * `Noise_NNpsk0_25519_ChaChaPoly_SHA256` (Noise Protocol Framework rev. 34), phía S (A-SVC, responder) — bắt tay + * bảo mật liên kết của 0.4.5 bước 1-5. Rev. 34 §9.2 (chế độ psk): mỗi token "e" làm MixHash(e.pub) RỒI + * MixKey(e.pub), khác pattern không-psk nơi "e" chỉ MixHash — cùng thuật toán của `noise_nnpsk0.py` + * (`handlive-shared/tools/vectors`), đã rà soát ở #3418/#3431 và kiểm chéo bằng thư viện `noiseprotocol` thật. + * Android luôn là S: không cần phía khởi tạo (dựng `noise1`, mở `noise2`) — đó là M7.1b (Mac). + */ +object NoiseNNpsk0 { + val PROTOCOL_NAME: ByteArray = "Noise_NNpsk0_25519_ChaChaPoly_SHA256".toByteArray(Charsets.US_ASCII) + const val PROLOGUE_LABEL = "HLBT1" + + private const val HASH_SIZE = 32 + private val ZERO_NONCE = ByteArray(ChaCha20Poly1305Aead.NONCE_SIZE) + private val EMPTY = ByteArray(0) + + /** prologue = `"HLBT1"` ‖ `n_s` (0.4.5 "Link-security handshake"). */ + fun prologue(nS: ByteArray): ByteArray = PROLOGUE_LABEL.toByteArray(Charsets.US_ASCII) + nS + + private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) + + private fun mixHash( + h: ByteArray, + data: ByteArray, + ): ByteArray = sha256(h + data) + + /** `h0 = ck0` = tên giao thức, hoặc SHA-256 của nó nếu dài hơn `HASHLEN` (ở đây 37 byte > 32). */ + private fun initializeSymmetric(): Pair { + val h = if (PROTOCOL_NAME.size <= HASH_SIZE) PROTOCOL_NAME else sha256(PROTOCOL_NAME) + return h to h + } + + private class MixKeyResult( + val ck: ByteArray, + val tempK: ByteArray, + ) + + /** Token DH ("ee"…) hoặc phần MixKey(e.pub) thêm của chế độ psk: 2 ngõ ra qua HMAC, KHÔNG MixHash. */ + private fun mixKey( + ck: ByteArray, + ikm: ByteArray, + ): MixKeyResult { + val tempKey = HmacSha256.mac(ck, ikm) + val out1 = HmacSha256.mac(tempKey, byteArrayOf(0x01)) + val out2 = HmacSha256.mac(tempKey, out1 + byteArrayOf(0x02)) + return MixKeyResult(ck = out1, tempK = out2) + } + + private class PskMixResult( + val ck: ByteArray, + val h: ByteArray, + ) + + /** Token "psk": MixKeyAndHash — 3 ngõ ra qua HMAC rồi MixHash(temp_h). */ + private fun mixKeyAndHashPsk( + ck: ByteArray, + h: ByteArray, + psk: ByteArray, + ): PskMixResult { + val tempKey = HmacSha256.mac(ck, psk) + val out1 = HmacSha256.mac(tempKey, byteArrayOf(0x01)) + val out2 = HmacSha256.mac(tempKey, out1 + byteArrayOf(0x02)) + return PskMixResult(ck = out1, h = mixHash(h, out2)) + } + + /** Kết quả xác thực "khung đầu" (message 1) của một psk — đủ để dựng message 2 (0.4.5 bước 3-4). */ + class Message1Result internal constructor( + internal val ck1: ByteArray, + internal val h4: ByteArray, + val ePubC: ByteArray, + ) + + /** + * Phía S thử một psk ứng viên (0.4.5 bước 3): `message1` = `e.pub` (32) ‖ ciphertext+tag (16). Trả `null` + * nếu tag không khớp psk này — như `reject`, không lộ lý do; gọi lại với psk kế tiếp. + */ + fun tryOpenMessage1( + psk: ByteArray, + prologue: ByteArray, + message1: ByteArray, + ): Message1Result? { + require(message1.size == X25519Keys.KEY_SIZE + ChaCha20Poly1305Aead.TAG_SIZE) { "bad message1 size" } + val ePubC = message1.copyOfRange(0, X25519Keys.KEY_SIZE) + val ciphertextTag = message1.copyOfRange(X25519Keys.KEY_SIZE, message1.size) + val (h0, ck0) = initializeSymmetric() + val h1 = mixHash(h0, prologue) + val pskStep = mixKeyAndHashPsk(ck0, h1, psk) + val h3 = mixHash(pskStep.h, ePubC) + val eStep = mixKey(pskStep.ck, ePubC) + return try { + ChaCha20Poly1305Aead.open(eStep.tempK, ZERO_NONCE, ciphertextTag, h3) + Message1Result(ck1 = eStep.ck, h4 = mixHash(h3, ciphertextTag), ePubC = ePubC) + } catch (_: GeneralSecurityException) { + null + } + } + + /** `message2` + cả hai khoá `Split()`: `c1` giải mã lượt S nhận, `c2` mã hoá lượt S gửi (0.4.5 bước 4-5). */ + class Message2Result internal constructor( + val message2: ByteArray, + val c1: ByteArray, + val c2: ByteArray, + ) + + /** + * Dựng message 2 ("e, ee") sau khi [tryOpenMessage1] đã xác thực [opened]; có thể ném + * [GeneralSecurityException] nếu `e.pub` của C là điểm bậc thấp (bí mật chung toàn 0, [X25519Keys]) — bên gọi + * coi như thất bại xác thực khoá (0.4.5 bước 4), gửi `reject` và đóng kênh, như `ServerHandshake` ở LAN. + */ + fun buildMessage2( + opened: Message1Result, + ePrivS: ByteArray, + ): Message2Result { + val ePubS = X25519Keys.publicFromPrivate(ePrivS) + val h5 = mixHash(opened.h4, ePubS) + val eStep = mixKey(opened.ck1, ePubS) + val dh = X25519Keys.sharedSecret(ePrivS, opened.ePubC) + val eeStep = mixKey(eStep.ck, dh) + val ciphertextTag = ChaCha20Poly1305Aead.seal(eeStep.tempK, ZERO_NONCE, EMPTY, h5) + val (c1, c2) = split(eeStep.ck) + return Message2Result(message2 = ePubS + ciphertextTag, c1 = c1, c2 = c2) + } + + /** `Split()` (0.4.5 bước 4): `c1` dùng để C mã hoá/S giải mã, `c2` ngược lại. */ + fun split(ck2: ByteArray): Pair { + val tempKey = HmacSha256.mac(ck2, EMPTY) + val c1 = HmacSha256.mac(tempKey, byteArrayOf(0x01)) + val c2 = HmacSha256.mac(tempKey, c1 + byteArrayOf(0x02)) + return c1 to c2 + } + + private const val NONCE_COUNTER_PREFIX_SIZE = 4 + private const val NONCE_MAX_COUNTER_SIZE = 8 + private const val NONCE_MAX_COUNTER_BYTE = 0xFF + private const val REKEY_OUTPUT_SIZE = 32 + + /** + * `Rekey()` mặc định của Noise rev. 34 §4.2 (không có vector riêng của HandLive, khác mọi hàm trên — rà soát + * kỹ trước khi khoá theo): khoá mới = 32 byte đầu của `ENCRYPT(k, maxnonce, "", 32 byte 0)`, `maxnonce` = + * 2^64-1 (8 byte LE toàn `0xFF`, 4 byte đầu vẫn 0 theo cấu trúc nonce vận chuyển của 0.4.5). + */ + fun rekey(key: ByteArray): ByteArray { + val maxNonce = + ByteArray(NONCE_COUNTER_PREFIX_SIZE) + + ByteArray(NONCE_MAX_COUNTER_SIZE) { NONCE_MAX_COUNTER_BYTE.toByte() } + val out = ChaCha20Poly1305Aead.seal(key, maxNonce, ByteArray(REKEY_OUTPUT_SIZE), EMPTY) + return out.copyOfRange(0, REKEY_OUTPUT_SIZE) + } +} diff --git a/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/derivation/BtLinkKeyDerivation.kt b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/derivation/BtLinkKeyDerivation.kt new file mode 100644 index 0000000..0c361f9 --- /dev/null +++ b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/derivation/BtLinkKeyDerivation.kt @@ -0,0 +1,16 @@ +package app.handlive.android.core.crypto.derivation + +import app.handlive.android.core.crypto.primitives.HkdfSha256 + +/** + * `K_bt_disc`/`K_bt_link` (0.4.5): HKDF-SHA256(`PRK`, salt rỗng, info, L = 32) — sống suốt đời cặp, tính khi cần + * như `K_push`. Khác `K_bt_pdisc`/`K_bt_plink` (dẫn từ `pairing_secret`, chỉ sống theo một QR). + */ +object BtLinkKeyDerivation { + const val DISCOVERY_INFO = "handlive/v1/bt-discovery" + const val LINK_INFO = "handlive/v1/bt-link" + + fun kBtDisc(prk: ByteArray): ByteArray = HkdfSha256.derive(ikm = prk, info = DISCOVERY_INFO) + + fun kBtLink(prk: ByteArray): ByteArray = HkdfSha256.derive(ikm = prk, info = LINK_INFO) +} diff --git a/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/ChaCha20Poly1305Aead.kt b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/ChaCha20Poly1305Aead.kt new file mode 100644 index 0000000..ea117a8 --- /dev/null +++ b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/ChaCha20Poly1305Aead.kt @@ -0,0 +1,37 @@ +package app.handlive.android.core.crypto.primitives + +import com.google.crypto.tink.aead.internal.InsecureNonceChaCha20Poly1305 + +/** + * ChaCha20-Poly1305 (RFC 8439), nonce 12 byte do bên gọi chỉ định — dùng cho Noise (0.4.5): nonce không bao giờ + * sinh ngẫu nhiên ở đây, luôn là 4 byte 0 ‖ bộ đếm 64-bit LE theo đúng cấu trúc CipherState của Noise. + */ +object ChaCha20Poly1305Aead { + const val KEY_SIZE = 32 + const val NONCE_SIZE = 12 + const val TAG_SIZE = 16 + + /** Trả `ciphertext ‖ tag`; [plaintext] rỗng → trả đúng [TAG_SIZE] byte (EncryptAndHash("") của Noise). */ + fun seal( + key: ByteArray, + nonce: ByteArray, + plaintext: ByteArray, + aad: ByteArray, + ): ByteArray { + require(key.size == KEY_SIZE) { "key must be $KEY_SIZE bytes" } + require(nonce.size == NONCE_SIZE) { "nonce must be $NONCE_SIZE bytes" } + return InsecureNonceChaCha20Poly1305(key).encrypt(nonce, plaintext, aad) + } + + /** Mở `ciphertext ‖ tag`; sai khóa, tag, AAD hoặc nonce → [java.security.GeneralSecurityException]. */ + fun open( + key: ByteArray, + nonce: ByteArray, + ciphertextAndTag: ByteArray, + aad: ByteArray, + ): ByteArray { + require(key.size == KEY_SIZE) { "key must be $KEY_SIZE bytes" } + require(nonce.size == NONCE_SIZE) { "nonce must be $NONCE_SIZE bytes" } + return InsecureNonceChaCha20Poly1305(key).decrypt(nonce, ciphertextAndTag, aad) + } +} diff --git a/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/BtLinkKeyDerivationTest.kt b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/BtLinkKeyDerivationTest.kt new file mode 100644 index 0000000..a8a6bd3 --- /dev/null +++ b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/BtLinkKeyDerivationTest.kt @@ -0,0 +1,29 @@ +package app.handlive.android.core.crypto + +import app.handlive.android.core.crypto.derivation.BtLinkKeyDerivation +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.objects +import app.handlive.android.core.protocol.testing.str +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +/** bt-link.json kind "key": `K_bt_disc`/`K_bt_link` = HKDF-SHA256(`PRK`, info, L = 32) (0.4.5). */ +class BtLinkKeyDerivationTest { + private val keyVectors = + SharedTestVectors.file("bt-link.json").objects("vectors").filter { it.str("kind") == "key" } + + @Test + fun discoveryAndLinkKeysMatchVectors() { + for (v in keyVectors) { + val name = v.str("name") + assertEquals(name, BtLinkKeyDerivation.DISCOVERY_INFO, v.str("k_bt_disc_info")) + assertEquals(name, BtLinkKeyDerivation.LINK_INFO, v.str("k_bt_link_info")) + val prk = v.hex("prk") + assertArrayEquals(name, v.hex("k_bt_disc"), BtLinkKeyDerivation.kBtDisc(prk)) + assertArrayEquals(name, v.hex("k_bt_link"), BtLinkKeyDerivation.kBtLink(prk)) + } + assertEquals(2, keyVectors.size) + } +} diff --git a/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/NoiseNNpsk0Test.kt b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/NoiseNNpsk0Test.kt new file mode 100644 index 0000000..1af10eb --- /dev/null +++ b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/NoiseNNpsk0Test.kt @@ -0,0 +1,69 @@ +package app.handlive.android.core.crypto + +import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.objects +import app.handlive.android.core.protocol.testing.str +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * bt-link.json kind "handshake": `Noise_NNpsk0_25519_ChaChaPoly_SHA256` phía S (0.4.5 bước 1-4). Vector không + * phải vector công bố của tài liệu Noise (xem mô tả file) — rà soát riêng ở #3418/#3431/#3432 (Trang, Huy) bằng + * thư viện `noiseprotocol` thật, nên đáng tin làm điểm neo cho cổng vào của HandLive. + */ +class NoiseNNpsk0Test { + private val handshakeVectors = + SharedTestVectors.file("bt-link.json").objects("vectors").filter { it.str("kind") == "handshake" } + + @Test + fun message1AndMessage2MatchVectors() { + for (v in handshakeVectors) { + val name = v.str("name") + val prologue = NoiseNNpsk0.prologue(v.hex("n_s")) + assertArrayEquals(name, v.hex("prologue"), prologue) + val opened = NoiseNNpsk0.tryOpenMessage1(v.hex("psk"), prologue, v.hex("message1")) + assertNotNull(name, opened) + assertArrayEquals(name, v.hex("e_pub_c"), opened!!.ePubC) + val built = NoiseNNpsk0.buildMessage2(opened, v.hex("e_priv_s")) + assertArrayEquals(name, v.hex("message2"), built.message2) + assertArrayEquals(name, v.hex("c1"), built.c1) + assertArrayEquals(name, v.hex("c2"), built.c2) + } + assertEquals(1, handshakeVectors.size) + } + + @Test + fun wrongPskNeverVerifies() { + val invalid = vectorByReason("wrong_key") + val prologue = NoiseNNpsk0.prologue(invalid.hex("n_s")) + val message1 = invalid.hex("e_pub_c") + invalid.hex("ciphertext_tag_1") + assertNull(NoiseNNpsk0.tryOpenMessage1(invalid.hex("wrong_psk"), prologue, message1)) + } + + @Test + fun tamperedNoise1TagNeverVerifies() { + val invalid = vectorByReason("tag_mismatch_noise1") + val pair1Psk = handshakeVectors.first { it.str("name").contains("cặp 1") }.hex("psk") + val prologue = NoiseNNpsk0.prologue(invalid.hex("n_s")) + val message1 = invalid.hex("e_pub_c") + invalid.hex("ciphertext_tag_1") + assertNull(NoiseNNpsk0.tryOpenMessage1(pair1Psk, prologue, message1)) + } + + @Test + fun replayedNoise1OnANewChannelNeverVerifies() { + val invalid = vectorByReason("replayed_channel") + val pair1Psk = handshakeVectors.first { it.str("name").contains("cặp 1") }.hex("psk") + // n_s của kênh mới (challenge khác) — message1 ghi lại từ kênh cũ vẫn mang e_pub/ciphertext cũ. + val prologue = NoiseNNpsk0.prologue(invalid.hex("n_s")) + val message1 = invalid.hex("e_pub_c") + invalid.hex("ciphertext_tag_1") + assertNull(NoiseNNpsk0.tryOpenMessage1(pair1Psk, prologue, message1)) + } + + private fun vectorByReason(reason: String) = + SharedTestVectors.invalidVectors("bt-link.json").single { it.str("reason") == reason } +} From c69e73a9722a1a77f6699310e22534096b2e1140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 17:40:06 +0700 Subject: [PATCH 06/17] feat(transport): A7.1 Bluetooth link core Admission, link-security handshake (steps 1-5), transport records with lanes and rekey, the advertisement data and serveBluetoothPeer on ControlServer (0.4.5, CONN-05). --- core/transport/build.gradle.kts | 9 +- .../bluetooth/BluetoothAdvertisementData.kt | 59 +++++ .../bluetooth/BluetoothLinkAcceptor.kt | 187 ++++++++++++++ .../bluetooth/BluetoothLinkAdmission.kt | 77 ++++++ .../bluetooth/BluetoothLinkConstants.kt | 25 ++ .../transport/bluetooth/BluetoothLinkIo.kt | 17 ++ .../bluetooth/BluetoothLinkScheduler.kt | 104 ++++++++ .../bluetooth/BluetoothLongLaneReassembler.kt | 29 +++ .../transport/bluetooth/BluetoothPairKeys.kt | 9 + .../bluetooth/BluetoothPeerSocket.kt | 152 +++++++++++ .../bluetooth/BluetoothPreauthSession.kt | 64 +++++ .../transport/bluetooth/BtTransportCipher.kt | 59 +++++ .../bluetooth/StreamBluetoothLinkIo.kt | 88 +++++++ .../transport/handshake/ServerHandshake.kt | 19 +- .../server/ControlConnectionHandler.kt | 59 ++++- .../core/transport/server/ControlServer.kt | 21 +- .../core/transport/server/ControlSession.kt | 12 +- .../BluetoothAdvertisementDataTest.kt | 98 +++++++ .../bluetooth/BluetoothLinkAcceptorTest.kt | 242 ++++++++++++++++++ .../bluetooth/BluetoothLinkAdmissionTest.kt | 76 ++++++ .../bluetooth/BluetoothLinkSchedulerTest.kt | 108 ++++++++ .../BluetoothLongLaneReassemblerTest.kt | 37 +++ .../bluetooth/BluetoothPeerSocketTest.kt | 156 +++++++++++ .../bluetooth/BluetoothPreauthSessionTest.kt | 81 ++++++ .../bluetooth/BtTransportCipherTest.kt | 56 ++++ .../bluetooth/StreamBluetoothLinkIoTest.kt | 67 +++++ .../testing/InMemoryBluetoothLinkIo.kt | 39 +++ .../server/BluetoothSessionRoutingTest.kt | 191 ++++++++++++++ 28 files changed, 2120 insertions(+), 21 deletions(-) create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothAdvertisementData.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAdmission.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkConstants.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkIo.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkScheduler.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLongLaneReassembler.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPairKeys.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocket.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtTransportCipher.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/StreamBluetoothLinkIo.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothAdvertisementDataTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptorTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAdmissionTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkSchedulerTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLongLaneReassemblerTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocketTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSessionTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtTransportCipherTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/StreamBluetoothLinkIoTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/testing/InMemoryBluetoothLinkIo.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/server/BluetoothSessionRoutingTest.kt diff --git a/core/transport/build.gradle.kts b/core/transport/build.gradle.kts index b122026..79c5302 100644 --- a/core/transport/build.gradle.kts +++ b/core/transport/build.gradle.kts @@ -39,6 +39,13 @@ android { .file(sharedDir.file("test-vectors/session-handshake.json")) .withPropertyName("sessionHandshakeVector") .withPathSensitivity(PathSensitivity.RELATIVE) + // Test liên kết Bluetooth đọc bt-link.json và bt-advertisement.json (0.4.5). + test.inputs + .files( + sharedDir.file("test-vectors/bt-link.json"), + sharedDir.file("test-vectors/bt-advertisement.json"), + ).withPropertyName("bluetoothVectors") + .withPathSensitivity(PathSensitivity.RELATIVE) test.inputs .file(sharedDir.file("schemas/common.schema.json")) .withPropertyName("commonSchema") @@ -80,7 +87,7 @@ dependencies { exclude(group = "org.eclipse.jetty.alpn", module = "alpn-api") // Giữ netty-transport-classes-epoll/kqueue: Ktor gọi Epoll/KQueue.isAvailable() khi chọn event loop. } - implementation(libs.ktor.server.websockets) + api(libs.ktor.server.websockets) // Relay client (CONN-03): HTTPS and WebSocket with SPKI pinning of the relay's certificate chain. implementation(libs.okhttp) diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothAdvertisementData.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothAdvertisementData.kt new file mode 100644 index 0000000..df977fc --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothAdvertisementData.kt @@ -0,0 +1,59 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.primitives.HmacSha256 +import java.nio.ByteBuffer +import java.util.UUID + +/** + * Nội dung quảng bá BLE của A-SVC (0.4.5 "Advertisement"): advertisement chỉ mang [SERVICE_UUID], scan response + * mang service data `ver` ‖ `psm` (uint16 LE) ‖ `hints` (4 × n, n ≤ [MAX_HINTS]). Hint của một cặp = 4 byte đầu + * HMAC-SHA256(`K_bt_disc`, "HLBTDISC1" ‖ int64 BE epoch), epoch = floor(now_ms / [HINT_PERIOD_MILLIS]). + */ +object BluetoothAdvertisementData { + /** `BT_SERVICE_UUID` (0.10); `0xFEED` của spike G7 không bao giờ dùng ở bản sản phẩm. */ + val SERVICE_UUID: UUID = UUID.fromString("3b3a01f9-ab27-44a8-bb68-b9b11a488bdb") + + /** `BT_HINT_PERIOD`: hint, PSM và bộ quảng bá xoay cùng lúc ở mỗi ranh giới. */ + const val HINT_PERIOD_MILLIS = 900_000L + + /** `BT_ADV_MAX_HINTS`: giữ scan response ≤ 31 byte. */ + const val MAX_HINTS = 2 + + const val HINT_SIZE = 4 + + /** `BT_PIN_HINT`: đánh dấu cửa sổ ghép đôi PIN đang mở, giống nhau cho mọi điện thoại (AC9). */ + val PIN_HINT: ByteArray get() = ByteArray(HINT_SIZE) + + private const val VERSION: Byte = 0x01 + private const val HEADER_SIZE = 3 + private const val PSM_MAX = 0xFFFF + private const val BYTE_MASK = 0xFF + private val HINT_LABEL = "HLBTDISC1".toByteArray(Charsets.US_ASCII) + + fun epoch(nowMillis: Long): Long = Math.floorDiv(nowMillis, HINT_PERIOD_MILLIS) + + fun millisUntilNextEpoch(nowMillis: Long): Long = (epoch(nowMillis) + 1) * HINT_PERIOD_MILLIS - nowMillis + + fun hint( + discoveryKey: ByteArray, + epoch: Long, + ): ByteArray { + val message = HINT_LABEL + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(epoch).array() + return HmacSha256.mac(discoveryKey, message).copyOf(HINT_SIZE) + } + + fun serviceData( + psm: Int, + hints: List, + ): ByteArray { + require(psm in 0..PSM_MAX) { "psm out of range: $psm" } + require(hints.size <= MAX_HINTS) { "at most $MAX_HINTS hints" } + require(hints.all { it.size == HINT_SIZE }) { "a hint is $HINT_SIZE bytes" } + val out = ByteArray(HEADER_SIZE + HINT_SIZE * hints.size) + out[0] = VERSION + out[1] = (psm and BYTE_MASK).toByte() + out[2] = (psm ushr Byte.SIZE_BITS and BYTE_MASK).toByte() + hints.forEachIndexed { index, hint -> hint.copyInto(out, HEADER_SIZE + HINT_SIZE * index) } + return out + } +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt new file mode 100644 index 0000000..1b6cfae --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt @@ -0,0 +1,187 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord +import io.ktor.websocket.WebSocketSession +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration + +/** + * Một kênh L2CAP vừa được A-SVC accept, từ đầu tới cuối (0.4.5 "Link-security handshake" bước 1-5, "Admission"): + * [BluetoothLinkAdmission] gác kênh ngay khi mở (`BT_LINK_LIMIT`, `BT_PREAUTH_LIMIT`, khoá chung); `challenge` → + * `noise1` (thử `K_bt_link` từng cặp) → `noise2`; liên kết chỉ được xác nhận khi bản ghi vận chuyển đầu tiên của C + * giải mã được (bước 5), tất cả trong `BT_PREAUTH_TIMEOUT`. Mọi lỗi trước khi gửi `noise2` nhận `reject` rồi đóng; + * lỗi xác nhận khoá (bước 3, 5) tính một `AUTH_FAILED` cho cả listener. Liên kết đã xác nhận đi vào [serve] + * (`ControlServer.serveBluetoothPeer`, bắt tay E2E bước 6) cùng `pair_id` của psk đã khớp. + */ +class BluetoothLinkAcceptor( + private val admission: BluetoothLinkAdmission, + private val serve: suspend (WebSocketSession, String) -> Unit, + private val preauthTimeout: Duration = BluetoothLinkConstants.PREAUTH_TIMEOUT, + private val newSession: () -> BluetoothPreauthSession, +) { + constructor( + admission: BluetoothLinkAdmission, + pairKeys: BluetoothPairKeys, + serve: suspend (WebSocketSession, String) -> Unit, + ) : this(admission, serve, newSession = { BluetoothPreauthSession(pairKeys) }) + + /** Trả về khi kênh đóng; luôn trả slot admission và đóng [io]. */ + suspend fun accept(io: BluetoothLinkIo) { + if (admission.admitNewChannel() != BluetoothLinkAdmission.AdmissionResult.Admitted) { + sendReject(io) + io.close() + return + } + val link = LinkState() + try { + coroutineScope { + val watchdog = + launch { + delay(preauthTimeout) + if (link.expire()) { + if (!link.noise2Sent) sendReject(io) + io.close() + } + } + val confirmed = runPreauth(io, link, this) + watchdog.cancel() + if (confirmed != null) { + try { + serve(confirmed.socket, confirmed.pairId) + } finally { + confirmed.socket.shutDown() + } + } else { + link.socket?.shutDown() + } + } + } finally { + admission.closeLink(wasPreauth = !link.confirmed) + io.close() + } + } + + private class Confirmed( + val socket: BluetoothPeerSocket, + val pairId: String, + ) + + private enum class FirstRecord { OPENED, KEY_FAILED, CLOSED } + + /** Bước 1-5; `null` khi kênh phải đóng (đã gửi `reject` nếu còn trước `noise2`). */ + private suspend fun runPreauth( + io: BluetoothLinkIo, + link: LinkState, + scope: CoroutineScope, + ): Confirmed? = + try { + val session = newSession() + io.writeRecord(session.challenge()) + val record = BtPreauthRecord.decode(io.readPreauthRecord()) + // `cpace1` chỉ hợp lệ khi cửa sổ PIN mở (A7.2a, chưa có): đóng không trả lời, như mọi kind lạc chỗ. + if (record.kind != BtPreauthRecord.KIND_NOISE1) return null + val verified = session.receiveNoise1(record.payload) as? BluetoothPreauthSession.Noise1Outcome.Verified + if (verified == null) { + admission.recordAuthFailure() + sendReject(io) + return null + } + val keys = session.buildNoise2() + io.writeRecord(keys.noise2) + link.noise2Sent = true + awaitFirstRecord(io, link, scope, keys)?.let { Confirmed(it, verified.pairId) } + } catch (_: BtLinkException) { + // Kênh đứt, `rlen` vượt cận hoặc bị watchdog đóng: không trả lời thêm. + null + } + + private suspend fun awaitFirstRecord( + io: BluetoothLinkIo, + link: LinkState, + scope: CoroutineScope, + keys: BluetoothPreauthSession.LinkKeys, + ): BluetoothPeerSocket? { + val first = CompletableDeferred() + // S nhận bằng c1 (C mã hoá), gửi bằng c2 (bt-link.json, Split()). + val socket = + BluetoothPeerSocket( + io, + sendCipher = BtTransportCipher(keys.c2), + receiveCipher = BtTransportCipher(keys.c1), + parent = scope, + callbacks = + BluetoothPeerSocketCallbacks( + onClosed = { first.complete(FirstRecord.CLOSED) }, + onFirstRecordOpened = { + val outcome = if (link.confirm()) FirstRecord.OPENED else FirstRecord.CLOSED + if (outcome == FirstRecord.OPENED) admission.confirmLink() + first.complete(outcome) + }, + onFirstRecordRejected = { first.complete(FirstRecord.KEY_FAILED) }, + ), + ) + link.socket = socket + return when (first.await()) { + FirstRecord.OPENED -> { + socket + } + + FirstRecord.KEY_FAILED -> { + admission.recordAuthFailure() + null + } + + FirstRecord.CLOSED -> { + null + } + } + } + + private suspend fun sendReject(io: BluetoothLinkIo) { + try { + io.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_REJECT)) + } catch (_: BtLinkException) { + // "when it can" (0.4.5): kênh đã đứt thì chỉ còn đóng. + } + } + + /** Bước 5 và hết giờ pre-auth loại trừ nhau: cái nào tới trước thắng. */ + private class LinkState { + @Volatile + var noise2Sent = false + + @Volatile + var socket: BluetoothPeerSocket? = null + + @Volatile + var confirmed = false + private set + + private var expired = false + + @Synchronized + fun confirm(): Boolean { + if (expired) return false + confirmed = true + return true + } + + @Synchronized + fun expire(): Boolean { + if (confirmed) return false + expired = true + return true + } + } +} + +private fun BluetoothPeerSocket.shutDown() { + end() + cancel() +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAdmission.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAdmission.kt new file mode 100644 index 0000000..313eb48 --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAdmission.kt @@ -0,0 +1,77 @@ +package app.handlive.android.core.transport.bluetooth + +import kotlin.time.Duration + +/** + * Admission của toàn bộ listener Bluetooth (0.4.5 "Admission"): BLE không cho địa chỉ client, nên cận và bộ đếm + * thất bại tính chung cho mọi kênh — không bao giờ khoá theo địa chỉ, khác `CTL_PREAUTH_LIMIT`/`CTL_IP_BLOCK` của + * LAN. An toàn đa luồng: mỗi kênh chạy `BluetoothLinkAcceptor` riêng, và `ControlConnectionHandler` đếm thêm + * `AUTH_FAILED` của `session/hello` từ coroutine khác. + */ +class BluetoothLinkAdmission( + private val linkLimit: Int = BluetoothLinkConstants.LINK_LIMIT, + private val preauthLimit: Int = BluetoothLinkConstants.PREAUTH_LIMIT, + private val preauthTimeout: Duration = BluetoothLinkConstants.PREAUTH_TIMEOUT, + private val authFailBlock: Int = BluetoothLinkConstants.AUTH_FAIL_BLOCK, + private val authFailWindow: Duration = BluetoothLinkConstants.AUTH_FAIL_WINDOW, + private val clock: () -> Long = System::currentTimeMillis, +) { + private var openLinks = 0 + private var preauthLinks = 0 + private val authFailureTimestamps = ArrayDeque() + private var blockedUntil: Long = 0 + + sealed interface AdmissionResult { + data object Admitted : AdmissionResult + + /** Cận chung hoặc listener đang bị khoá sau `BT_AUTH_FAIL_BLOCK` → `reject`, đóng kênh. */ + data object Rejected : AdmissionResult + } + + val isBlocked: Boolean + @Synchronized get() = clock() < blockedUntil + + /** Một kênh L2CAP mới mở: kiểm khoá chung rồi slot pre-auth (0.4.5 "Admission" bước 1). */ + @Synchronized + fun admitNewChannel(): AdmissionResult { + if (isBlocked || openLinks >= linkLimit || preauthLinks >= preauthLimit) { + return AdmissionResult.Rejected + } + openLinks++ + preauthLinks++ + return AdmissionResult.Admitted + } + + /** Bước 5 hoàn tất: không còn tính vào slot pre-auth, vẫn tính vào `BT_LINK_LIMIT`. */ + @Synchronized + fun confirmLink() { + preauthLinks-- + } + + /** Kênh đóng ở bất kỳ giai đoạn: trả lại slot. [wasPreauth] = chưa qua bước 5 khi đóng. */ + @Synchronized + fun closeLink(wasPreauth: Boolean) { + openLinks-- + if (wasPreauth) preauthLinks-- + } + + /** Một lần xác thực khoá thất bại (bước 3/5) hoặc `session/hello` bị `AUTH_FAILED` — tính cho toàn listener. */ + @Synchronized + fun recordAuthFailure() { + pruneAuthFailures() + authFailureTimestamps.addLast(clock()) + if (authFailureTimestamps.size >= authFailBlock) { + blockedUntil = clock() + authFailWindow.inWholeMilliseconds + } + } + + fun isPreauthTimedOut(channelOpenedAt: Long): Boolean = + clock() - channelOpenedAt >= preauthTimeout.inWholeMilliseconds + + private fun pruneAuthFailures() { + val windowStart = clock() - authFailWindow.inWholeMilliseconds + while (authFailureTimestamps.isNotEmpty() && authFailureTimestamps.first() < windowStart) { + authFailureTimestamps.removeFirst() + } + } +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkConstants.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkConstants.kt new file mode 100644 index 0000000..b2928f3 --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkConstants.kt @@ -0,0 +1,25 @@ +package app.handlive.android.core.transport.bluetooth + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** Hằng số phiên liên kết Bluetooth (0.10): admission, rekey, lập lịch — khác hằng số khung của `core:protocol`. */ +object BluetoothLinkConstants { + const val LINK_LIMIT = 4 + const val PREAUTH_LIMIT = 1 + val PREAUTH_TIMEOUT: Duration = 7.seconds + + const val AUTH_FAIL_BLOCK = 5 + val AUTH_FAIL_WINDOW: Duration = 1.minutes + + /** Noise `Rekey()` trên CipherState gửi sau mỗi bao nhiêu bản ghi một chiều. */ + const val LINK_REKEY_RECORDS = 65_536L + + /** Số bản ghi làn dài + bulk cùng lúc đã gửi mà chưa xả (0.4.5 "Priority"). */ + const val BULK_INFLIGHT = 2 + + /** `BT_ENABLE_PROMPT_INTERVAL` (SET-01 API 10): tối đa một lần xin bật Bluetooth tự động trong khoảng này. */ + val ENABLE_PROMPT_INTERVAL: Duration = 24.hours +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkIo.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkIo.kt new file mode 100644 index 0000000..caf2e73 --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkIo.kt @@ -0,0 +1,17 @@ +package app.handlive.android.core.transport.bluetooth + +/** + * Đọc/viết một bản ghi (`rlen` ‖ thân, 0.4.5 "Link records") trên một kênh BLE L2CAP CoC; [readRecord]/[writeRecord] + * nhận và trả THÂN bản ghi, phần `rlen` do bên triển khai lo. Bên triển khai kiểm `rlen` TRƯỚC khi cấp phát thân + * (review #165): ≤ `BT_PREAUTH_RECORD_MAX` cho [readPreauthRecord] (bước 1-4), đúng một trong ba cỡ cố định cho + * [readRecord] (sau bước 4). Bản thật là [StreamBluetoothLinkIo] trên `BluetoothSocket`; test dùng ống trong bộ nhớ. + */ +interface BluetoothLinkIo { + suspend fun readPreauthRecord(): ByteArray + + suspend fun readRecord(): ByteArray + + suspend fun writeRecord(body: ByteArray) + + fun close() +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkScheduler.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkScheduler.kt new file mode 100644 index 0000000..1ddd977 --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkScheduler.kt @@ -0,0 +1,104 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import app.handlive.android.core.protocol.envelope.MessageType + +/** + * Bộ lập lịch gửi của một liên kết Bluetooth (0.4.5 "Priority"): ba hàng đợi, ở mỗi ranh giới mảnh [poll] trả + * mảnh từ hàng đợi không rỗng đầu tiên theo thứ tự ngắn, dài, bulk. Tối đa [bulkInflight] bản ghi dài+bulk đã + * gửi (đã [poll]) mà chưa xả (gọi [onDrained]) cùng lúc; làn ngắn không bị giới hạn này nên luôn tới ngay sau + * mảnh đang gửi, không phải chờ thêm mảnh dài/bulk nào khác xếp sau nó. + * + * Một phong bì cùng `type` với một tin làn dài còn đang xếp hàng/trên đường gửi phải đi làn dài theo sau nó + * (0.4.5 "Order"): [typesPendingLong] theo dõi điều này, xoá khi mảnh CUỐI của tin đó được [poll] — thứ tự trên + * dây đã chốt ngay lúc đó vì liên kết chỉ có một luồng byte nối tiếp. + */ +class BluetoothLinkScheduler( + private val bulkInflight: Int = BluetoothLinkConstants.BULK_INFLIGHT, +) { + private class QueuedFrame( + val bytes: ByteArray, + val type: String?, + val isLastOfMessage: Boolean, + ) + + private val short = ArrayDeque() + private val long = ArrayDeque() + private val bulk = ArrayDeque() + private var longBulkInflight = 0 + private val typesPendingLong = mutableSetOf() + + /** Phong bì JSON (0.5.1) trên kênh điều khiển `0x01`; tự chọn làn ngắn hay dài theo 0.4.5 "Control channel". */ + fun enqueueControlEnvelope( + type: String, + envelope: ByteArray, + ) { + val mustUseLong = + envelope.size > BtLinkConstants.FRAGMENT_MAX || type == MessageType.SESSION.wire || type in typesPendingLong + if (mustUseLong) { + typesPendingLong += type + long.addAll(fragment(type, envelope, BtLinkConstants.CHAN_CONTROL, long = true)) + } else { + val chan = BtLinkConstants.CHAN_CONTROL + short.addLast(BtTransportFrame.encode(chan, more = false, long = false, data = envelope)) + } + } + + /** Dữ liệu bulk trên kênh `0x02` (nội dung: 0.4.7, ngoài phạm vi thẻ này). */ + fun enqueueBulk(data: ByteArray) { + bulk.addAll(fragment(type = null, data, BtLinkConstants.CHAN_BULK, long = false)) + } + + fun hasPending(): Boolean = short.isNotEmpty() || long.isNotEmpty() || bulk.isNotEmpty() + + /** Mảnh plaintext kế tiếp theo ưu tiên 0.4.5 "Priority", hay `null` nếu không còn gì để gửi ngay. */ + fun poll(): ByteArray? = + short.removeFirstOrNull() + ?: if (longBulkInflight >= bulkInflight) { + null + } else { + long.removeFirstOrNull()?.let { dispatch(it) } + ?: bulk.removeFirstOrNull()?.let { dispatch(it) } + } + + /** Gọi khi một bản ghi dài/bulk đã [poll] trước đó thật sự xả khỏi bộ đệm ghi (mở lại một chỗ inflight). */ + fun onDrained() { + if (longBulkInflight > 0) longBulkInflight-- + } + + private fun dispatch(frame: QueuedFrame): ByteArray { + longBulkInflight++ + if (frame.isLastOfMessage) frame.type?.let { typesPendingLong -= it } + return frame.bytes + } + + private fun fragment( + type: String?, + data: ByteArray, + chan: Int, + long: Boolean, + ): List { + val chunks = chunk(data, BtLinkConstants.BULK_FRAGMENT_MAX) + return chunks.mapIndexed { index, part -> + val more = index != chunks.lastIndex + val plaintext = BtTransportFrame.encode(chan, more = more, long = long, data = part) + QueuedFrame(plaintext, type, isLastOfMessage = !more) + } + } + + private fun chunk( + data: ByteArray, + maxSize: Int, + ): List { + if (data.isEmpty()) return listOf(ByteArray(0)) + val out = mutableListOf() + var offset = 0 + while (offset < data.size) { + val end = minOf(offset + maxSize, data.size) + out += data.copyOfRange(offset, end) + offset = end + } + return out + } +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLongLaneReassembler.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLongLaneReassembler.kt new file mode 100644 index 0000000..fc19c2d --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLongLaneReassembler.kt @@ -0,0 +1,29 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import java.io.ByteArrayOutputStream + +/** + * Ghép lại làn dài của kênh điều khiển (0.4.5): một tin một lúc mỗi hướng. Đóng liên kết khi tổng byte tích luỹ + * vượt `BT_MESSAGE_MAX` — kiểm ở MỌI mảnh, không chỉ mảnh cuối (`more` = 0): một mảnh giữa đường đã đẩy tổng + * vượt cận phải đóng ngay, không chờ mảnh cuối đọc hết mới biết tin quá lớn. + */ +class BluetoothLongLaneReassembler( + private val messageMax: Int = BtLinkConstants.MESSAGE_MAX, +) { + private val buffer = ByteArrayOutputStream() + + /** Trả tin hoàn chỉnh khi [frame] là mảnh cuối (`more` = false), `null` nếu còn chờ mảnh tiếp. */ + fun accept(frame: BtTransportFrame): ByteArray? { + require(frame.long) { "not a long-lane frame" } + val total = buffer.size() + frame.data.size + if (total > messageMax) throw BtLinkException("long-lane message exceeds BT_MESSAGE_MAX") + buffer.write(frame.data) + if (frame.more) return null + val result = buffer.toByteArray() + buffer.reset() + return result + } +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPairKeys.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPairKeys.kt new file mode 100644 index 0000000..4e48d7b --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPairKeys.kt @@ -0,0 +1,9 @@ +package app.handlive.android.core.transport.bluetooth + +/** + * `K_bt_link` của mọi cặp chưa thu hồi (0.4.5 bước 3): quảng bá BLE không mang `pair_id`, nên S thử lần lượt + * theo thứ tự trả về đây. Bản thật dẫn mỗi khoá bằng `BtLinkKeyDerivation.kBtLink(pair.prk)` khi cần, như `K_push`. + */ +fun interface BluetoothPairKeys { + fun unrevokedLinkKeys(): List> +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocket.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocket.kt new file mode 100644 index 0000000..bd8ad85 --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocket.kt @@ -0,0 +1,152 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import app.handlive.android.core.protocol.envelope.Envelope +import app.handlive.android.core.protocol.envelope.EnvelopeCodec +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketExtension +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.readText +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import java.security.GeneralSecurityException +import kotlin.coroutines.CoroutineContext + +/** + * Một phiên `/v1/ctl` qua liên kết Bluetooth đã qua bắt tay bảo mật liên kết (0.4.5 bước 4-5, #191): như + * `RelayPeerSocket` của relay, `ControlServer.serveBluetoothPeer` nhận socket ảo này mà không đụng tới dây L2CAP + * bên dưới — E2E `session/hello` (0.6.3) chạy y nguyên bên trong (0.4.5 bước 6). [sendCipher]/[receiveCipher] là + * hai nửa của `Split()`; việc cắt mảnh/ưu tiên là [BluetoothLinkScheduler], ghép lại làn dài là + * [BluetoothLongLaneReassembler] (cả hai của #191). Kênh bulk (`chan` = 0x02) bỏ qua ở đây — nội dung của nó + * thuộc 0.4.7 (A7.4), ngoài phạm vi thẻ A7.1b. [onFirstRecordOpened]/[onFirstRecordRejected] báo bước 5 cho + * `BluetoothLinkAcceptor` (admission, `BT_PREAUTH_TIMEOUT`). + */ +internal data class BluetoothPeerSocketCallbacks( + val onClosed: (BluetoothPeerSocket) -> Unit = {}, + val onFirstRecordOpened: () -> Unit = {}, + val onFirstRecordRejected: () -> Unit = {}, +) + +internal class BluetoothPeerSocket( + private val io: BluetoothLinkIo, + private val sendCipher: BtTransportCipher, + private val receiveCipher: BtTransportCipher, + parent: CoroutineScope, + private val callbacks: BluetoothPeerSocketCallbacks = BluetoothPeerSocketCallbacks(), +) : WebSocketSession { + override val coroutineContext: CoroutineContext = + parent.coroutineContext + SupervisorJob(parent.coroutineContext.job) + + private val incomingFrames = Channel(Channel.UNLIMITED) + private val outgoingFrames = Channel(Channel.UNLIMITED) + private val scheduler = BluetoothLinkScheduler() + private val reassembler = BluetoothLongLaneReassembler() + private var anyRecordOpened = false + + override val incoming: ReceiveChannel = incomingFrames + override val outgoing: SendChannel = outgoingFrames + override val extensions: List> = emptyList() + override var masking: Boolean = false + override var maxFrameSize: Long = Envelope.MAX_BYTES.toLong() + + init { + coroutineContext.job.invokeOnCompletion { end() } + launch { readLoop() } + launch { writeLoop() } + } + + private suspend fun readLoop() { + try { + while (true) { + val body = io.readRecord() + val plaintext = openRecord(body) + val frame = BtTransportFrame.decode(plaintext) + if (frame.chan == BtLinkConstants.CHAN_CONTROL) deliverControlFrame(frame) + } + } catch (e: CancellationException) { + throw e + } catch (_: BtLinkException) { + end() + } catch (_: GeneralSecurityException) { + end() + } + } + + /** Bản ghi đầu tiên giải mã được là bước 5 của 0.4.5 (liên kết được xác nhận); hỏng ở đó là lỗi xác nhận khoá. */ + private fun openRecord(body: ByteArray): ByteArray { + val plaintext = + try { + receiveCipher.open(body) + } catch (e: GeneralSecurityException) { + if (!anyRecordOpened) callbacks.onFirstRecordRejected() + throw e + } + if (!anyRecordOpened) { + anyRecordOpened = true + callbacks.onFirstRecordOpened() + } + return plaintext + } + + private fun deliverControlFrame(frame: BtTransportFrame) { + val message = if (frame.long) reassembler.accept(frame) else frame.data + message?.let { incomingFrames.trySend(Frame.Text(it.toString(Charsets.UTF_8))) } + } + + private suspend fun writeLoop() { + try { + for (frame in outgoingFrames) { + when (frame) { + is Frame.Text -> enqueueEnvelope(frame.readText()) + is Frame.Close -> break + else -> Unit + } + drainScheduler() + } + } catch (e: CancellationException) { + throw e + } catch (_: BtLinkException) { + // The link died mid-write (`more`/`dlen` only this side can produce correctly) — nothing to retry. + } + end() + } + + private fun enqueueEnvelope(json: String) { + val type = EnvelopeCodec.decode(json).type + scheduler.enqueueControlEnvelope(type, json.toByteArray(Charsets.UTF_8)) + } + + private suspend fun drainScheduler() { + var plaintext = scheduler.poll() + while (plaintext != null) { + io.writeRecord(sendCipher.seal(plaintext)) + scheduler.onDrained() + plaintext = scheduler.poll() + } + } + + /** Dây L2CAP đứt hoặc phiên đóng: đóng cả hai hàng đợi và dây vật lý bên dưới. */ + fun end() { + incomingFrames.close() + outgoingFrames.close() + io.close() + callbacks.onClosed(this) + } + + override suspend fun flush() = Unit + + @Deprecated("Use cancel() instead.", ReplaceWith("cancel()", "kotlinx.coroutines.cancel")) + override fun terminate() { + end() + cancel() + } +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt new file mode 100644 index 0000000..01e4cd6 --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt @@ -0,0 +1,64 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.crypto.primitives.SecureRandomBytes +import app.handlive.android.core.crypto.primitives.X25519Keys +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord + +/** + * Phía S (A-SVC) của bắt tay bảo mật liên kết cho MỘT kênh (0.4.5 "Link-security handshake" bước 1-4): thử + * `K_bt_link` của từng cặp chưa thu hồi theo lượt — quảng bá không mang `pair_id` nên S không biết trước cặp + * nào. Không tự đóng kênh hay cập nhật admission: bên gọi (listener, A7.1b) làm việc đó theo kết quả trả về. + */ +class BluetoothPreauthSession( + private val pairKeys: BluetoothPairKeys, + private val nonce: () -> ByteArray = { SecureRandomBytes.next(NONCE_SIZE) }, + private val ephemeralPrivateKey: () -> ByteArray = X25519Keys::generatePrivateKey, +) { + private lateinit var prologue: ByteArray + private var opened: NoiseNNpsk0.Message1Result? = null + + /** Bước 1: `n_s` mới cho kênh này; trói buộc vào prologue nên `noise1` ghi lại từ kênh khác không bao giờ khớp. */ + fun challenge(): ByteArray { + val nS = nonce() + prologue = NoiseNNpsk0.prologue(nS) + return BtPreauthRecord.encode(BtPreauthRecord.KIND_CHALLENGE, nS) + } + + sealed interface Noise1Outcome { + class Verified( + val pairId: String, + ) : Noise1Outcome + + data object Rejected : Noise1Outcome + } + + /** Bước 2-3: [message1] = thân `noise1` sau byte kind (0.4.5 bước 3, thử lần lượt). */ + fun receiveNoise1(message1: ByteArray): Noise1Outcome { + for ((pairId, psk) in pairKeys.unrevokedLinkKeys()) { + val result = NoiseNNpsk0.tryOpenMessage1(psk, prologue, message1) + if (result != null) { + opened = result + return Noise1Outcome.Verified(pairId) + } + } + return Noise1Outcome.Rejected + } + + class LinkKeys( + val noise2: ByteArray, + val c1: ByteArray, + val c2: ByteArray, + ) + + /** Bước 4: chỉ gọi sau [receiveNoise1] trả [Noise1Outcome.Verified]. */ + fun buildNoise2(): LinkKeys { + val verified = checkNotNull(opened) { "message1 chưa xác thực" } + val result = NoiseNNpsk0.buildMessage2(verified, ephemeralPrivateKey()) + return LinkKeys(BtPreauthRecord.encode(BtPreauthRecord.KIND_NOISE2, result.message2), result.c1, result.c2) + } + + private companion object { + const val NONCE_SIZE = 32 + } +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtTransportCipher.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtTransportCipher.kt new file mode 100644 index 0000000..6803cdf --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtTransportCipher.kt @@ -0,0 +1,59 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.crypto.primitives.ChaCha20Poly1305Aead + +/** + * Trạng thái khoá một chiều sau `Split()` (0.4.5 bước 4): nonce = 4 byte 0 ‖ bộ đếm 64-bit LE của chiều đó, AAD + * rỗng; `Rekey()` tự động sau mỗi [rekeyAfterRecords] bản ghi — bộ đếm KHÔNG reset theo (Noise rev. 34 §4.2: + * `Rekey()` chỉ đổi khoá, không đổi `n`). Không an toàn đa luồng: bên gọi tự tuần tự hoá, như + * [app.handlive.android.core.transport.session.SessionCipher] của phiên LAN. + */ +class BtTransportCipher( + initialKey: ByteArray, + private val rekeyAfterRecords: Long = BluetoothLinkConstants.LINK_REKEY_RECORDS, + initialCounter: Long = 0, +) { + private var key = initialKey + private var counter: Long = initialCounter + private var recordsSinceRekey: Long = 0 + + val recordCount: Long get() = counter + + fun seal(paddedPlaintext: ByteArray): ByteArray { + val out = ChaCha20Poly1305Aead.seal(key, nonce(counter), paddedPlaintext, EMPTY) + advance() + return out + } + + fun open(ciphertextTag: ByteArray): ByteArray { + val out = ChaCha20Poly1305Aead.open(key, nonce(counter), ciphertextTag, EMPTY) + advance() + return out + } + + private fun advance() { + counter++ + recordsSinceRekey++ + if (recordsSinceRekey >= rekeyAfterRecords) { + key = NoiseNNpsk0.rekey(key) + recordsSinceRekey = 0 + } + } + + private companion object { + val EMPTY = ByteArray(0) + const val NONCE_COUNTER_OFFSET = 4 + const val COUNTER_BYTES = 8 + const val BITS_PER_BYTE = 8 + const val BYTE_MASK = 0xFFL + + fun nonce(counter: Long): ByteArray { + val out = ByteArray(ChaCha20Poly1305Aead.NONCE_SIZE) + for (i in 0 until COUNTER_BYTES) { + out[NONCE_COUNTER_OFFSET + i] = ((counter ushr (BITS_PER_BYTE * i)) and BYTE_MASK).toByte() + } + return out + } + } +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/StreamBluetoothLinkIo.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/StreamBluetoothLinkIo.kt new file mode 100644 index 0000000..e5dd6f9 --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/StreamBluetoothLinkIo.kt @@ -0,0 +1,88 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtRecordCodec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * [BluetoothLinkIo] trên luồng byte của một `BluetoothSocket` L2CAP CoC (A-SVC). Đọc/viết chặn chạy trên luồng IO; + * huỷ coroutine đang chờ thì đóng socket ([onClose]) — cách duy nhất làm `read()` của socket nhả ra. + */ +class StreamBluetoothLinkIo( + private val input: InputStream, + private val output: OutputStream, + private val onClose: () -> Unit, +) : BluetoothLinkIo { + private val writeLock = Any() + + override suspend fun readPreauthRecord(): ByteArray = + cancellableBlocking(::close) { + val rlen = readRlen() + BtRecordCodec.requireWithinBound(rlen, BtLinkConstants.PREAUTH_RECORD_MAX) + readExactly(rlen) + } + + override suspend fun readRecord(): ByteArray = + cancellableBlocking(::close) { + val rlen = readRlen() + BtRecordCodec.requireValidTransportRlen(rlen) + readExactly(rlen) + } + + /** Một lần `write` cho cả `rlen` và thân, dưới khoá: hai coroutine viết cùng lúc không xen byte vào nhau. */ + override suspend fun writeRecord(body: ByteArray) { + val record = BtRecordCodec.encode(body) + cancellableBlocking(::close) { + synchronized(writeLock) { + output.write(record) + output.flush() + } + } + } + + override fun close() { + runCatching(onClose) + } + + private fun readRlen(): Int = BtRecordCodec.decodeRlen(readExactly(BtRecordCodec.LENGTH_PREFIX_SIZE)) + + private fun readExactly(size: Int): ByteArray { + val out = ByteArray(size) + var read = 0 + while (read < size) { + val n = input.read(out, read, size - read) + if (n < 0) throw BtLinkException("stream ended inside a record") + read += n + } + return out + } +} + +/** + * Chạy [block] chặn trên luồng IO; huỷ coroutine thì gọi [onCancel] ngay (đóng socket để nhả `read`/`accept`) thay vì + * chờ [block] tự trả. [IOException] của socket thành [BtLinkException]: kênh đứt, đóng như mọi lỗi liên kết. + */ +suspend fun cancellableBlocking( + onCancel: () -> Unit, + block: () -> T, +): T = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { onCancel() } + Dispatchers.IO.asExecutor().execute { + val result = + runCatching(block).recoverCatching { e -> + throw if (e is IOException) BtLinkException("link i/o failed: ${e.javaClass.simpleName}") else e + } + if (continuation.isActive) { + result.fold(continuation::resume, continuation::resumeWithException) + } + } + } diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/handshake/ServerHandshake.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/handshake/ServerHandshake.kt index aeae46e..854c159 100644 --- a/core/transport/src/main/kotlin/app/handlive/android/core/transport/handshake/ServerHandshake.kt +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/handshake/ServerHandshake.kt @@ -57,15 +57,28 @@ class ServerHandshake( private val ephemeralPrivateKey: () -> ByteArray = X25519Keys::generatePrivateKey, private val nonce: () -> ByteArray = { SecureRandomBytes.next(NONCE_SIZE) }, ) { - fun respond(helloText: String): HandshakeOutcome = + /** + * [boundPairId]: trên liên kết Bluetooth, `pair_id` mà `K_bt_link` đã khớp (0.4.5 bước 6) — hello nêu cặp khác bị + * `AUTH_FAILED`, kiểm trước khi tra cặp nên không lộ cặp đó có tồn tại hay không. + */ + fun respond( + helloText: String, + boundPairId: String? = null, + ): HandshakeOutcome = try { - respondChecked(helloText) + respondChecked(helloText, boundPairId) } catch (e: ProtocolException) { if (e.code == ErrorCode.UNSUPPORTED_VERSION) unsupportedVersion() else badRequest() } - private fun respondChecked(helloText: String): HandshakeOutcome { + private fun respondChecked( + helloText: String, + boundPairId: String?, + ): HandshakeOutcome { val hello = parseHello(helloText) + if (boundPairId != null && hello.pairId != boundPairId) { + return reject(ErrorCode.AUTH_FAILED, WsCloseCode.AUTH_FAILED) + } val pair = pairs.find(hello.pairId) return rejectionFor(hello, pair) ?: welcome(checkNotNull(pair), hello) } diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlConnectionHandler.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlConnectionHandler.kt index 322363c..11914ea 100644 --- a/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlConnectionHandler.kt +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlConnectionHandler.kt @@ -6,6 +6,7 @@ import app.handlive.android.core.protocol.envelope.Envelope import app.handlive.android.core.protocol.envelope.EnvelopeCodec import app.handlive.android.core.protocol.id.UuidV7Generator import app.handlive.android.core.transport.WsCloseCode +import app.handlive.android.core.transport.bluetooth.BluetoothLinkAdmission import app.handlive.android.core.transport.handshake.HandshakeOutcome import app.handlive.android.core.transport.handshake.ServerHandshake import io.ktor.websocket.CloseReason @@ -26,18 +27,28 @@ import kotlinx.coroutines.withTimeoutOrNull internal class ControlConnectionHandler( private val config: ControlServerConfig, private val registry: ActiveSessionRegistry, + /** `null` khi phone không bật Bluetooth: không có kênh nào gọi [handle] với `transport = BLUETOOTH`. */ + private val bluetoothAdmission: BluetoothLinkAdmission? = null, ) { private val clock = config.options.clock private val handshake = ServerHandshake(config.localDeviceId, config.pairs, UuidV7Generator(clock), clock) val admission = ConnectionAdmission(config.options.limits, clock) + /** + * [boundPairId]: chỉ cho Bluetooth — `pair_id` của `K_bt_link` đã khớp ở bước 3 (0.4.5); `session/hello` nêu cặp + * khác bị `AUTH_FAILED` (bước 6). Admission kênh Bluetooth (`BT_LINK_LIMIT`/`BT_PREAUTH_LIMIT`/bước 5) đã chạy ở + * `BluetoothLinkAcceptor` trước khi tới đây; cận theo IP của ConnectionAdmission không áp dụng (BLE không có địa + * chỉ ổn định để đếm). + */ suspend fun handle( socket: WebSocketSession, remoteAddress: String, transport: SessionTransport = SessionTransport.LAN, + boundPairId: String? = null, ) { - val ticket = admission.admit(remoteAddress) - if (ticket == null) { + val isBluetooth = transport == SessionTransport.BLUETOOTH + val ticket = if (isBluetooth) null else admission.admit(remoteAddress) + if (!isBluetooth && ticket == null) { socket.close(RATE_LIMITED) return } @@ -47,13 +58,14 @@ internal class ControlConnectionHandler( try { withTimeoutOrNull( config.options.handshakeTimeout, - ) { establish(socket, frames, remoteAddress, transport, progress) } + ) { establish(socket, frames, Origin(remoteAddress, transport, boundPairId), progress) } } finally { - ticket.release() + ticket?.release() } // CTL_IP_BLOCK: only failures before a session/hello passed its mac check count (a timeout, or 4400). val unauthenticatedFailure = - !progress.macVerified && + !isBluetooth && + !progress.macVerified && (result == null || (result as? Result.Closed)?.reason?.code == WsCloseCode.BAD_REQUEST) if (unauthenticatedFailure) admission.recordPreHandshakeFailure(remoteAddress) when (result) { @@ -67,7 +79,7 @@ internal class ControlConnectionHandler( is Result.Established -> { // An established session clears the address's count of pre-handshake failures. - admission.clearPreHandshakeFailures(remoteAddress) + if (!isBluetooth) admission.clearPreHandshakeFailures(remoteAddress) serve(socket, frames, result.session) } } @@ -135,25 +147,46 @@ internal class ControlConnectionHandler( ) : Result } + /** Where a connection came from: [boundPairId] only for Bluetooth (the pair whose link psk verified). */ + private class Origin( + val remoteAddress: String, + val transport: SessionTransport, + val boundPairId: String?, + ) + /** `session/hello` → `welcome` + `capability/hello` của S; hoặc `session/error` rồi đóng. */ private suspend fun establish( socket: WebSocketSession, frames: InboundFrames, - remoteAddress: String, - transport: SessionTransport, + origin: Origin, progress: Progress, ): Result { + val remoteAddress = origin.remoteAddress + val transport = origin.transport val hello = frames.receive() if (hello !is InboundMessage.Text) return Result.Closed(hello.handshakeCloseReason()) - return when (val outcome = handshake.respond(hello.text)) { + return when (val outcome = handshake.respond(hello.text, origin.boundPairId)) { is HandshakeOutcome.Rejected -> { when (outcome.code) { - ErrorCode.AUTH_FAILED -> admission.recordAuthFailure(remoteAddress) + // 0.4.5 "Admission": a session/hello rejected AUTH_FAILED over Bluetooth counts toward the + // listener-wide BT_AUTH_FAIL_BLOCK too, not the LAN's per-address counter. + ErrorCode.AUTH_FAILED -> { + if (transport == SessionTransport.BLUETOOTH) { + bluetoothAdmission?.recordAuthFailure() + } else { + admission.recordAuthFailure(remoteAddress) + } + } - // `BAD_REQUEST` closes 4400 and is counted by [handle]. - ErrorCode.PAIR_UNKNOWN -> admission.recordPreHandshakeFailure(remoteAddress) + // `BAD_REQUEST` closes 4400 and is counted by [handle]; no Bluetooth equivalent (0.4.5 only + // lists key-confirmation failures and AUTH_FAILED toward BT_AUTH_FAIL_BLOCK). + ErrorCode.PAIR_UNKNOWN -> { + if (transport != SessionTransport.BLUETOOTH) admission.recordPreHandshakeFailure(remoteAddress) + } - else -> Unit + else -> { + Unit + } } outcome.error?.let { socket.send(Frame.Text(EnvelopeCodec.encode(it))) } Result.Closed(CloseReason(outcome.closeCode, outcome.code.name)) diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlServer.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlServer.kt index f76f2bc..9890d78 100644 --- a/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlServer.kt +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlServer.kt @@ -4,6 +4,7 @@ import app.handlive.android.core.protocol.capability.CapabilityData import app.handlive.android.core.protocol.envelope.Envelope import app.handlive.android.core.transport.TransportConstants import app.handlive.android.core.transport.WsCloseCode +import app.handlive.android.core.transport.bluetooth.BluetoothLinkAdmission import app.handlive.android.core.transport.handshake.PairRegistry import app.handlive.android.core.transport.relay.RelayPeerLink import app.handlive.android.core.transport.tls.TlsIdentity @@ -17,6 +18,7 @@ import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.webSocketRaw import io.ktor.websocket.CloseReason +import io.ktor.websocket.WebSocketSession import io.ktor.websocket.close import kotlinx.coroutines.CancellationException import java.net.BindException @@ -56,9 +58,11 @@ class ControlServerOptions( */ class ControlServer( private val config: ControlServerConfig, + /** Dùng chung với A-SVC phía liên kết Bluetooth (A7.1b): cùng một bộ đếm BT_LINK_LIMIT/BT_AUTH_FAIL_BLOCK. */ + val bluetoothAdmission: BluetoothLinkAdmission = BluetoothLinkAdmission(), ) { val sessions = ActiveSessionRegistry() - private val handler = ControlConnectionHandler(config, sessions) + private val handler = ControlConnectionHandler(config, sessions, bluetoothAdmission) private val pairAdmission = ConnectionAdmission(config.options.limits.pairing, config.options.clock) /** Connections admitted but still in their handshake (CONN-01 API 3: at most 16). */ @@ -102,6 +106,18 @@ class ControlServer( peerDeviceId: String, ) = handler.handle(link.socket, "$RELAY_ADDRESS_PREFIX$peerDeviceId", SessionTransport.RELAY) + /** + * Runs one `/v1/ctl` session over a confirmed Bluetooth link (0.4.5 step 6): [socket] is the `BluetoothPeerSocket` + * that `BluetoothLinkAcceptor` built once the link passed admission and steps 1–5; [pairId] is the pair whose + * `K_bt_link` verified, and `session/hello` must name it. Handshake, limits and rekey are those of the LAN; the + * LAN's per-address admission does not apply (BLE addresses rotate) and an `AUTH_FAILED` hello counts toward + * [bluetoothAdmission]. + */ + suspend fun serveBluetoothPeer( + socket: WebSocketSession, + pairId: String, + ) = handler.handle(socket, BLUETOOTH_PSEUDO_ADDRESS, SessionTransport.BLUETOOTH, boundPairId = pairId) + /** * `relay.enabled` turned off (SET-02 API 1 step 6): each session through the relay gets the current capability, * then `session/bye {reason: shutdown}` and close 1000; LAN sessions stay. A session that fails on the way is @@ -169,6 +185,9 @@ class ControlServer( private companion object { /** Admission limits count relay peers by `device_id`, as LAN clients by IP address. */ const val RELAY_ADDRESS_PREFIX = "relay:" + + /** Not used for admission (Bluetooth has its own, listener-wide); kept for logs only. */ + const val BLUETOOTH_PSEUDO_ADDRESS = "bluetooth" const val BYE_SHUTDOWN = "shutdown" const val TLS_1_3 = "TLSv1.3" const val STOP_GRACE_MILLIS = 500L diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlSession.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlSession.kt index 84abfbb..41bf184 100644 --- a/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlSession.kt +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/server/ControlSession.kt @@ -41,6 +41,9 @@ enum class SessionTransport { /** Envelopes wrapped `{to, env}` / `{from, env}` through `/v1/relay` (CONN-03). */ RELAY, + + /** Noise transport records inside a BLE L2CAP CoC link, no network (CONN-05, 0.4.5). */ + BLUETOOTH, } /** @@ -112,8 +115,9 @@ class ControlSession internal constructor( /** * Ends the session with [code]. A relayed session has no close frame between the peers (the `/v1/relay` link - * stays open), so unless a `session/bye` was already exchanged it first sends `session/bye {reason: shutdown}` — - * the end of an idle (4411), failed rekey (4410), bad request (4400) or internal error (4500) close on the LAN + * stays open) and a Bluetooth link has no close codes at all (0.4.5 "No close codes"), so on either route, + * unless a `session/bye` was already exchanged, it first sends `session/bye {reason: shutdown}` — the end of + * an idle (4411), failed rekey (4410), bad request (4400) or internal error (4500) close on the LAN * (CONN-02 API 4, CONN-03 API 6 logic 7). */ suspend fun close( @@ -121,7 +125,9 @@ class ControlSession internal constructor( message: String, ) { if (!closing.compareAndSet(false, true)) return - if (transport == SessionTransport.RELAY) goodbye.send(BYE_SHUTDOWN) + if (transport == SessionTransport.RELAY || transport == SessionTransport.BLUETOOTH) { + goodbye.send(BYE_SHUTDOWN) + } markClosed() socket.close(CloseReason(code, message)) } diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothAdvertisementDataTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothAdvertisementDataTest.kt new file mode 100644 index 0000000..c5b2fc9 --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothAdvertisementDataTest.kt @@ -0,0 +1,98 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.testing.Hex +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.int +import app.handlive.android.core.protocol.testing.long +import app.handlive.android.core.protocol.testing.objects +import app.handlive.android.core.protocol.testing.str +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** Service data của scan response và hint quảng bá (0.4.5 "Advertisement"), đối chiếu bt-advertisement.json. */ +class BluetoothAdvertisementDataTest { + private val advertisement = SharedTestVectors.vectors("bt-advertisement.json") + private val linkVectors = SharedTestVectors.vectors("bt-link.json") + private val pair1Disc = linkVectors.first { it.str("kind") == "key" && it.str("name") == "cặp 1" }.hex("k_bt_disc") + + private fun hintsOf(vector: kotlinx.serialization.json.JsonObject): List = + vector.getValue("hints").jsonArray.map { Hex.decode(it.jsonPrimitive.content) } + + @Test + fun serviceDataMatchesEveryScanResponseVector() { + val scanResponses = advertisement.filter { it.str("kind") == "scan_response" } + assertEquals(3, scanResponses.size) + for (vector in scanResponses) { + val data = BluetoothAdvertisementData.serviceData(vector.int("psm"), hintsOf(vector)) + assertArrayEquals(vector.str("name"), vector.hex("service_data_bytes"), data) + assertEquals(vector.int("service_data_len"), data.size) + assertEquals(vector.int("scan_response_total"), vector.int("ad_uuid_struct_bytes") + data.size) + } + } + + @Test + fun pinWindowVectorCarriesOnlyThePinHint() { + val vector = advertisement.first { it.str("kind") == "pin_window" } + assertEquals(listOf(Hex.encode(BluetoothAdvertisementData.PIN_HINT)), hintsOf(vector).map(Hex::encode)) + val pinHint = BluetoothAdvertisementData.PIN_HINT + val data = BluetoothAdvertisementData.serviceData(vector.int("psm"), listOf(pinHint)) + assertArrayEquals(vector.hex("service_data_bytes"), data) + } + + @Test + fun hintIsTheHmacOfTheEpochUnderKBtDisc() { + val hintVector = linkVectors.first { it.str("kind") == "hint" } + assertEquals(BluetoothAdvertisementData.HINT_PERIOD_MILLIS, hintVector.long("bt_hint_period_ms")) + for (epoch in hintVector.objects("epochs")) { + assertArrayEquals(epoch.hex("hint"), BluetoothAdvertisementData.hint(pair1Disc, epoch.long("epoch"))) + } + } + + @Test + fun theHintOfTheOutOfWindowVectorIsTheOneThisPhoneWouldAdvertise() { + val outside = + SharedTestVectors + .invalidVectors("bt-advertisement.json") + .first { it.str("reason") == "hint_outside_window" } + val epoch = outside.long("advertised_epoch") + assertArrayEquals(outside.hex("advertised_hint"), BluetoothAdvertisementData.hint(pair1Disc, epoch)) + assertArrayEquals( + outside.hex("service_data_bytes"), + BluetoothAdvertisementData.serviceData(PSM, listOf(BluetoothAdvertisementData.hint(pair1Disc, epoch))), + ) + } + + @Test + fun epochsChangeEveryFifteenMinutes() { + val period = BluetoothAdvertisementData.HINT_PERIOD_MILLIS + assertEquals(0L, BluetoothAdvertisementData.epoch(period - 1)) + assertEquals(1L, BluetoothAdvertisementData.epoch(period)) + assertEquals(1L, BluetoothAdvertisementData.millisUntilNextEpoch(period - 1)) + assertEquals(period, BluetoothAdvertisementData.millisUntilNextEpoch(period)) + } + + @Test + fun moreThanTwoHintsOrAnOutOfRangePsmIsRefused() { + val hint = ByteArray(BluetoothAdvertisementData.HINT_SIZE) + assertThrows(IllegalArgumentException::class.java) { + BluetoothAdvertisementData.serviceData(PSM, listOf(hint, hint, hint)) + } + assertThrows(IllegalArgumentException::class.java) { + BluetoothAdvertisementData.serviceData(0x1_0000, emptyList()) + } + } + + @Test + fun serviceUuidIsTheProductUuidNotTheSpikeOne() { + assertEquals("3b3a01f9-ab27-44a8-bb68-b9b11a488bdb", BluetoothAdvertisementData.SERVICE_UUID.toString()) + } + + private companion object { + const val PSM = 23456 + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptorTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptorTest.kt new file mode 100644 index 0000000..b95ea2f --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptorTest.kt @@ -0,0 +1,242 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.primitives.SecureRandomBytes +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.str +import app.handlive.android.core.transport.bluetooth.testing.InMemoryBluetoothLinkIo +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.readText +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Một kênh L2CAP vừa accept, phía A-SVC (0.4.5 "Link-security handshake" bước 1-5 và "Admission"): admission theo + * kênh, `challenge` → `noise1` → `noise2` → bản ghi vận chuyển đầu tiên giải mã được (bước 5), `reject` cho mọi lỗi + * trước bước 5. Phía client dựng từ bt-link.json (`n_s`, `e` cố định) qua [InMemoryBluetoothLinkIo] — không radio. + */ +class BluetoothLinkAcceptorTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val keys = SharedTestVectors.vectors("bt-link.json").filter { it.str("kind") == "key" } + private val pair1 = keys.first { it.str("name") == "cặp 1" } + private val pair2 = keys.first { it.str("name") == "cặp 2" } + private val handshake = + SharedTestVectors.vectors("bt-link.json").first { + it.str("kind") == "handshake" && it.str("name").contains("cặp 1") + } + private val pairKeys = + BluetoothPairKeys { + listOf(pair2.str("pair_id") to pair2.hex("k_bt_link"), pair1.str("pair_id") to pair1.hex("k_bt_link")) + } + private val goodNoise1 = handshake.hex("e_pub_c") + handshake.hex("ciphertext_tag_1") + + @After + fun tearDown() = scope.cancel() + + private class Served( + val pairId: String, + val firstText: String, + ) + + private fun acceptor( + admission: BluetoothLinkAdmission, + served: CompletableDeferred = CompletableDeferred(), + holdLink: CompletableDeferred = CompletableDeferred(Unit), + preauthTimeout: Duration = BluetoothLinkConstants.PREAUTH_TIMEOUT, + ) = BluetoothLinkAcceptor( + admission = admission, + serve = { socket: WebSocketSession, pairId: String -> + val first = (socket.incoming.receive() as Frame.Text).readText() + served.complete(Served(pairId, first)) + holdLink.await() + }, + preauthTimeout = preauthTimeout, + newSession = { + BluetoothPreauthSession( + pairKeys, + nonce = { handshake.hex("n_s") }, + ephemeralPrivateKey = { handshake.hex("e_priv_s") }, + ) + }, + ) + + private fun admission( + linkLimit: Int = 1, + authFailBlock: Int = 5, + ) = BluetoothLinkAdmission(linkLimit = linkLimit, authFailBlock = authFailBlock, authFailWindow = 1.minutes) + + private fun firstTransportRecord(text: String): ByteArray = + BtTransportCipher(handshake.hex("c1")).seal( + BtTransportFrame.encode(BtLinkConstants.CHAN_CONTROL, more = false, long = false, text.toByteArray()), + ) + + /** Client side of steps 1-4 with the vector's message 1; returns the record S answered (noise2 or reject). */ + private suspend fun clientNoise( + client: InMemoryBluetoothLinkIo, + message1: ByteArray = goodNoise1, + ): BtPreauthRecord.Decoded { + val challenge = BtPreauthRecord.decode(client.readPreauthRecord()) + assertEquals(BtPreauthRecord.KIND_CHALLENGE, challenge.kind) + client.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_NOISE1, message1)) + return BtPreauthRecord.decode(client.readPreauthRecord()) + } + + @Test + fun aValidLinkReachesTheSessionBoundToThePairWhosePskVerified() = + runBlocking { + val admission = admission() + val served = CompletableDeferred() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission, served).accept(server) } + val noise2 = clientNoise(client) + assertEquals(BtPreauthRecord.KIND_NOISE2, noise2.kind) + assertArrayEquals(handshake.hex("message2"), noise2.payload) + client.writeRecord(firstTransportRecord("hello")) + val session = withTimeout(WAIT) { served.await() } + assertEquals(pair1.str("pair_id"), session.pairId) + assertEquals("hello", session.firstText) + withTimeout(WAIT) { link.await() } + } + + @Test + fun theLinkHoldsALinkSlotButFreesThePreauthSlotOnceConfirmedAndBothWhenItEnds() = + runBlocking { + val admission = BluetoothLinkAdmission(linkLimit = 2, preauthLimit = 1) + val served = CompletableDeferred() + val hold = CompletableDeferred() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission, served, hold).accept(server) } + clientNoise(client) + client.writeRecord(firstTransportRecord("hello")) + withTimeout(WAIT) { served.await() } + // Confirmed (step 5): the single pre-auth slot is free again, the link still counts toward the limit. + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Rejected, admission.admitNewChannel()) + admission.closeLink(wasPreauth = true) + hold.complete(Unit) + withTimeout(WAIT) { link.await() } + // The link ended: both of its slots are back, so two links fit again. + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + admission.confirmLink() + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + } + + @Test + fun aChannelOverTheCapGetsRejectInsteadOfAChallenge() = + runBlocking { + val admission = admission() + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + val (client, server) = InMemoryBluetoothLinkIo.pair() + withTimeout(WAIT) { acceptor(admission).accept(server) } + assertEquals(BtPreauthRecord.KIND_REJECT, BtPreauthRecord.decode(client.readPreauthRecord()).kind) + assertThrows(BtLinkException::class.java) { runBlocking { client.readPreauthRecord() } } + } + + @Test + fun aNoise1NoPairVerifiesGetsRejectAndCountsAsAnAuthFailure() = + runBlocking { + val admission = admission(authFailBlock = 2) + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission).accept(server) } + val answer = clientNoise(client, message1 = SecureRandomBytes.next(goodNoise1.size)) + assertEquals(BtPreauthRecord.KIND_REJECT, answer.kind) + withTimeout(WAIT) { link.await() } + assertFalse(admission.isBlocked) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) // slot freed + admission.closeLink(wasPreauth = true) + admission.recordAuthFailure() + assertTrue(admission.isBlocked) + } + + @Test + fun aBlockedListenerRejectsEveryNewChannel() = + runBlocking { + val admission = admission(authFailBlock = 1) + admission.recordAuthFailure() + val (client, server) = InMemoryBluetoothLinkIo.pair() + withTimeout(WAIT) { acceptor(admission).accept(server) } + assertEquals(BtPreauthRecord.KIND_REJECT, BtPreauthRecord.decode(client.readPreauthRecord()).kind) + } + + @Test + fun aSilentClientGetsRejectAtThePreauthTimeoutWithoutCountingAFailure() = + runBlocking { + val admission = admission(authFailBlock = 1) + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission, preauthTimeout = SHORT).accept(server) } + assertEquals(BtPreauthRecord.KIND_CHALLENGE, BtPreauthRecord.decode(client.readPreauthRecord()).kind) + val answer = withTimeout(WAIT) { BtPreauthRecord.decode(client.readPreauthRecord()) } + assertEquals(BtPreauthRecord.KIND_REJECT, answer.kind) + withTimeout(WAIT) { link.await() } + assertFalse(admission.isBlocked) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + } + + @Test + fun aFirstTransportRecordThatDoesNotDecryptIsAKeyConfirmationFailure() = + runBlocking { + val admission = admission(authFailBlock = 1) + val served = CompletableDeferred() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission, served).accept(server) } + clientNoise(client) + client.writeRecord(SecureRandomBytes.next(BtLinkConstants.TRANSPORT_RECORD_SIZES.first())) + withTimeout(WAIT) { link.await() } + assertFalse(served.isCompleted) + assertTrue(admission.isBlocked) + } + + @Test + fun aLinkThatNeverSendsItsFirstTransportRecordEndsAtThePreauthTimeout() = + runBlocking { + val admission = admission(authFailBlock = 1) + val served = CompletableDeferred() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission, served, preauthTimeout = SHORT).accept(server) } + assertEquals(BtPreauthRecord.KIND_NOISE2, clientNoise(client).kind) + withTimeout(WAIT) { link.await() } + assertFalse(served.isCompleted) + assertFalse(admission.isBlocked) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + } + + @Test + fun cpace1WithoutAPinWindowClosesTheChannelWithoutAnAnswer() = + runBlocking { + val admission = admission() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission).accept(server) } + client.readPreauthRecord() // challenge + client.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_CPACE1, ByteArray(32))) + withTimeout(WAIT) { link.await() } + assertThrows(BtLinkException::class.java) { runBlocking { client.readPreauthRecord() } } + assertFalse(admission.isBlocked) + } + + private companion object { + val WAIT = 5.seconds + val SHORT = 300.milliseconds + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAdmissionTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAdmissionTest.kt new file mode 100644 index 0000000..759461c --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAdmissionTest.kt @@ -0,0 +1,76 @@ +package app.handlive.android.core.transport.bluetooth + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** 0.4.5 "Admission": cận và bộ đếm thất bại của toàn bộ listener Bluetooth, không khoá theo địa chỉ. */ +class BluetoothLinkAdmissionTest { + @Test + fun rejectsOverTheLinkLimit() { + val admission = BluetoothLinkAdmission(linkLimit = 2, preauthLimit = 2) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Rejected, admission.admitNewChannel()) + } + + @Test + fun rejectsOverThePreauthLimitEvenUnderTheLinkLimit() { + val admission = BluetoothLinkAdmission(linkLimit = 4, preauthLimit = 1) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Rejected, admission.admitNewChannel()) + admission.confirmLink() // bước 5 xong: không còn tính vào slot pre-auth + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + } + + @Test + fun closingALinkFreesItsSlots() { + val admission = BluetoothLinkAdmission(linkLimit = 1, preauthLimit = 1) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + admission.closeLink(wasPreauth = true) + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + } + + @Test + fun preauthTimesOutAfterTheConfiguredDuration() { + var now = 0L + val admission = BluetoothLinkAdmission(preauthTimeout = 7.seconds, clock = { now }) + val openedAt = now + now += 6_999 + assertFalse(admission.isPreauthTimedOut(openedAt)) + now += 1 + assertTrue(admission.isPreauthTimedOut(openedAt)) + } + + @Test + fun blocksEveryNewChannelAfterAuthFailBlockWithinTheWindow() { + var now = 0L + val admission = + BluetoothLinkAdmission( + authFailBlock = 5, + authFailWindow = 1.minutes, + clock = { now }, + linkLimit = 100, + preauthLimit = 100, + ) + repeat(4) { admission.recordAuthFailure() } + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + admission.recordAuthFailure() // lần thất bại thứ 5 trong một phút + assertEquals(BluetoothLinkAdmission.AdmissionResult.Rejected, admission.admitNewChannel()) + now += 60_000 + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + } + + @Test + fun oldFailuresOutsideTheWindowDoNotCount() { + var now = 0L + val admission = BluetoothLinkAdmission(authFailBlock = 5, authFailWindow = 1.minutes, clock = { now }) + repeat(4) { admission.recordAuthFailure() } + now += 61_000 // 4 lần thất bại đầu đã quá cũ + admission.recordAuthFailure() + assertEquals(BluetoothLinkAdmission.AdmissionResult.Admitted, admission.admitNewChannel()) + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkSchedulerTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkSchedulerTest.kt new file mode 100644 index 0000000..ea1b37b --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkSchedulerTest.kt @@ -0,0 +1,108 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 0.4.5 "Priority": ngắn > dài > bulk ở mỗi ranh giới mảnh; `BT_BULK_INFLIGHT` chỉ chặn dài+bulk, không chặn ngắn. */ +class BluetoothLinkSchedulerTest { + @Test + fun shortAlwaysPollsBeforeLongAndBulk() { + val scheduler = BluetoothLinkScheduler() + scheduler.enqueueBulk(ByteArray(10)) + // "clipboard" vượt BT_FRAGMENT_MAX -> làn dài; "ping" nhỏ -> làn ngắn. + scheduler.enqueueControlEnvelope("clipboard", ByteArray(BtLinkConstants.FRAGMENT_MAX + 10)) + scheduler.enqueueControlEnvelope("ping", ByteArray(5)) + val first = scheduler.poll() + assertNotNull(first) + val frame = BtTransportFrame.decode(first!!) + assertEquals(BtLinkConstants.CHAN_CONTROL, frame.chan) + assertEquals(false, frame.long) + assertEquals(5, frame.data.size) + } + + @Test + fun longGoesBeforeBulkWhenNoShortIsPending() { + val scheduler = BluetoothLinkScheduler() + scheduler.enqueueBulk(ByteArray(10)) + scheduler.enqueueControlEnvelope("session", ByteArray(20)) // type "session" -> luôn làn dài + val frame = BtTransportFrame.decode(scheduler.poll()!!) + assertEquals(BtLinkConstants.CHAN_CONTROL, frame.chan) + assertTrue(frame.long) + } + + @Test + fun bulkInflightCapNeverBlocksAShortEnvelopeStillBoundedByAtMostThatManyRecordsAhead() { + val scheduler = BluetoothLinkScheduler(bulkInflight = 2) + scheduler.enqueueBulk(ByteArray(3000)) // 3 mảnh bulk + scheduler.poll() // mảnh 1 -> inflight = 1 + scheduler.poll() // mảnh 2 -> inflight = 2 (chạm cận) + assertNull(scheduler.poll()) // cận chặn mảnh 3 trong khi chưa có gì khác để gửi + + scheduler.enqueueControlEnvelope("ping", ByteArray(5)) + // Ngắn không bị cận inflight chặn: tới ngay, sau tối đa BT_BULK_INFLIGHT mảnh dài/bulk đã gửi. + val next = scheduler.poll() + assertNotNull(next) + val frame = BtTransportFrame.decode(next!!) + assertEquals(5, frame.data.size) + assertEquals(false, frame.long) + + scheduler.onDrained() + scheduler.onDrained() + val third = BtTransportFrame.decode(scheduler.poll()!!) + assertEquals(BtLinkConstants.CHAN_BULK, third.chan) + } + + @Test + fun shortEnvelopeOvertakesA256KibEnvelopeAlreadyQueued() { + val scheduler = BluetoothLinkScheduler(bulkInflight = 2) + scheduler.enqueueControlEnvelope("clipboard", ByteArray(256 * 1024)) + // Hai mảnh đầu của tin 256 KiB đã "gửi" (chiếm hết inflight), đúng như một liên kết thật đang bận. + scheduler.poll() + scheduler.poll() + scheduler.enqueueControlEnvelope("ping", ByteArray(5)) + val shortFrame = BtTransportFrame.decode(scheduler.poll()!!) + assertEquals(false, shortFrame.long) + assertEquals(5, shortFrame.data.size) + } + + @Test + fun sameTypeEnvelopesKeepTheirSendOrder() { + val scheduler = BluetoothLinkScheduler(bulkInflight = 100) + // A: vượt cận -> làn dài. B: cùng type, còn nhỏ nhưng A vẫn đang xếp hàng -> cũng làn dài. C: type khác. + scheduler.enqueueControlEnvelope("clipboard", ByteArray(BtLinkConstants.FRAGMENT_MAX + 10)) + scheduler.enqueueControlEnvelope("clipboard", ByteArray(5)) + scheduler.enqueueControlEnvelope("ping", ByteArray(5)) + + val output = generateSequence { scheduler.poll()?.also { scheduler.onDrained() } }.toList() + val frames = output.map { BtTransportFrame.decode(it) } + + // C (ping, type khác) đi làn ngắn, luôn trước mọi mảnh dài — không ảnh hưởng thứ tự A/B. + assertEquals(false, frames.first().long) + assertEquals(5, frames.first().data.size) + + val longFrames = frames.filter { it.long } + assertTrue("B phải sau mọi mảnh của A", longFrames.last().data.size == 5) + assertEquals(1, longFrames.count { it.data.size == 5 }) + } + + @Test + fun sessionTypeAlwaysUsesTheLongLaneRegardlessOfSize() { + val scheduler = BluetoothLinkScheduler() + scheduler.enqueueControlEnvelope("session", ByteArray(5)) + val frame = BtTransportFrame.decode(scheduler.poll()!!) + assertTrue(frame.long) + } + + @Test + fun hasPendingReflectsAllThreeQueues() { + val scheduler = BluetoothLinkScheduler() + assertEquals(false, scheduler.hasPending()) + scheduler.enqueueBulk(ByteArray(1)) + assertTrue(scheduler.hasPending()) + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLongLaneReassemblerTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLongLaneReassemblerTest.kt new file mode 100644 index 0000000..560f8ea --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLongLaneReassemblerTest.kt @@ -0,0 +1,37 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.int +import app.handlive.android.core.protocol.testing.str +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +/** 0.4.5: ghép làn dài, một tin một lúc mỗi hướng; đóng liên kết khi tổng byte vượt `BT_MESSAGE_MAX`. */ +class BluetoothLongLaneReassemblerTest { + @Test + fun reassemblesTheTwoLongLaneFragmentsOfBtFrameJson() { + val fragment1 = SharedTestVectors.vectors("bt-frame.json").first { it.str("name").contains("mảnh 1/2") } + val fragment2 = SharedTestVectors.vectors("bt-frame.json").first { it.str("name").contains("mảnh 2/2") } + val reassembler = BluetoothLongLaneReassembler() + assertNull(reassembler.accept(BtTransportFrame.decode(fragment1.hex("plaintext")))) + val message = reassembler.accept(BtTransportFrame.decode(fragment2.hex("plaintext"))) + assertArrayEquals(fragment1.hex("data") + fragment2.hex("data"), message) + } + + @Test + fun messageOverMaxClosesTheLinkEvenMidStream() { + val v = SharedTestVectors.invalidVectors("bt-frame.json").single { it.str("reason") == "message_over_max" } + val reassembler = BluetoothLongLaneReassembler() + val priorData = ByteArray(v.int("prior_bytes_received")) + val priorFrame = BtTransportFrame(chan = 0x01, more = true, long = true, data = priorData) + assertNull(reassembler.accept(priorFrame)) + val lastData = ByteArray(v.int("last_fragment_dlen")) + val lastFrame = BtTransportFrame(chan = 0x01, more = false, long = true, data = lastData) + assertThrows(BtLinkException::class.java) { reassembler.accept(lastFrame) } + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocketTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocketTest.kt new file mode 100644 index 0000000..e63e706 --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocketTest.kt @@ -0,0 +1,156 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.primitives.SecureRandomBytes +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.encoding.Base64Codecs +import app.handlive.android.core.protocol.envelope.Envelope +import app.handlive.android.core.protocol.envelope.EnvelopeCodec +import app.handlive.android.core.protocol.id.UuidV7Generator +import app.handlive.android.core.transport.bluetooth.testing.InMemoryBluetoothLinkIo +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +/** + * `BluetoothPeerSocket` theo mẫu `RelayPeerSocket` (A7.1b), qua một đôi [InMemoryBluetoothLinkIo] — không đụng + * radio, như #191 "fake pipe". Phủ lại #191: mã hoá/giải mã (`Split()` c1/c2), cắt mảnh/ghép lại làn dài. + */ +class BluetoothPeerSocketTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val ids = UuidV7Generator() + + private fun link(): Pair { + val c1 = SecureRandomBytes.next(KEY_SIZE) + val c2 = SecureRandomBytes.next(KEY_SIZE) + val (ioA, ioB) = InMemoryBluetoothLinkIo.pair() + // A đóng vai C (mã bằng c1, giải bằng c2), B đóng vai S (mã bằng c2, giải bằng c1) — đúng chiều Split(). + val a = BluetoothPeerSocket(ioA, BtTransportCipher(c1), BtTransportCipher(c2), scope) + val b = BluetoothPeerSocket(ioB, BtTransportCipher(c2), BtTransportCipher(c1), scope) + return a to b + } + + @After + fun tearDown() = scope.cancel() + + private fun envelope( + type: String, + payloadBytes: Int, + ) = EnvelopeCodec.encode( + Envelope( + v = Envelope.VERSION, + type = type, + id = ids.next(), + ts = System.currentTimeMillis(), + payload = Base64Codecs.encodeB64(ByteArray(payloadBytes)), + ), + ) + + @Test + fun aShortEnvelopeRoundTripsBothWays() = + runBlocking { + val (a, b) = link() + val sent = envelope("ping", payloadBytes = 8) + a.outgoing.send(Frame.Text(sent)) + val received = withTimeout(WAIT) { b.incoming.receive() as Frame.Text } + assertEquals(sent, received.readText()) + + val reply = envelope("ack", payloadBytes = 8) + b.outgoing.send(Frame.Text(reply)) + val backAtA = withTimeout(WAIT) { a.incoming.receive() as Frame.Text } + assertEquals(reply, backAtA.readText()) + } + + @Test + fun anEnvelopeOverFragmentMaxReassemblesWholeOnTheOtherSide() = + runBlocking { + val (a, b) = link() + val sent = envelope("clipboard", payloadBytes = BtLinkConstants.FRAGMENT_MAX + 5_000) + assertTrue(sent.toByteArray(Charsets.UTF_8).size > BtLinkConstants.FRAGMENT_MAX) + a.outgoing.send(Frame.Text(sent)) + val received = withTimeout(WAIT) { b.incoming.receive() as Frame.Text } + assertEquals(sent, received.readText()) + } + + @Test + fun sessionTypeEnvelopesStillArriveWhole() = + runBlocking { + val (a, b) = link() + val sent = envelope("session", payloadBytes = 16) + a.outgoing.send(Frame.Text(sent)) + val received = withTimeout(WAIT) { b.incoming.receive() as Frame.Text } + assertEquals(sent, received.readText()) + } + + @Test + fun closingOneEndClosesTheOtherSidesIncoming() = + runBlocking { + val (a, b) = link() + a.end() + withTimeout(WAIT) { + var closed = false + try { + b.incoming.receive() + } catch (_: Exception) { + closed = true + } + assertTrue(closed || b.incoming.isClosedForReceive) + } + } + + @Test + fun theFirstRecordThatOpensIsReportedOnce() = + runBlocking { + val c1 = SecureRandomBytes.next(KEY_SIZE) + val c2 = SecureRandomBytes.next(KEY_SIZE) + val (ioA, ioB) = InMemoryBluetoothLinkIo.pair() + var opened = 0 + val a = BluetoothPeerSocket(ioA, BtTransportCipher(c1), BtTransportCipher(c2), scope) + val b = + BluetoothPeerSocket( + ioB, + BtTransportCipher(c2), + BtTransportCipher(c1), + scope, + callbacks = BluetoothPeerSocketCallbacks(onFirstRecordOpened = { opened++ }), + ) + repeat(2) { + a.outgoing.send(Frame.Text(envelope("ping", payloadBytes = 8))) + withTimeout(WAIT) { b.incoming.receive() } + } + assertEquals(1, opened) + } + + @Test + fun aFirstRecordUnderTheWrongKeyIsReportedAsAKeyConfirmationFailure() = + runBlocking { + val (ioA, ioB) = InMemoryBluetoothLinkIo.pair() + val rejected = kotlinx.coroutines.CompletableDeferred() + val a = BluetoothPeerSocket(ioA, randomCipher(), randomCipher(), scope) + BluetoothPeerSocket( + ioB, + randomCipher(), + randomCipher(), + scope, + callbacks = BluetoothPeerSocketCallbacks(onFirstRecordRejected = { rejected.complete(Unit) }), + ) + a.outgoing.send(Frame.Text(envelope("ping", payloadBytes = 8))) + withTimeout(WAIT) { rejected.await() } + } + + private fun randomCipher() = BtTransportCipher(SecureRandomBytes.next(KEY_SIZE)) + + private companion object { + const val KEY_SIZE = 32 + val WAIT = 5.seconds + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSessionTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSessionTest.kt new file mode 100644 index 0000000..d1ea87d --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSessionTest.kt @@ -0,0 +1,81 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.str +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Phía S của 0.4.5 bước 1-4 qua "ống giả": một kênh giả lập chỉ truyền byte đã dựng sẵn từ bt-link.json (không + * đụng radio, A7.1b lo việc đó) — đúng tinh thần "fake pipe" của thẻ A7.1a. + */ +class BluetoothPreauthSessionTest { + private val keyVectors = SharedTestVectors.vectors("bt-link.json").filter { it.str("kind") == "key" } + private val pair1 = keyVectors.first { it.str("name").contains("1") } + private val pair2 = keyVectors.first { it.str("name").contains("2") } + private val handshake = + SharedTestVectors.vectors("bt-link.json").first { + it.str("kind") == "handshake" && it.str("name").contains("cặp 1") + } + + private fun pairKeysInOrder( + firstPairId: String, + secondPairId: String, + ) = BluetoothPairKeys { listOf(firstPairId to pairFor(firstPairId), secondPairId to pairFor(secondPairId)) } + + private fun pairFor(pairId: String): ByteArray = + if (pairId == pair1.str("pair_id")) pair1.hex("k_bt_link") else pair2.hex("k_bt_link") + + @Test + fun verifiesTheCorrectPairEvenWhenItIsNotTriedFirst() { + val pairKeys = pairKeysInOrder(pair2.str("pair_id"), pair1.str("pair_id")) // cặp 2 (sai) thử trước + val session = + BluetoothPreauthSession( + pairKeys, + nonce = { handshake.hex("n_s") }, + ephemeralPrivateKey = { handshake.hex("e_priv_s") }, + ) + session.challenge() + val message1 = handshake.hex("e_pub_c") + handshake.hex("ciphertext_tag_1") + val outcome = session.receiveNoise1(message1) + assertTrue(outcome is BluetoothPreauthSession.Noise1Outcome.Verified) + assertEquals(pair1.str("pair_id"), (outcome as BluetoothPreauthSession.Noise1Outcome.Verified).pairId) + val keys = session.buildNoise2() + assertArrayEquals(handshake.hex("c1"), keys.c1) + assertArrayEquals(handshake.hex("c2"), keys.c2) + assertArrayEquals( + BtPreauthRecord.encode(BtPreauthRecord.KIND_NOISE2, handshake.hex("message2")), + keys.noise2, + ) + } + + @Test + fun challengeCarriesTheKindByte() { + val pairKeys = pairKeysInOrder(pair1.str("pair_id"), pair2.str("pair_id")) + val session = BluetoothPreauthSession(pairKeys, nonce = { handshake.hex("n_s") }) + val challenge = session.challenge() + assertEquals(BtPreauthRecord.KIND_CHALLENGE, BtPreauthRecord.decode(challenge).kind) + assertArrayEquals(handshake.hex("n_s"), BtPreauthRecord.decode(challenge).payload) + } + + @Test + fun noPairMatchingRejectsTheChannel() { + val session = BluetoothPreauthSession(BluetoothPairKeys { emptyList() }, nonce = { handshake.hex("n_s") }) + session.challenge() + val message1 = handshake.hex("e_pub_c") + handshake.hex("ciphertext_tag_1") + assertEquals(BluetoothPreauthSession.Noise1Outcome.Rejected, session.receiveNoise1(message1)) + } + + @Test + fun replayedNoise1OnANewChannelIsRejected() { + // Kênh mới -> n_s mới (nonce() mặc định sinh ngẫu nhiên), nhưng message1 là bản ghi lại từ kênh cũ. + val session = BluetoothPreauthSession(pairKeysInOrder(pair1.str("pair_id"), pair2.str("pair_id"))) + session.challenge() + val recordedMessage1 = handshake.hex("e_pub_c") + handshake.hex("ciphertext_tag_1") + assertEquals(BluetoothPreauthSession.Noise1Outcome.Rejected, session.receiveNoise1(recordedMessage1)) + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtTransportCipherTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtTransportCipherTest.kt new file mode 100644 index 0000000..6067abc --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtTransportCipherTest.kt @@ -0,0 +1,56 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.crypto.primitives.ChaCha20Poly1305Aead +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.int +import app.handlive.android.core.protocol.testing.str +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +/** + * bt-frame.json kind "transport_frame": nonce = 4 byte 0 ‖ bộ đếm 64-bit LE, khoá = `c1` của bt-link.json + * (0.4.5 "Transport records and channels"). `Rekey()` không có vector riêng (NoiseNNpsk0.rekey) — kiểm bằng + * cách tính thủ công khoá kỳ vọng sau rekey và so khớp với đầu ra của [BtTransportCipher], không chỉ "khác nhau". + */ +class BtTransportCipherTest { + private val c1 = + SharedTestVectors + .vectors("bt-link.json") + .first { it.str("kind") == "handshake" && it.str("name").contains("cặp 1") } + .hex("c1") + + @Test + fun sealMatchesVectorsAtTheirCounter() { + for (v in SharedTestVectors.vectors("bt-frame.json").filter { it.str("kind") == "transport_frame" }) { + val cipher = cipherAtVectorCounter(v) + assertArrayEquals(v.str("name"), v.hex("ciphertext_tag"), cipher.seal(v.hex("plaintext"))) + } + } + + @Test + fun openMatchesVectorsAtTheirCounter() { + for (v in SharedTestVectors.vectors("bt-frame.json").filter { it.str("kind") == "transport_frame" }) { + val cipher = cipherAtVectorCounter(v) + assertArrayEquals(v.str("name"), v.hex("plaintext"), cipher.open(v.hex("ciphertext_tag"))) + } + } + + private fun cipherAtVectorCounter(v: JsonObject) = + BtTransportCipher(c1, rekeyAfterRecords = Long.MAX_VALUE, initialCounter = v.int("counter").toLong()) + + @Test + fun rekeysAfterTheConfiguredRecordCountWithoutResettingTheCounter() { + val key0 = ByteArray(32) { it.toByte() } + val plaintext = ByteArray(256) + val cipher = BtTransportCipher(key0, rekeyAfterRecords = 2) + cipher.seal(plaintext) // counter 0, key0 + cipher.seal(plaintext) // counter 1, key0 -> rekey triggers after this record + val key1 = NoiseNNpsk0.rekey(key0) + val nonceAtCounter2 = ByteArray(12).also { it[4] = 2 } + val expected = ChaCha20Poly1305Aead.seal(key1, nonceAtCounter2, plaintext, ByteArray(0)) + assertArrayEquals(expected, cipher.seal(plaintext)) + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/StreamBluetoothLinkIoTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/StreamBluetoothLinkIoTest.kt new file mode 100644 index 0000000..ea0c364 --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/StreamBluetoothLinkIoTest.kt @@ -0,0 +1,67 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtRecordCodec +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/** + * `rlen` ‖ thân trên luồng byte của socket L2CAP (0.4.5 "Link records"): `rlen` sai cận bị từ chối TRƯỚC khi + * đọc/cấp phát thân (review #165) — luồng thử chỉ chứa 2 byte tiền tố, nên đọc thêm sẽ hết dữ liệu chứ không ra + * [BtLinkException] về cận. + */ +class StreamBluetoothLinkIoTest { + private fun ioReading(bytes: ByteArray) = + StreamBluetoothLinkIo(ByteArrayInputStream(bytes), ByteArrayOutputStream()) {} + + @Test + fun aPreauthRecordWithinTheBoundIsRead() = + runBlocking { + val body = ByteArray(33) { it.toByte() } + assertArrayEquals(body, ioReading(BtRecordCodec.encode(body)).readPreauthRecord()) + } + + @Test + fun aPreauthRlenOverTheBoundFailsBeforeReadingTheBody() { + val prefixOnly = byteArrayOf(0, (BtLinkConstants.PREAUTH_RECORD_MAX + 1).toByte()) + val e = assertThrows(BtLinkException::class.java) { runBlocking { ioReading(prefixOnly).readPreauthRecord() } } + assertTrue(e.message.orEmpty().contains("exceeds bound")) + } + + @Test + fun aTransportRecordMustHaveOneOfTheThreeSizes() { + assertArrayEquals(ByteArray(272), runBlocking { ioReading(BtRecordCodec.encode(ByteArray(272))).readRecord() }) + val prefixOnly = byteArrayOf(0x01, 0x2C) // 300 + val e = assertThrows(BtLinkException::class.java) { runBlocking { ioReading(prefixOnly).readRecord() } } + assertTrue(e.message.orEmpty().contains("not a valid transport record size")) + } + + @Test + fun aStreamEndingInsideARecordIsALinkFailure() { + val truncated = BtRecordCodec.encode(ByteArray(33)).copyOf(10) + assertThrows(BtLinkException::class.java) { runBlocking { ioReading(truncated).readPreauthRecord() } } + assertThrows(BtLinkException::class.java) { runBlocking { ioReading(ByteArray(0)).readRecord() } } + } + + @Test + fun writingPrefixesTheBodyWithItsRlen() = + runBlocking { + val out = ByteArrayOutputStream() + val io = StreamBluetoothLinkIo(ByteArrayInputStream(ByteArray(0)), out) {} + io.writeRecord(byteArrayOf(0x7F)) + assertArrayEquals(byteArrayOf(0, 1, 0x7F), out.toByteArray()) + } + + @Test + fun closeRunsTheSocketClose() { + var closed = false + StreamBluetoothLinkIo(ByteArrayInputStream(ByteArray(0)), ByteArrayOutputStream()) { closed = true }.close() + assertTrue(closed) + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/testing/InMemoryBluetoothLinkIo.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/testing/InMemoryBluetoothLinkIo.kt new file mode 100644 index 0000000..cb6d0fc --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/testing/InMemoryBluetoothLinkIo.kt @@ -0,0 +1,39 @@ +package app.handlive.android.core.transport.bluetooth.testing + +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtRecordCodec +import app.handlive.android.core.transport.bluetooth.BluetoothLinkIo +import kotlinx.coroutines.channels.Channel + +/** Một đôi ống trong bộ nhớ nối hai đầu một liên kết Bluetooth giả — không đụng radio (A7.1a/A7.1b "fake pipe"). */ +class InMemoryBluetoothLinkIo private constructor( + private val readQueue: Channel, + private val writeQueue: Channel, +) : BluetoothLinkIo { + override suspend fun readPreauthRecord(): ByteArray = + readRecord().also { BtRecordCodec.requireWithinBound(it.size, BtLinkConstants.PREAUTH_RECORD_MAX) } + + override suspend fun readRecord(): ByteArray = + readQueue.receiveCatching().getOrNull() ?: throw BtLinkException("pipe closed") + + override suspend fun writeRecord(body: ByteArray) { + writeQueue.send(body) + } + + override fun close() { + readQueue.close() + writeQueue.close() + } + + companion object { + /** Đầu A đọc từ cái B viết và ngược lại. */ + fun pair(): Pair { + val aToB = Channel(Channel.UNLIMITED) + val bToA = Channel(Channel.UNLIMITED) + val endA = InMemoryBluetoothLinkIo(readQueue = bToA, writeQueue = aToB) + val endB = InMemoryBluetoothLinkIo(readQueue = aToB, writeQueue = bToA) + return endA to endB + } + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/server/BluetoothSessionRoutingTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/server/BluetoothSessionRoutingTest.kt new file mode 100644 index 0000000..eb7df8d --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/server/BluetoothSessionRoutingTest.kt @@ -0,0 +1,191 @@ +package app.handlive.android.core.transport.server + +import app.handlive.android.core.crypto.derivation.PeerRole +import app.handlive.android.core.crypto.primitives.SecureRandomBytes +import app.handlive.android.core.protocol.capability.CapabilityData +import app.handlive.android.core.protocol.capability.CapabilityOp +import app.handlive.android.core.protocol.envelope.Envelope +import app.handlive.android.core.protocol.envelope.EnvelopeCodec +import app.handlive.android.core.protocol.envelope.EnvelopeHeader +import app.handlive.android.core.protocol.envelope.MessageType +import app.handlive.android.core.protocol.envelope.PlaintextCodec +import app.handlive.android.core.protocol.id.UuidV7Generator +import app.handlive.android.core.transport.TransportConstants +import app.handlive.android.core.transport.bluetooth.BluetoothLinkAdmission +import app.handlive.android.core.transport.handshake.PairRecord +import app.handlive.android.core.transport.session.SessionCipher +import app.handlive.android.core.transport.testing.LoopbackServerFixture +import app.handlive.android.core.transport.testing.TestClientPeer +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketExtension +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * `ControlServer.serveBluetoothPeer` nhận một liên kết đã qua bước 5 (admission kênh nằm ở `BluetoothLinkAcceptor`): + * KHÔNG qua [ConnectionAdmission] theo địa chỉ IP (BLE không có địa chỉ ổn định), `AUTH_FAILED` của `session/hello` + * đếm vào [BluetoothLinkAdmission], và hello phải nêu đúng cặp của psk liên kết (0.4.5 bước 6). Không đụng radio/TLS + * thật: [FakeSocket] là một đôi socket thuần trong bộ nhớ, gọi trực tiếp `serveBluetoothPeer` không qua Netty. + */ +class BluetoothSessionRoutingTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val pairs = ConcurrentHashMap() + private val established = LinkedBlockingQueue() + private val bluetoothAdmission = BluetoothLinkAdmission(authFailBlock = AUTH_FAIL_BLOCK, authFailWindow = 1.minutes) + private val server = + ControlServer( + ControlServerConfig( + tls = LoopbackServerFixture.sharedIdentity, + localDeviceId = SERVER_DEVICE_ID, + pairs = { pairs[it] }, + localCapability = { LoopbackServerFixture.ANDROID_CAPABILITY }, + onSessionEstablished = { established.add(it) }, + options = ControlServerOptions(handshakeTimeout = 3.seconds), + ), + bluetoothAdmission = bluetoothAdmission, + ) + + @After + fun tearDown() = scope.cancel() + + private fun addPair(): TestClientPeer { + val pairId = UUID.randomUUID().toString() + val clientDeviceId = UuidV7Generator().next() + val prk = SecureRandomBytes.next(PRK_SIZE) + pairs[pairId] = PairRecord(pairId, clientDeviceId, prk, revoked = false) + return TestClientPeer(pairId, clientDeviceId, prk, SERVER_DEVICE_ID) + } + + @Test + fun aHandshakeOverBluetoothGetsTransportBluetoothNotLan() = + runBlocking { + val peer = addPair() + val (clientEnd, serverEnd) = FakeSocket.pair(scope) + scope.launch { server.serveBluetoothPeer(serverEnd, peer.pairId) } + completeHandshake(peer, clientEnd) + val session = established.poll(WAIT_SECONDS, TimeUnit.SECONDS) + assertEquals(SessionTransport.BLUETOOTH, session?.transport) + } + + @Test + fun manyBluetoothHandshakesAreNotRateLimitedByTheLanPerAddressCap() = + runBlocking { + // More than the LAN's per-address cap (4) — none of these go through ConnectionAdmission at all. + repeat(TransportConstants.MAX_UNAUTHENTICATED_PER_ADDRESS + 2) { + val peer = addPair() + val (clientEnd, serverEnd) = FakeSocket.pair(scope) + val job = scope.launch { server.serveBluetoothPeer(serverEnd, peer.pairId) } + completeHandshake(peer, clientEnd) + assertEquals(SessionTransport.BLUETOOTH, established.poll(WAIT_SECONDS, TimeUnit.SECONDS)?.transport) + job.cancelAndJoin() + } + } + + @Test + fun authFailedOverBluetoothCountsTowardTheListenerWideBlockNotThePerAddressOne() = + runBlocking { + val goodPeer = addPair() + repeat(AUTH_FAIL_BLOCK) { + val (clientEnd, serverEnd) = FakeSocket.pair(scope) + val job = scope.launch { server.serveBluetoothPeer(serverEnd, goodPeer.pairId) } + val wrongKey = SecureRandomBytes.next(PRK_SIZE) + val wrongKeyPeer = TestClientPeer(goodPeer.pairId, goodPeer.deviceId, wrongKey, SERVER_DEVICE_ID) + val (hello, _) = wrongKeyPeer.hello() + clientEnd.outgoing.send(Frame.Text(hello)) + withTimeout(WAIT_MILLIS) { job.join() } + } + assertTrue(bluetoothAdmission.isBlocked) + } + + @Test + fun aHelloForAnotherPairThanTheLinkPskIsAuthFailedAndCounted() = + runBlocking { + // 0.4.5 step 6: session/hello must name the pair whose K_bt_link verified noise1. + val linkPair = addPair() + val otherPair = addPair() + repeat(AUTH_FAIL_BLOCK) { + val (clientEnd, serverEnd) = FakeSocket.pair(scope) + val job = scope.launch { server.serveBluetoothPeer(serverEnd, linkPair.pairId) } + val (hello, _) = otherPair.hello() + clientEnd.outgoing.send(Frame.Text(hello)) + withTimeout(WAIT_MILLIS) { job.join() } + } + assertTrue(bluetoothAdmission.isBlocked) + assertTrue(established.isEmpty()) + } + + /** The client side of 0.6.3 steps 1-4, through [socket] directly — no TLS, no real network. */ + private suspend fun completeHandshake( + peer: TestClientPeer, + socket: WebSocketSession, + ) { + val (hello, state) = peer.hello() + socket.outgoing.send(Frame.Text(hello)) + val welcomeText = (withTimeout(WAIT_MILLIS) { socket.incoming.receive() } as Frame.Text).readText() + val keys = peer.acceptWelcome(welcomeText, state) + val cipher = SessionCipher(keys, PeerRole.CLIENT) + val macCapability = LoopbackServerFixture.MAC_CAPABILITY + val capability = PlaintextCodec.encodeOp(CapabilityOp.HELLO, CapabilityData.serializer(), macCapability) + val header = EnvelopeHeader(MessageType.CAPABILITY.wire, peer.ids.next(), System.currentTimeMillis()) + val envelope = cipher.seal(header, capability) + socket.outgoing.send(Frame.Text(EnvelopeCodec.encode(envelope))) + withTimeout(WAIT_MILLIS) { socket.incoming.receive() } // capability/hello of the phone + } + + /** A plain in-memory pair of [WebSocketSession] — no Netty, no TLS, just two cross-wired frame channels. */ + private class FakeSocket( + parent: CoroutineScope, + override val incoming: ReceiveChannel, + override val outgoing: SendChannel, + ) : WebSocketSession { + override val coroutineContext: CoroutineContext = parent.coroutineContext + override val extensions: List> = emptyList() + override var masking: Boolean = false + override var maxFrameSize: Long = Envelope.MAX_BYTES.toLong() + + override suspend fun flush() = Unit + + @Deprecated("Use cancel() instead.", ReplaceWith("cancel()", "kotlinx.coroutines.cancel")) + override fun terminate() = Unit + + companion object { + fun pair(scope: CoroutineScope): Pair { + val clientToServer = Channel(Channel.UNLIMITED) + val serverToClient = Channel(Channel.UNLIMITED) + val client = FakeSocket(scope, incoming = serverToClient, outgoing = clientToServer) + val server = FakeSocket(scope, incoming = clientToServer, outgoing = serverToClient) + return client to server + } + } + } + + private companion object { + const val SERVER_DEVICE_ID = "39f713d0-a644-853f-8452-9421b9f51b9c" + const val PRK_SIZE = 32 + const val AUTH_FAIL_BLOCK = 3 + const val WAIT_SECONDS = 5L + const val WAIT_MILLIS = 5_000L + } +} From 950e1c7a3aa6509ce0caa1f8db4d64dda97a656f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 17:40:13 +0700 Subject: [PATCH 07/17] feat(connection): A7.1 Bluetooth advertising and wiring Peripheral (advertising set and L2CAP server, hint rotation), adapter lifecycle, Nearby devices permissions and the Bluetooth session channel in ConnectionRuntime (CONN-05). --- .../android/feature/call/CallBenchTrace.kt | 7 +- .../connection/src/main/AndroidManifest.xml | 16 ++ .../feature/connection/ConnectionRuntime.kt | 11 +- .../feature/connection/bench/BenchLog.kt | 9 + .../bluetooth/AndroidBluetoothRadio.kt | 117 +++++++++ .../bluetooth/BluetoothAdvertising.kt | 144 +++++++++++ .../bluetooth/BluetoothLinkLifecycle.kt | 39 +++ .../bluetooth/BluetoothPeripheral.kt | 113 +++++++++ .../bluetooth/BluetoothPermissionPrompts.kt | 37 +++ .../connection/bluetooth/BluetoothRadio.kt | 28 +++ .../feature/connection/session/PeerSession.kt | 2 +- .../connection/session/SessionTable.kt | 10 +- .../bluetooth/BluetoothLinkLifecycleTest.kt | 69 +++++ .../bluetooth/BluetoothPeripheralTest.kt | 235 ++++++++++++++++++ .../BluetoothPermissionPromptsTest.kt | 53 ++++ .../pairing/devices/DeviceListModel.kt | 3 + 16 files changed, 882 insertions(+), 11 deletions(-) create mode 100644 feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/AndroidBluetoothRadio.kt create mode 100644 feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt create mode 100644 feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycle.kt create mode 100644 feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheral.kt create mode 100644 feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPermissionPrompts.kt create mode 100644 feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothRadio.kt create mode 100644 feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycleTest.kt create mode 100644 feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt create mode 100644 feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPermissionPromptsTest.kt diff --git a/feature/call/src/main/kotlin/app/handlive/android/feature/call/CallBenchTrace.kt b/feature/call/src/main/kotlin/app/handlive/android/feature/call/CallBenchTrace.kt index 8a48c42..9544f1a 100644 --- a/feature/call/src/main/kotlin/app/handlive/android/feature/call/CallBenchTrace.kt +++ b/feature/call/src/main/kotlin/app/handlive/android/feature/call/CallBenchTrace.kt @@ -116,7 +116,12 @@ internal class CallBenchTrace( mode: String, ) = log(CallBenchEvent.APP_CALL_INTENT_SENT, listOf("call" to callId, "action" to action, "mode" to mode)) - private fun via(session: PeerSession) = if (session.channel == PeerSession.Channel.RELAY) "relay" else "lan" + private fun via(session: PeerSession) = + when (session.channel) { + PeerSession.Channel.RELAY -> "relay" + PeerSession.Channel.BLUETOOTH -> "bluetooth" + PeerSession.Channel.LAN, PeerSession.Channel.USB -> "lan" + } private companion object { const val PEER_ID = 8 diff --git a/feature/connection/src/main/AndroidManifest.xml b/feature/connection/src/main/AndroidManifest.xml index 274a0ed..1b64756 100644 --- a/feature/connection/src/main/AndroidManifest.xml +++ b/feature/connection/src/main/AndroidManifest.xml @@ -11,6 +11,22 @@ + + + + + + + diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt index d2fda2a..c065e7e 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt @@ -16,6 +16,7 @@ import app.handlive.android.core.transport.server.SessionTransport import app.handlive.android.core.transport.tls.AndroidTlsIdentityStorage import app.handlive.android.core.transport.tls.TlsIdentityProvider import app.handlive.android.feature.connection.bench.BenchLog +import app.handlive.android.feature.connection.bluetooth.BluetoothAdvertising import app.handlive.android.feature.connection.capability.CapabilityPublisher import app.handlive.android.feature.connection.capability.LocalEnvironmentReader import app.handlive.android.feature.connection.capability.SimChangeWatcher @@ -48,9 +49,9 @@ import kotlinx.coroutines.withContext /** * A-SVC's engine, one per process (CONN-01, CONN-02): runs the TLS server on 47800–47809, advertises - * `_handlive._tcp` with hourly hints, keeps this phone's capability current on every session, and hands decrypted - * envelopes to the feature modules through [router]. [HandLiveService] starts and stops it; the UI and the feature - * modules read [connectedPeers], [sessions] and [state]. + * `_handlive._tcp` with hourly hints, serves the Bluetooth link (CONN-05) beside it, keeps this phone's capability + * current on every session, and hands decrypted envelopes to the feature modules through [router]. [HandLiveService] + * starts and stops it; the UI and the feature modules read [connectedPeers], [sessions] and [state]. */ class ConnectionRuntime private constructor( context: Context, @@ -67,6 +68,7 @@ class ConnectionRuntime private constructor( private val localCapabilityFlow = MutableStateFlow(null) private val simWatcher = SimChangeWatcher(appContext, ::refreshEnvironment) private val discovery = DiscoveryAdvertising(appContext, data.pairs, clock) + private val bluetooth = BluetoothAdvertising(appContext, data.pairs, clock) private var scope: CoroutineScope? = null private var server: ControlServer? = null @@ -206,6 +208,8 @@ class ConnectionRuntime private constructor( phoneCapability.state.onEach { localCapabilityFlow.value = it }.launchIn(runtimeScope) capability.publishUpdates(runtimeScope, phoneCapability.state) { controlServer.sessions.all() } discovery.start(runtimeScope, port, pairingAdvert) + // CONN-05: the Bluetooth link runs beside the LAN server, whatever the network does. + bluetooth.start(runtimeScope, controlServer, table.sessions.map { it.keys }, environmentVersion) simWatcher.start() } @@ -216,6 +220,7 @@ class ConnectionRuntime private constructor( server?.stop() server = null discovery.stop() + bluetooth.stop() scope?.cancel() scope = null table.clear() diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bench/BenchLog.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bench/BenchLog.kt index 552af17..e7b060b 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bench/BenchLog.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bench/BenchLog.kt @@ -80,4 +80,13 @@ object BenchEvent { const val ACK_SENT = "ack_sent" const val ACK_RECEIVED = "ack_received" const val NET = "net" + + /** Bluetooth link (0.4.5), for the S25 + Mac checks: `state` of the adapter (`on`, `off`). */ + const val BT_STATE = "bt_state" + + /** A new advertising set: `psm`, number of `hints`; then `status` (0 = started) when the stack answers. */ + const val BT_ADVERTISE = "bt_advertise" + + /** An L2CAP channel was accepted (before admission and the link-security handshake). */ + const val BT_ACCEPT = "bt_accept" } diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/AndroidBluetoothRadio.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/AndroidBluetoothRadio.kt new file mode 100644 index 0000000..3c56ff4 --- /dev/null +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/AndroidBluetoothRadio.kt @@ -0,0 +1,117 @@ +package app.handlive.android.feature.connection.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothServerSocket +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertisingSet +import android.bluetooth.le.AdvertisingSetCallback +import android.bluetooth.le.AdvertisingSetParameters +import android.content.Context +import android.os.ParcelUuid +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.transport.bluetooth.BluetoothAdvertisementData +import app.handlive.android.core.transport.bluetooth.BluetoothLinkIo +import app.handlive.android.core.transport.bluetooth.StreamBluetoothLinkIo +import app.handlive.android.core.transport.bluetooth.cancellableBlocking +import app.handlive.android.feature.connection.bench.BenchEvent +import app.handlive.android.feature.connection.bench.BenchLog + +/** + * Radio BLE thật của A-SVC (0.4.5 "Roles", "Advertisement"): `listenUsingInsecureL2capChannel()` (PSM động, không + * bond, không GATT) và một bộ quảng bá legacy kết nối được, quét được, `INTERVAL_LOW`, `TX_POWER_HIGH` (cấu hình đo + * của G7), không tên thiết bị, không trường TX power; advertisement chỉ mang `BT_SERVICE_UUID` (21 byte), scan + * response mang service data (≤ 29 byte). + * + * CHƯA ĐO TRÊN MÁY, chờ S25 + Mac: scan response với service data UUID 128-bit (spike G7 chưa thử bố cục này qua + * `startAdvertisingSet`), địa chỉ radio đổi khi dựng lại bộ quảng bá (AC9), và accept/kết nối thật. Lớp này không có + * test đơn vị — mọi logic quanh nó ([BluetoothPeripheral], `BluetoothLinkAcceptor`) test qua radio giả. + * + * `MissingPermission`: [BluetoothAdvertising] chỉ dựng lớp này khi "Nearby devices" (`BLUETOOTH_ADVERTISE`, + * `BLUETOOTH_CONNECT`) đã được cấp trên Android 12+; quyền bị thu hồi giữa chừng thì lệnh gọi ném `SecurityException` + * và [BluetoothPeripheral] thử lại sau. + */ +@SuppressLint("MissingPermission") +class AndroidBluetoothRadio( + context: Context, +) : BluetoothRadio { + private val manager = context.getSystemService(BluetoothManager::class.java) + + @Volatile + private var callback: AdvertisingSetCallback? = null + + override suspend fun listen(): BluetoothServerChannel { + val adapter = manager?.adapter ?: throw BtLinkException("no Bluetooth adapter") + return AndroidServerChannel(cancellableBlocking({}) { adapter.listenUsingInsecureL2capChannel() }) + } + + override fun startAdvertising(serviceData: ByteArray) { + val advertiser = manager?.adapter?.bluetoothLeAdvertiser ?: throw BtLinkException("no LE advertiser") + val uuid = ParcelUuid(BluetoothAdvertisementData.SERVICE_UUID) + val parameters = + AdvertisingSetParameters + .Builder() + .setLegacyMode(true) + .setConnectable(true) + .setScannable(true) + .setInterval(AdvertisingSetParameters.INTERVAL_LOW) + .setTxPowerLevel(AdvertisingSetParameters.TX_POWER_HIGH) + .build() + val advertisement = + AdvertiseData + .Builder() + .addServiceUuid(uuid) + .setIncludeDeviceName(false) + .setIncludeTxPowerLevel(false) + .build() + val scanResponse = + AdvertiseData + .Builder() + .addServiceData(uuid, serviceData) + .setIncludeDeviceName(false) + .setIncludeTxPowerLevel(false) + .build() + val psm = (serviceData[1].toInt() and BYTE_MASK) or ((serviceData[2].toInt() and BYTE_MASK) shl Byte.SIZE_BITS) + val hints = (serviceData.size - SERVICE_DATA_HEADER) / BluetoothAdvertisementData.HINT_SIZE + BenchLog.event(BenchEvent.BT_ADVERTISE, "psm" to psm, "hints" to hints) + val started = + object : AdvertisingSetCallback() { + override fun onAdvertisingSetStarted( + advertisingSet: AdvertisingSet?, + txPower: Int, + status: Int, + ) { + BenchLog.event(BenchEvent.BT_ADVERTISE, "psm" to psm, "status" to status) + } + } + callback = started + advertiser.startAdvertisingSet(parameters, advertisement, scanResponse, null, null, started) + } + + override fun stopAdvertising() { + val started = callback ?: return + callback = null + runCatching { manager?.adapter?.bluetoothLeAdvertiser?.stopAdvertisingSet(started) } + } + + private class AndroidServerChannel( + private val socket: BluetoothServerSocket, + ) : BluetoothServerChannel { + override val psm: Int = socket.psm + + override suspend fun accept(): BluetoothLinkIo { + val client = cancellableBlocking(::close) { socket.accept() } + BenchLog.event(BenchEvent.BT_ACCEPT, "psm" to psm) + return StreamBluetoothLinkIo(client.inputStream, client.outputStream, client::close) + } + + override fun close() { + runCatching { socket.close() } + } + } + + private companion object { + const val BYTE_MASK = 0xFF + const val SERVICE_DATA_HEADER = 3 + } +} diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt new file mode 100644 index 0000000..2923eae --- /dev/null +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt @@ -0,0 +1,144 @@ +package app.handlive.android.feature.connection.bluetooth + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import app.handlive.android.core.crypto.derivation.BtLinkKeyDerivation +import app.handlive.android.core.data.pairing.PairStore +import app.handlive.android.core.transport.bluetooth.BluetoothLinkAcceptor +import app.handlive.android.core.transport.bluetooth.BluetoothPairKeys +import app.handlive.android.core.transport.server.ControlServer +import app.handlive.android.feature.connection.bench.BenchEvent +import app.handlive.android.feature.connection.bench.BenchLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +/** + * Kênh Bluetooth của A-SVC (CONN-05, 0.4.5), chạy song song và độc lập với server LAN: quảng bá + server L2CAP + * ([BluetoothPeripheral] trên [AndroidBluetoothRadio]), mỗi kênh qua [BluetoothLinkAcceptor] rồi vào + * `ControlServer.serveBluetoothPeer`. [BluetoothLinkLifecycle] dựng lại tất cả khi adapter về `STATE_ON` và dừng khi + * tắt (điều kiện 2 của spike G7); thiếu "Nearby devices" trên Android 12+ coi như adapter tắt. + * + * Quảng bá hint của các cặp chưa thu hồi KHÔNG có session sống trên route nào, `last_seen_at` gần nhất trước; thử + * `K_bt_link` của mọi cặp chưa thu hồi khi kênh mở (một session mới thay session cũ, 0.4.1). + */ +class BluetoothAdvertising( + context: Context, + private val pairs: PairStore, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val appContext = context.applicationContext + + @Volatile + private var linkKeys: List> = emptyList() + private var receiver: BroadcastReceiver? = null + private var lifecycle: BluetoothLinkLifecycle? = null + private var peripheral: BluetoothPeripheral? = null + + /** + * [liveSessions]: `pair_id` có session đang mở (mọi route). [environment] phát lại khi quyền có thể đã đổi + * (`refreshEnvironment`), để quảng bá bật/tắt theo "Nearby devices". + */ + fun start( + scope: CoroutineScope, + server: ControlServer, + liveSessions: Flow>, + environment: Flow<*>, + ) { + if (appContext.getSystemService(BluetoothManager::class.java)?.adapter == null) return + val acceptor = + BluetoothLinkAcceptor(server.bluetoothAdmission, BluetoothPairKeys { linkKeys }, server::serveBluetoothPeer) + val radioPeripheral = BluetoothPeripheral(AndroidBluetoothRadio(appContext), acceptor::accept, clock) + peripheral = radioPeripheral + val discoveryKeys = discoveryKeys(liveSessions) + val stateMachine = + BluetoothLinkLifecycle( + start = { radioPeripheral.start(scope, discoveryKeys) }, + stop = radioPeripheral::stop, + ).also { lifecycle = it } + receiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + BenchLog.event(BenchEvent.BT_STATE, "state" to state) + stateMachine.onAdapterState(gated(adapterStateOf(state))) + } + }.also { + ContextCompat.registerReceiver( + appContext, + it, + IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + environment.onEach { stateMachine.onAdapterState(gated(currentAdapterState())) }.launchIn(scope) + } + + fun stop() { + receiver?.let { runCatching { appContext.unregisterReceiver(it) } } + receiver = null + lifecycle?.onAdapterState(BluetoothAdapterState.OFF) + lifecycle = null + peripheral?.stop() + peripheral = null + linkKeys = emptyList() + } + + /** `K_bt_disc` của các cặp cần quảng bá; cập nhật luôn [linkKeys] (mọi cặp chưa thu hồi) cho bước 3. */ + private fun discoveryKeys(liveSessions: Flow>): Flow> = + combine(pairs.observeActive(), liveSessions) { devices, live -> devices to live } + .map { (devices, live) -> + val prks = + pairs + .activeSecrets() + .mapNotNull { secret -> secret.prk?.let { secret.pairId to it } } + .toMap() + linkKeys = prks.map { (pairId, prk) -> pairId to BtLinkKeyDerivation.kBtLink(prk) } + devices + .filter { it.pairId !in live } + .sortedByDescending { it.lastSeenAt ?: Long.MIN_VALUE } + .mapNotNull { prks[it.pairId] } + .map(BtLinkKeyDerivation::kBtDisc) + } + + private fun currentAdapterState(): BluetoothAdapterState { + val adapter = appContext.getSystemService(BluetoothManager::class.java)?.adapter + return adapterStateOf(adapter?.state ?: BluetoothAdapter.STATE_OFF) + } + + /** Thiếu "Nearby devices" (Android 12+) = không được quảng bá hay nghe: đối xử như adapter tắt. */ + private fun gated(state: BluetoothAdapterState): BluetoothAdapterState = + if (nearbyDevicesGranted()) state else BluetoothAdapterState.OFF + + private fun nearbyDevicesGranted(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + NEARBY_DEVICES.all { + ContextCompat.checkSelfPermission(appContext, it) == PackageManager.PERMISSION_GRANTED + } + + private companion object { + val NEARBY_DEVICES = listOf(Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT) + + fun adapterStateOf(state: Int): BluetoothAdapterState = + when (state) { + BluetoothAdapter.STATE_ON -> BluetoothAdapterState.ON + BluetoothAdapter.STATE_TURNING_ON -> BluetoothAdapterState.TURNING_ON + BluetoothAdapter.STATE_TURNING_OFF -> BluetoothAdapterState.TURNING_OFF + else -> BluetoothAdapterState.OFF + } + } +} diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycle.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycle.kt new file mode 100644 index 0000000..5dce0a4 --- /dev/null +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycle.kt @@ -0,0 +1,39 @@ +package app.handlive.android.feature.connection.bluetooth + +/** Trạng thái adapter Bluetooth tối giản mà vòng đời này cần biết (ánh xạ từ `BluetoothAdapter.STATE_*`). */ +enum class BluetoothAdapterState { + OFF, + TURNING_ON, + ON, + TURNING_OFF, +} + +/** + * Dựng lại advertiser + server L2CAP khi Bluetooth chuyển sang [BluetoothAdapterState.ON], độc lập với + * `ControlServer` (điều kiện 2 của spike G7: tắt/mở lại Bluetooth phải tự phục hồi, không cần khởi động lại + * A-SVC). Chỉ quyết định NÊN gọi [start]/[stop] lúc nào; [BluetoothAdvertising] nối [start]/[stop] vào + * [BluetoothPeripheral] (radio thật: [AndroidBluetoothRadio]) và báo trạng thái từ `ACTION_STATE_CHANGED`. + */ +class BluetoothLinkLifecycle( + private val start: () -> Unit, + private val stop: () -> Unit, +) { + var isRunning: Boolean = false + private set + + fun onAdapterState(state: BluetoothAdapterState) { + when (state) { + BluetoothAdapterState.ON -> if (!isRunning) { + isRunning = true + start() + } + + BluetoothAdapterState.OFF, BluetoothAdapterState.TURNING_OFF -> if (isRunning) { + isRunning = false + stop() + } + + BluetoothAdapterState.TURNING_ON -> Unit + } + } +} diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheral.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheral.kt new file mode 100644 index 0000000..8a35af5 --- /dev/null +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheral.kt @@ -0,0 +1,113 @@ +package app.handlive.android.feature.connection.bluetooth + +import app.handlive.android.core.transport.bluetooth.BluetoothAdvertisementData +import app.handlive.android.core.transport.bluetooth.BluetoothLinkIo +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Quảng bá + server L2CAP của A-SVC (0.4.5 "Advertisement"), độc lập với server LAN và với mạng. Mỗi lần nội dung đổi + * — sang epoch `BT_HINT_PERIOD` mới, hoặc danh sách cặp cần quảng bá đổi — nó dừng bộ quảng bá, đóng server socket + * (kênh đã mở vẫn sống), mở server mới (PSM mới) và quảng bá lại: hint, PSM và bộ quảng bá đổi cùng nhau ("Rotation"; + * dựng lại bộ quảng bá là cách duy nhất app đổi được địa chỉ radio). Không cặp nào cần quảng bá → không server, không + * quảng bá. [BluetoothLinkLifecycle] gọi [start]/[stop] theo trạng thái adapter. + */ +class BluetoothPeripheral( + private val radio: BluetoothRadio, + private val accept: suspend (BluetoothLinkIo) -> Unit, + private val clock: () -> Long = System::currentTimeMillis, +) { + private var job: Job? = null + + /** + * [discoveryKeys]: `K_bt_disc` của các cặp cần quảng bá, cặp `last_seen_at` gần nhất trước; chỉ + * `BT_ADV_MAX_HINTS` cặp đầu lên sóng. Kênh accept được chạy trong [scope] nên sống qua các lần xoay và [stop]. + */ + @Synchronized + fun start( + scope: CoroutineScope, + discoveryKeys: Flow>, + ) { + if (job?.isActive == true) return + job = + scope.launch { + val advertised = + discoveryKeys + .map { it.take(BluetoothAdvertisementData.MAX_HINTS) } + .distinctUntilChanged { old, new -> old.sameKeys(new) } + combine(advertised, epochs()) { keys, epoch -> keys to epoch } + .collectLatest { (keys, epoch) -> if (keys.isNotEmpty()) serve(scope, keys, epoch) } + } + } + + @Synchronized + fun stop() { + job?.cancel() + job = null + } + + /** Một bộ quảng bá + server cho tới khi bị huỷ; server hỏng thì mở lại sau [RETRY_DELAY_MILLIS]. */ + private suspend fun serve( + linkScope: CoroutineScope, + keys: List, + epoch: Long, + ) { + while (true) { + // Stack Bluetooth bỏ socket (đang tắt radio, lỗi tạm): advertiseAndAccept đã dừng sạch, thử lại. + val failure = runCatching { advertiseAndAccept(linkScope, keys, epoch) }.exceptionOrNull() + if (failure is CancellationException) throw failure + delay(RETRY_DELAY_MILLIS) + } + } + + private suspend fun advertiseAndAccept( + linkScope: CoroutineScope, + keys: List, + epoch: Long, + ) { + val server = radio.listen() + try { + val hints = keys.map { BluetoothAdvertisementData.hint(it, epoch) } + radio.startAdvertising(BluetoothAdvertisementData.serviceData(server.psm, hints)) + try { + while (true) { + val io = server.accept() + linkScope.launch { + // One broken link must not take A-SVC down: the acceptor already closed it. + val failure = runCatching { accept(io) }.exceptionOrNull() + if (failure is CancellationException) throw failure + } + } + } finally { + radio.stopAdvertising() + } + } finally { + server.close() + } + } + + /** Epoch hiện tại, rồi lại ngay sau mỗi ranh giới `BT_HINT_PERIOD`. */ + private fun epochs(): Flow = + flow { + while (true) { + emit(BluetoothAdvertisementData.epoch(clock())) + delay(BluetoothAdvertisementData.millisUntilNextEpoch(clock())) + } + } + + companion object { + const val RETRY_DELAY_MILLIS = 2_000L + + private fun List.sameKeys(other: List): Boolean = + size == other.size && zip(other).all { (a, b) -> a.contentEquals(b) } + } +} diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPermissionPrompts.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPermissionPrompts.kt new file mode 100644 index 0000000..e305738 --- /dev/null +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPermissionPrompts.kt @@ -0,0 +1,37 @@ +package app.handlive.android.feature.connection.bluetooth + +import app.handlive.android.core.transport.bluetooth.BluetoothLinkConstants +import kotlin.time.Duration + +/** + * Phần Bluetooth của A7.7 (SET-01 API 10): hỏi "Nearby devices" tối đa một lần (API 31+, cần cho quét/quảng bá + * BLE — trước đó `ACCESS_FINE_LOCATION` đã đủ); xin bật Bluetooth (`ACTION_REQUEST_ENABLE`) tối đa một lần mỗi + * [BluetoothLinkConstants.ENABLE_PROMPT_INTERVAL], chỉ khi phone không có đường LAN. Chỉ quyết định NÊN hỏi lúc + * nào; hiển thị dialog/Activity thật và lưu trạng thái đã hỏi là việc của lớp gọi. + */ +class BluetoothPermissionPrompts( + private val promptInterval: Duration = BluetoothLinkConstants.ENABLE_PROMPT_INTERVAL, + private val clock: () -> Long = System::currentTimeMillis, +) { + /** API 31 (Android 12): `BLUETOOTH_ADVERTISE`/`BLUETOOTH_CONNECT`/`BLUETOOTH_SCAN` thay cho vị trí cũ. */ + fun needsNearbyDevicesPermission(apiLevel: Int): Boolean = apiLevel >= API_31 + + /** Hỏi khi: cần quyền ở API này, chưa có quyền, và chưa hỏi lần nào (một lần duy nhất, không hỏi lại). */ + fun shouldAskNearbyDevices( + apiLevel: Int, + granted: Boolean, + alreadyAsked: Boolean, + ): Boolean = needsNearbyDevicesPermission(apiLevel) && !granted && !alreadyAsked + + /** Hỏi khi: không có LAN, Bluetooth đang tắt, và (chưa hỏi lần nào hoặc đã qua [promptInterval] từ lần trước). */ + fun shouldRequestEnable( + hasLan: Boolean, + bluetoothOn: Boolean, + lastAskedAt: Long, + ): Boolean = + !hasLan && !bluetoothOn && (lastAskedAt <= 0L || clock() - lastAskedAt >= promptInterval.inWholeMilliseconds) + + private companion object { + const val API_31 = 31 + } +} diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothRadio.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothRadio.kt new file mode 100644 index 0000000..fc0faad --- /dev/null +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothRadio.kt @@ -0,0 +1,28 @@ +package app.handlive.android.feature.connection.bluetooth + +import app.handlive.android.core.transport.bluetooth.BluetoothLinkIo + +/** + * Phần radio BLE mà A-SVC cần (0.4.5 "Roles", "Advertisement"): server L2CAP CoC không bond và một bộ quảng bá kết + * nối được. Tách khỏi `android.bluetooth.*` để [BluetoothPeripheral] test được bằng radio giả; bản thật là + * [AndroidBluetoothRadio]. + */ +interface BluetoothRadio { + /** `listenUsingInsecureL2capChannel()`: PSM LE động, không bond, không GATT. */ + suspend fun listen(): BluetoothServerChannel + + /** Advertisement mang `BT_SERVICE_UUID`, scan response mang [serviceData] (đã gồm `ver` ‖ `psm` ‖ `hints`). */ + fun startAdvertising(serviceData: ByteArray) + + fun stopAdvertising() +} + +/** Một server socket L2CAP đang nghe; đóng nó không đóng các kênh đã accept. */ +interface BluetoothServerChannel { + val psm: Int + + /** Chờ kênh kế tiếp; ném khi server đóng hoặc stack Bluetooth bỏ socket. */ + suspend fun accept(): BluetoothLinkIo + + fun close() +} diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/session/PeerSession.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/session/PeerSession.kt index eeb08c1..7467266 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/session/PeerSession.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/session/PeerSession.kt @@ -54,7 +54,7 @@ class PeerSession( ) /** How the session reaches the phone (0.11, PAIR-02 field 5). */ - enum class Channel { LAN, RELAY, USB } + enum class Channel { LAN, RELAY, USB, BLUETOOTH } val pairId: String get() = peer.pairId val peerDeviceId: String get() = peer.peerDeviceId diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/session/SessionTable.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/session/SessionTable.kt index d5b0f4a..0e58ac5 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/session/SessionTable.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/session/SessionTable.kt @@ -41,12 +41,10 @@ class SessionTable( ) { val device = pairs.find(control.pairId) val channel = - if (control.transport == - SessionTransport.RELAY - ) { - PeerSession.Channel.RELAY - } else { - PeerSession.Channel.LAN + when (control.transport) { + SessionTransport.RELAY -> PeerSession.Channel.RELAY + SessionTransport.BLUETOOTH -> PeerSession.Channel.BLUETOOTH + SessionTransport.LAN -> PeerSession.Channel.LAN } val peer = PeerSession( diff --git a/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycleTest.kt b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycleTest.kt new file mode 100644 index 0000000..b765f06 --- /dev/null +++ b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycleTest.kt @@ -0,0 +1,69 @@ +package app.handlive.android.feature.connection.bluetooth + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Dựng lại khi `STATE_ON` (điều kiện 2 của spike G7), bằng adapter giả — không đụng radio thật. */ +class BluetoothLinkLifecycleTest { + @Test + fun startsOnceWhenTheAdapterTurnsOn() { + var starts = 0 + val lifecycle = BluetoothLinkLifecycle(start = { starts++ }, stop = {}) + lifecycle.onAdapterState(BluetoothAdapterState.ON) + assertEquals(1, starts) + assertTrue(lifecycle.isRunning) + } + + @Test + fun doesNotRestartWhileAlreadyRunning() { + var starts = 0 + val lifecycle = BluetoothLinkLifecycle(start = { starts++ }, stop = {}) + lifecycle.onAdapterState(BluetoothAdapterState.ON) + lifecycle.onAdapterState(BluetoothAdapterState.ON) + assertEquals(1, starts) + } + + @Test + fun stopsWhenTheAdapterTurnsOff() { + var stops = 0 + val lifecycle = BluetoothLinkLifecycle(start = {}, stop = { stops++ }) + lifecycle.onAdapterState(BluetoothAdapterState.ON) + lifecycle.onAdapterState(BluetoothAdapterState.OFF) + assertEquals(1, stops) + assertFalse(lifecycle.isRunning) + } + + @Test + fun stopsOnTurningOffTooSoItDoesNotKeepAdvertisingDuringTheTransition() { + var stops = 0 + val lifecycle = BluetoothLinkLifecycle(start = {}, stop = { stops++ }) + lifecycle.onAdapterState(BluetoothAdapterState.ON) + lifecycle.onAdapterState(BluetoothAdapterState.TURNING_OFF) + assertEquals(1, stops) + } + + @Test + fun doesNotStopTwiceOrStartOnTurningOn() { + var starts = 0 + var stops = 0 + val lifecycle = BluetoothLinkLifecycle(start = { starts++ }, stop = { stops++ }) + lifecycle.onAdapterState(BluetoothAdapterState.OFF) + lifecycle.onAdapterState(BluetoothAdapterState.TURNING_ON) + assertEquals(0, starts) + assertEquals(0, stops) + } + + @Test + fun flappingOffAndOnRestartsEachTime() { + var starts = 0 + var stops = 0 + val lifecycle = BluetoothLinkLifecycle(start = { starts++ }, stop = { stops++ }) + lifecycle.onAdapterState(BluetoothAdapterState.ON) + lifecycle.onAdapterState(BluetoothAdapterState.OFF) + lifecycle.onAdapterState(BluetoothAdapterState.ON) + assertEquals(2, starts) + assertEquals(1, stops) + } +} diff --git a/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt new file mode 100644 index 0000000..78e8bb7 --- /dev/null +++ b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt @@ -0,0 +1,235 @@ +package app.handlive.android.feature.connection.bluetooth + +import app.handlive.android.core.protocol.testing.Hex +import app.handlive.android.core.transport.bluetooth.BluetoothAdvertisementData +import app.handlive.android.core.transport.bluetooth.BluetoothLinkIo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Quảng bá + server L2CAP của A-SVC (0.4.5 "Advertisement", "Rotation"; điều kiện 2 của spike G7) qua một radio + * giả: PSM của server đang mở nằm trong service data, hint đổi ở mỗi ranh giới `BT_HINT_PERIOD` cùng PSM và bộ + * quảng bá, tắt/bật Bluetooth dựng lại tất cả. Thời gian ảo của kotlinx-coroutines-test, không radio thật. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class BluetoothPeripheralTest { + private val period = BluetoothAdvertisementData.HINT_PERIOD_MILLIS + private val key1 = ByteArray(32) { 1 } + private val key2 = ByteArray(32) { 2 } + private val key3 = ByteArray(32) { 3 } + + private class FakeServer( + override val psm: Int, + ) : BluetoothServerChannel { + val incoming = Channel(Channel.UNLIMITED) + var closed = false + + override suspend fun accept(): BluetoothLinkIo = incoming.receive() + + override fun close() { + closed = true + incoming.close() + } + } + + private class FakeRadio : BluetoothRadio { + private var nextPsm = FIRST_PSM + val servers = mutableListOf() + val advertisedHistory = mutableListOf() + var advertising: ByteArray? = null + + override suspend fun listen(): BluetoothServerChannel = FakeServer(nextPsm++).also { servers += it } + + override fun startAdvertising(serviceData: ByteArray) { + check(advertising == null) { "the previous advertising set was not stopped" } + advertising = serviceData + advertisedHistory += serviceData + } + + override fun stopAdvertising() { + advertising = null + } + } + + private object NoIo : BluetoothLinkIo { + override suspend fun readPreauthRecord(): ByteArray = awaitCancellation() + + override suspend fun readRecord(): ByteArray = awaitCancellation() + + override suspend fun writeRecord(body: ByteArray) = Unit + + override fun close() = Unit + } + + /** Starts at 10 ms into epoch [EPOCH] so the first boundary is one period minus 10 ms away. */ + private fun TestScope.clock(): () -> Long = { EPOCH * period + START_OFFSET + testScheduler.currentTime } + + private fun expected( + psm: Int, + epoch: Long, + vararg keys: ByteArray, + ) = Hex.encode(BluetoothAdvertisementData.serviceData(psm, keys.map { BluetoothAdvertisementData.hint(it, epoch) })) + + private fun FakeRadio.current() = advertising?.let(Hex::encode) + + @Test + fun advertisesThePsmOfTheOpenServerAndTheHintsOfTheCurrentEpoch() = + runTest { + val radio = FakeRadio() + val peripheral = BluetoothPeripheral(radio, accept = {}, clock = clock()) + peripheral.start(backgroundScope, MutableStateFlow(listOf(key1, key2))) + runCurrent() + assertEquals(expected(FIRST_PSM, EPOCH, key1, key2), radio.current()) + peripheral.stop() + } + + @Test + fun atMostTwoHintsAreAdvertisedMostRecentPairsFirst() = + runTest { + val radio = FakeRadio() + val peripheral = BluetoothPeripheral(radio, accept = {}, clock = clock()) + peripheral.start(backgroundScope, MutableStateFlow(listOf(key1, key2, key3))) + runCurrent() + assertEquals(expected(FIRST_PSM, EPOCH, key1, key2), radio.current()) + peripheral.stop() + } + + @Test + fun noPairToAdvertiseMeansNoServerAndNoAdvertising() = + runTest { + val radio = FakeRadio() + val peripheral = BluetoothPeripheral(radio, accept = {}, clock = clock()) + peripheral.start(backgroundScope, MutableStateFlow(emptyList())) + runCurrent() + assertNull(radio.advertising) + assertTrue(radio.servers.isEmpty()) + peripheral.stop() + } + + @Test + fun hintPsmAndAdvertisingSetRotateTogetherAtTheEpochBoundaryAndLinksStayOpen() = + runTest { + val radio = FakeRadio() + val linkCancelled = CompletableDeferred() + val peripheral = + BluetoothPeripheral( + radio, + accept = { + try { + awaitCancellation() + } finally { + linkCancelled.complete(true) + } + }, + clock = clock(), + ) + peripheral.start(backgroundScope, MutableStateFlow(listOf(key1))) + runCurrent() + radio.servers.single().incoming.send(NoIo) // a link accepted before the rotation + runCurrent() + advanceTimeBy(period - START_OFFSET - 1) + runCurrent() + assertEquals(1, radio.servers.size) + advanceTimeBy(2) + runCurrent() + assertEquals(2, radio.servers.size) + assertTrue(radio.servers[0].closed) + assertFalse(radio.servers[1].closed) + assertEquals(expected(FIRST_PSM + 1, EPOCH + 1, key1), radio.current()) + assertFalse("an open link survives the rotation", linkCancelled.isCompleted) + peripheral.stop() + } + + @Test + fun aChangedPairListRestartsTheSetWithANewServer() = + runTest { + val radio = FakeRadio() + val keys = MutableStateFlow(listOf(key1)) + val peripheral = BluetoothPeripheral(radio, accept = {}, clock = clock()) + peripheral.start(backgroundScope, keys) + runCurrent() + keys.value = listOf(key2, key1) + runCurrent() + assertTrue(radio.servers[0].closed) + assertEquals(expected(FIRST_PSM + 1, EPOCH, key2, key1), radio.current()) + keys.value = emptyList() + runCurrent() + assertNull(radio.advertising) + assertTrue(radio.servers.all { it.closed }) + peripheral.stop() + } + + @Test + fun eachAcceptedChannelGoesToTheAcceptor() = + runTest { + val radio = FakeRadio() + val accepted = mutableListOf() + val peripheral = BluetoothPeripheral(radio, accept = { accepted += it }, clock = clock()) + peripheral.start(backgroundScope, MutableStateFlow(listOf(key1))) + runCurrent() + radio.servers.single().incoming.send(NoIo) + radio.servers.single().incoming.send(NoIo) + runCurrent() + assertEquals(2, accepted.size) + peripheral.stop() + } + + @Test + fun bluetoothOffStopsEverythingAndBluetoothOnRebuildsWithANewServer() = + runTest { + val radio = FakeRadio() + val peripheral = BluetoothPeripheral(radio, accept = {}, clock = clock()) + val keys = MutableStateFlow(listOf(key1)) + val lifecycle = + BluetoothLinkLifecycle( + start = { peripheral.start(backgroundScope, keys) }, + stop = peripheral::stop, + ) + lifecycle.onAdapterState(BluetoothAdapterState.ON) + runCurrent() + assertEquals(expected(FIRST_PSM, EPOCH, key1), radio.current()) + lifecycle.onAdapterState(BluetoothAdapterState.TURNING_OFF) + runCurrent() + assertNull(radio.advertising) + assertTrue(radio.servers.single().closed) + lifecycle.onAdapterState(BluetoothAdapterState.ON) + runCurrent() + assertEquals(expected(FIRST_PSM + 1, EPOCH, key1), radio.current()) + peripheral.stop() + } + + @Test + fun aServerThatFailsIsReopenedAfterABriefPause() = + runTest { + val radio = FakeRadio() + val peripheral = BluetoothPeripheral(radio, accept = {}, clock = clock()) + peripheral.start(backgroundScope, MutableStateFlow(listOf(key1))) + runCurrent() + radio.servers.single().close() // accept() now fails, as when the stack drops the socket + runCurrent() + assertNull(radio.advertising) + advanceTimeBy(BluetoothPeripheral.RETRY_DELAY_MILLIS + 1) + runCurrent() + assertEquals(2, radio.servers.size) + assertEquals(expected(FIRST_PSM + 1, EPOCH, key1), radio.current()) + peripheral.stop() + } + + private companion object { + const val FIRST_PSM = 0x0081 + const val EPOCH = 479_762L + const val START_OFFSET = 10L + } +} diff --git a/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPermissionPromptsTest.kt b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPermissionPromptsTest.kt new file mode 100644 index 0000000..ada8d87 --- /dev/null +++ b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPermissionPromptsTest.kt @@ -0,0 +1,53 @@ +package app.handlive.android.feature.connection.bluetooth + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.hours + +/** A7.7 phần Bluetooth (SET-01 API 10): số lần hỏi quyền theo API level 31/33/37, chu kỳ xin bật Bluetooth. */ +class BluetoothPermissionPromptsTest { + private val prompts = BluetoothPermissionPrompts() + + @Test + fun belowApi31NeverNeedsNearbyDevices() { + assertFalse(prompts.needsNearbyDevicesPermission(apiLevel = 29)) + assertFalse(prompts.needsNearbyDevicesPermission(apiLevel = 30)) + } + + @Test + fun api31And33And37NeedNearbyDevices() { + assertTrue(prompts.needsNearbyDevicesPermission(apiLevel = 31)) + assertTrue(prompts.needsNearbyDevicesPermission(apiLevel = 33)) + assertTrue(prompts.needsNearbyDevicesPermission(apiLevel = 37)) + } + + @Test + fun asksNearbyDevicesOnlyOnceOnApi31Plus() { + assertTrue(prompts.shouldAskNearbyDevices(apiLevel = 31, granted = false, alreadyAsked = false)) + assertFalse(prompts.shouldAskNearbyDevices(apiLevel = 31, granted = false, alreadyAsked = true)) + assertFalse(prompts.shouldAskNearbyDevices(apiLevel = 31, granted = true, alreadyAsked = false)) + assertFalse(prompts.shouldAskNearbyDevices(apiLevel = 29, granted = false, alreadyAsked = false)) + } + + @Test + fun requestsEnableOnlyWithoutLanAndWithoutBluetoothOn() { + var now = 0L + val p = BluetoothPermissionPrompts(clock = { now }) + assertTrue(p.shouldRequestEnable(hasLan = false, bluetoothOn = false, lastAskedAt = 0)) + assertFalse(p.shouldRequestEnable(hasLan = true, bluetoothOn = false, lastAskedAt = 0)) + assertFalse(p.shouldRequestEnable(hasLan = false, bluetoothOn = true, lastAskedAt = 0)) + } + + @Test + fun waitsTheFullIntervalBeforeAskingAgain() { + var now = 0L + val p = BluetoothPermissionPrompts(promptInterval = 24.hours, clock = { now }) + // lastAskedAt > 0 here: a real previous ask, distinct from the "never asked" sentinel (0L) of the test above. + val lastAskedAt = 1L + now = lastAskedAt + 23.hours.inWholeMilliseconds + assertFalse(p.shouldRequestEnable(hasLan = false, bluetoothOn = false, lastAskedAt)) + now = lastAskedAt + 24.hours.inWholeMilliseconds + assertTrue(p.shouldRequestEnable(hasLan = false, bluetoothOn = false, lastAskedAt)) + } +} diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt index 916d824..4a8d16a 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt @@ -217,5 +217,8 @@ object DeviceListModel { PeerSession.Channel.LAN -> DeviceLink.WIFI PeerSession.Channel.RELAY -> DeviceLink.INTERNET PeerSession.Channel.USB -> DeviceLink.USB + // TODO(A7.1b): chưa có DeviceLink/chuỗi riêng cho Bluetooth (status.connected_bluetooth chưa có trong + // ui-strings.json, như placeholder .connectedWiFi đã chấp nhận ở M7.1a phía Apple cho #199/#190). + PeerSession.Channel.BLUETOOTH -> DeviceLink.WIFI } } From 3946d360a66594ad29d25b19edf36fe63be3f80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 17:40:14 +0700 Subject: [PATCH 08/17] style(connection): keep the A7.1 Bluetooth files within ktlint rules Braces on every when entry, one call per line in chained test calls, blank lines between multiline when entries; no behaviour change. --- .../bluetooth/BluetoothLinkLifecycle.kt | 20 ++++++++++++------- .../bluetooth/BluetoothPeripheralTest.kt | 8 +++++--- .../pairing/devices/DeviceListModel.kt | 3 +++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycle.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycle.kt index 5dce0a4..82acc31 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycle.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycle.kt @@ -23,17 +23,23 @@ class BluetoothLinkLifecycle( fun onAdapterState(state: BluetoothAdapterState) { when (state) { - BluetoothAdapterState.ON -> if (!isRunning) { - isRunning = true - start() + BluetoothAdapterState.ON -> { + if (!isRunning) { + isRunning = true + start() + } } - BluetoothAdapterState.OFF, BluetoothAdapterState.TURNING_OFF -> if (isRunning) { - isRunning = false - stop() + BluetoothAdapterState.OFF, BluetoothAdapterState.TURNING_OFF -> { + if (isRunning) { + isRunning = false + stop() + } } - BluetoothAdapterState.TURNING_ON -> Unit + BluetoothAdapterState.TURNING_ON -> { + // Chờ STATE_ON: chưa mở được server hay quảng bá. + } } } } diff --git a/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt index 78e8bb7..af53f3d 100644 --- a/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt +++ b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt @@ -137,7 +137,8 @@ class BluetoothPeripheralTest { ) peripheral.start(backgroundScope, MutableStateFlow(listOf(key1))) runCurrent() - radio.servers.single().incoming.send(NoIo) // a link accepted before the rotation + val first = radio.servers.single() + first.incoming.send(NoIo) // a link accepted before the rotation runCurrent() advanceTimeBy(period - START_OFFSET - 1) runCurrent() @@ -179,8 +180,9 @@ class BluetoothPeripheralTest { val peripheral = BluetoothPeripheral(radio, accept = { accepted += it }, clock = clock()) peripheral.start(backgroundScope, MutableStateFlow(listOf(key1))) runCurrent() - radio.servers.single().incoming.send(NoIo) - radio.servers.single().incoming.send(NoIo) + val server = radio.servers.single() + server.incoming.send(NoIo) + server.incoming.send(NoIo) runCurrent() assertEquals(2, accepted.size) peripheral.stop() diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt index 4a8d16a..2fe3ea8 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt @@ -215,8 +215,11 @@ object DeviceListModel { private fun PeerSession.Channel.toLink() = when (this) { PeerSession.Channel.LAN -> DeviceLink.WIFI + PeerSession.Channel.RELAY -> DeviceLink.INTERNET + PeerSession.Channel.USB -> DeviceLink.USB + // TODO(A7.1b): chưa có DeviceLink/chuỗi riêng cho Bluetooth (status.connected_bluetooth chưa có trong // ui-strings.json, như placeholder .connectedWiFi đã chấp nhận ở M7.1a phía Apple cho #199/#190). PeerSession.Channel.BLUETOOTH -> DeviceLink.WIFI From 93b41e306b06a598427cfcbddecbb269f8b4c403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 17:40:24 +0700 Subject: [PATCH 09/17] test(android): cover QR pairing over Bluetooth (PAIR-01 flow B) bt-pairing.json keys, pr_bt and first frame; a single pairing slot (wrong or missing MAC, second claimant, hold timeout); pr_bt first in the advertising while a QR window is open; a K_bt_plink link running hello, offer, confirm and done through PairingExchange, and a pin hello refused on a QR link. --- core/transport/build.gradle.kts | 3 +- .../bluetooth/BtQrPairingAcceptorTest.kt | 318 ++++++++++++++++++ .../bluetooth/BtQrPairingKeysTest.kt | 65 ++++ .../bluetooth/BluetoothPeripheralTest.kt | 51 +++ .../exchange/BtQrPairingKeySourceTest.kt | 78 +++++ .../pairing/exchange/BtQrPairingLinkTest.kt | 137 ++++++++ .../pairing/testing/BtQrPairingTestKit.kt | 154 +++++++++ 7 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingAcceptorTest.kt create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingKeysTest.kt create mode 100644 feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingKeySourceTest.kt create mode 100644 feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingLinkTest.kt create mode 100644 feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/testing/BtQrPairingTestKit.kt diff --git a/core/transport/build.gradle.kts b/core/transport/build.gradle.kts index 79c5302..398efa0 100644 --- a/core/transport/build.gradle.kts +++ b/core/transport/build.gradle.kts @@ -39,11 +39,12 @@ android { .file(sharedDir.file("test-vectors/session-handshake.json")) .withPropertyName("sessionHandshakeVector") .withPathSensitivity(PathSensitivity.RELATIVE) - // Test liên kết Bluetooth đọc bt-link.json và bt-advertisement.json (0.4.5). + // Test liên kết Bluetooth đọc bt-link.json, bt-advertisement.json (0.4.5) và bt-pairing.json (A7.2-B). test.inputs .files( sharedDir.file("test-vectors/bt-link.json"), sharedDir.file("test-vectors/bt-advertisement.json"), + sharedDir.file("test-vectors/bt-pairing.json"), ).withPropertyName("bluetoothVectors") .withPathSensitivity(PathSensitivity.RELATIVE) test.inputs diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingAcceptorTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingAcceptorTest.kt new file mode 100644 index 0000000..1fcf121 --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingAcceptorTest.kt @@ -0,0 +1,318 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.crypto.primitives.ChaCha20Poly1305Aead +import app.handlive.android.core.crypto.primitives.HmacSha256 +import app.handlive.android.core.crypto.primitives.SecureRandomBytes +import app.handlive.android.core.crypto.primitives.X25519Keys +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.str +import app.handlive.android.core.transport.bluetooth.testing.InMemoryBluetoothLinkIo +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.readText +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.GeneralSecurityException +import java.security.MessageDigest +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Luồng B (QR) của ghép đôi qua Bluetooth (0.4.5 "Pairing mode", A7.2-B): `noise1` xác thực bằng `K_bt_plink` của + * cửa sổ QR đang mở đi vào [BluetoothLinkAcceptor.servePairing] (không có `pair_id`), chiếm [BtQrPairingSlot] cho + * tới khi liên kết đó kết thúc — một liên kết khác xác thực đúng cùng psk trong lúc đó không được thử, rơi xuống + * `K_bt_link` và bị `reject`. Client dựng từ `bt-pairing.json` (`n_s` cố định) qua [InMemoryBluetoothLinkIo] — + * không radio, không PAIR-01 thật (đó là `PairingCoordinator` của feature/pairing, cắm vào qua + * [BluetoothPairingKeySource]/[BtQrPairingKeys]; [FakeNoiseInitiator] chỉ đóng vai C để lái bắt tay tới bước 5, + * độc lập với [NoiseNNpsk0] như `FakePairingClient` độc lập với phía phone của PAIR-01). + */ +class BtQrPairingAcceptorTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val firstFrame = SharedTestVectors.vectors("bt-pairing.json").first { it.str("kind") == "first_frame" } + private val goodMessage1 = firstFrame.hex("message1") + private val plinkKey = firstFrame.hex("psk") + private val prologue = NoiseNNpsk0.prologue(firstFrame.hex("n_s")) + + @After + fun tearDown() = scope.cancel() + + private fun admission(linkLimit: Int = 4) = + BluetoothLinkAdmission(linkLimit = linkLimit, preauthLimit = linkLimit, authFailWindow = 1.minutes) + + private class Served( + val text: String, + ) + + private fun acceptor( + admission: BluetoothLinkAdmission, + slot: BtQrPairingSlot, + pairingKey: ByteArray? = plinkKey, + served: CompletableDeferred = CompletableDeferred(), + holdLink: CompletableDeferred = CompletableDeferred(Unit), + ) = BluetoothLinkAcceptor( + admission = admission, + serve = { _, _ -> error("liên kết ghép đôi QR không có pair_id, không được vào serve() của cặp đã ghép") }, + newSession = { + BluetoothPreauthSession( + pairKeys = BluetoothPairKeys { emptyList() }, + pairingKeySource = BluetoothPairingKeySource { pairingKey }, + pairingSlot = slot, + nonce = { firstFrame.hex("n_s") }, + ) + }, + servePairing = { socket: WebSocketSession -> + val first = (socket.incoming.receive() as Frame.Text).readText() + served.complete(Served(first)) + holdLink.await() + }, + ) + + /** Client phía bước 1-3 với [message1]; trả bản ghi S đáp (`noise2` hoặc `reject`). */ + private suspend fun clientNoise( + client: InMemoryBluetoothLinkIo, + message1: ByteArray = goodMessage1, + ): BtPreauthRecord.Decoded { + val challenge = BtPreauthRecord.decode(client.readPreauthRecord()) + assertEquals(BtPreauthRecord.KIND_CHALLENGE, challenge.kind) + client.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_NOISE1, message1)) + return BtPreauthRecord.decode(client.readPreauthRecord()) + } + + @Test + fun aValidFirstFrameClaimsTheSlotAndReachesServePairingWithPairIdNull() = + runBlocking { + val slot = BtQrPairingSlot() + val served = CompletableDeferred() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission(), slot, served = served).accept(server) } + val noise2 = clientNoise(client) + assertEquals(BtPreauthRecord.KIND_NOISE2, noise2.kind) + val handshake = checkNotNull(openMessage2(noise2.payload)) + client.writeRecord(firstTransportRecord(handshake.c1, "pair/hello")) + val result = withTimeout(WAIT) { served.await() } + assertEquals("pair/hello", result.text) + withTimeout(WAIT) { link.await() } + } + + @Test + fun aNoise1WithAWrongPlinkKeyNeverClaimsTheSlotAndGetsRejected() = + runBlocking { + val slot = BtQrPairingSlot() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = + scope.async { + acceptor(admission(), slot, pairingKey = SecureRandomBytes.next(plinkKey.size)).accept(server) + } + val answer = clientNoise(client) + assertEquals(BtPreauthRecord.KIND_REJECT, answer.kind) + withTimeout(WAIT) { link.await() } + // Slot chưa từng bị chiếm: một kênh khác xin ngay vẫn chiếm được. + assertTrue(slot.tryClaim()) + } + + @Test + fun aWrongFirstFrameWithTheRightSizeButNoValidMacNeverClaimsTheSlot() = + runBlocking { + val slot = BtQrPairingSlot() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission(), slot).accept(server) } + val answer = clientNoise(client, message1 = SecureRandomBytes.next(goodMessage1.size)) + assertEquals(BtPreauthRecord.KIND_REJECT, answer.kind) + withTimeout(WAIT) { link.await() } + assertTrue(slot.tryClaim()) + } + + @Test + fun aFirstFrameMissingItsMacBytesIsRejectedInsteadOfCrashing() = + runBlocking { + val slot = BtQrPairingSlot() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission(), slot).accept(server) } + // e_pub trọn, nhưng không còn chỗ cho 16 byte MAC/tag: "khung đầu thiếu MAC", khác trường hợp MAC sai. + val answer = clientNoise(client, message1 = goodMessage1.copyOfRange(0, 32)) + assertEquals(BtPreauthRecord.KIND_REJECT, answer.kind) + withTimeout(WAIT) { link.await() } + assertTrue(slot.tryClaim()) + } + + @Test + fun aSecondClaimantWhileTheSlotIsHeldGetsRejectedEvenWithTheSamePsk() = + runBlocking { + val slot = BtQrPairingSlot(holdTimeout = 10.seconds) + val served = CompletableDeferred() + val hold = CompletableDeferred() + val (client1, server1) = InMemoryBluetoothLinkIo.pair() + val link1 = scope.async { acceptor(admission(), slot, served = served, holdLink = hold).accept(server1) } + val noise2a = clientNoise(client1) + val handshake1 = checkNotNull(openMessage2(noise2a.payload)) + client1.writeRecord(firstTransportRecord(handshake1.c1, "pair/hello")) + // Liên kết 1 đã xác nhận và đang chạy PAIR-01 (bị giữ ở servePairing bởi `hold`): slot còn bị chiếm. + withTimeout(WAIT) { served.await() } + + // Cùng psk, cùng n_s (test dùng nonce cố định): nếu được thử, noise1 này cũng xác thực đúng — nhưng + // slot của liên kết 1 còn giữ nên S không thử K_bt_plink với kênh này ("one client per window"). + val (client2, server2) = InMemoryBluetoothLinkIo.pair() + val link2 = scope.async { acceptor(admission(), slot).accept(server2) } + val answer2 = clientNoise(client2) + assertEquals(BtPreauthRecord.KIND_REJECT, answer2.kind) + withTimeout(WAIT) { link2.await() } + + hold.complete(Unit) + withTimeout(WAIT) { link1.await() } + // servePairing() của liên kết 1 đã xong: slot trống lại cho kênh kế tiếp. + assertTrue(slot.tryClaim()) + } + + @Test + fun theSlotFreesItselfAfterItsHoldTimeoutSoTheNextClaimantGetsIn() = + runBlocking { + val slot = BtQrPairingSlot(holdTimeout = SHORT) + assertTrue(slot.tryClaim()) + kotlinx.coroutines.delay(SHORT + 100.milliseconds) + assertTrue(slot.tryClaim()) + } + + @Test + fun theSlotReleasesAssoonAsServePairingReturnsSoTheNextLinkCanClaimIt() = + runBlocking { + val slot = BtQrPairingSlot(holdTimeout = 10.seconds) + val served = CompletableDeferred() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission(), slot, served = served).accept(server) } + val noise2 = clientNoise(client) + val handshake = checkNotNull(openMessage2(noise2.payload)) + client.writeRecord(firstTransportRecord(handshake.c1, "pair/hello")) + withTimeout(WAIT) { served.await() } + // servePairing() của acceptor() này trả về ngay (holdLink mặc định đã complete) — slot phải trống lại. + withTimeout(WAIT) { link.await() } + assertTrue(slot.tryClaim()) + } + + private fun firstTransportRecord( + c1: ByteArray, + text: String, + ): ByteArray = + BtTransportCipher(c1).seal( + BtTransportFrame.encode(BtLinkConstants.CHAN_CONTROL, more = false, long = false, text.toByteArray()), + ) + + /** Phía C của bước 4-5: mở `message2` và tính `Split()` — không phụ thuộc [NoiseNNpsk0] (chỉ dùng [split]). */ + private fun openMessage2(message2: ByteArray): FakeNoiseInitiator.Handshake? { + val ePrivC = firstFrame.hex("e_priv") + val ePubC = X25519Keys.publicFromPrivate(ePrivC) + assertArrayEquals(firstFrame.hex("e_pub"), ePubC) + return FakeNoiseInitiator.openMessage2(plinkKey, prologue, ePrivC, goodMessage1, message2) + } + + private companion object { + val WAIT = 5.seconds + val SHORT = 300.milliseconds + } +} + +/** + * Phía C độc lập của `Noise_NNpsk0_25519_ChaChaPoly_SHA256` (rev. 34) — dựng từ đặc tả, không dùng lại [NoiseNNpsk0] + * (phía S), để test có thể lái bắt tay tới bước 5 mà không cần một bộ vector `message2`/`c1`/`c2` riêng cho khoá + * ghép đôi QR. Cùng tinh thần `FakePairingClient` của feature/pairing cho PAIR-01. + */ +private object FakeNoiseInitiator { + private const val HASH_SIZE = 32 + private val ZERO_NONCE = ByteArray(ChaCha20Poly1305Aead.NONCE_SIZE) + + class Handshake( + val c1: ByteArray, + val c2: ByteArray, + ) + + /** [message1] = `e.pub` của C (dẫn từ [ePrivC]) ‖ tag đã gửi; [message2] = `noise2` S đáp. */ + fun openMessage2( + psk: ByteArray, + prologue: ByteArray, + ePrivC: ByteArray, + message1: ByteArray, + message2: ByteArray, + ): Handshake? { + val ePubC = message1.copyOfRange(0, X25519Keys.KEY_SIZE) + val message1Tag = message1.copyOfRange(X25519Keys.KEY_SIZE, message1.size) + val (h0, ck0) = initializeSymmetric() + val h1 = mixHash(h0, prologue) + val pskStep = mixKeyAndHashPsk(ck0, h1, psk) + val h3 = mixHash(pskStep.h, ePubC) + val eStep1 = mixKey(pskStep.ck, ePubC) + val h4 = mixHash(h3, message1Tag) + val ePubS = message2.copyOfRange(0, X25519Keys.KEY_SIZE) + val tag2 = message2.copyOfRange(X25519Keys.KEY_SIZE, message2.size) + val h5 = mixHash(h4, ePubS) + val eStep2 = mixKey(eStep1.ck, ePubS) + val dh = X25519Keys.sharedSecret(ePrivC, ePubS) + val eeStep = mixKey(eStep2.ck, dh) + return try { + ChaCha20Poly1305Aead.open(eeStep.tempK, ZERO_NONCE, tag2, h5) + val (c1, c2) = NoiseNNpsk0.split(eeStep.ck) + Handshake(c1, c2) + } catch (_: GeneralSecurityException) { + null + } + } + + private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) + + private fun mixHash( + h: ByteArray, + data: ByteArray, + ): ByteArray = sha256(h + data) + + private fun initializeSymmetric(): Pair { + val name = NoiseNNpsk0.PROTOCOL_NAME + val h = if (name.size <= HASH_SIZE) name else sha256(name) + return h to h + } + + private class MixKeyResult( + val ck: ByteArray, + val tempK: ByteArray, + ) + + private fun mixKey( + ck: ByteArray, + ikm: ByteArray, + ): MixKeyResult { + val tempKey = HmacSha256.mac(ck, ikm) + val out1 = HmacSha256.mac(tempKey, byteArrayOf(0x01)) + val out2 = HmacSha256.mac(tempKey, out1 + byteArrayOf(0x02)) + return MixKeyResult(ck = out1, tempK = out2) + } + + private class PskMixResult( + val ck: ByteArray, + val h: ByteArray, + ) + + private fun mixKeyAndHashPsk( + ck: ByteArray, + h: ByteArray, + psk: ByteArray, + ): PskMixResult { + val tempKey = HmacSha256.mac(ck, psk) + val out1 = HmacSha256.mac(tempKey, byteArrayOf(0x01)) + val out2 = HmacSha256.mac(tempKey, out1 + byteArrayOf(0x02)) + return PskMixResult(ck = out1, h = mixHash(h, out2)) + } +} diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingKeysTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingKeysTest.kt new file mode 100644 index 0000000..5e56c05 --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingKeysTest.kt @@ -0,0 +1,65 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.str +import app.handlive.android.core.protocol.testing.strOrNull +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `K_bt_pdisc`/`K_bt_plink`/`pr_bt` của một QR (0.4.5 "Keys", "Pairing mode") và `noise1` "khung đầu" của một liên + * kết ghép đôi mà `K_bt_plink` trở thành psk — `shared/test-vectors/bt-pairing.json`. Không phải vector công bố + * của Noise (xem mô tả file); phần hằng thời gian của CPace (A7.2a) không ở đây. + */ +class BtQrPairingKeysTest { + private val keyVectors = SharedTestVectors.vectors("bt-pairing.json").filter { it.str("kind") == "key" } + private val pair1 = keyVectors.first { it.str("name") == "cặp 1" } + private val pair2 = keyVectors.first { it.str("name") == "cặp 2" } + private val firstFrame = SharedTestVectors.vectors("bt-pairing.json").first { it.str("kind") == "first_frame" } + private val invalid = SharedTestVectors.invalidVectors("bt-pairing.json") + + @Test + fun derivesKBtPdiscAndKBtPlinkFromThePairingSecret() { + for (vector in listOf(pair1, pair2)) { + val secret = vector.hex("pairing_secret") + assertArrayEquals(vector.hex("k_bt_pdisc"), BtQrPairingKeys.kBtPdisc(secret)) + assertArrayEquals(vector.hex("k_bt_plink"), BtQrPairingKeys.kBtPlink(secret)) + } + } + + @Test + fun prBtIsTheFirstFourBytesOfHmacOfKBtPdisc() { + for (vector in listOf(pair1, pair2)) { + val kBtPdisc = BtQrPairingKeys.kBtPdisc(vector.hex("pairing_secret")) + assertArrayEquals(vector.hex("pr_bt"), BtQrPairingKeys.prBt(kBtPdisc)) + } + } + + @Test + fun theDerivedKBtPlinkOpensTheFirstFrameOfAPairingLink() { + val kBtPlink = BtQrPairingKeys.kBtPlink(pair1.hex("pairing_secret")) + assertArrayEquals(firstFrame.hex("psk"), kBtPlink) + val prologue = NoiseNNpsk0.prologue(firstFrame.hex("n_s")) + val opened = NoiseNNpsk0.tryOpenMessage1(kBtPlink, prologue, firstFrame.hex("message1")) + assertTrue(opened != null) + val record = BtPreauthRecord.decode(firstFrame.hex("record").let { it.copyOfRange(2, it.size) }) + assertArrayEquals(firstFrame.hex("message1"), record.payload) + } + + @Test + fun theInvalidVectorsNeverOpen() { + for (vector in invalid) { + val nS = vector.hex("n_s") + val ePub = vector.hex("e_pub") + val tag = vector.hex("ciphertext_tag") + val psk = if (vector.strOrNull("wrong_psk") != null) vector.hex("wrong_psk") else firstFrame.hex("psk") + val prologue = NoiseNNpsk0.prologue(nS) + assertNull(vector.str("name"), NoiseNNpsk0.tryOpenMessage1(psk, prologue, ePub + tag)) + } + } +} diff --git a/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt index af53f3d..da04938 100644 --- a/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt +++ b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt @@ -172,6 +172,54 @@ class BluetoothPeripheralTest { peripheral.stop() } + private fun expectedWithPairing( + psm: Int, + epoch: Long, + prBt: ByteArray, + vararg keys: ByteArray, + ) = Hex.encode( + BluetoothAdvertisementData.serviceData( + psm, + listOf(prBt) + keys.map { BluetoothAdvertisementData.hint(it, epoch) }, + ), + ) + + @Test + fun anOpenQrWindowPutsPrBtFirstAndKeepsOnlyOnePairHint() = + runTest { + val radio = FakeRadio() + val pairing = MutableStateFlow(null) + val peripheral = BluetoothPeripheral(radio, accept = {}, clock = clock()) + peripheral.start(backgroundScope, MutableStateFlow(listOf(key1, key2)), pairing) + runCurrent() + assertEquals(expected(FIRST_PSM, EPOCH, key1, key2), radio.current()) + pairing.value = PR_BT + runCurrent() + // Cửa sổ mở: dựng lại bộ quảng bá (server mới, PSM mới) với pr_bt đứng đầu (0.4.5 "Pairing mode"). + assertTrue(radio.servers[0].closed) + assertEquals(expectedWithPairing(FIRST_PSM + 1, EPOCH, PR_BT, key1), radio.current()) + pairing.value = null + runCurrent() + assertEquals(expected(FIRST_PSM + 2, EPOCH, key1, key2), radio.current()) + peripheral.stop() + } + + @Test + fun aQrWindowAloneAdvertisesEvenWithNoPairs() = + runTest { + val radio = FakeRadio() + val pairing = MutableStateFlow(PR_BT) + val peripheral = BluetoothPeripheral(radio, accept = {}, clock = clock()) + peripheral.start(backgroundScope, MutableStateFlow(emptyList()), pairing) + runCurrent() + assertEquals(expectedWithPairing(FIRST_PSM, EPOCH, PR_BT), radio.current()) + pairing.value = null + runCurrent() + assertNull(radio.advertising) + assertTrue(radio.servers.all { it.closed }) + peripheral.stop() + } + @Test fun eachAcceptedChannelGoesToTheAcceptor() = runTest { @@ -233,5 +281,8 @@ class BluetoothPeripheralTest { const val FIRST_PSM = 0x0081 const val EPOCH = 479_762L const val START_OFFSET = 10L + + /** `pr_bt` của "cặp 1" trong bt-pairing.json. */ + val PR_BT: ByteArray = Hex.decode("24be2584") } } diff --git a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingKeySourceTest.kt b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingKeySourceTest.kt new file mode 100644 index 0000000..878f5aa --- /dev/null +++ b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingKeySourceTest.kt @@ -0,0 +1,78 @@ +package app.handlive.android.feature.pairing.exchange + +import app.handlive.android.core.transport.bluetooth.BtQrPairingKeys +import app.handlive.android.feature.pairing.testing.FakePairingClient +import app.handlive.android.feature.pairing.testing.PairStoreFixture +import app.handlive.android.feature.pairing.testing.localPhone +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Điểm nối [PairingCoordinator] với [app.handlive.android.core.transport.bluetooth.BluetoothPairingKeySource] + * (0.4.5 "Pairing mode", A7.2-B): `K_bt_plink` chỉ khi cửa sổ đang mở là QR, không phải PIN hay không có cửa sổ + * nào — `BluetoothLinkAcceptor` dùng đúng khoá của cửa sổ đang chạy trên điện thoại, không phải một snapshot cũ. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class BtQrPairingKeySourceTest { + private val pairs = PairStoreFixture() + private val client = FakePairingClient() + + @After + fun tearDown() = pairs.database.close() + + private fun kotlinx.coroutines.test.TestScope.coordinator() = + PairingCoordinator( + local = { localPhone() }, + pairs = pairs.store, + advertise = {}, + scope = backgroundScope, + clock = { testScheduler.currentTime }, + ) + + @Test + fun noKeyWithNoWindowOpen() = + runTest { + val source = BtQrPairingKeySource(coordinator()) + assertNull(source.currentPlinkKey()) + } + + @Test + fun theQrWindowsKBtPlinkIsDerivedFromItsPairingSecret() = + runTest { + val coordinator = coordinator() + val source = BtQrPairingKeySource(coordinator) + coordinator.onScanned(client.qrCode()) + coordinator.confirm() + assertArrayEquals(BtQrPairingKeys.kBtPlink(client.pairingSecret), source.currentPlinkKey()) + } + + @Test + fun noKeyOnceTheWindowClosesAgain() = + runTest { + val coordinator = coordinator() + val source = BtQrPairingKeySource(coordinator) + coordinator.onScanned(client.qrCode()) + coordinator.confirm() + coordinator.cancel() + assertNull(source.currentPlinkKey()) + } + + @Test + fun noKeyForAPinWindow() = + runTest { + val coordinator = coordinator() + val source = BtQrPairingKeySource(coordinator) + coordinator.startPin() + coordinator.submitPin("123456") + assertNull(source.currentPlinkKey()) + } +} diff --git a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingLinkTest.kt b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingLinkTest.kt new file mode 100644 index 0000000..0b96b8d --- /dev/null +++ b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingLinkTest.kt @@ -0,0 +1,137 @@ +package app.handlive.android.feature.pairing.exchange + +import app.handlive.android.core.crypto.primitives.SecureRandomBytes +import app.handlive.android.core.protocol.ErrorCode +import app.handlive.android.core.protocol.pairing.PairErrorData +import app.handlive.android.core.protocol.pairing.PairHelloData +import app.handlive.android.core.protocol.pairing.PairOp +import app.handlive.android.core.transport.bluetooth.BluetoothLinkAdmission +import app.handlive.android.core.transport.bluetooth.BtQrPairingKeys +import app.handlive.android.feature.connection.bluetooth.BluetoothAdvertising +import app.handlive.android.feature.connection.bluetooth.BluetoothQrPairingHooks +import app.handlive.android.feature.pairing.testing.FakeBtPairingClientLink +import app.handlive.android.feature.pairing.testing.FakePairingClient +import app.handlive.android.feature.pairing.testing.PairStoreFixture +import app.handlive.android.feature.pairing.testing.PipeBluetoothLinkIo +import app.handlive.android.feature.pairing.testing.localPhone +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.seconds + +/** + * Đường thật của PAIR-01 luồng B qua Bluetooth (0.4.5 "Pairing mode", PAIR-01 B3): bộ accept mà app dựng + * ([BluetoothAdvertising.acceptor], một slot cho cả listener) + cửa sổ QR thật của [PairingCoordinator] qua + * [BtQrPairingKeySource]; một Mac giả ([FakeBtPairingClientLink] + [FakePairingClient]) bắt tay với `K_bt_plink` từ mã + * QR rồi chạy hello → offer → confirm → done trên kênh `0x01`, và cặp được lưu như luồng LAN. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class BtQrPairingLinkTest { + private val pairs = PairStoreFixture() + private val client = FakePairingClient() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val phone = localPhone() + private val coordinator = + PairingCoordinator(local = { phone }, pairs = pairs.store, advertise = {}, scope = scope) + private val window = BtQrPairingKeySource(coordinator) + private val acceptor = + BluetoothAdvertising.acceptor( + admission = BluetoothLinkAdmission(), + serve = { _, _ -> error("liên kết ghép đôi không bao giờ vào serve() của cặp đã ghép") }, + qrPairing = BluetoothQrPairingHooks({ window }, flowOf(Unit), { coordinator }), + clock = System::currentTimeMillis, + linkKeys = { emptyList() }, + ) + + @After + fun tearDown() { + scope.cancel() + pairs.database.close() + } + + private suspend fun openQrWindow() { + coordinator.onScanned(client.qrCode()) + coordinator.confirm() + } + + private fun connect(): FakeBtPairingClientLink { + val (mac, phoneEnd) = PipeBluetoothLinkIo.pair() + scope.async { acceptor.accept(phoneEnd) } + return FakeBtPairingClientLink(mac) + } + + @Test + fun aQrPairingLinkRunsPair01ThroughPairingExchangeAndStoresThePair() = + runBlocking { + openQrWindow() + val link = connect() + assertTrue(link.handshake(BtQrPairingKeys.kBtPlink(client.pairingSecret))) + withTimeout(WAIT) { + link.sendText(client.hello()) + val offerText = checkNotNull(link.receiveText()) + val offer = checkNotNull(client.checkOffer(offerText, client.pairingSecret, phone.tlsSha256)) + val pairId = "0193a0c1-7e5f-7a2b-8c3d-4e5f6a7b8c9d" + link.sendText(client.confirm(offer, pairId = pairId)) + assertTrue(client.checkDone(checkNotNull(link.receiveText()), offer, pairId, CREATED_AT)) + link.close() + // Cặp được lưu trước `pair/done`; trạng thái "đã ghép" chỉ đổi khi PAIR-01 trên liên kết kết thúc. + while (coordinator.state.value !is PairingState.Paired) kotlinx.coroutines.delay(POLL) + } + assertEquals(1, pairs.store.activeCount()) + assertTrue(coordinator.state.value is PairingState.Paired) + } + + @Test + fun aPinHelloOnAQrPairingLinkIsRefusedAndStoresNothing() = + runBlocking { + openQrWindow() + val link = connect() + assertTrue(link.handshake(BtQrPairingKeys.kBtPlink(client.pairingSecret))) + val refusal = + withTimeout(WAIT) { + link.sendText(client.hello(mode = PairHelloData.MODE_PIN)) + checkNotNull(link.receiveText()) + } + assertEquals(PairOp.ERROR, client.opOf(refusal)) + assertEquals(ErrorCode.PAIRING_CLOSED.name, client.read(refusal, PairErrorData.serializer())?.code) + assertEquals(0, pairs.store.activeCount()) + } + + @Test + fun aLinkWithoutThePairingSecretIsRejectedBeforeAnyPairEnvelope() = + runBlocking { + openQrWindow() + val link = connect() + assertFalse(withTimeout(WAIT) { link.handshake(SecureRandomBytes.next(KEY_SIZE)) }) + assertEquals(0, pairs.store.activeCount()) + assertTrue(coordinator.state.value is PairingState.Waiting) + } + + @Test + fun withoutAQrWindowEvenTheRightKeyGetsNoPairingLink() = + runBlocking { + val link = connect() + assertFalse(withTimeout(WAIT) { link.handshake(BtQrPairingKeys.kBtPlink(client.pairingSecret)) }) + } + + private companion object { + val WAIT = 10.seconds + const val POLL = 20L + const val KEY_SIZE = 32 + const val CREATED_AT = 1_727_150_003_210L + } +} diff --git a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/testing/BtQrPairingTestKit.kt b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/testing/BtQrPairingTestKit.kt new file mode 100644 index 0000000..d48f931 --- /dev/null +++ b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/testing/BtQrPairingTestKit.kt @@ -0,0 +1,154 @@ +package app.handlive.android.feature.pairing.testing + +import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.crypto.primitives.ChaCha20Poly1305Aead +import app.handlive.android.core.crypto.primitives.HmacSha256 +import app.handlive.android.core.crypto.primitives.X25519Keys +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import app.handlive.android.core.transport.bluetooth.BluetoothLinkIo +import app.handlive.android.core.transport.bluetooth.BtTransportCipher +import kotlinx.coroutines.channels.Channel +import java.security.GeneralSecurityException +import java.security.MessageDigest + +/** Hai đầu một liên kết L2CAP giả trong bộ nhớ: đầu này đọc cái đầu kia viết (không radio). */ +class PipeBluetoothLinkIo private constructor( + private val readQueue: Channel, + private val writeQueue: Channel, +) : BluetoothLinkIo { + override suspend fun readPreauthRecord(): ByteArray = readRecord() + + override suspend fun readRecord(): ByteArray = + readQueue.receiveCatching().getOrNull() ?: throw BtLinkException("pipe closed") + + override suspend fun writeRecord(body: ByteArray) { + writeQueue.send(body) + } + + override fun close() { + readQueue.close() + writeQueue.close() + } + + companion object { + fun pair(): Pair { + val aToB = Channel(Channel.UNLIMITED) + val bToA = Channel(Channel.UNLIMITED) + return PipeBluetoothLinkIo(bToA, aToB) to PipeBluetoothLinkIo(aToB, bToA) + } + } +} + +/** + * Phía M-APP (C) của một liên kết Bluetooth: bắt tay `Noise_NNpsk0_25519_ChaChaPoly_SHA256` (rev. 34) dựng độc lập với + * [NoiseNNpsk0] (phía S, chỉ dùng chung `split`), rồi gửi/nhận envelope trên kênh điều khiển `0x01` — đủ để lái + * PAIR-01 qua liên kết ghép đôi QR như Mac sẽ làm (0.4.5 "Pairing link"). + */ +class FakeBtPairingClientLink( + private val io: BluetoothLinkIo, +) { + private var send: BtTransportCipher? = null + private var receive: BtTransportCipher? = null + + /** Bước 1-4 với [psk]; `true` khi S trả `noise2` hợp lệ, `false` khi S trả `reject` hoặc `noise2` sai. */ + suspend fun handshake(psk: ByteArray): Boolean { + val challenge = BtPreauthRecord.decode(io.readPreauthRecord()) + check(challenge.kind == BtPreauthRecord.KIND_CHALLENGE) + val initiator = Initiator(psk, NoiseNNpsk0.prologue(challenge.payload), X25519Keys.generatePrivateKey()) + io.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_NOISE1, initiator.message1())) + val answer = BtPreauthRecord.decode(io.readPreauthRecord()) + val keys = if (answer.kind == BtPreauthRecord.KIND_NOISE2) initiator.openMessage2(answer.payload) else null + keys?.let { (c1, c2) -> + send = BtTransportCipher(c1) + receive = BtTransportCipher(c2) + } + return keys != null + } + + suspend fun sendText(text: String) { + val frame = + BtTransportFrame.encode(BtLinkConstants.CHAN_CONTROL, more = false, long = false, text.toByteArray()) + io.writeRecord(checkNotNull(send).seal(frame)) + } + + /** Envelope kế tiếp từ S, hoặc `null` khi S đã đóng liên kết. */ + suspend fun receiveText(): String? = + try { + val frame = BtTransportFrame.decode(checkNotNull(receive).open(io.readRecord())) + frame.data.toString(Charsets.UTF_8) + } catch (_: BtLinkException) { + null + } + + fun close() = io.close() + + private class Initiator( + private val psk: ByteArray, + private val prologue: ByteArray, + private val ePrivC: ByteArray, + ) { + private lateinit var ck1: ByteArray + private lateinit var h4: ByteArray + + fun message1(): ByteArray { + val (h0, ck0) = initializeSymmetric() + val h1 = mixHash(h0, prologue) + val pskStep = mixKey(ck0, psk) + // MixKeyAndHash(psk): ck, temp_h, temp_k = HKDF(ck, psk, 3); MixHash(temp_h) — temp_h là ngõ ra thứ hai. + val h2 = mixHash(h1, pskStep.second) + val ePubC = X25519Keys.publicFromPrivate(ePrivC) + val h3 = mixHash(h2, ePubC) + val eStep = mixKey(pskStep.first, ePubC) + val tag = ChaCha20Poly1305Aead.seal(eStep.second, ZERO_NONCE, ByteArray(0), h3) + ck1 = eStep.first + h4 = mixHash(h3, tag) + return ePubC + tag + } + + fun openMessage2(message2: ByteArray): Pair? { + val ePubS = message2.copyOfRange(0, X25519Keys.KEY_SIZE) + val tag = message2.copyOfRange(X25519Keys.KEY_SIZE, message2.size) + val h5 = mixHash(h4, ePubS) + val eStep = mixKey(ck1, ePubS) + val eeStep = mixKey(eStep.first, X25519Keys.sharedSecret(ePrivC, ePubS)) + return try { + ChaCha20Poly1305Aead.open(eeStep.second, ZERO_NONCE, tag, h5) + NoiseNNpsk0.split(eeStep.first) + } catch (_: GeneralSecurityException) { + null + } + } + + private fun initializeSymmetric(): Pair { + val name = NoiseNNpsk0.PROTOCOL_NAME + val h = if (name.size <= HASH_SIZE) name else sha256(name) + return h to h + } + + /** HKDF của Noise, hai ngõ ra đầu: (`ck` mới, ngõ ra thứ hai — `temp_k`, hoặc `temp_h` của token "psk"). */ + private fun mixKey( + ck: ByteArray, + ikm: ByteArray, + ): Pair { + val tempKey = HmacSha256.mac(ck, ikm) + val out1 = HmacSha256.mac(tempKey, byteArrayOf(1)) + val out2 = HmacSha256.mac(tempKey, out1 + byteArrayOf(2)) + return out1 to out2 + } + + private fun mixHash( + h: ByteArray, + data: ByteArray, + ): ByteArray = sha256(h + data) + + private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) + + private companion object { + const val HASH_SIZE = 32 + val ZERO_NONCE = ByteArray(ChaCha20Poly1305Aead.NONCE_SIZE) + } + } +} From efb0f5feba1f60d5d47b1a8656999728be92a9d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Thu, 29 Oct 2026 17:40:25 +0700 Subject: [PATCH 10/17] feat(android): pair over Bluetooth with a QR code (PAIR-01 flow B) While a QR window is open the phone advertises pr_bt first, tries K_bt_plink before every pair key through one slot per listener, and runs PAIR-01 on the confirmed pairing link through PairingCoordinator, storing the pair as on the LAN. A noise1 of the wrong size is rejected instead of throwing, and the last pair envelope reaches the link before it closes. --- .../bluetooth/BluetoothLinkAcceptor.kt | 71 +++++++++++++------ .../bluetooth/BluetoothPeerSocket.kt | 15 ++++ .../bluetooth/BluetoothPreauthSession.kt | 48 +++++++++++-- .../transport/bluetooth/BtQrPairingKeys.kt | 39 ++++++++++ .../transport/bluetooth/BtQrPairingLink.kt | 27 +++++++ .../transport/bluetooth/BtQrPairingSlot.kt | 31 ++++++++ .../feature/connection/ConnectionRuntime.kt | 13 +++- .../bluetooth/BluetoothAdvertising.kt | 69 ++++++++++++++++-- .../bluetooth/BluetoothPeripheral.kt | 49 +++++++++---- .../android/feature/pairing/PairingFeature.kt | 2 + .../pairing/exchange/BtQrPairingKeySource.kt | 18 +++++ .../pairing/exchange/PairingCoordinator.kt | 6 ++ 12 files changed, 341 insertions(+), 47 deletions(-) create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingKeys.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingLink.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingSlot.kt create mode 100644 feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingKeySource.kt diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt index 1b6cfae..1c8b190 100644 --- a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt @@ -16,14 +16,16 @@ import kotlin.time.Duration * [BluetoothLinkAdmission] gác kênh ngay khi mở (`BT_LINK_LIMIT`, `BT_PREAUTH_LIMIT`, khoá chung); `challenge` → * `noise1` (thử `K_bt_link` từng cặp) → `noise2`; liên kết chỉ được xác nhận khi bản ghi vận chuyển đầu tiên của C * giải mã được (bước 5), tất cả trong `BT_PREAUTH_TIMEOUT`. Mọi lỗi trước khi gửi `noise2` nhận `reject` rồi đóng; - * lỗi xác nhận khoá (bước 3, 5) tính một `AUTH_FAILED` cho cả listener. Liên kết đã xác nhận đi vào [serve] - * (`ControlServer.serveBluetoothPeer`, bắt tay E2E bước 6) cùng `pair_id` của psk đã khớp. + * lỗi xác nhận khoá (bước 3, 5) tính một `AUTH_FAILED` cho cả listener. Liên kết đã xác nhận bằng `K_bt_link` đi + * vào [serve] (`ControlServer.serveBluetoothPeer`, bắt tay E2E bước 6) cùng `pair_id` của psk đã khớp; liên kết + * xác nhận bằng `K_bt_plink` (0.4.5 "Pairing mode", A7.2-B) đi vào [servePairing], không có `pair_id`. */ class BluetoothLinkAcceptor( private val admission: BluetoothLinkAdmission, private val serve: suspend (WebSocketSession, String) -> Unit, private val preauthTimeout: Duration = BluetoothLinkConstants.PREAUTH_TIMEOUT, private val newSession: () -> BluetoothPreauthSession, + private val servePairing: suspend (WebSocketSession) -> Unit = {}, ) { constructor( admission: BluetoothLinkAdmission, @@ -53,9 +55,13 @@ class BluetoothLinkAcceptor( watchdog.cancel() if (confirmed != null) { try { - serve(confirmed.socket, confirmed.pairId) + val pairId = confirmed.pairId + if (pairId != null) serve(confirmed.socket, pairId) else servePairing(confirmed.socket) } finally { confirmed.socket.shutDown() + // Slot ghép đôi QR (nếu liên kết này giữ): chỉ nhả khi PAIR-01 trên nó đã xong, không + // phải ngay khi bước 5 xác nhận — một liên kết khác phải chờ hết PAIR-01 của liên kết này. + confirmed.session.releasePairingSlotIfHeld() } } else { link.socket?.shutDown() @@ -67,9 +73,14 @@ class BluetoothLinkAcceptor( } } + /** + * [pairId] `null` cho một liên kết ghép đôi QR (0.4.5 "Pairing link"), không phải một cặp đã ghép. [session] + * đi kèm để [accept] nhả slot ghép đôi QR (nếu liên kết này giữ) chỉ sau khi [serve]/[servePairing] xong. + */ private class Confirmed( val socket: BluetoothPeerSocket, - val pairId: String, + val pairId: String?, + val session: BluetoothPreauthSession, ) private enum class FirstRecord { OPENED, KEY_FAILED, CLOSED } @@ -79,27 +90,45 @@ class BluetoothLinkAcceptor( io: BluetoothLinkIo, link: LinkState, scope: CoroutineScope, - ): Confirmed? = + ): Confirmed? { + val session = newSession() + var confirmed: Confirmed? = null try { - val session = newSession() - io.writeRecord(session.challenge()) - val record = BtPreauthRecord.decode(io.readPreauthRecord()) - // `cpace1` chỉ hợp lệ khi cửa sổ PIN mở (A7.2a, chưa có): đóng không trả lời, như mọi kind lạc chỗ. - if (record.kind != BtPreauthRecord.KIND_NOISE1) return null - val verified = session.receiveNoise1(record.payload) as? BluetoothPreauthSession.Noise1Outcome.Verified - if (verified == null) { - admission.recordAuthFailure() - sendReject(io) - return null - } - val keys = session.buildNoise2() - io.writeRecord(keys.noise2) - link.noise2Sent = true - awaitFirstRecord(io, link, scope, keys)?.let { Confirmed(it, verified.pairId) } + confirmed = handshake(io, link, scope, session) } catch (_: BtLinkException) { // Kênh đứt, `rlen` vượt cận hoặc bị watchdog đóng: không trả lời thêm. - null + } finally { + // Không xác nhận được (bị từ chối, đứt kênh, hết giờ): nhả ngay slot ghép đôi QR nếu kênh này giữ. + // Xác nhận được: [accept] nhả slot sau khi [serve]/[servePairing] xong, không phải ở đây. + if (confirmed == null) session.releasePairingSlotIfHeld() } + return confirmed + } + + // Mỗi lối ra sớm là một bước kiểm của 0.4.5 theo đúng thứ tự; gộp lại chỉ để bớt `return` thì khó soát hơn. + @Suppress("ReturnCount") + private suspend fun handshake( + io: BluetoothLinkIo, + link: LinkState, + scope: CoroutineScope, + session: BluetoothPreauthSession, + ): Confirmed? { + io.writeRecord(session.challenge()) + val record = BtPreauthRecord.decode(io.readPreauthRecord()) + // `cpace1` chỉ hợp lệ khi cửa sổ PIN mở (A7.2c, chưa có): đóng không trả lời, như mọi kind lạc chỗ. + if (record.kind != BtPreauthRecord.KIND_NOISE1) return null + val outcome = session.receiveNoise1(record.payload) + if (outcome is BluetoothPreauthSession.Noise1Outcome.Rejected) { + admission.recordAuthFailure() + sendReject(io) + return null + } + val keys = session.buildNoise2() + io.writeRecord(keys.noise2) + link.noise2Sent = true + val pairId = (outcome as? BluetoothPreauthSession.Noise1Outcome.Verified)?.pairId + return awaitFirstRecord(io, link, scope, keys)?.let { Confirmed(it, pairId, session) } + } private suspend fun awaitFirstRecord( io: BluetoothLinkIo, diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocket.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocket.kt index bd8ad85..5da84cb 100644 --- a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocket.kt +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocket.kt @@ -10,6 +10,7 @@ import io.ktor.websocket.WebSocketExtension import io.ktor.websocket.WebSocketSession import io.ktor.websocket.readText import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel @@ -18,6 +19,7 @@ import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.job import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.security.GeneralSecurityException import kotlin.coroutines.CoroutineContext @@ -52,6 +54,9 @@ internal class BluetoothPeerSocket( private val reassembler = BluetoothLongLaneReassembler() private var anyRecordOpened = false + /** Vòng ghi đã dừng: mọi frame xếp trước một `Frame.Close` đã lên dây (hoặc dây đã đứt). */ + private val writesFinished = CompletableDeferred() + override val incoming: ReceiveChannel = incomingFrames override val outgoing: SendChannel = outgoingFrames override val extensions: List> = emptyList() @@ -116,10 +121,20 @@ internal class BluetoothPeerSocket( throw e } catch (_: BtLinkException) { // The link died mid-write (`more`/`dlen` only this side can produce correctly) — nothing to retry. + } finally { + writesFinished.complete(Unit) } end() } + /** + * Chờ vòng ghi xả xong (sau một `Frame.Close`), tối đa [timeoutMillis]: bên gọi sắp huỷ socket mà vừa gửi một + * envelope cuối (như `pair/error`) thì không để lệnh huỷ nuốt mất nó. + */ + suspend fun awaitWritesFinished(timeoutMillis: Long) { + withTimeoutOrNull(timeoutMillis) { writesFinished.await() } + } + private fun enqueueEnvelope(json: String) { val type = EnvelopeCodec.decode(json).type scheduler.enqueueControlEnvelope(type, json.toByteArray(Charsets.UTF_8)) diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt index 01e4cd6..24e10bb 100644 --- a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt @@ -1,23 +1,30 @@ package app.handlive.android.core.transport.bluetooth import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.crypto.primitives.ChaCha20Poly1305Aead import app.handlive.android.core.crypto.primitives.SecureRandomBytes import app.handlive.android.core.crypto.primitives.X25519Keys import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord /** * Phía S (A-SVC) của bắt tay bảo mật liên kết cho MỘT kênh (0.4.5 "Link-security handshake" bước 1-4): thử - * `K_bt_link` của từng cặp chưa thu hồi theo lượt — quảng bá không mang `pair_id` nên S không biết trước cặp - * nào. Không tự đóng kênh hay cập nhật admission: bên gọi (listener, A7.1b) làm việc đó theo kết quả trả về. + * `K_bt_plink` của cửa sổ ghép đôi QR đang mở (nếu còn slot, 0.4.5 "Pairing mode", A7.2-B) rồi `K_bt_link` của + * từng cặp chưa thu hồi theo lượt — quảng bá không mang `pair_id` nên S không biết trước cặp nào. Không tự đóng + * kênh hay cập nhật admission: bên gọi (listener, A7.1b) làm việc đó theo kết quả trả về. */ class BluetoothPreauthSession( private val pairKeys: BluetoothPairKeys, + private val pairingKeySource: BluetoothPairingKeySource = BluetoothPairingKeySource { null }, + private val pairingSlot: BtQrPairingSlot = BtQrPairingSlot(), private val nonce: () -> ByteArray = { SecureRandomBytes.next(NONCE_SIZE) }, private val ephemeralPrivateKey: () -> ByteArray = X25519Keys::generatePrivateKey, ) { private lateinit var prologue: ByteArray private var opened: NoiseNNpsk0.Message1Result? = null + @Volatile + private var pairingSlotHeld = false + /** Bước 1: `n_s` mới cho kênh này; trói buộc vào prologue nên `noise1` ghi lại từ kênh khác không bao giờ khớp. */ fun challenge(): ByteArray { val nS = nonce() @@ -30,11 +37,35 @@ class BluetoothPreauthSession( val pairId: String, ) : Noise1Outcome + /** `noise1` xác thực bằng `K_bt_plink`: liên kết ghép đôi QR, chưa có `pair_id` (0.4.5 "Pairing link"). */ + data object PairingVerified : Noise1Outcome + data object Rejected : Noise1Outcome } - /** Bước 2-3: [message1] = thân `noise1` sau byte kind (0.4.5 bước 3, thử lần lượt). */ + /** Bước 2-3: [message1] = thân `noise1` sau byte kind (0.4.5 bước 3, thử lần lượt, QR trước cặp đã ghép). */ fun receiveNoise1(message1: ByteArray): Noise1Outcome { + // `tryOpenMessage1` giả định đúng cỡ (`require`); thiếu MAC (cỡ sai) là `reject` như một tag không khớp, + // không phải lỗi chưa bắt — không có tag nào để thử dù còn `K_bt_plink` hay cặp nào chưa thu hồi. + if (message1.size != MESSAGE1_SIZE) return Noise1Outcome.Rejected + return tryPairingPsk(message1) ?: tryPairKeys(message1) + } + + /** `null` khi không có cửa sổ QR mở hoặc slot đã bị một kênh khác chiếm — rơi xuống `K_bt_link` như thường. */ + private fun tryPairingPsk(message1: ByteArray): Noise1Outcome? { + val psk = pairingKeySource.currentPlinkKey() + if (psk == null || !pairingSlot.tryClaim()) return null + val result = NoiseNNpsk0.tryOpenMessage1(psk, prologue, message1) + if (result == null) { + pairingSlot.release() + } else { + opened = result + pairingSlotHeld = true + } + return result?.let { Noise1Outcome.PairingVerified } + } + + private fun tryPairKeys(message1: ByteArray): Noise1Outcome { for ((pairId, psk) in pairKeys.unrevokedLinkKeys()) { val result = NoiseNNpsk0.tryOpenMessage1(psk, prologue, message1) if (result != null) { @@ -51,14 +82,23 @@ class BluetoothPreauthSession( val c2: ByteArray, ) - /** Bước 4: chỉ gọi sau [receiveNoise1] trả [Noise1Outcome.Verified]. */ + /** Bước 4: chỉ gọi sau [receiveNoise1] trả [Noise1Outcome.Verified] hoặc [Noise1Outcome.PairingVerified]. */ fun buildNoise2(): LinkKeys { val verified = checkNotNull(opened) { "message1 chưa xác thực" } val result = NoiseNNpsk0.buildMessage2(verified, ephemeralPrivateKey()) return LinkKeys(BtPreauthRecord.encode(BtPreauthRecord.KIND_NOISE2, result.message2), result.c1, result.c2) } + /** Nhả slot ghép đôi QR nếu kênh này đang giữ, dù kết cục là gì — bên gọi gọi trong `finally`. */ + fun releasePairingSlotIfHeld() { + if (pairingSlotHeld) { + pairingSlotHeld = false + pairingSlot.release() + } + } + private companion object { const val NONCE_SIZE = 32 + const val MESSAGE1_SIZE = X25519Keys.KEY_SIZE + ChaCha20Poly1305Aead.TAG_SIZE } } diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingKeys.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingKeys.kt new file mode 100644 index 0000000..f2e6c23 --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingKeys.kt @@ -0,0 +1,39 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.primitives.HkdfSha256 +import app.handlive.android.core.crypto.primitives.HmacSha256 + +/** + * Khoá của một QR đang mở (0.4.5 "Keys", "Pairing mode"): `K_bt_pdisc`/`K_bt_plink` dẫn từ `pairing_secret`, + * sống trong trí nhớ suốt đời QR đó — khác `K_bt_disc`/`K_bt_link` của một cặp đã ghép (`BtLinkKeyDerivation` của + * `core:crypto`, dẫn từ `PRK`). `pr_bt` là hint quảng bá của cửa sổ, đổi theo mỗi QR và không suy ra được + * `pairing_secret` hay `pk` (AC9). Vector: `shared/test-vectors/bt-pairing.json`. + */ +object BtQrPairingKeys { + const val PDISC_INFO = "handlive/v1/bt-pair-disc" + const val PLINK_INFO = "handlive/v1/bt-pair-link" + const val PR_BT_SIZE = 4 + private val PR_BT_LABEL = "HLBTPAIR1".toByteArray(Charsets.US_ASCII) + + fun kBtPdisc(pairingSecret: ByteArray): ByteArray = HkdfSha256.derive(ikm = pairingSecret, info = PDISC_INFO) + + fun kBtPlink(pairingSecret: ByteArray): ByteArray = HkdfSha256.derive(ikm = pairingSecret, info = PLINK_INFO) + + /** `pr_bt` = 4 byte đầu của HMAC-SHA256(`K_bt_pdisc`, `"HLBTPAIR1"`). */ + fun prBt(kBtPdisc: ByteArray): ByteArray = HmacSha256.mac(kBtPdisc, PR_BT_LABEL).copyOf(PR_BT_SIZE) +} + +/** + * `K_bt_plink` của cửa sổ ghép đôi QR đang mở, hoặc `null` khi không có cửa sổ nào (0.4.5 "Pairing mode"). Cài + * đặt thật đọc cửa sổ hiện tại của `PairingCoordinator` (feature/pairing, A7.2-B); đây chỉ là điểm nối để + * `BluetoothPreauthSession` không phụ thuộc feature/pairing. + */ +fun interface BluetoothPairingKeySource { + fun currentPlinkKey(): ByteArray? +} + +/** Cửa sổ ghép đôi QR như listener Bluetooth thấy: khoá thử `noise1` và hint `pr_bt` đứng đầu quảng bá. */ +interface BluetoothQrPairingWindow : BluetoothPairingKeySource { + /** `pr_bt` của cửa sổ QR đang mở, hoặc `null` khi không có cửa sổ QR nào. */ + fun currentPrBt(): ByteArray? +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingLink.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingLink.kt new file mode 100644 index 0000000..05a59fa --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingLink.kt @@ -0,0 +1,27 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.transport.server.PairingEndpoint +import app.handlive.android.core.transport.server.RawTextMessageSocket +import io.ktor.websocket.WebSocketSession + +/** + * Chạy PAIR-01 API 2–6 trên một liên kết ghép đôi QR đã xác nhận (0.4.5 "Pairing link"): các envelope `pair` đi trên + * kênh điều khiển `0x01` như trên `/v1/pair`, nên [endpoint] (`PairingCoordinator`) chạy y nguyên như ở LAN. + */ +object BtQrPairingLink { + /** Không dùng cho admission (Bluetooth có admission riêng của listener); chỉ để phân biệt với địa chỉ LAN. */ + const val PSEUDO_ADDRESS = "bluetooth" + + /** Thời gian tối đa chờ envelope cuối (`pair/error`, `pair/done`) lên dây trước khi liên kết bị huỷ. */ + const val FLUSH_TIMEOUT_MILLIS = 2_000L + + suspend fun serve( + socket: WebSocketSession, + endpoint: PairingEndpoint, + clock: () -> Long = System::currentTimeMillis, + ) { + endpoint.handle(RawTextMessageSocket(socket, PSEUDO_ADDRESS, clock)) + // BluetoothLinkAcceptor huỷ socket ngay khi hàm này trả về: `pair/error` vừa xếp hàng phải lên dây trước đã. + (socket as? BluetoothPeerSocket)?.awaitWritesFinished(FLUSH_TIMEOUT_MILLIS) + } +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingSlot.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingSlot.kt new file mode 100644 index 0000000..b9a9de6 --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtQrPairingSlot.kt @@ -0,0 +1,31 @@ +package app.handlive.android.core.transport.bluetooth + +import kotlin.time.Duration + +/** + * Slot ghép đôi QR duy nhất trên toàn listener (0.4.5 "Pairing link"): [tryClaim] chiếm ngay khi `noise1` của một + * kênh xác thực được bằng `K_bt_plink` ([BluetoothPreauthSession]); [release] nhả khi kênh đó kết thúc hoặc xác + * thực hỏng. Một kênh khác xác thực đúng cùng psk trong lúc slot còn bị chiếm vẫn bị từ chối ("S stops trying + * K_bt_plink", một người ghép mỗi cửa sổ) — [BluetoothPreauthSession] không thử `K_bt_plink` với nó, nên nó rơi + * xuống `K_bt_link` như một kênh thường và thất bại. [holdTimeout] tự nhả nếu kênh giữ slot biến mất mà không gọi + * [release] (ví dụ tiến trình bị giết giữa chừng). + */ +class BtQrPairingSlot( + private val holdTimeout: Duration = BluetoothLinkConstants.PREAUTH_TIMEOUT, + private val clock: () -> Long = System::currentTimeMillis, +) { + @Volatile + private var heldUntil: Long = 0 + + @Synchronized + fun tryClaim(): Boolean { + if (clock() < heldUntil) return false + heldUntil = clock() + holdTimeout.inWholeMilliseconds + return true + } + + @Synchronized + fun release() { + heldUntil = 0 + } +} diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt index c065e7e..e29d64a 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt @@ -6,6 +6,7 @@ import app.handlive.android.core.data.db.PeerPlatform import app.handlive.android.core.data.pairing.PairStore import app.handlive.android.core.protocol.ProtocolJson import app.handlive.android.core.protocol.capability.CapabilityData +import app.handlive.android.core.transport.bluetooth.BluetoothQrPairingWindow import app.handlive.android.core.transport.handshake.PairRecord import app.handlive.android.core.transport.relay.RelayPeerLink import app.handlive.android.core.transport.server.ControlServer @@ -17,6 +18,7 @@ import app.handlive.android.core.transport.tls.AndroidTlsIdentityStorage import app.handlive.android.core.transport.tls.TlsIdentityProvider import app.handlive.android.feature.connection.bench.BenchLog import app.handlive.android.feature.connection.bluetooth.BluetoothAdvertising +import app.handlive.android.feature.connection.bluetooth.BluetoothQrPairingHooks import app.handlive.android.feature.connection.capability.CapabilityPublisher import app.handlive.android.feature.connection.capability.LocalEnvironmentReader import app.handlive.android.feature.connection.capability.SimChangeWatcher @@ -99,6 +101,13 @@ class ConnectionRuntime private constructor( @Volatile var pairingEndpoint: PairingEndpoint? = null + /** + * Installed by the pairing feature: the QR pairing window as the Bluetooth listener sees it (0.4.5 "Pairing mode", + * PAIR-01 B1–B3) — `pr_bt` in the advertising, `K_bt_plink` for `noise1`; pairing links then run [pairingEndpoint]. + */ + @Volatile + var bluetoothQrWindow: BluetoothQrPairingWindow? = null + /** SHA-256 of the TLS certificate (`tls_sha256` of PAIR-01 API 3), known once the server runs. */ @Volatile var certificateSha256: ByteArray? = null @@ -209,7 +218,9 @@ class ConnectionRuntime private constructor( capability.publishUpdates(runtimeScope, phoneCapability.state) { controlServer.sessions.all() } discovery.start(runtimeScope, port, pairingAdvert) // CONN-05: the Bluetooth link runs beside the LAN server, whatever the network does. - bluetooth.start(runtimeScope, controlServer, table.sessions.map { it.keys }, environmentVersion) + // A QR window opens or closes through setPairingAdvert, so the same flow restarts the Bluetooth advertising. + val qrPairing = BluetoothQrPairingHooks({ bluetoothQrWindow }, pairingAdvert, { pairingEndpoint }) + bluetooth.start(runtimeScope, controlServer, table.sessions.map { it.keys }, environmentVersion, qrPairing) simWatcher.start() } diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt index 2923eae..9758be3 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt @@ -13,13 +13,22 @@ import androidx.core.content.ContextCompat import app.handlive.android.core.crypto.derivation.BtLinkKeyDerivation import app.handlive.android.core.data.pairing.PairStore import app.handlive.android.core.transport.bluetooth.BluetoothLinkAcceptor +import app.handlive.android.core.transport.bluetooth.BluetoothLinkAdmission import app.handlive.android.core.transport.bluetooth.BluetoothPairKeys +import app.handlive.android.core.transport.bluetooth.BluetoothPairingKeySource +import app.handlive.android.core.transport.bluetooth.BluetoothPreauthSession +import app.handlive.android.core.transport.bluetooth.BluetoothQrPairingWindow +import app.handlive.android.core.transport.bluetooth.BtQrPairingLink +import app.handlive.android.core.transport.bluetooth.BtQrPairingSlot import app.handlive.android.core.transport.server.ControlServer +import app.handlive.android.core.transport.server.PairingEndpoint import app.handlive.android.feature.connection.bench.BenchEvent import app.handlive.android.feature.connection.bench.BenchLog +import io.ktor.websocket.WebSocketSession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -31,7 +40,9 @@ import kotlinx.coroutines.flow.onEach * tắt (điều kiện 2 của spike G7); thiếu "Nearby devices" trên Android 12+ coi như adapter tắt. * * Quảng bá hint của các cặp chưa thu hồi KHÔNG có session sống trên route nào, `last_seen_at` gần nhất trước; thử - * `K_bt_link` của mọi cặp chưa thu hồi khi kênh mở (một session mới thay session cũ, 0.4.1). + * `K_bt_link` của mọi cặp chưa thu hồi khi kênh mở (một session mới thay session cũ, 0.4.1). Cửa sổ ghép đôi QR + * ([BluetoothQrPairingHooks], 0.4.5 "Pairing mode"): `pr_bt` đứng đầu quảng bá, `K_bt_plink` được thử trước mọi cặp + * qua MỘT [BtQrPairingSlot] cho cả listener, và liên kết ghép đôi chạy PAIR-01 ở `PairingCoordinator`. */ class BluetoothAdvertising( context: Context, @@ -55,16 +66,18 @@ class BluetoothAdvertising( server: ControlServer, liveSessions: Flow>, environment: Flow<*>, + qrPairing: BluetoothQrPairingHooks = BluetoothQrPairingHooks.NONE, ) { if (appContext.getSystemService(BluetoothManager::class.java)?.adapter == null) return val acceptor = - BluetoothLinkAcceptor(server.bluetoothAdmission, BluetoothPairKeys { linkKeys }, server::serveBluetoothPeer) + acceptor(server.bluetoothAdmission, server::serveBluetoothPeer, qrPairing, clock) { linkKeys } val radioPeripheral = BluetoothPeripheral(AndroidBluetoothRadio(appContext), acceptor::accept, clock) peripheral = radioPeripheral val discoveryKeys = discoveryKeys(liveSessions) + val pairingHints = qrPairing.pairingHints() val stateMachine = BluetoothLinkLifecycle( - start = { radioPeripheral.start(scope, discoveryKeys) }, + start = { radioPeripheral.start(scope, discoveryKeys, pairingHints) }, stop = radioPeripheral::stop, ).also { lifecycle = it } receiver = @@ -130,10 +143,35 @@ class BluetoothAdvertising( ContextCompat.checkSelfPermission(appContext, it) == PackageManager.PERMISSION_GRANTED } - private companion object { - val NEARBY_DEVICES = listOf(Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT) + companion object { + private val NEARBY_DEVICES = + listOf(Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT) - fun adapterStateOf(state: Int): BluetoothAdapterState = + /** + * Bộ accept của listener: thử `K_bt_plink` (qua MỘT slot cho mọi kênh của listener này) rồi `K_bt_link` của + * từng cặp; liên kết cặp vào [serve] (`ControlServer.serveBluetoothPeer`), liên kết ghép đôi QR vào endpoint + * của [qrPairing]. Tách ra để test dựng đúng bộ accept của app mà không cần radio thật. + */ + fun acceptor( + admission: BluetoothLinkAdmission, + serve: suspend (WebSocketSession, String) -> Unit, + qrPairing: BluetoothQrPairingHooks, + clock: () -> Long, + linkKeys: () -> List>, + ): BluetoothLinkAcceptor { + val slot = BtQrPairingSlot() + val pairingKeys = BluetoothPairingKeySource { qrPairing.window()?.currentPlinkKey() } + return BluetoothLinkAcceptor( + admission = admission, + serve = serve, + newSession = { BluetoothPreauthSession(BluetoothPairKeys(linkKeys), pairingKeys, slot) }, + servePairing = { socket -> + qrPairing.endpoint()?.let { BtQrPairingLink.serve(socket, it, clock) } + }, + ) + } + + private fun adapterStateOf(state: Int): BluetoothAdapterState = when (state) { BluetoothAdapter.STATE_ON -> BluetoothAdapterState.ON BluetoothAdapter.STATE_TURNING_ON -> BluetoothAdapterState.TURNING_ON @@ -142,3 +180,22 @@ class BluetoothAdvertising( } } } + +/** + * Cửa sổ ghép đôi QR cho listener Bluetooth (0.4.5 "Pairing mode"), do `ConnectionRuntime` cấp: [window] đọc cửa sổ + * đang mở ngay lúc cần (khoá `noise1`, `pr_bt`), [windowChanges] phát lại mỗi khi cửa sổ mở hay đóng để quảng bá + * dựng lại (phải phát ngay một lần lúc đầu, như một `StateFlow`), [endpoint] chạy PAIR-01 (`PairingCoordinator`, + * như `/v1/pair`). + */ +class BluetoothQrPairingHooks( + val window: () -> BluetoothQrPairingWindow?, + val windowChanges: Flow<*>, + val endpoint: () -> PairingEndpoint?, +) { + fun pairingHints(): Flow = windowChanges.map { window()?.currentPrBt() } + + companion object { + /** Không có cửa sổ QR nào: phát một lần để quảng bá các cặp vẫn chạy (combine cần một giá trị). */ + val NONE = BluetoothQrPairingHooks({ null }, flowOf(Unit), { null }) + } +} diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheral.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheral.kt index 8a35af5..d705a1f 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheral.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheral.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch /** @@ -30,22 +30,26 @@ class BluetoothPeripheral( /** * [discoveryKeys]: `K_bt_disc` của các cặp cần quảng bá, cặp `last_seen_at` gần nhất trước; chỉ - * `BT_ADV_MAX_HINTS` cặp đầu lên sóng. Kênh accept được chạy trong [scope] nên sống qua các lần xoay và [stop]. + * `BT_ADV_MAX_HINTS` cặp đầu lên sóng. [pairingHints]: `pr_bt` của cửa sổ ghép đôi QR đang mở (`null` khi không + * có), luôn đứng đầu và chiếm một chỗ của cặp ("Pairing mode"); cửa sổ mở hay đóng đều dựng lại bộ quảng bá. Kênh + * accept được chạy trong [scope] nên sống qua các lần xoay và [stop]. */ @Synchronized fun start( scope: CoroutineScope, discoveryKeys: Flow>, + pairingHints: Flow = flowOf(null), ) { if (job?.isActive == true) return job = scope.launch { val advertised = - discoveryKeys - .map { it.take(BluetoothAdvertisementData.MAX_HINTS) } - .distinctUntilChanged { old, new -> old.sameKeys(new) } - combine(advertised, epochs()) { keys, epoch -> keys to epoch } - .collectLatest { (keys, epoch) -> if (keys.isNotEmpty()) serve(scope, keys, epoch) } + combine(discoveryKeys, pairingHints) { keys, pairing -> Advertised(pairing, keys) } + .distinctUntilChanged { old, new -> old.sameAs(new) } + combine(advertised, epochs()) { content, epoch -> content to epoch } + .collectLatest { (content, epoch) -> + if (content.isNotEmpty()) serve(scope, content.hints(epoch)) + } } } @@ -58,12 +62,11 @@ class BluetoothPeripheral( /** Một bộ quảng bá + server cho tới khi bị huỷ; server hỏng thì mở lại sau [RETRY_DELAY_MILLIS]. */ private suspend fun serve( linkScope: CoroutineScope, - keys: List, - epoch: Long, + hints: List, ) { while (true) { // Stack Bluetooth bỏ socket (đang tắt radio, lỗi tạm): advertiseAndAccept đã dừng sạch, thử lại. - val failure = runCatching { advertiseAndAccept(linkScope, keys, epoch) }.exceptionOrNull() + val failure = runCatching { advertiseAndAccept(linkScope, hints) }.exceptionOrNull() if (failure is CancellationException) throw failure delay(RETRY_DELAY_MILLIS) } @@ -71,12 +74,10 @@ class BluetoothPeripheral( private suspend fun advertiseAndAccept( linkScope: CoroutineScope, - keys: List, - epoch: Long, + hints: List, ) { val server = radio.listen() try { - val hints = keys.map { BluetoothAdvertisementData.hint(it, epoch) } radio.startAdvertising(BluetoothAdvertisementData.serviceData(server.psm, hints)) try { while (true) { @@ -104,10 +105,28 @@ class BluetoothPeripheral( } } + /** Nội dung cần quảng bá: `pr_bt` (nếu có cửa sổ QR) rồi `K_bt_disc` của các cặp, tối đa `BT_ADV_MAX_HINTS`. */ + private class Advertised( + val pairing: ByteArray?, + allKeys: List, + ) { + val keys: List = + allKeys.take(BluetoothAdvertisementData.MAX_HINTS - if (pairing == null) 0 else 1) + + fun isNotEmpty(): Boolean = pairing != null || keys.isNotEmpty() + + fun hints(epoch: Long): List = + listOfNotNull(pairing) + keys.map { BluetoothAdvertisementData.hint(it, epoch) } + + fun sameAs(other: Advertised): Boolean = + pairing.sameBytes(other.pairing) && keys.size == other.keys.size && + keys.zip(other.keys).all { (a, b) -> a.contentEquals(b) } + } + companion object { const val RETRY_DELAY_MILLIS = 2_000L - private fun List.sameKeys(other: List): Boolean = - size == other.size && zip(other).all { (a, b) -> a.contentEquals(b) } + private fun ByteArray?.sameBytes(other: ByteArray?): Boolean = + if (this == null) other == null else other != null && contentEquals(other) } } diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/PairingFeature.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/PairingFeature.kt index 6e1b6de..3c25629 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/PairingFeature.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/PairingFeature.kt @@ -8,6 +8,7 @@ import app.handlive.android.feature.connection.ConnectionRuntime import app.handlive.android.feature.connection.LocalDeviceName import app.handlive.android.feature.pairing.devices.DeviceListItem import app.handlive.android.feature.pairing.devices.DeviceListModel +import app.handlive.android.feature.pairing.exchange.BtQrPairingKeySource import app.handlive.android.feature.pairing.exchange.LocalPairingDevice import app.handlive.android.feature.pairing.exchange.PairingCoordinator import app.handlive.android.feature.pairing.revoke.UnpairController @@ -78,6 +79,7 @@ class PairingFeature private constructor( fun install(context: Context) { val feature = get(context) feature.runtime.pairingEndpoint = feature.coordinator + feature.runtime.bluetoothQrWindow = BtQrPairingKeySource(feature.coordinator) feature.runtime.router.register(MessageType.PAIR, feature.unpair.handler) feature.runtime.sessionEnded .onEach(feature.unpair::onSessionEnded) diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingKeySource.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingKeySource.kt new file mode 100644 index 0000000..31f6e3a --- /dev/null +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingKeySource.kt @@ -0,0 +1,18 @@ +package app.handlive.android.feature.pairing.exchange + +import app.handlive.android.core.transport.bluetooth.BluetoothQrPairingWindow +import app.handlive.android.core.transport.bluetooth.BtQrPairingKeys + +/** + * Cửa sổ QR của [coordinator] như listener Bluetooth thấy (0.4.5 "Pairing mode", PAIR-01 B1–B3): `K_bt_plink` để + * thử `noise1`, `pr_bt` để quảng bá, cả hai dẫn từ `pairing_secret` của cửa sổ đang mở; không có ở cửa sổ PIN hay khi + * không có cửa sổ nào. `PairingFeature.install` gắn nó vào `ConnectionRuntime`. + */ +class BtQrPairingKeySource( + private val coordinator: PairingCoordinator, +) : BluetoothQrPairingWindow { + override fun currentPlinkKey(): ByteArray? = coordinator.currentQrPairingSecret?.let(BtQrPairingKeys::kBtPlink) + + override fun currentPrBt(): ByteArray? = + coordinator.currentQrPairingSecret?.let { BtQrPairingKeys.prBt(BtQrPairingKeys.kBtPdisc(it)) } +} diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingCoordinator.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingCoordinator.kt index 89f1844..bae8a94 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingCoordinator.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingCoordinator.kt @@ -105,6 +105,12 @@ class PairingCoordinator( val state: StateFlow = stateFlow.asStateFlow() + /** + * `pairing_secret` của cửa sổ QR đang mở, hoặc `null` khi không có cửa sổ hay cửa sổ hiện tại là PIN + * (0.4.5 "Pairing mode"): nguồn của `K_bt_pdisc`/`K_bt_plink` cho ghép đôi qua Bluetooth (A7.2-B). + */ + val currentQrPairingSecret: ByteArray? get() = (window as? PairingWindow.Qr)?.invite?.pairingSecret + /** PAIR-01 step 4: E1 for a code that is not HandLive's, E6 when 8 pairs exist already. */ suspend fun onScanned(text: String) { val invite = PairingInvite.parse(text) From 055a138aa3975d09eff63f967cc96c22ef2ef50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Fri, 30 Oct 2026 03:03:46 +0700 Subject: [PATCH 11/17] test(android): cover PIN pairing over Bluetooth (PAIR-01 flow C) CPace then noise1 keyed by K_bt_pinlink reaching the PIN pairing link with K_bt_pin; cpace1 with no PIN window and a low-order Ya closed without an answer; a wrong PIN rejected and counted once; PAIR-01 over the PIN link and a qr hello refused on it; three wrong PINs over Bluetooth, and wrong PINs mixed across Bluetooth and the LAN, closing the window at the third for both transports with BT_PIN_LOCKOUT. cpace.json and bt-pairing.json point at their tests. --- .../core/crypto/VectorFileCoverageTest.kt | 7 +- .../bluetooth/BtPinPairingAcceptorTest.kt | 341 ++++++++++++++++++ .../pairing/exchange/BtPinPairingLinkTest.kt | 151 ++++++++ .../exchange/BtPinPairingSharedCounterTest.kt | 144 ++++++++ .../pairing/exchange/BtQrPairingLinkTest.kt | 3 +- .../testing/FakeBtPinPairingClientLink.kt | 168 +++++++++ 6 files changed, 808 insertions(+), 6 deletions(-) create mode 100644 core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingAcceptorTest.kt create mode 100644 feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingLinkTest.kt create mode 100644 feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingSharedCounterTest.kt create mode 100644 feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/testing/FakeBtPinPairingClientLink.kt diff --git a/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/VectorFileCoverageTest.kt b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/VectorFileCoverageTest.kt index 42517c6..9788e91 100644 --- a/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/VectorFileCoverageTest.kt +++ b/core/crypto/src/test/kotlin/app/handlive/android/core/crypto/VectorFileCoverageTest.kt @@ -39,11 +39,8 @@ class VectorFileCoverageTest { "bt-frame.json" to "BtRecordCodecTest, BtPreauthRecordTest, BtTransportFrameTest " + "(core:transport: BtTransportCipherTest, BluetoothLongLaneReassemblerTest)", "bt-advertisement.json" to "(core:transport: BluetoothAdvertisementDataTest)", - // 1 vector dưới chưa có test Android (ghi nhận ở #3447): bt-pairing.json thuộc A7.2 (PAIR-01 qua - // Bluetooth, CPace, chưa bắt đầu). Ghi vào đây để test này KHÔNG ngầm bỏ sót — xoá ghi chú và trỏ - // đúng test owner khi thẻ tương ứng viết test. - "bt-pairing.json" to "(chưa có test Android — A7.2, PAIR-01 qua Bluetooth, chưa bắt đầu)", - "cpace.json" to "CPaceX25519SHA512VectorTest", + "bt-pairing.json" to "BtQrPairingKeysTest (core:transport)", + "cpace.json" to "CPaceX25519SHA512VectorTest, BtPinPairingAcceptorTest (core:transport)", EnvelopeRoundtripTest.ANDROID_FILE to "EnvelopeRoundtripTest", EnvelopeRoundtripTest.APPLE_FILE to "EnvelopeRoundtripTest", ) diff --git a/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingAcceptorTest.kt b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingAcceptorTest.kt new file mode 100644 index 0000000..150f33e --- /dev/null +++ b/core/transport/src/test/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingAcceptorTest.kt @@ -0,0 +1,341 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.crypto.primitives.CPaceX25519SHA512 +import app.handlive.android.core.crypto.primitives.ChaCha20Poly1305Aead +import app.handlive.android.core.crypto.primitives.HmacSha256 +import app.handlive.android.core.crypto.primitives.X25519Keys +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import app.handlive.android.core.protocol.testing.SharedTestVectors +import app.handlive.android.core.protocol.testing.hex +import app.handlive.android.core.protocol.testing.str +import app.handlive.android.core.transport.bluetooth.testing.InMemoryBluetoothLinkIo +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.readText +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.GeneralSecurityException +import java.security.MessageDigest +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Luồng C (PIN) của ghép đôi qua Bluetooth (0.4.5 "PIN", A7.2-C): `cpace1`/`cpace2` rồi `noise1` khoá bằng + * `K_bt_pinlink` vừa dẫn qua CPace đi vào [BluetoothLinkAcceptor.servePinPairing] cùng `K_bt_pin` (không có + * `pair_id`) — PAIR-01 thật là `PairingCoordinator` (feature/pairing); đây chỉ lái CPace rồi Noise tới bước 5 bằng + * [FakePinClient], dùng lại [CPaceX25519SHA512] (đã có vector riêng, `CPaceX25519SHA512VectorTest`) cho phần CPace + * nhưng tự dựng Noise phía C độc lập với [NoiseNNpsk0] (phía S) — như [BtQrPairingAcceptorTest]. + */ +class BtPinPairingAcceptorTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val pinVectors = SharedTestVectors.vectors("cpace.json").filter { it.str("kind") == "handlive_pin" } + private val vector = pinVectors.first { it.str("name") == "HandLive PIN 482917" } + private val invalid = SharedTestVectors.invalidVectors("cpace.json") + + @After + fun tearDown() = scope.cancel() + + private fun admission(linkLimit: Int = 4) = + BluetoothLinkAdmission(linkLimit = linkLimit, preauthLimit = linkLimit, authFailWindow = 1.minutes) + + private class Served( + val text: String, + val kBtPin: ByteArray, + ) + + private class FakePinWindow( + @Volatile var pin: ByteArray?, + ) : BluetoothPinPairingWindow { + @Volatile + var failures = 0 + + override fun currentPin(): ByteArray? = pin + + override fun onPinChannelFailedAfterCpace1() { + failures++ + } + } + + private fun acceptor( + admission: BluetoothLinkAdmission, + window: FakePinWindow, + nS: ByteArray = vector.hex("n_s"), + ybScalar: ByteArray = vector.hex("yb"), + served: CompletableDeferred = CompletableDeferred(), + ) = BluetoothLinkAcceptor( + admission = admission, + serve = { _, _ -> error("liên kết ghép đôi PIN không có pair_id, không được vào serve() của cặp đã ghép") }, + newSession = { + BluetoothPreauthSession( + pairKeys = BluetoothPairKeys { emptyList() }, + pinPairing = BtPinPairingResponder(window, scalar = { ybScalar }), + nonce = { nS }, + ) + }, + servePinPairing = { socket: WebSocketSession, kBtPin: ByteArray -> + val first = (socket.incoming.receive() as Frame.Text).readText() + served.complete(Served(first, kBtPin)) + }, + ) + + @Test + fun theRightPinReachesServePinPairingWithTheDerivedKBtPin() = + runBlocking { + val window = FakePinWindow(vector.str("pin").toByteArray(Charsets.UTF_8)) + val served = CompletableDeferred() + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission(), window, served = served).accept(server) } + val pin = vector.str("pin").toByteArray(Charsets.UTF_8) + val fakeClient = FakePinClient(pin, vector.hex("n_s"), vector.hex("ya")) + val handshake = checkNotNull(runCpaceAndNoise(client, fakeClient)) + client.writeRecord(firstTransportRecord(handshake.c1, "pair/hello")) + val result = withTimeout(WAIT) { served.await() } + assertEquals("pair/hello", result.text) + assertArrayEquals(vector.hex("k_bt_pin"), result.kBtPin) + withTimeout(WAIT) { link.await() } + assertEquals(0, window.failures) + } + + @Test + fun noPinWindowOpenClosesTheChannelWithoutAnAnswer() = + runBlocking { + val window = FakePinWindow(pin = null) + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = scope.async { acceptor(admission(), window).accept(server) } + val challenge = BtPreauthRecord.decode(client.readPreauthRecord()) + assertEquals(BtPreauthRecord.KIND_CHALLENGE, challenge.kind) + client.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_CPACE1, vector.hex("Ya"))) + // Không trả lời gì (không `cpace2`, không `reject`): kênh chỉ đóng. + assertTrue(client.readPreauthRecord2OrClosed()) + withTimeout(WAIT) { link.await() } + assertEquals(0, window.failures) + } + + @Test + fun aLowOrderYaClosesTheChannelWithoutAnAnswer() = + runBlocking { + val lowOrder = invalid.single { it.str("reason") == "low_order_ya" } + val window = FakePinWindow(lowOrder.str("pin").toByteArray(Charsets.UTF_8)) + val (client, server) = + InMemoryBluetoothLinkIo.pair() + val link = + scope.async { + acceptor(admission(), window, nS = lowOrder.hex("n_s"), ybScalar = lowOrder.hex("yb")) + .accept(server) + } + val challenge = BtPreauthRecord.decode(client.readPreauthRecord()) + assertEquals(BtPreauthRecord.KIND_CHALLENGE, challenge.kind) + client.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_CPACE1, lowOrder.hex("Ya_attacker"))) + assertTrue(client.readPreauthRecord2OrClosed()) + withTimeout(WAIT) { link.await() } + // Không phải một lần thử PIN thật (điểm bậc thấp, không cần biết PIN): không tính vào bộ đếm. + assertEquals(0, window.failures) + } + + @Test + fun aWrongPinOnTheClientSideIsRejectedAndCountsOneFailure() = + runBlocking { + // Vector âm "PIN sai ở một phía": client gõ pin_c, S đang chờ pin_s — generator mỗi bên khác nhau. + val wrongPin = invalid.single { it.str("reason") == "wrong_pin" } + val window = FakePinWindow(wrongPin.str("pin_s").toByteArray(Charsets.UTF_8)) + val (client, server) = InMemoryBluetoothLinkIo.pair() + val link = + scope.async { + acceptor(admission(), window, nS = wrongPin.hex("n_s"), ybScalar = wrongPin.hex("yb")) + .accept(server) + } + val pinC = wrongPin.str("pin_c").toByteArray(Charsets.UTF_8) + val fakeClient = FakePinClient(pinC, wrongPin.hex("n_s"), wrongPin.hex("ya")) + assertArrayEquals(wrongPin.hex("Ya"), fakeClient.ya) + val outcome = runCpaceAndNoiseOrRejected(client, fakeClient) + assertEquals(FakeNoiseResult.REJECTED_OR_CLOSED, outcome) + withTimeout(WAIT) { link.await() } + assertEquals(1, window.failures) + } + + /** Bước 1-4 của CPace+Noise phía C (0.4.5 "PIN"); `null` khi `noise2` không mở được (PIN sai). */ + private suspend fun runCpaceAndNoise( + io: InMemoryBluetoothLinkIo, + fakeClient: FakePinClient, + ): NoiseHandshake? { + val challenge = BtPreauthRecord.decode(io.readPreauthRecord()) + assertEquals(BtPreauthRecord.KIND_CHALLENGE, challenge.kind) + io.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_CPACE1, fakeClient.ya)) + val cpace2 = BtPreauthRecord.decode(io.readPreauthRecord()) + assertEquals(BtPreauthRecord.KIND_CPACE2, cpace2.kind) + val keyed = fakeClient.deriveLinkKeyFromCpace2(cpace2.payload) + val prologue = NoiseNNpsk0.prologue(fakeClient.nS) + val initiator = NoiseInitiator(keyed, prologue, X25519Keys.generatePrivateKey()) + io.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_NOISE1, initiator.message1())) + val noise2 = BtPreauthRecord.decode(io.readPreauthRecord()) + if (noise2.kind != BtPreauthRecord.KIND_NOISE2) return null + return initiator.openMessage2(noise2.payload) + } + + private enum class FakeNoiseResult { REJECTED_OR_CLOSED } + + private suspend fun runCpaceAndNoiseOrRejected( + io: InMemoryBluetoothLinkIo, + fakeClient: FakePinClient, + ): FakeNoiseResult { + val handshake = runCpaceAndNoise(io, fakeClient) + check(handshake == null) { "PIN sai mà noise2 vẫn mở được" } + return FakeNoiseResult.REJECTED_OR_CLOSED + } + + /** `true` nếu đọc tiếp chỉ gặp kênh đóng (không có `reject` lẫn `cpace2`) — "đóng không trả lời". */ + private suspend fun InMemoryBluetoothLinkIo.readPreauthRecord2OrClosed(): Boolean = + try { + readPreauthRecord() + false + } catch (_: BtLinkException) { + true + } + + private fun firstTransportRecord( + c1: ByteArray, + text: String, + ): ByteArray = + BtTransportCipher(c1).seal( + BtTransportFrame.encode(BtLinkConstants.CHAN_CONTROL, more = false, long = false, text.toByteArray()), + ) + + private companion object { + val WAIT = 5.seconds + } +} + +/** + * Phía C của CPace+Noise cho ghép đôi PIN (0.4.5 "PIN"): CPace dùng lại [CPaceX25519SHA512] (đã có vector riêng, + * không phải mục tiêu của test này); Noise dựng độc lập với [NoiseNNpsk0] qua [NoiseInitiator], như + * [BtQrPairingAcceptorTest]'s `FakeNoiseInitiator`. + */ +private class FakePinClient( + prs: ByteArray, + val nS: ByteArray, + private val yaScalar: ByteArray, +) { + private val ci = BtPinPairingKeys.CPACE_CI.toByteArray(Charsets.US_ASCII) + private val g = CPaceX25519SHA512.calculateGenerator(prs, ci, nS) + val ya: ByteArray = checkNotNull(CPaceX25519SHA512.scalarMultVfy(yaScalar, g)) + + /** `K_bt_pinlink` từ `cpace2` (`Yb`) — client của test luôn tính ra một khoá, dù (nếu PIN sai ở client) nó sẽ + * khác khoá S dùng, nên `noise1` sẽ không mở được (hành vi cần kiểm). */ + fun deriveLinkKeyFromCpace2(yb: ByteArray): ByteArray { + val k = checkNotNull(CPaceX25519SHA512.scalarMultVfy(yaScalar, yb)) + val isk = CPaceX25519SHA512.isk(nS, k, ya, EMPTY, yb, EMPTY) + return BtPinPairingKeys.kBtPinlink(isk, nS) + } + + private companion object { + val EMPTY = ByteArray(0) + } +} + +private class NoiseHandshake( + val c1: ByteArray, + val c2: ByteArray, +) + +/** Phía C độc lập của `Noise_NNpsk0_25519_ChaChaPoly_SHA256` (rev. 34) — như `FakeNoiseInitiator` của + * [BtQrPairingAcceptorTest], ở đây keyed bằng `K_bt_pinlink` thay vì `K_bt_plink`. */ +private class NoiseInitiator( + private val psk: ByteArray, + private val prologue: ByteArray, + private val ePrivC: ByteArray, +) { + private lateinit var ck1: ByteArray + private lateinit var h4: ByteArray + + fun message1(): ByteArray { + val (h0, ck0) = initializeSymmetric() + val h1 = mixHash(h0, prologue) + val pskStep = mixKeyAndHashPsk(ck0, h1, psk) + val ePubC = X25519Keys.publicFromPrivate(ePrivC) + val h3 = mixHash(pskStep.h, ePubC) + val eStep = mixKey(pskStep.ck, ePubC) + val tag = ChaCha20Poly1305Aead.seal(eStep.tempK, ZERO_NONCE, ByteArray(0), h3) + ck1 = eStep.ck + h4 = mixHash(h3, tag) + return ePubC + tag + } + + fun openMessage2(message2: ByteArray): NoiseHandshake? { + val ePubS = message2.copyOfRange(0, X25519Keys.KEY_SIZE) + val tag = message2.copyOfRange(X25519Keys.KEY_SIZE, message2.size) + val h5 = mixHash(h4, ePubS) + val eStep = mixKey(ck1, ePubS) + val eeStep = mixKey(eStep.ck, X25519Keys.sharedSecret(ePrivC, ePubS)) + return try { + ChaCha20Poly1305Aead.open(eeStep.tempK, ZERO_NONCE, tag, h5) + val (c1, c2) = NoiseNNpsk0.split(eeStep.ck) + NoiseHandshake(c1, c2) + } catch (_: GeneralSecurityException) { + null + } + } + + private class MixKeyResult( + val ck: ByteArray, + val tempK: ByteArray, + ) + + private fun mixKey( + ck: ByteArray, + ikm: ByteArray, + ): MixKeyResult { + val tempKey = HmacSha256.mac(ck, ikm) + val out1 = HmacSha256.mac(tempKey, byteArrayOf(0x01)) + val out2 = HmacSha256.mac(tempKey, out1 + byteArrayOf(0x02)) + return MixKeyResult(ck = out1, tempK = out2) + } + + private class PskMixResult( + val ck: ByteArray, + val h: ByteArray, + ) + + private fun mixKeyAndHashPsk( + ck: ByteArray, + h: ByteArray, + psk: ByteArray, + ): PskMixResult { + val tempKey = HmacSha256.mac(ck, psk) + val out1 = HmacSha256.mac(tempKey, byteArrayOf(0x01)) + val out2 = HmacSha256.mac(tempKey, out1 + byteArrayOf(0x02)) + return PskMixResult(ck = out1, h = mixHash(h, out2)) + } + + private fun initializeSymmetric(): Pair { + val name = NoiseNNpsk0.PROTOCOL_NAME + val h = if (name.size <= HASH_SIZE) name else sha256(name) + return h to h + } + + private fun mixHash( + h: ByteArray, + data: ByteArray, + ): ByteArray = sha256(h + data) + + private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) + + private companion object { + const val HASH_SIZE = 32 + val ZERO_NONCE = ByteArray(ChaCha20Poly1305Aead.NONCE_SIZE) + } +} diff --git a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingLinkTest.kt b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingLinkTest.kt new file mode 100644 index 0000000..55f9ade --- /dev/null +++ b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingLinkTest.kt @@ -0,0 +1,151 @@ +package app.handlive.android.feature.pairing.exchange + +import app.handlive.android.core.protocol.ErrorCode +import app.handlive.android.core.protocol.pairing.PairErrorData +import app.handlive.android.core.protocol.pairing.PairHelloData +import app.handlive.android.core.protocol.pairing.PairOp +import app.handlive.android.core.transport.bluetooth.BluetoothLinkAdmission +import app.handlive.android.feature.connection.PairingAdvert +import app.handlive.android.feature.connection.bluetooth.BluetoothAdvertising +import app.handlive.android.feature.connection.bluetooth.BluetoothPairingHooks +import app.handlive.android.feature.connection.bluetooth.BluetoothPinPairingHooks +import app.handlive.android.feature.pairing.testing.FakeBtPinPairingClientLink +import app.handlive.android.feature.pairing.testing.FakePairingClient +import app.handlive.android.feature.pairing.testing.PairStoreFixture +import app.handlive.android.feature.pairing.testing.PipeBluetoothLinkIo +import app.handlive.android.feature.pairing.testing.localPhone +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.seconds + +/** + * Đường thật của PAIR-01 luồng C (PIN) qua Bluetooth (0.4.5 "PIN", PAIR-01 C1-C4): bộ accept mà app dựng + * ([BluetoothAdvertising.acceptor]) + cửa sổ PIN thật của [PairingCoordinator] qua [BtPinPairingKeySource]; một Mac + * giả ([FakeBtPinPairingClientLink] + [FakePairingClient]) chạy CPace rồi Noise rồi hello → offer → confirm → done + * trên kênh `0x01` với `K_bt_pin` thay `K_pin`, và cặp được lưu như luồng LAN. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class BtPinPairingLinkTest { + private val pairs = PairStoreFixture() + private val client = FakePairingClient() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val phone = localPhone() + private val adverts = mutableListOf() + private val coordinator = + PairingCoordinator(local = { phone }, pairs = pairs.store, advertise = { adverts += it }, scope = scope) + private val window = BtPinPairingKeySource(coordinator) + private val acceptor = + BluetoothAdvertising.acceptor( + admission = BluetoothLinkAdmission(), + serve = { _, _ -> error("liên kết ghép đôi không bao giờ vào serve() của cặp đã ghép") }, + pairing = BluetoothPairingHooks(pin = BluetoothPinPairingHooks({ window }, flowOf(Unit), { coordinator })), + clock = System::currentTimeMillis, + linkKeys = { emptyList() }, + ) + + @After + fun tearDown() { + scope.cancel() + pairs.database.close() + } + + private suspend fun confirmPin(value: String = PIN) { + coordinator.startPin() + coordinator.submitPin(value) + } + + private fun connect(): FakeBtPinPairingClientLink { + val (mac, phoneEnd) = PipeBluetoothLinkIo.pair() + scope.async { acceptor.accept(phoneEnd) } + return FakeBtPinPairingClientLink(mac) + } + + @Test + fun aPinPairingLinkRunsPair01ThroughPairingExchangeWithKBtPinAndStoresThePair() = + runBlocking { + confirmPin() + val link = connect() + assertTrue(link.handshake(PIN.toByteArray(Charsets.UTF_8))) + withTimeout(WAIT) { + link.sendText(client.hello(mode = PairHelloData.MODE_PIN)) + val offerText = checkNotNull(link.receiveText()) + val offer = checkNotNull(client.checkOffer(offerText, link.kBtPin, phone.tlsSha256)) + val pairId = "0193a0c1-7e5f-7a2b-8c3d-4e5f6a7b8c9d" + link.sendText(client.confirm(offer, pairId = pairId)) + assertTrue(client.checkDone(checkNotNull(link.receiveText()), offer, pairId, CREATED_AT)) + link.close() + while (coordinator.state.value !is PairingState.Paired) delay(POLL) + } + assertEquals(1, pairs.store.activeCount()) + assertTrue(coordinator.state.value is PairingState.Paired) + } + + @Test + fun aQrHelloOnAPinPairingLinkIsRefusedAndStoresNothing() = + runBlocking { + confirmPin() + val link = connect() + assertTrue(link.handshake(PIN.toByteArray(Charsets.UTF_8))) + val refusal = + withTimeout(WAIT) { + link.sendText(client.hello(mode = PairHelloData.MODE_QR)) + checkNotNull(link.receiveText()) + } + assertEquals(PairOp.ERROR, client.opOf(refusal)) + assertEquals(ErrorCode.PAIRING_CLOSED.name, client.read(refusal, PairErrorData.serializer())?.code) + assertEquals(0, pairs.store.activeCount()) + } + + @Test + fun withoutAPinWindowEvenTheRightPinGetsNoPairingLink() = + runBlocking { + val link = connect() + assertFalse(withTimeout(WAIT) { link.handshake(PIN.toByteArray(Charsets.UTF_8)) }) + } + + @Test + fun threeWrongPinChannelsCloseTheWindowAndLockOutTheNextOne() = + runBlocking { + confirmPin() + repeat(3) { + val (mac, phoneEnd) = PipeBluetoothLinkIo.pair() + val accepted = scope.async { acceptor.accept(phoneEnd) } + val link = FakeBtPinPairingClientLink(mac) + assertFalse(withTimeout(WAIT) { link.handshake(WRONG_PIN.toByteArray(Charsets.UTF_8)) }) + // The phone counts the failed channel when the acceptor ends it, after the reject reached the Mac. + withTimeout(WAIT) { accepted.await() } + } + assertEquals(PairingState.Failed(PairingFailure.PIN_EXPIRED), coordinator.state.value) + val advertsBeforeRetry = adverts.size + // BT_PIN_LOCKOUT (0.4.5 "PIN", E10): một cửa sổ PIN mới không mở, nên không có advert `pinMode = true` + // nào nữa dù `startPin`/`submitPin` vẫn được gọi (chỉ `closeWindow` của `startPin` phát NONE như mọi lần). + confirmPin("111111") + assertTrue(adverts.drop(advertsBeforeRetry).none { it.pinMode }) + assertNull(window.currentPin()) + } + + private companion object { + val WAIT = 10.seconds + const val POLL = 20L + const val PIN = "482915" + const val WRONG_PIN = "000000" + const val CREATED_AT = 1_727_150_003_210L + } +} diff --git a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingSharedCounterTest.kt b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingSharedCounterTest.kt new file mode 100644 index 0000000..67ee88b --- /dev/null +++ b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingSharedCounterTest.kt @@ -0,0 +1,144 @@ +package app.handlive.android.feature.pairing.exchange + +import app.handlive.android.core.protocol.ErrorCode +import app.handlive.android.core.protocol.pairing.PairErrorData +import app.handlive.android.core.protocol.pairing.PairHelloData +import app.handlive.android.core.protocol.pairing.PairOp +import app.handlive.android.core.transport.bluetooth.BluetoothLinkAdmission +import app.handlive.android.feature.connection.PairingAdvert +import app.handlive.android.feature.connection.bluetooth.BluetoothAdvertising +import app.handlive.android.feature.connection.bluetooth.BluetoothPairingHooks +import app.handlive.android.feature.connection.bluetooth.BluetoothPinPairingHooks +import app.handlive.android.feature.pairing.testing.FakeBtPinPairingClientLink +import app.handlive.android.feature.pairing.testing.FakePairingClient +import app.handlive.android.feature.pairing.testing.FakeTextSocket +import app.handlive.android.feature.pairing.testing.PairStoreFixture +import app.handlive.android.feature.pairing.testing.PipeBluetoothLinkIo +import app.handlive.android.feature.pairing.testing.localPhone +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.seconds + +/** + * `PIN_MAX_ATTEMPTS` đếm MỘT lần cho cả LAN và Bluetooth (00-common-specs 0.10, 0.4.5 "PIN", PAIR-01 A4/C3): một + * `pair/offer` LAN không tới `pair/done` và một kênh Bluetooth hỏng sau `cpace1` cộng vào cùng bộ đếm của cửa sổ PIN + * đang mở, theo thứ tự nào cũng vậy. Lần thứ 3 đóng cửa sổ ("PIN expired"), cả hai kênh từ chối lần sau, và + * `BT_PIN_LOCKOUT` chặn cửa sổ PIN mới. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class BtPinPairingSharedCounterTest { + private val pairs = PairStoreFixture() + private val client = FakePairingClient() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val adverts = mutableListOf() + private val coordinator = + PairingCoordinator(local = { localPhone() }, pairs = pairs.store, advertise = { adverts += it }, scope = scope) + private val window = BtPinPairingKeySource(coordinator) + private val acceptor = + BluetoothAdvertising.acceptor( + admission = BluetoothLinkAdmission(), + serve = { _, _ -> error("liên kết ghép đôi không bao giờ vào serve() của cặp đã ghép") }, + pairing = BluetoothPairingHooks(pin = BluetoothPinPairingHooks({ window }, flowOf(Unit), { coordinator })), + clock = System::currentTimeMillis, + linkKeys = { emptyList() }, + ) + + @After + fun tearDown() { + scope.cancel() + pairs.database.close() + } + + @Test + fun twoWrongPinsOverBluetoothThenOneOverTheLanCloseTheWindowForBothAndLockOut() = + runBlocking { + confirmPin() + wrongPinOverBluetooth(offersLeftAfter = 2) + wrongPinOverBluetooth(offersLeftAfter = 1) + assertEquals(PairingState.Waiting(pinMode = true), coordinator.state.value) + wrongPinOverLan() + assertClosedOnBothTransportsAndLockedOut() + } + + @Test + fun oneWrongPinOverTheLanThenTwoOverBluetoothCloseTheWindowForBothAndLockOut() = + runBlocking { + confirmPin() + wrongPinOverLan() + assertEquals(PairingState.EnterPin(attemptsLeft = 2), coordinator.state.value) + // A4: the LAN's wrong PIN is dropped; the user types it again on the same window. + coordinator.submitPin(PIN) + wrongPinOverBluetooth(offersLeftAfter = 1) + wrongPinOverBluetooth(offersLeftAfter = 0) + assertClosedOnBothTransportsAndLockedOut() + } + + private suspend fun confirmPin() { + coordinator.startPin() + coordinator.submitPin(PIN) + } + + /** One Bluetooth channel that fails after `cpace1`; counted when the acceptor ends it, so wait for that. */ + private suspend fun wrongPinOverBluetooth(offersLeftAfter: Int) { + val (mac, phoneEnd) = PipeBluetoothLinkIo.pair() + val accepted = scope.async { acceptor.accept(phoneEnd) } + assertFalse(withTimeout(WAIT) { FakeBtPinPairingClientLink(mac).handshake(WRONG_PIN.toByteArray()) }) + withTimeout(WAIT) { accepted.await() } + assertEquals(offersLeftAfter, coordinator.currentPinWindow?.offersLeft ?: 0) + } + + /** One LAN `pair/offer` that the Mac answers with `PIN_INVALID` (A5). */ + private suspend fun wrongPinOverLan() = + coroutineScope { + val socket = FakeTextSocket() + val run = async { coordinator.handle(socket) } + socket.toPhone.send(client.hello(mode = PairHelloData.MODE_PIN)) + assertEquals(PairOp.OFFER, client.opOf(withTimeout(WAIT) { socket.toClient.receive() })) + socket.toPhone.send(client.error(ErrorCode.PIN_INVALID.name, attemptsLeft = 2)) + withTimeout(WAIT) { run.await() } + } + + private suspend fun assertClosedOnBothTransportsAndLockedOut() { + assertEquals(PairingState.Failed(PairingFailure.PIN_EXPIRED), coordinator.state.value) + assertNull(coordinator.currentPinWindow) + // The LAN refuses the next client... + val lan = FakeTextSocket() + lan.toPhone.send(client.hello(mode = PairHelloData.MODE_PIN)) + withTimeout(WAIT) { coordinator.handle(lan) } + val refusal = client.read(lan.toClient.receive(), PairErrorData.serializer()) + assertEquals(ErrorCode.PAIRING_CLOSED.name, refusal?.code) + // ...and Bluetooth gives no PIN pairing link, even to the right PIN. + val (mac, phoneEnd) = PipeBluetoothLinkIo.pair() + scope.async { acceptor.accept(phoneEnd) } + assertFalse(withTimeout(WAIT) { FakeBtPinPairingClientLink(mac).handshake(PIN.toByteArray()) }) + // BT_PIN_LOCKOUT (E10): confirming a PIN again opens no window, so nothing advertises `pm = 1`/BT_PIN_HINT. + val advertsBefore = adverts.size + confirmPin() + assertTrue(adverts.drop(advertsBefore).none { it.pinMode }) + assertNull(window.currentPin()) + assertEquals(PairingState.Failed(PairingFailure.PIN_EXPIRED), coordinator.state.value) + } + + private companion object { + val WAIT = 30.seconds + const val PIN = "482915" + const val WRONG_PIN = "000000" + } +} diff --git a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingLinkTest.kt b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingLinkTest.kt index 0b96b8d..84bc3ba 100644 --- a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingLinkTest.kt +++ b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingLinkTest.kt @@ -8,6 +8,7 @@ import app.handlive.android.core.protocol.pairing.PairOp import app.handlive.android.core.transport.bluetooth.BluetoothLinkAdmission import app.handlive.android.core.transport.bluetooth.BtQrPairingKeys import app.handlive.android.feature.connection.bluetooth.BluetoothAdvertising +import app.handlive.android.feature.connection.bluetooth.BluetoothPairingHooks import app.handlive.android.feature.connection.bluetooth.BluetoothQrPairingHooks import app.handlive.android.feature.pairing.testing.FakeBtPairingClientLink import app.handlive.android.feature.pairing.testing.FakePairingClient @@ -52,7 +53,7 @@ class BtQrPairingLinkTest { BluetoothAdvertising.acceptor( admission = BluetoothLinkAdmission(), serve = { _, _ -> error("liên kết ghép đôi không bao giờ vào serve() của cặp đã ghép") }, - qrPairing = BluetoothQrPairingHooks({ window }, flowOf(Unit), { coordinator }), + pairing = BluetoothPairingHooks(qr = BluetoothQrPairingHooks({ window }, flowOf(Unit), { coordinator })), clock = System::currentTimeMillis, linkKeys = { emptyList() }, ) diff --git a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/testing/FakeBtPinPairingClientLink.kt b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/testing/FakeBtPinPairingClientLink.kt new file mode 100644 index 0000000..785d934 --- /dev/null +++ b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/testing/FakeBtPinPairingClientLink.kt @@ -0,0 +1,168 @@ +package app.handlive.android.feature.pairing.testing + +import app.handlive.android.core.crypto.bluetooth.NoiseNNpsk0 +import app.handlive.android.core.crypto.primitives.CPaceX25519SHA512 +import app.handlive.android.core.crypto.primitives.ChaCha20Poly1305Aead +import app.handlive.android.core.crypto.primitives.HmacSha256 +import app.handlive.android.core.crypto.primitives.X25519Keys +import app.handlive.android.core.protocol.bluetooth.BtLinkConstants +import app.handlive.android.core.protocol.bluetooth.BtLinkException +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord +import app.handlive.android.core.protocol.bluetooth.BtTransportFrame +import app.handlive.android.core.transport.bluetooth.BluetoothLinkIo +import app.handlive.android.core.transport.bluetooth.BtPinPairingKeys +import app.handlive.android.core.transport.bluetooth.BtTransportCipher +import java.security.GeneralSecurityException +import java.security.MessageDigest + +/** + * Phía M-APP (C) của một liên kết ghép đôi PIN qua Bluetooth (0.4.5 "PIN", A7.2-C), như [FakeBtPairingClientLink] + * (luồng QR) nhưng chạy CPace trước Noise: `cpace1`/`cpace2` dẫn `K_bt_pinlink`/`K_bt_pin`, rồi bắt tay + * `Noise_NNpsk0_25519_ChaChaPoly_SHA256` dựng độc lập với [NoiseNNpsk0] (phía S, chỉ dùng chung `split`). CPace + * dùng lại [CPaceX25519SHA512] — đã có vector riêng (`CPaceX25519SHA512VectorTest`), không phải mục tiêu của test + * dùng lớp này. + */ +class FakeBtPinPairingClientLink( + private val io: BluetoothLinkIo, +) { + private var send: BtTransportCipher? = null + private var receive: BtTransportCipher? = null + + /** `K_bt_pin` của lần ghép đôi vừa xác nhận, thay `K_pin` ở bước 9-11 PAIR-01 (chỉ hợp lệ sau [handshake] trả + * `true`). */ + lateinit var kBtPin: ByteArray + private set + + /** + * `challenge` → `cpace1`/`cpace2` (PRS = [pin]) → `noise1`/`noise2` keyed bằng `K_bt_pinlink` vừa dẫn; `true` + * khi S trả `noise2` hợp lệ, `false` khi S trả `reject`, `noise2` sai hay đóng kênh không trả lời (PIN sai, + * cửa sổ PIN không mở, điểm bậc thấp). + */ + suspend fun handshake( + pin: ByteArray, + yaScalar: ByteArray = X25519Keys.generatePrivateKey(), + ): Boolean { + val challenge = BtPreauthRecord.decode(io.readPreauthRecord()) + check(challenge.kind == BtPreauthRecord.KIND_CHALLENGE) + val nS = challenge.payload + val kBtPinlink = runCpace(pin, yaScalar, nS) ?: return false + val initiator = Initiator(kBtPinlink, NoiseNNpsk0.prologue(nS), X25519Keys.generatePrivateKey()) + io.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_NOISE1, initiator.message1())) + val answer = BtPreauthRecord.decode(io.readPreauthRecord()) + val keys = if (answer.kind == BtPreauthRecord.KIND_NOISE2) initiator.openMessage2(answer.payload) else null + keys?.let { (c1, c2) -> + send = BtTransportCipher(c1) + receive = BtTransportCipher(c2) + } + return keys != null + } + + /** `cpace1` → `cpace2`; `K_bt_pinlink`, hoặc `null` khi S đóng kênh không trả lời (không cửa sổ PIN...). */ + private suspend fun runCpace( + pin: ByteArray, + yaScalar: ByteArray, + nS: ByteArray, + ): ByteArray? { + val ci = BtPinPairingKeys.CPACE_CI.toByteArray(Charsets.US_ASCII) + val g = CPaceX25519SHA512.calculateGenerator(pin, ci, nS) + val ya = checkNotNull(CPaceX25519SHA512.scalarMultVfy(yaScalar, g)) { "ya bậc thấp, đổi vector" } + io.writeRecord(BtPreauthRecord.encode(BtPreauthRecord.KIND_CPACE1, ya)) + val cpace2 = + try { + BtPreauthRecord.decode(io.readPreauthRecord()) + } catch (_: BtLinkException) { + null + } + val yb = cpace2?.takeIf { it.kind == BtPreauthRecord.KIND_CPACE2 }?.payload + val k = yb?.let { CPaceX25519SHA512.scalarMultVfy(yaScalar, it) } ?: return null + val isk = CPaceX25519SHA512.isk(nS, k, ya, EMPTY, checkNotNull(yb), EMPTY) + kBtPin = BtPinPairingKeys.kBtPin(isk, nS) + return BtPinPairingKeys.kBtPinlink(isk, nS) + } + + suspend fun sendText(text: String) { + val frame = + BtTransportFrame.encode(BtLinkConstants.CHAN_CONTROL, more = false, long = false, text.toByteArray()) + io.writeRecord(checkNotNull(send).seal(frame)) + } + + /** Envelope kế tiếp từ S, hoặc `null` khi S đã đóng liên kết. */ + suspend fun receiveText(): String? = + try { + val frame = BtTransportFrame.decode(checkNotNull(receive).open(io.readRecord())) + frame.data.toString(Charsets.UTF_8) + } catch (_: BtLinkException) { + null + } + + fun close() = io.close() + + private class Initiator( + private val psk: ByteArray, + private val prologue: ByteArray, + private val ePrivC: ByteArray, + ) { + private lateinit var ck1: ByteArray + private lateinit var h4: ByteArray + + fun message1(): ByteArray { + val (h0, ck0) = initializeSymmetric() + val h1 = mixHash(h0, prologue) + val pskStep = mixKey(ck0, psk) + val h2 = mixHash(h1, pskStep.second) + val ePubC = X25519Keys.publicFromPrivate(ePrivC) + val h3 = mixHash(h2, ePubC) + val eStep = mixKey(pskStep.first, ePubC) + val tag = ChaCha20Poly1305Aead.seal(eStep.second, ZERO_NONCE, ByteArray(0), h3) + ck1 = eStep.first + h4 = mixHash(h3, tag) + return ePubC + tag + } + + fun openMessage2(message2: ByteArray): Pair? { + val ePubS = message2.copyOfRange(0, X25519Keys.KEY_SIZE) + val tag = message2.copyOfRange(X25519Keys.KEY_SIZE, message2.size) + val h5 = mixHash(h4, ePubS) + val eStep = mixKey(ck1, ePubS) + val eeStep = mixKey(eStep.first, X25519Keys.sharedSecret(ePrivC, ePubS)) + return try { + ChaCha20Poly1305Aead.open(eeStep.second, ZERO_NONCE, tag, h5) + NoiseNNpsk0.split(eeStep.first) + } catch (_: GeneralSecurityException) { + null + } + } + + private fun initializeSymmetric(): Pair { + val name = NoiseNNpsk0.PROTOCOL_NAME + val h = if (name.size <= HASH_SIZE) name else sha256(name) + return h to h + } + + private fun mixKey( + ck: ByteArray, + ikm: ByteArray, + ): Pair { + val tempKey = HmacSha256.mac(ck, ikm) + val out1 = HmacSha256.mac(tempKey, byteArrayOf(1)) + val out2 = HmacSha256.mac(tempKey, out1 + byteArrayOf(2)) + return out1 to out2 + } + + private fun mixHash( + h: ByteArray, + data: ByteArray, + ): ByteArray = sha256(h + data) + + private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) + + private companion object { + const val HASH_SIZE = 32 + val ZERO_NONCE = ByteArray(ChaCha20Poly1305Aead.NONCE_SIZE) + } + } + + private companion object { + val EMPTY = ByteArray(0) + } +} From 9b59e176e9f377718afe3c09ec74353afb18cda4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Fri, 30 Oct 2026 03:03:47 +0700 Subject: [PATCH 12/17] feat(android): pair over Bluetooth with a PIN through CPace (PAIR-01 flow C) While a PIN window is open the phone advertises BT_PIN_HINT, answers cpace1 with cpace2 and accepts only the noise1 keyed by K_bt_pinlink from ISK; the PIN link runs PAIR-01 with K_bt_pin in place of K_pin. A channel that fails after cpace1 counts on the open PIN window, the same counter as the LAN offers, so the two transports share PIN_MAX_ATTEMPTS; the third failure closes the window and starts BT_PIN_LOCKOUT on both. --- .../bluetooth/BluetoothLinkAcceptor.kt | 82 ++++++++++++++++--- .../bluetooth/BluetoothPreauthSession.kt | 60 ++++++++++++-- .../transport/bluetooth/BtPinPairingKeys.kt | 52 ++++++++++++ .../transport/bluetooth/BtPinPairingLink.kt | 44 ++++++++++ .../bluetooth/BtPinPairingResponder.kt | 68 +++++++++++++++ .../feature/connection/ConnectionRuntime.kt | 19 ++++- .../bluetooth/BluetoothAdvertising.kt | 62 ++++++++++++-- .../android/feature/pairing/PairingFeature.kt | 2 + .../pairing/exchange/BtPinPairingKeySource.kt | 18 ++++ .../pairing/exchange/PairingCoordinator.kt | 46 +++++++++-- .../pairing/exchange/PairingExchange.kt | 27 ++++-- .../feature/pairing/exchange/PairingWindow.kt | 27 +++++- 12 files changed, 465 insertions(+), 42 deletions(-) create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingKeys.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingLink.kt create mode 100644 core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingResponder.kt create mode 100644 feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingKeySource.kt diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt index 1c8b190..c69e82b 100644 --- a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt @@ -18,7 +18,8 @@ import kotlin.time.Duration * giải mã được (bước 5), tất cả trong `BT_PREAUTH_TIMEOUT`. Mọi lỗi trước khi gửi `noise2` nhận `reject` rồi đóng; * lỗi xác nhận khoá (bước 3, 5) tính một `AUTH_FAILED` cho cả listener. Liên kết đã xác nhận bằng `K_bt_link` đi * vào [serve] (`ControlServer.serveBluetoothPeer`, bắt tay E2E bước 6) cùng `pair_id` của psk đã khớp; liên kết - * xác nhận bằng `K_bt_plink` (0.4.5 "Pairing mode", A7.2-B) đi vào [servePairing], không có `pair_id`. + * xác nhận bằng `K_bt_plink` (0.4.5 "Pairing mode", A7.2-B) đi vào [servePairing], không có `pair_id`; liên kết xác + * nhận bằng `K_bt_pinlink` vừa dẫn qua CPace (0.4.5 "PIN", A7.2-C) đi vào [servePinPairing] cùng `K_bt_pin`. */ class BluetoothLinkAcceptor( private val admission: BluetoothLinkAdmission, @@ -26,6 +27,7 @@ class BluetoothLinkAcceptor( private val preauthTimeout: Duration = BluetoothLinkConstants.PREAUTH_TIMEOUT, private val newSession: () -> BluetoothPreauthSession, private val servePairing: suspend (WebSocketSession) -> Unit = {}, + private val servePinPairing: suspend (WebSocketSession, ByteArray) -> Unit = { _, _ -> }, ) { constructor( admission: BluetoothLinkAdmission, @@ -55,8 +57,11 @@ class BluetoothLinkAcceptor( watchdog.cancel() if (confirmed != null) { try { - val pairId = confirmed.pairId - if (pairId != null) serve(confirmed.socket, pairId) else servePairing(confirmed.socket) + when (val purpose = confirmed.purpose) { + is LinkPurpose.Pair -> serve(confirmed.socket, purpose.pairId) + LinkPurpose.QrPairing -> servePairing(confirmed.socket) + is LinkPurpose.PinPairing -> servePinPairing(confirmed.socket, purpose.kBtPin) + } } finally { confirmed.socket.shutDown() // Slot ghép đôi QR (nếu liên kết này giữ): chỉ nhả khi PAIR-01 trên nó đã xong, không @@ -73,19 +78,41 @@ class BluetoothLinkAcceptor( } } + /** Lý do một liên kết được xác nhận (bước 5): một cặp đã ghép, hay chưa có `pair_id` — ghép đôi QR hay PIN. */ + private sealed interface LinkPurpose { + class Pair( + val pairId: String, + ) : LinkPurpose + + data object QrPairing : LinkPurpose + + class PinPairing( + val kBtPin: ByteArray, + ) : LinkPurpose + } + + private fun purposeOf(outcome: BluetoothPreauthSession.Noise1Outcome): LinkPurpose = + when (outcome) { + is BluetoothPreauthSession.Noise1Outcome.Verified -> LinkPurpose.Pair(outcome.pairId) + BluetoothPreauthSession.Noise1Outcome.PairingVerified -> LinkPurpose.QrPairing + is BluetoothPreauthSession.Noise1Outcome.PinPairingVerified -> LinkPurpose.PinPairing(outcome.kBtPin) + BluetoothPreauthSession.Noise1Outcome.Rejected -> error("Rejected đã bị lọc trước khi gọi purposeOf") + } + /** - * [pairId] `null` cho một liên kết ghép đôi QR (0.4.5 "Pairing link"), không phải một cặp đã ghép. [session] - * đi kèm để [accept] nhả slot ghép đôi QR (nếu liên kết này giữ) chỉ sau khi [serve]/[servePairing] xong. + * [purpose] `null` khi kênh đã đóng (gửi `reject` hoặc không trả lời). [session] đi kèm để [accept] nhả slot + * ghép đôi QR (nếu liên kết này giữ) chỉ sau khi [serve]/[servePairing]/[servePinPairing] xong. */ private class Confirmed( val socket: BluetoothPeerSocket, - val pairId: String?, + val purpose: LinkPurpose, val session: BluetoothPreauthSession, ) private enum class FirstRecord { OPENED, KEY_FAILED, CLOSED } - /** Bước 1-5; `null` khi kênh phải đóng (đã gửi `reject` nếu còn trước `noise2`). */ + /** Bước 1-5 (QR/cặp) hoặc CPace rồi bước 1-5 (PIN, A7.2-C); `null` khi kênh phải đóng (đã gửi `reject` nếu + * còn trước `noise2`). */ private suspend fun runPreauth( io: BluetoothLinkIo, link: LinkState, @@ -101,6 +128,9 @@ class BluetoothLinkAcceptor( // Không xác nhận được (bị từ chối, đứt kênh, hết giờ): nhả ngay slot ghép đôi QR nếu kênh này giữ. // Xác nhận được: [accept] nhả slot sau khi [serve]/[servePairing] xong, không phải ở đây. if (confirmed == null) session.releasePairingSlotIfHeld() + // Một lần thử PIN mà noise1 không xác nhận được (hay kênh đứt trước đó) tính một lần sai (0.4.5 "PIN", + // C3); xác nhận được thì không có gì để báo (no-op) — gọi vô điều kiện ở đây gộp đủ mọi lối ra. + session.recordPinFailureIfNeeded() } return confirmed } @@ -114,10 +144,7 @@ class BluetoothLinkAcceptor( session: BluetoothPreauthSession, ): Confirmed? { io.writeRecord(session.challenge()) - val record = BtPreauthRecord.decode(io.readPreauthRecord()) - // `cpace1` chỉ hợp lệ khi cửa sổ PIN mở (A7.2c, chưa có): đóng không trả lời, như mọi kind lạc chỗ. - if (record.kind != BtPreauthRecord.KIND_NOISE1) return null - val outcome = session.receiveNoise1(record.payload) + val outcome = receiveFirstNoise1(io, session) ?: return null if (outcome is BluetoothPreauthSession.Noise1Outcome.Rejected) { admission.recordAuthFailure() sendReject(io) @@ -126,8 +153,37 @@ class BluetoothLinkAcceptor( val keys = session.buildNoise2() io.writeRecord(keys.noise2) link.noise2Sent = true - val pairId = (outcome as? BluetoothPreauthSession.Noise1Outcome.Verified)?.pairId - return awaitFirstRecord(io, link, scope, keys)?.let { Confirmed(it, pairId, session) } + return awaitFirstRecord(io, link, scope, keys)?.let { Confirmed(it, purposeOf(outcome), session) } + } + + /** + * Bước 2 của 0.4.5: `noise1` thẳng (QR/cặp), hoặc `cpace1` rồi `cpace2` rồi `noise1` keyed bằng `K_bt_pinlink` + * (PIN, một round trip thêm — 0.4.5 "PIN"). Bất kỳ kind lạc chỗ ở bước nào — kể cả `cpace1` khi không có cửa + * sổ PIN mở, hay một kind khác `noise1` sau `cpace2` — đóng kênh không trả lời (`null`), như nhau. + */ + private suspend fun receiveFirstNoise1( + io: BluetoothLinkIo, + session: BluetoothPreauthSession, + ): BluetoothPreauthSession.Noise1Outcome? { + val record = BtPreauthRecord.decode(io.readPreauthRecord()) + return when (record.kind) { + BtPreauthRecord.KIND_NOISE1 -> session.receiveNoise1(record.payload) + BtPreauthRecord.KIND_CPACE1 -> receivePinNoise1(io, session, record.payload) + else -> null + } + } + + @Suppress("ReturnCount") + private suspend fun receivePinNoise1( + io: BluetoothLinkIo, + session: BluetoothPreauthSession, + cpace1: ByteArray, + ): BluetoothPreauthSession.Noise1Outcome? { + val ready = session.receiveCpace1(cpace1) as? BluetoothPreauthSession.Cpace1Outcome.Ready ?: return null + io.writeRecord(ready.cpace2) + val record = BtPreauthRecord.decode(io.readPreauthRecord()) + if (record.kind != BtPreauthRecord.KIND_NOISE1) return null + return session.receivePinNoise1(record.payload) } private suspend fun awaitFirstRecord( diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt index 24e10bb..199f1fa 100644 --- a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt @@ -9,27 +9,33 @@ import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord /** * Phía S (A-SVC) của bắt tay bảo mật liên kết cho MỘT kênh (0.4.5 "Link-security handshake" bước 1-4): thử * `K_bt_plink` của cửa sổ ghép đôi QR đang mở (nếu còn slot, 0.4.5 "Pairing mode", A7.2-B) rồi `K_bt_link` của - * từng cặp chưa thu hồi theo lượt — quảng bá không mang `pair_id` nên S không biết trước cặp nào. Không tự đóng - * kênh hay cập nhật admission: bên gọi (listener, A7.1b) làm việc đó theo kết quả trả về. + * từng cặp chưa thu hồi theo lượt — quảng bá không mang `pair_id` nên S không biết trước cặp nào. [receiveCpace1]/ + * [receivePinNoise1] là nhánh PIN (A7.2-C): một round trip CPace thêm trước `noise1`, khoá bằng `K_bt_pinlink` vừa + * dẫn từ `ISK`, không rơi xuống `K_bt_plink` hay `K_bt_link` nào. Không tự đóng kênh hay cập nhật admission: bên + * gọi (listener, A7.1b) làm việc đó theo kết quả trả về. */ class BluetoothPreauthSession( private val pairKeys: BluetoothPairKeys, private val pairingKeySource: BluetoothPairingKeySource = BluetoothPairingKeySource { null }, private val pairingSlot: BtQrPairingSlot = BtQrPairingSlot(), + private val pinPairing: BtPinPairingResponder = BtPinPairingResponder(), private val nonce: () -> ByteArray = { SecureRandomBytes.next(NONCE_SIZE) }, private val ephemeralPrivateKey: () -> ByteArray = X25519Keys::generatePrivateKey, ) { private lateinit var prologue: ByteArray + private lateinit var nS: ByteArray private var opened: NoiseNNpsk0.Message1Result? = null @Volatile private var pairingSlotHeld = false - /** Bước 1: `n_s` mới cho kênh này; trói buộc vào prologue nên `noise1` ghi lại từ kênh khác không bao giờ khớp. */ + /** Bước 1: `n_s` mới cho kênh này; trói buộc vào prologue nên `noise1` ghi lại từ kênh khác không bao giờ khớp; + * cũng là `sid` của CPace (nhánh PIN). */ fun challenge(): ByteArray { - val nS = nonce() - prologue = NoiseNNpsk0.prologue(nS) - return BtPreauthRecord.encode(BtPreauthRecord.KIND_CHALLENGE, nS) + val n = nonce() + nS = n + prologue = NoiseNNpsk0.prologue(n) + return BtPreauthRecord.encode(BtPreauthRecord.KIND_CHALLENGE, n) } sealed interface Noise1Outcome { @@ -40,9 +46,48 @@ class BluetoothPreauthSession( /** `noise1` xác thực bằng `K_bt_plink`: liên kết ghép đôi QR, chưa có `pair_id` (0.4.5 "Pairing link"). */ data object PairingVerified : Noise1Outcome + /** `noise1` xác thực bằng `K_bt_pinlink` vừa dẫn qua CPace: liên kết ghép đôi PIN, chưa có `pair_id` + * (0.4.5 "PIN"); [kBtPin] thay `K_pin` ở bước 9-11 PAIR-01. */ + class PinPairingVerified( + val kBtPin: ByteArray, + ) : Noise1Outcome + data object Rejected : Noise1Outcome } + sealed interface Cpace1Outcome { + /** `cpace2` (`Yb`) để gửi tiếp; `K_bt_pinlink`/`K_bt_pin` đã dẫn xong, chờ `noise1` kế tiếp + * ([receivePinNoise1]). */ + class Ready( + val cpace2: ByteArray, + ) : Cpace1Outcome + + /** Không có cửa sổ PIN đang mở, `Ya` sai cỡ, hoặc `Ya`/sinh `g` bậc thấp (`K` toàn 0, G.I abort): đóng kênh + * không trả lời, như mọi kind lạc chỗ (0.4.5 "PIN" — "an all-zero K → close without answer"). */ + data object Closed : Cpace1Outcome + } + + /** Bước CPace của 0.4.5 "PIN" (trước `noise1`), sid = `n_s` của kênh này ([BtPinPairingResponder]). */ + fun receiveCpace1(ya: ByteArray): Cpace1Outcome = + pinPairing.receiveCpace1(ya, nS)?.let { Cpace1Outcome.Ready(it) } ?: Cpace1Outcome.Closed + + /** `noise1` của một kênh ghép đôi PIN, sau [receiveCpace1]: chỉ thử `K_bt_pinlink` vừa dẫn — một PIN sai ở + * một phía làm `ISK` (nên `K_bt_pinlink`) khác nhau, nên tag không mở được, như mọi khoá sai (0.4.5 "The link + * that passes noise1 with K_bt_pinlink is a PIN pairing link"). */ + fun receivePinNoise1(message1: ByteArray): Noise1Outcome { + val psk = pinPairing.linkKey + val result = + psk + ?.takeIf { message1.size == MESSAGE1_SIZE } + ?.let { NoiseNNpsk0.tryOpenMessage1(it, prologue, message1) } + ?: return Noise1Outcome.Rejected + opened = result + return Noise1Outcome.PinPairingVerified(pinPairing.confirm()) + } + + /** Lần thử PIN của kênh hỏng sau `cpace1` → một lần sai ở bộ đếm chung với LAN (C3); gọi trong `finally`. */ + fun recordPinFailureIfNeeded() = pinPairing.recordFailureIfNeeded() + /** Bước 2-3: [message1] = thân `noise1` sau byte kind (0.4.5 bước 3, thử lần lượt, QR trước cặp đã ghép). */ fun receiveNoise1(message1: ByteArray): Noise1Outcome { // `tryOpenMessage1` giả định đúng cỡ (`require`); thiếu MAC (cỡ sai) là `reject` như một tag không khớp, @@ -82,7 +127,8 @@ class BluetoothPreauthSession( val c2: ByteArray, ) - /** Bước 4: chỉ gọi sau [receiveNoise1] trả [Noise1Outcome.Verified] hoặc [Noise1Outcome.PairingVerified]. */ + /** Bước 4: chỉ gọi sau [receiveNoise1]/[receivePinNoise1] trả về một kết quả đã xác thực (không phải + * [Noise1Outcome.Rejected]). */ fun buildNoise2(): LinkKeys { val verified = checkNotNull(opened) { "message1 chưa xác thực" } val result = NoiseNNpsk0.buildMessage2(verified, ephemeralPrivateKey()) diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingKeys.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingKeys.kt new file mode 100644 index 0000000..f29b23c --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingKeys.kt @@ -0,0 +1,52 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.primitives.HkdfSha256 + +/** + * Khoá CPace của một cửa sổ PIN đang mở qua Bluetooth (0.4.5 "PIN (PAIR-01 luồng C)"): [CPACE_CI] là `CI` cố định + * của bắt tay, `K_bt_pinlink` thay `K_bt_plink` làm psk của bắt tay Noise, `K_bt_pin` thay `K_pin` ở bước 9-11 + * PAIR-01. Cả hai dẫn từ `ISK` của CPace (`CPACE-X25519-SHA512`, core:crypto), muối bằng `n_s` của chính kênh đó — + * khác nhau dù cùng PIN, dù cùng cửa sổ. Vector: `shared/test-vectors/cpace.json` (kind `handlive_pin`). + */ +object BtPinPairingKeys { + const val CPACE_CI = "HLBTPIN1" + const val PINLINK_INFO = "handlive/v1/bt-pin-link" + const val PIN_INFO = "handlive/v1/bt-pin-pair" + private const val KEY_SIZE = 32 + + fun kBtPinlink( + isk: ByteArray, + nS: ByteArray, + ): ByteArray = HkdfSha256.derive(ikm = isk, salt = nS, info = PINLINK_INFO, length = KEY_SIZE) + + fun kBtPin( + isk: ByteArray, + nS: ByteArray, + ): ByteArray = HkdfSha256.derive(ikm = isk, salt = nS, info = PIN_INFO, length = KEY_SIZE) +} + +/** PIN (UTF-8, PRS của CPace) của cửa sổ PIN đang mở, hoặc `null` khi không có cửa sổ PIN nào hay PIN chưa gõ xong + * (0.4.5 "PIN"). Cài đặt thật đọc cửa sổ hiện tại của `PairingCoordinator` (feature/pairing, A7.2-C); đây chỉ là + * điểm nối để [BluetoothPreauthSession] không phụ thuộc feature/pairing — như [BluetoothPairingKeySource] của QR. */ +fun interface BluetoothPinPairingKeySource { + fun currentPin(): ByteArray? +} + +/** + * Cửa sổ ghép đôi PIN như listener Bluetooth thấy (0.4.5 "PIN", PAIR-01 C1-C3): [currentPin] để chạy CPace, + * [onPinChannelFailedAfterCpace1] báo một kênh đã nhận `cpace1` nhưng không xác nhận được bằng `K_bt_pinlink` — + * tính một lần sai vào bộ đếm chung với LAN (A4/C3), dù chưa từng chạm tới `pair/hello`. + */ +interface BluetoothPinPairingWindow : BluetoothPinPairingKeySource { + fun onPinChannelFailedAfterCpace1() + + companion object { + /** Không có cửa sổ PIN nào: không chạy CPace, không bao giờ báo lỗi (listener không có cửa sổ PIN). */ + val NONE: BluetoothPinPairingWindow = + object : BluetoothPinPairingWindow { + override fun currentPin(): ByteArray? = null + + override fun onPinChannelFailedAfterCpace1() = Unit + } + } +} diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingLink.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingLink.kt new file mode 100644 index 0000000..a00ec2b --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingLink.kt @@ -0,0 +1,44 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.transport.server.PairingEndpoint +import app.handlive.android.core.transport.server.RawTextMessageSocket +import app.handlive.android.core.transport.server.TextMessageSocket +import io.ktor.websocket.WebSocketSession + +/** + * Chạy PAIR-01 API 2-6 trên một liên kết ghép đôi PIN đã xác nhận bằng `K_bt_pinlink` (0.4.5 "PIN", A7.2-C): như + * [BtQrPairingLink], các envelope `pair` đi trên kênh điều khiển `0x01` nên [endpoint] (`PairingCoordinator`) chạy y + * nguyên như ở LAN — chỉ khác socket mang thêm [kBtPin] để [PairingExchange] dùng làm `K_pa` thay Argon2id + * (0.4.5 "Keys from ISK": `K_bt_pin` thay `K_pin` ở bước 9-11). + */ +object BtPinPairingLink { + /** Không dùng cho admission (Bluetooth có admission riêng của listener); chỉ để phân biệt với địa chỉ LAN. */ + const val PSEUDO_ADDRESS = "bluetooth" + + /** Thời gian tối đa chờ envelope cuối (`pair/error`, `pair/done`) lên dây trước khi liên kết bị huỷ. */ + const val FLUSH_TIMEOUT_MILLIS = 2_000L + + suspend fun serve( + socket: WebSocketSession, + kBtPin: ByteArray, + endpoint: PairingEndpoint, + clock: () -> Long = System::currentTimeMillis, + ) { + val wrapped = BluetoothPinSecretTextMessageSocket(RawTextMessageSocket(socket, PSEUDO_ADDRESS, clock), kBtPin) + endpoint.handle(wrapped) + // BluetoothLinkAcceptor huỷ socket ngay khi hàm này trả về: `pair/error` vừa xếp hàng phải lên dây trước đã. + (socket as? BluetoothPeerSocket)?.awaitWritesFinished(FLUSH_TIMEOUT_MILLIS) + } +} + +/** Một socket ghép đôi PIN qua Bluetooth mang theo `K_bt_pin` của chính kênh đó (0.4.5 "PIN"): [PairingExchange] + * dùng [kBtPin] thay vì dẫn `K_pin` qua Argon2id khi đang chạy trên một liên kết như thế này. */ +interface BluetoothPinSecretSocket : TextMessageSocket { + val kBtPin: ByteArray +} + +private class BluetoothPinSecretTextMessageSocket( + private val delegate: TextMessageSocket, + override val kBtPin: ByteArray, +) : BluetoothPinSecretSocket, + TextMessageSocket by delegate diff --git a/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingResponder.kt b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingResponder.kt new file mode 100644 index 0000000..504e8fe --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BtPinPairingResponder.kt @@ -0,0 +1,68 @@ +package app.handlive.android.core.transport.bluetooth + +import app.handlive.android.core.crypto.primitives.CPaceX25519SHA512 +import app.handlive.android.core.crypto.primitives.X25519Keys +import app.handlive.android.core.protocol.bluetooth.BtPreauthRecord + +/** + * Phía S (A-SVC) của CPace cho MỘT kênh ghép đôi PIN (0.4.5 "PIN", A7.2-C): `cpace1` (`Ya`) → `cpace2` (`Yb`), dẫn + * `K_bt_pinlink` (psk của `noise1` kế tiếp) và `K_bt_pin` từ `ISK`. Giữ trạng thái lần thử PIN của kênh để báo + * [window] đúng một lần khi kênh hỏng sau `cpace1` — bộ đếm thật nằm ở cửa sổ PIN, dùng chung với LAN (C3). + */ +class BtPinPairingResponder( + private val window: BluetoothPinPairingWindow = BluetoothPinPairingWindow.NONE, + private val scalar: () -> ByteArray = X25519Keys::generatePrivateKey, +) { + /** `K_bt_pinlink` sau một `cpace1` hợp lệ, `null` trước đó. */ + var linkKey: ByteArray? = null + private set + private var pinKey: ByteArray? = null + + @Volatile + private var attemptStarted = false + + @Volatile + private var attemptConfirmed = false + + /** + * Thân `cpace2` để gửi, hoặc `null` (đóng kênh không trả lời) khi không có cửa sổ PIN, `Ya` sai cỡ hay `K` toàn 0 + * ("an all-zero K → close without answer"). Một `null` không tính vào bộ đếm: chỉ một `noise1` không mở được SAU + * khi CPace đã chạy mới là một lần đoán PIN. Mỗi lối ra sớm là một bước kiểm của 0.4.5 theo đúng thứ tự. + */ + @Suppress("ReturnCount") + fun receiveCpace1( + ya: ByteArray, + nS: ByteArray, + ): ByteArray? { + val pin = window.currentPin() ?: return null + if (ya.size != X25519Keys.KEY_SIZE) return null + val g = CPaceX25519SHA512.calculateGenerator(pin, CI, nS) + val ybScalar = scalar() + val yb = CPaceX25519SHA512.scalarMultVfy(ybScalar, g) ?: return null + val k = CPaceX25519SHA512.scalarMultVfy(ybScalar, ya) ?: return null + val isk = CPaceX25519SHA512.isk(nS, k, ya, EMPTY, yb, EMPTY) + linkKey = BtPinPairingKeys.kBtPinlink(isk, nS) + pinKey = BtPinPairingKeys.kBtPin(isk, nS) + attemptStarted = true + return BtPreauthRecord.encode(BtPreauthRecord.KIND_CPACE2, yb) + } + + /** `noise1` đã mở bằng [linkKey]: lần thử thành công; trả `K_bt_pin` cho bước 9-11 PAIR-01. */ + fun confirm(): ByteArray { + attemptConfirmed = true + return checkNotNull(pinKey) { "cpace1 chưa chạy" } + } + + /** Gọi trong `finally` của listener: lần thử đã qua `cpace1` mà không [confirm] → một lần sai, chỉ một lần. */ + fun recordFailureIfNeeded() { + if (attemptStarted && !attemptConfirmed) { + attemptStarted = false + window.onPinChannelFailedAfterCpace1() + } + } + + private companion object { + val CI = BtPinPairingKeys.CPACE_CI.toByteArray(Charsets.US_ASCII) + val EMPTY = ByteArray(0) + } +} diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt index e29d64a..fd5b1b4 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt @@ -6,6 +6,7 @@ import app.handlive.android.core.data.db.PeerPlatform import app.handlive.android.core.data.pairing.PairStore import app.handlive.android.core.protocol.ProtocolJson import app.handlive.android.core.protocol.capability.CapabilityData +import app.handlive.android.core.transport.bluetooth.BluetoothPinPairingWindow import app.handlive.android.core.transport.bluetooth.BluetoothQrPairingWindow import app.handlive.android.core.transport.handshake.PairRecord import app.handlive.android.core.transport.relay.RelayPeerLink @@ -18,6 +19,8 @@ import app.handlive.android.core.transport.tls.AndroidTlsIdentityStorage import app.handlive.android.core.transport.tls.TlsIdentityProvider import app.handlive.android.feature.connection.bench.BenchLog import app.handlive.android.feature.connection.bluetooth.BluetoothAdvertising +import app.handlive.android.feature.connection.bluetooth.BluetoothPairingHooks +import app.handlive.android.feature.connection.bluetooth.BluetoothPinPairingHooks import app.handlive.android.feature.connection.bluetooth.BluetoothQrPairingHooks import app.handlive.android.feature.connection.capability.CapabilityPublisher import app.handlive.android.feature.connection.capability.LocalEnvironmentReader @@ -108,6 +111,13 @@ class ConnectionRuntime private constructor( @Volatile var bluetoothQrWindow: BluetoothQrPairingWindow? = null + /** + * Installed by the pairing feature: the PIN pairing window as the Bluetooth listener sees it (0.4.5 "PIN", + * PAIR-01 C1–C3) — PIN for CPace, the shared wrong-PIN counter; pairing links then run [pairingEndpoint]. + */ + @Volatile + var bluetoothPinWindow: BluetoothPinPairingWindow? = null + /** SHA-256 of the TLS certificate (`tls_sha256` of PAIR-01 API 3), known once the server runs. */ @Volatile var certificateSha256: ByteArray? = null @@ -220,7 +230,14 @@ class ConnectionRuntime private constructor( // CONN-05: the Bluetooth link runs beside the LAN server, whatever the network does. // A QR window opens or closes through setPairingAdvert, so the same flow restarts the Bluetooth advertising. val qrPairing = BluetoothQrPairingHooks({ bluetoothQrWindow }, pairingAdvert, { pairingEndpoint }) - bluetooth.start(runtimeScope, controlServer, table.sessions.map { it.keys }, environmentVersion, qrPairing) + val pinPairing = BluetoothPinPairingHooks({ bluetoothPinWindow }, pairingAdvert, { pairingEndpoint }) + bluetooth.start( + runtimeScope, + controlServer, + table.sessions.map { it.keys }, + environmentVersion, + BluetoothPairingHooks(qrPairing, pinPairing), + ) simWatcher.start() } diff --git a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt index 9758be3..e7ba6f6 100644 --- a/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt @@ -12,12 +12,16 @@ import android.os.Build import androidx.core.content.ContextCompat import app.handlive.android.core.crypto.derivation.BtLinkKeyDerivation import app.handlive.android.core.data.pairing.PairStore +import app.handlive.android.core.transport.bluetooth.BluetoothAdvertisementData import app.handlive.android.core.transport.bluetooth.BluetoothLinkAcceptor import app.handlive.android.core.transport.bluetooth.BluetoothLinkAdmission import app.handlive.android.core.transport.bluetooth.BluetoothPairKeys import app.handlive.android.core.transport.bluetooth.BluetoothPairingKeySource +import app.handlive.android.core.transport.bluetooth.BluetoothPinPairingWindow import app.handlive.android.core.transport.bluetooth.BluetoothPreauthSession import app.handlive.android.core.transport.bluetooth.BluetoothQrPairingWindow +import app.handlive.android.core.transport.bluetooth.BtPinPairingLink +import app.handlive.android.core.transport.bluetooth.BtPinPairingResponder import app.handlive.android.core.transport.bluetooth.BtQrPairingLink import app.handlive.android.core.transport.bluetooth.BtQrPairingSlot import app.handlive.android.core.transport.server.ControlServer @@ -66,15 +70,16 @@ class BluetoothAdvertising( server: ControlServer, liveSessions: Flow>, environment: Flow<*>, - qrPairing: BluetoothQrPairingHooks = BluetoothQrPairingHooks.NONE, + pairing: BluetoothPairingHooks = BluetoothPairingHooks(), ) { if (appContext.getSystemService(BluetoothManager::class.java)?.adapter == null) return val acceptor = - acceptor(server.bluetoothAdmission, server::serveBluetoothPeer, qrPairing, clock) { linkKeys } + acceptor(server.bluetoothAdmission, server::serveBluetoothPeer, pairing, clock) { linkKeys } val radioPeripheral = BluetoothPeripheral(AndroidBluetoothRadio(appContext), acceptor::accept, clock) peripheral = radioPeripheral val discoveryKeys = discoveryKeys(liveSessions) - val pairingHints = qrPairing.pairingHints() + // `pr_bt` (QR) và `BT_PIN_HINT` không bao giờ cùng mở (một cửa sổ ghép đôi một lúc, PairingCoordinator). + val pairingHints = combine(pairing.qr.pairingHints(), pairing.pin.pinHints()) { pr, pin -> pr ?: pin } val stateMachine = BluetoothLinkLifecycle( start = { radioPeripheral.start(scope, discoveryKeys, pairingHints) }, @@ -149,25 +154,41 @@ class BluetoothAdvertising( /** * Bộ accept của listener: thử `K_bt_plink` (qua MỘT slot cho mọi kênh của listener này) rồi `K_bt_link` của - * từng cặp; liên kết cặp vào [serve] (`ControlServer.serveBluetoothPeer`), liên kết ghép đôi QR vào endpoint - * của [qrPairing]. Tách ra để test dựng đúng bộ accept của app mà không cần radio thật. + * từng cặp; liên kết cặp vào [serve] (`ControlServer.serveBluetoothPeer`), liên kết ghép đôi QR hay PIN (CPace, + * A7.2-C) vào endpoint của [pairing]. Tách ra để test dựng đúng bộ accept của app mà không cần radio thật. */ fun acceptor( admission: BluetoothLinkAdmission, serve: suspend (WebSocketSession, String) -> Unit, - qrPairing: BluetoothQrPairingHooks, + pairing: BluetoothPairingHooks, clock: () -> Long, linkKeys: () -> List>, ): BluetoothLinkAcceptor { val slot = BtQrPairingSlot() + val qrPairing = pairing.qr + val pinPairing = pairing.pin val pairingKeys = BluetoothPairingKeySource { qrPairing.window()?.currentPlinkKey() } + val pinWindow = + object : BluetoothPinPairingWindow { + override fun currentPin(): ByteArray? = pinPairing.window()?.currentPin() + + override fun onPinChannelFailedAfterCpace1() { + pinPairing.window()?.onPinChannelFailedAfterCpace1() + } + } return BluetoothLinkAcceptor( admission = admission, serve = serve, - newSession = { BluetoothPreauthSession(BluetoothPairKeys(linkKeys), pairingKeys, slot) }, + newSession = { + val pinResponder = BtPinPairingResponder(pinWindow) + BluetoothPreauthSession(BluetoothPairKeys(linkKeys), pairingKeys, slot, pinResponder) + }, servePairing = { socket -> qrPairing.endpoint()?.let { BtQrPairingLink.serve(socket, it, clock) } }, + servePinPairing = { socket, kBtPin -> + pinPairing.endpoint()?.let { BtPinPairingLink.serve(socket, kBtPin, it, clock) } + }, ) } @@ -181,6 +202,12 @@ class BluetoothAdvertising( } } +/** Cửa sổ ghép đôi của listener Bluetooth (0.4.5 "Pairing mode"): QR (luồng B) và PIN (luồng C), mỗi lúc chỉ một mở. */ +class BluetoothPairingHooks( + val qr: BluetoothQrPairingHooks = BluetoothQrPairingHooks.NONE, + val pin: BluetoothPinPairingHooks = BluetoothPinPairingHooks.NONE, +) + /** * Cửa sổ ghép đôi QR cho listener Bluetooth (0.4.5 "Pairing mode"), do `ConnectionRuntime` cấp: [window] đọc cửa sổ * đang mở ngay lúc cần (khoá `noise1`, `pr_bt`), [windowChanges] phát lại mỗi khi cửa sổ mở hay đóng để quảng bá @@ -199,3 +226,24 @@ class BluetoothQrPairingHooks( val NONE = BluetoothQrPairingHooks({ null }, flowOf(Unit), { null }) } } + +/** + * Cửa sổ ghép đôi PIN cho listener Bluetooth (0.4.5 "PIN", A7.2-C), do `ConnectionRuntime` cấp — cùng hình dạng + * [BluetoothQrPairingHooks]: [window] đọc cửa sổ đang mở ngay lúc cần (PIN cho CPace, bộ đếm sai), [windowChanges] + * phát lại mỗi khi cửa sổ mở hay đóng để quảng bá `BT_PIN_HINT` dựng lại, [endpoint] chạy PAIR-01 trên liên kết đã + * xác nhận bằng `K_bt_pinlink`. + */ +class BluetoothPinPairingHooks( + val window: () -> BluetoothPinPairingWindow?, + val windowChanges: Flow<*>, + val endpoint: () -> PairingEndpoint?, +) { + /** `BT_PIN_HINT` khi có cửa sổ PIN đang mở; marker giống nhau cho mọi điện thoại, không suy ra từ PIN (AC9). */ + fun pinHints(): Flow = + windowChanges.map { if (window()?.currentPin() != null) BluetoothAdvertisementData.PIN_HINT else null } + + companion object { + /** Không có cửa sổ PIN nào: phát một lần để `combine` ở [BluetoothAdvertising.start] có giá trị. */ + val NONE = BluetoothPinPairingHooks({ null }, flowOf(Unit), { null }) + } +} diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/PairingFeature.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/PairingFeature.kt index 3c25629..c4618df 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/PairingFeature.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/PairingFeature.kt @@ -8,6 +8,7 @@ import app.handlive.android.feature.connection.ConnectionRuntime import app.handlive.android.feature.connection.LocalDeviceName import app.handlive.android.feature.pairing.devices.DeviceListItem import app.handlive.android.feature.pairing.devices.DeviceListModel +import app.handlive.android.feature.pairing.exchange.BtPinPairingKeySource import app.handlive.android.feature.pairing.exchange.BtQrPairingKeySource import app.handlive.android.feature.pairing.exchange.LocalPairingDevice import app.handlive.android.feature.pairing.exchange.PairingCoordinator @@ -80,6 +81,7 @@ class PairingFeature private constructor( val feature = get(context) feature.runtime.pairingEndpoint = feature.coordinator feature.runtime.bluetoothQrWindow = BtQrPairingKeySource(feature.coordinator) + feature.runtime.bluetoothPinWindow = BtPinPairingKeySource(feature.coordinator) feature.runtime.router.register(MessageType.PAIR, feature.unpair.handler) feature.runtime.sessionEnded .onEach(feature.unpair::onSessionEnded) diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingKeySource.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingKeySource.kt new file mode 100644 index 0000000..92b6b59 --- /dev/null +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/BtPinPairingKeySource.kt @@ -0,0 +1,18 @@ +package app.handlive.android.feature.pairing.exchange + +import app.handlive.android.core.transport.bluetooth.BluetoothPinPairingWindow + +/** + * Cửa sổ PIN của [coordinator] như listener Bluetooth thấy (0.4.5 "PIN", PAIR-01 C1-C3): PIN để chạy CPace, báo + * một kênh hỏng sau `cpace1` vào bộ đếm chung với LAN (A4/C3). Không có PIN khi cửa sổ hiện tại là QR hay không + * có cửa sổ nào. `PairingFeature.install` gắn nó vào `ConnectionRuntime` như [BtQrPairingKeySource]. + */ +class BtPinPairingKeySource( + private val coordinator: PairingCoordinator, +) : BluetoothPinPairingWindow { + override fun currentPin(): ByteArray? = coordinator.currentPinWindowSecret + + override fun onPinChannelFailedAfterCpace1() { + coordinator.currentPinWindow?.failBluetoothAttempt() + } +} diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingCoordinator.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingCoordinator.kt index bae8a94..4e60075 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingCoordinator.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingCoordinator.kt @@ -82,6 +82,10 @@ class PairingCoordinator( private var expiry: Job? = null private var rendezvousId: String? = null + /** `BT_PIN_LOCKOUT` (0.4.5 "PIN", E10): no new PIN window, LAN or Bluetooth, until this time. */ + @Volatile + private var pinLockedUntil: Long = 0 + /** Orders a client taking the window against the PIN being confirmed, so "Pairing…" starts exactly once. */ private val pinLock = Any() @@ -111,6 +115,19 @@ class PairingCoordinator( */ val currentQrPairingSecret: ByteArray? get() = (window as? PairingWindow.Qr)?.invite?.pairingSecret + /** + * PIN của cửa sổ PIN đang mở, hoặc `null` khi không có cửa sổ nào, cửa sổ hiện tại là QR, hay PIN chưa gõ + * xong (0.4.5 "PIN"): PRS cho CPace qua Bluetooth (A7.2-C). + */ + val currentPinWindowSecret: ByteArray? + get() = currentPinWindow?.confirmedPin?.toByteArray(Charsets.UTF_8) + + /** + * The open PIN window, the one object holding the wrong-PIN count of both transports (0.4.5 "PIN", C3): the LAN + * counts its `pair/offer` here ([PairingExchange]), Bluetooth its failed channels ([BtPinPairingKeySource]). + */ + internal val currentPinWindow: PairingWindow.Pin? get() = window as? PairingWindow.Pin + /** PAIR-01 step 4: E1 for a code that is not HandLive's, E6 when 8 pairs exist already. */ suspend fun onScanned(text: String) { val invite = PairingInvite.parse(text) @@ -158,14 +175,28 @@ class PairingCoordinator( /** * The user confirmed the PIN (end of A3). The first PIN opens the 120 s window with TXT `pm = 1` (A4); after - * `PIN_INVALID` the window is still open and takes the new PIN. + * `PIN_INVALID` the window is still open and takes the new PIN. During `BT_PIN_LOCKOUT` it opens no window + * (E10) — an existing window (another wrong PIN on the same one) is unaffected, the lockout only blocks a + * brand new one. Reuses `PIN_EXPIRED`'s text until shared/strings carries a dedicated `BT_PIN_LOCKOUT` key. */ fun submitPin(pin: String) { + val existing = window as? PairingWindow.Pin + if (existing == null && clock() < pinLockedUntil) { + stateFlow.value = PairingState.Failed(PairingFailure.PIN_EXPIRED) + return + } + // The third failed attempt was a Bluetooth channel (C3): same end as the LAN's third offer (A4). + val onUsedUp: (PairingWindow.Pin) -> Unit = { usedUp -> + if (window === usedUp) { + closeWindow() + pinLockedUntil = clock() + PairingWindow.Pin.LOCKOUT_DURATION_MILLIS + stateFlow.value = PairingState.Failed(PairingFailure.PIN_EXPIRED) + } + } val current = - window as? PairingWindow.Pin - ?: PairingWindow.Pin(clock() + PairingWindow.DURATION_MILLIS).also { - open(it, PairingAdvert(pinMode = true)) - } + existing ?: PairingWindow.Pin(clock() + PairingWindow.DURATION_MILLIS, onUsedUp).also { + open(it, PairingAdvert(pinMode = true)) + } synchronized(pinLock) { current.submit(pin) // A client that connected while the PIN was typed has been waiting for it: checking starts now. @@ -218,6 +249,11 @@ class PairingCoordinator( if (window !== current || !claimed) return val (next, close) = current.after(outcome, shown = stateFlow.value, now = clock(), firstPair = noPairBefore) if (close) closeWindow() + // A4: the window's own third failed offer (`PIN_MAX_ATTEMPTS`, not a plain 120 s timeout — that path never + // reaches here) starts BT_PIN_LOCKOUT, same as a third failed Bluetooth channel. + if (current is PairingWindow.Pin && current.offersLeft == 0) { + pinLockedUntil = clock() + PairingWindow.Pin.LOCKOUT_DURATION_MILLIS + } if (outcome is PairingOutcome.Paired) onPaired(outcome.pairId) stateFlow.value = next } diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingExchange.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingExchange.kt index 5f3ccab..f65d8c9 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingExchange.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingExchange.kt @@ -25,6 +25,7 @@ import app.handlive.android.core.protocol.pairing.PairHelloData import app.handlive.android.core.protocol.pairing.PairOfferData import app.handlive.android.core.protocol.pairing.PairOp import app.handlive.android.core.transport.WsCloseCode +import app.handlive.android.core.transport.bluetooth.BluetoothPinSecretSocket import app.handlive.android.core.transport.server.TextMessageSocket import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -160,7 +161,7 @@ class PairingExchange( ): Handshake { val clientNonce = Base64Codecs.decodeB64u(hello.nonce, NONCE_SIZE) val serverNonce = nonce() - val secret = secretFor(clientNonce, serverNonce) + val secret = secretFor(socket, clientNonce, serverNonce) // A4: at most 3 offers per PIN, counted here; a used-up PIN gets no offer (the coordinator closes the window). if (window is PairingWindow.Pin && !window.takeOffer()) throw refused(ErrorCode.PAIRING_CLOSED) val transcript = @@ -212,21 +213,31 @@ class PairingExchange( } } - /** `pairing_secret` from the QR code, or `K_pin` from the PIN the user typed (A4), awaited within the window. */ + /** + * `pairing_secret` from the QR code, `K_bt_pin` of this Bluetooth link (0.4.5 "PIN", A7.2-C: already proven by + * the CPace + Noise handshake before `pair/hello`, so it replaces `K_pin` outright, no Argon2id), or `K_pin` + * from the PIN the user typed on the LAN (A4), awaited within the window. + */ private suspend fun secretFor( + socket: TextMessageSocket, clientNonce: ByteArray, serverNonce: ByteArray, ): ByteArray = - when (window) { + when (val current = window) { is PairingWindow.Qr -> { - window.invite.pairingSecret + current.invite.pairingSecret } is PairingWindow.Pin -> { - val pin = - withTimeoutOrNull(window.expiresAt - clock()) { window.awaitPin() } - ?: throw refused(ErrorCode.PAIRING_CLOSED) - withContext(Dispatchers.Default) { PairingAuthDerivation.pinKey(pin, clientNonce, serverNonce) } + val bluetoothPinKey = (socket as? BluetoothPinSecretSocket)?.kBtPin + if (bluetoothPinKey != null) { + bluetoothPinKey + } else { + val pin = + withTimeoutOrNull(current.expiresAt - clock()) { current.awaitPin() } + ?: throw refused(ErrorCode.PAIRING_CLOSED) + withContext(Dispatchers.Default) { PairingAuthDerivation.pinKey(pin, clientNonce, serverNonce) } + } } } diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingWindow.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingWindow.kt index b234ce4..e48bf0d 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingWindow.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/exchange/PairingWindow.kt @@ -4,6 +4,7 @@ import app.handlive.android.feature.pairing.invite.PairingInvite import kotlinx.coroutines.CompletableDeferred import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.minutes /** * An open pairing window (PAIR-01 step 6, A4): `/v1/pair` accepts one client while it is open and not expired @@ -33,13 +34,22 @@ sealed class PairingWindow( class Pin( expiresAt: Long, + /** Called once, by the Bluetooth failure that uses the PIN up ([failBluetoothAttempt]). */ + private val onUsedUpOverBluetooth: (Pin) -> Unit = {}, ) : PairingWindow(expiresAt) { @Volatile private var entry = CompletableDeferred() + /** The confirmed PIN right now, without waiting (unlike [awaitPin]): Bluetooth needs the digits the instant + * `cpace1` arrives (0.4.5 "PIN", A7.2-C), not a suspend point; `null` while A3 is still waiting for one. */ + @Volatile + var confirmedPin: String? = null + private set + /** The user typed the PIN shown on the Mac (A3). */ fun submit(value: String) { if (!entry.complete(value)) entry = CompletableDeferred(value) + confirmedPin = value } /** The user confirmed a PIN that no `PIN_INVALID` has discarded yet. */ @@ -50,6 +60,7 @@ sealed class PairingWindow( /** After `PIN_INVALID` the next attempt needs a new PIN. */ fun reset() { + confirmedPin = null entry = CompletableDeferred() } @@ -64,9 +75,23 @@ sealed class PairingWindow( /** Offers left for this PIN, shown after a wrong PIN (field 7). */ val offersLeft: Int get() = (MAX_OFFERS - offers.get()).coerceAtLeast(0) + /** + * A Bluetooth channel failed after `cpace1` (0.4.5 "PIN", C3): one attempt on the same counter as the LAN + * offers, so both transports together get [MAX_OFFERS]. The attempt that uses the PIN up closes the window + * through [onUsedUpOverBluetooth]; one past it changes nothing. + */ + fun failBluetoothAttempt() { + if (takeOffer() && offersLeft == 0) onUsedUpOverBluetooth(this) + } + companion object { - /** `PIN_MAX_ATTEMPTS` (0.10): the Mac's 3 attempts, one offer each. */ + /** `PIN_MAX_ATTEMPTS` (0.10): the Mac's 3 attempts, one offer each — shared with Bluetooth channels + * that fail after `cpace1` (0.4.5 "PIN", C3; [failBluetoothAttempt]). */ const val MAX_OFFERS = 3 + + /** `BT_PIN_LOCKOUT` (0.10): a window closed by [MAX_OFFERS] failures blocks a new one, LAN or + * Bluetooth, for this long (PAIR-01 E10). */ + val LOCKOUT_DURATION_MILLIS = 5.minutes.inWholeMilliseconds } } From ad07e552e15ffe513f4bbe59b2975da758aa93d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Fri, 30 Oct 2026 08:10:29 +0700 Subject: [PATCH 13/17] test(android): cover Bluetooth link as its own connection status DeviceListModelTest and HLComponentSemanticsTest expect a dedicated DeviceLink.BLUETOOTH / HLConnectionStatus.ConnectedBluetooth instead of the Wi-Fi placeholder; red until the next commit adds them. --- .../design/component/HLComponentSemanticsTest.kt | 2 ++ .../feature/pairing/devices/DeviceListModelTest.kt | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/core/design/src/test/kotlin/app/handlive/android/core/design/component/HLComponentSemanticsTest.kt b/core/design/src/test/kotlin/app/handlive/android/core/design/component/HLComponentSemanticsTest.kt index 8b2284c..5b9136f 100644 --- a/core/design/src/test/kotlin/app/handlive/android/core/design/component/HLComponentSemanticsTest.kt +++ b/core/design/src/test/kotlin/app/handlive/android/core/design/component/HLComponentSemanticsTest.kt @@ -103,6 +103,7 @@ class HLComponentSemanticsTest { HandLiveTheme(darkTheme = false, highContrast = false) { Column { HLStatusIndicator(status = HLConnectionStatus.ConnectedWiFi, deviceName = "Pixel 8 của Lan") + HLStatusIndicator(status = HLConnectionStatus.ConnectedBluetooth, deviceName = "MacBook của Lan") HLStatusIndicator(status = HLConnectionStatus.ConnectedInternet, deviceName = "iPad") // Not connected: the device name is not read. HLStatusIndicator(status = HLConnectionStatus.Disconnected, deviceName = "Pixel 8 của Lan") @@ -112,6 +113,7 @@ class HLComponentSemanticsTest { compose .onNodeWithContentDescription("Đã kết nối qua Wi-Fi với Pixel 8 của Lan") .assert(SemanticsMatcher.expectValue(SemanticsProperties.LiveRegion, LiveRegionMode.Polite)) + compose.onNodeWithContentDescription("Đã kết nối qua Bluetooth với MacBook của Lan").assertExists() compose.onNodeWithContentDescription("Đã kết nối qua Internet với iPad").assertExists() compose.onNodeWithContentDescription("Mất kết nối").assertExists() } diff --git a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModelTest.kt b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModelTest.kt index eb0ab0d..d86f12c 100644 --- a/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModelTest.kt +++ b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModelTest.kt @@ -46,6 +46,17 @@ class DeviceListModelTest { assertEquals(ClipboardAvailability.ON, item.clipboard) } + @Test + fun bluetoothChannelIsItsOwnLinkNotWifi() { + val item = + DeviceListModel.item( + device, + session(setOf(Feature.CLIPBOARD), clipboardOnPeer = true, channel = PeerSession.Channel.BLUETOOTH), + true, + ) + assertEquals(DeviceLink.BLUETOOTH, item.link) + } + @Test fun clipboardOffOnEitherSideIsExplained() { assertEquals( @@ -127,9 +138,10 @@ class DeviceListModelTest { clipboardOnPeer: Boolean, smsOnPeer: Boolean = false, callsOnPeer: Boolean = false, + channel: PeerSession.Channel = PeerSession.Channel.LAN, ) = PeerSession( peer = PeerSession.PeerInfo(device.pairId, device.peerDeviceId, device.peerName, device.peerPlatform), - channel = PeerSession.Channel.LAN, + channel = channel, effectiveFeatures = MutableStateFlow(effective), peerCapability = MutableStateFlow( From 1d13bdb4bf556132d73e0b26a9bf7d7859ff80ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Fri, 30 Oct 2026 08:10:33 +0700 Subject: [PATCH 14/17] feat(android): show Connected via Bluetooth instead of the Wi-Fi label DeviceLink gets its own BLUETOOTH case and HLConnectionStatus gets ConnectedBluetooth (status_connected_bluetooth/_to, same green tint as Wi-Fi), so DeviceListModel stops mapping PeerSession.Channel.BLUETOOTH to the Wi-Fi placeholder. --- .../kotlin/app/handlive/android/ui/devices/DeviceRows.kt | 1 + .../android/core/design/component/HLConnectionStatus.kt | 6 +++++- .../android/feature/pairing/devices/DeviceListModel.kt | 6 ++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/app/handlive/android/ui/devices/DeviceRows.kt b/app/src/main/kotlin/app/handlive/android/ui/devices/DeviceRows.kt index 4ff35eb..315a7e6 100644 --- a/app/src/main/kotlin/app/handlive/android/ui/devices/DeviceRows.kt +++ b/app/src/main/kotlin/app/handlive/android/ui/devices/DeviceRows.kt @@ -72,6 +72,7 @@ val DeviceLink.status: HLConnectionStatus get() = when (this) { DeviceLink.WIFI -> HLConnectionStatus.ConnectedWiFi + DeviceLink.BLUETOOTH -> HLConnectionStatus.ConnectedBluetooth DeviceLink.INTERNET -> HLConnectionStatus.ConnectedInternet DeviceLink.USB -> HLConnectionStatus.Usb DeviceLink.DISCONNECTED -> HLConnectionStatus.Disconnected diff --git a/core/design/src/main/kotlin/app/handlive/android/core/design/component/HLConnectionStatus.kt b/core/design/src/main/kotlin/app/handlive/android/core/design/component/HLConnectionStatus.kt index 2258ea4..9376569 100644 --- a/core/design/src/main/kotlin/app/handlive/android/core/design/component/HLConnectionStatus.kt +++ b/core/design/src/main/kotlin/app/handlive/android/core/design/component/HLConnectionStatus.kt @@ -12,6 +12,7 @@ import app.handlive.android.core.strings.R */ enum class HLConnectionStatus { ConnectedWiFi, + ConnectedBluetooth, ConnectedInternet, Usb, Connecting, @@ -26,6 +27,7 @@ internal val HLConnectionStatus.textRes: Int get() = when (this) { HLConnectionStatus.ConnectedWiFi -> R.string.status_connected_wifi + HLConnectionStatus.ConnectedBluetooth -> R.string.status_connected_bluetooth HLConnectionStatus.ConnectedInternet -> R.string.status_connected_internet HLConnectionStatus.Usb -> R.string.status_connected_usb HLConnectionStatus.Connecting -> R.string.status_connecting @@ -38,6 +40,7 @@ internal val HLConnectionStatus.withDeviceRes: Int? get() = when (this) { HLConnectionStatus.ConnectedWiFi -> R.string.status_connected_wifi_to + HLConnectionStatus.ConnectedBluetooth -> R.string.status_connected_bluetooth_to HLConnectionStatus.ConnectedInternet -> R.string.status_connected_internet_to HLConnectionStatus.Usb -> R.string.status_connected_usb_to HLConnectionStatus.Connecting, HLConnectionStatus.Disconnected -> null @@ -55,13 +58,14 @@ internal val HLConnectionStatus.symbol: HLSymbol? HLConnectionStatus.ConnectedInternet -> HLSymbol.Public HLConnectionStatus.Usb -> HLSymbol.Usb HLConnectionStatus.Disconnected -> HLSymbol.WifiOff - HLConnectionStatus.Connecting -> null + HLConnectionStatus.ConnectedBluetooth, HLConnectionStatus.Connecting -> null } /** Connected is green, connecting orange, disconnected grey — never red for an ordinary disconnection. */ internal fun HLConnectionStatus.tint(colors: HandLiveColors): Color = when (this) { HLConnectionStatus.ConnectedWiFi, + HLConnectionStatus.ConnectedBluetooth, HLConnectionStatus.ConnectedInternet, HLConnectionStatus.Usb, -> colors.statusConnected diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt index 2fe3ea8..d57784a 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.serialization.SerializationException /** Link of a paired client as the Devices tab shows it (PAIR-02 fields 4–5, 0.11 seen from the phone). */ -enum class DeviceLink { WIFI, INTERNET, USB, DISCONNECTED } +enum class DeviceLink { WIFI, BLUETOOTH, INTERNET, USB, DISCONNECTED } /** The clipboard row of the details (PAIR-02 field 8, SET-02 field 24). */ enum class ClipboardAvailability { @@ -220,8 +220,6 @@ object DeviceListModel { PeerSession.Channel.USB -> DeviceLink.USB - // TODO(A7.1b): chưa có DeviceLink/chuỗi riêng cho Bluetooth (status.connected_bluetooth chưa có trong - // ui-strings.json, như placeholder .connectedWiFi đã chấp nhận ở M7.1a phía Apple cho #199/#190). - PeerSession.Channel.BLUETOOTH -> DeviceLink.WIFI + PeerSession.Channel.BLUETOOTH -> DeviceLink.BLUETOOTH } } From 56960c265276b6a60495c3dde585c320027cc35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Fri, 30 Oct 2026 08:50:06 +0700 Subject: [PATCH 15/17] style(pairing): remove blank lines between when conditions --- .../android/feature/pairing/devices/DeviceListModel.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt index d57784a..1fcdde6 100644 --- a/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt +++ b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt @@ -215,11 +215,8 @@ object DeviceListModel { private fun PeerSession.Channel.toLink() = when (this) { PeerSession.Channel.LAN -> DeviceLink.WIFI - PeerSession.Channel.RELAY -> DeviceLink.INTERNET - PeerSession.Channel.USB -> DeviceLink.USB - PeerSession.Channel.BLUETOOTH -> DeviceLink.BLUETOOTH } } From 3900d8b0cdee6e86fd9241b90c4991dc22f25211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Fri, 30 Oct 2026 09:42:31 +0700 Subject: [PATCH 16/17] chore(release): bump version to 0.2.0 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a452686..cc7a787 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,8 +59,8 @@ android { applicationId = "com.handlive.android" minSdk = 29 targetSdk = 36 - versionCode = 6 - versionName = "0.1.1" + versionCode = 7 + versionName = "0.2.0" // CONN-03: `{RELAY_HOST}` is configured at build time (0.4.3); without it the build has no relay. The pins are // ISRG Root X1 and X2 (in the code) plus the project's backup key, `sha256/` values separated by commas. From ed8e41fbb751844c3210ae4b5abcbf190da6d771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Fri, 30 Oct 2026 10:11:28 +0700 Subject: [PATCH 17/17] docs(changelog): correct Phase 7 0.2.0 entry to match shipped code Drop the per-connection bulk stream keys (K_bulk) and Wi-Fi Direct Location-prompt claims into a Specs section: core/transport reserves the bulk channel and scheduling, but nothing calls enqueueBulk or encrypts bulk content yet, and there is no Wi-Fi Direct code at all. Credit the UI status label and permission-prompt logic that are actually wired up. --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b827374..80b3914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.2.0] — 2026-10-30 + +### Added + +- **Offline connectivity (Phase 7):** Bluetooth L2CAP pairing and control channel over BLE (foreground service `connectedDevice` + advertises the service UUID and rotating hint, link-security handshake with Noise keyed by the pairing secret). Link codec + (channel IDs, short and long control lanes, scheduler: control > bulk), Bluetooth admission policy (pre-auth link cap, + nonce challenge, authentication failure counter and lockout). + +- **Pairing over Bluetooth:** offline first pairing (flow B): QR scan, advertise proof (HMAC of pairing secret), phone grants + pairing slot after MAC check, handshake and store as paired. PIN pairing over Bluetooth (flow C): advertise PIN hint, + perform CPace-X25519-SHA512 exchange, three online attempts per PIN with global counter and lockout. + +- **UI labels and prompts:** "Connected via Bluetooth" status line (`HLConnectionStatus.ConnectedBluetooth`, `core/design` + + `ui/devices/DeviceRows`); Bluetooth permission decision logic (SET-01: ask "Nearby devices" once on Android 12+, re-ask + `ACTION_REQUEST_ENABLE` on an interval when there is no LAN and Bluetooth is off — `BluetoothPermissionPrompts`). + +### Specs + +- Designed but not wired into the app yet: per-connection bulk stream keys (`K_bulk`) for clipboard images over the + Bluetooth bulk lane — `core/transport`'s `BluetoothLinkScheduler`/`BtLinkConstants` reserve channel `0x02` and the + chunking/priority logic, but nothing calls `enqueueBulk` and no feature encrypts or sends bulk content yet (0.4.7, + A7.4). Wi-Fi Direct itself (A7.5: SSID/passphrase, P2P socket binding, Location prompt) has no code at all. + ## [0.1.1] — 2026-10-22 ### Changed