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 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. 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/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/CPaceX25519SHA512.kt b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt new file mode 100644 index 0000000..3fe25fa --- /dev/null +++ b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/CPaceX25519SHA512.kt @@ -0,0 +1,154 @@ +@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 (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 / LEB128_CAPACITY_HINT_DIVISOR + 1) + var length = data.size + while (true) { + 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 + } + + /** "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) + } + + /** 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() + val lastIndex = Field25519.FIELD_SIZE_BYTES - 1 + masked[lastIndex] = (masked[lastIndex].toInt() and TOP_BIT_CLEAR_MASK).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. + */ + @Suppress("LongParameterList") + 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/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/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..303e4b3 --- /dev/null +++ b/core/crypto/src/main/kotlin/app/handlive/android/core/crypto/primitives/Field25519.kt @@ -0,0 +1,343 @@ +@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 + +/** 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) +} 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/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/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 } +} 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..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 @@ -33,6 +33,14 @@ 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)", + "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/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/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/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) + } +} diff --git a/core/transport/build.gradle.kts b/core/transport/build.gradle.kts index b122026..398efa0 100644 --- a/core/transport/build.gradle.kts +++ b/core/transport/build.gradle.kts @@ -39,6 +39,14 @@ android { .file(sharedDir.file("test-vectors/session-handshake.json")) .withPropertyName("sessionHandshakeVector") .withPathSensitivity(PathSensitivity.RELATIVE) + // 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 .file(sharedDir.file("schemas/common.schema.json")) .withPropertyName("commonSchema") @@ -80,7 +88,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..c69e82b --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothLinkAcceptor.kt @@ -0,0 +1,272 @@ +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 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`; 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, + private val serve: suspend (WebSocketSession, String) -> Unit, + 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, + 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 { + 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 + // 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() + } + } + } finally { + admission.closeLink(wasPreauth = !link.confirmed) + io.close() + } + } + + /** 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") + } + + /** + * [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 purpose: LinkPurpose, + val session: BluetoothPreauthSession, + ) + + private enum class FirstRecord { OPENED, KEY_FAILED, CLOSED } + + /** 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, + scope: CoroutineScope, + ): Confirmed? { + val session = newSession() + var confirmed: Confirmed? = null + try { + 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. + } 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() + // 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 + } + + // 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 outcome = receiveFirstNoise1(io, session) ?: return null + if (outcome is BluetoothPreauthSession.Noise1Outcome.Rejected) { + admission.recordAuthFailure() + sendReject(io) + return null + } + val keys = session.buildNoise2() + io.writeRecord(keys.noise2) + link.noise2Sent = true + 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( + 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..5da84cb --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPeerSocket.kt @@ -0,0 +1,167 @@ +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.CompletableDeferred +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 kotlinx.coroutines.withTimeoutOrNull +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 + + /** 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() + 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. + } 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)) + } + + 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..199f1fa --- /dev/null +++ b/core/transport/src/main/kotlin/app/handlive/android/core/transport/bluetooth/BluetoothPreauthSession.kt @@ -0,0 +1,150 @@ +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_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. [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; + * cũng là `sid` của CPace (nhánh PIN). */ + fun challenge(): ByteArray { + val n = nonce() + nS = n + prologue = NoiseNNpsk0.prologue(n) + return BtPreauthRecord.encode(BtPreauthRecord.KIND_CHALLENGE, n) + } + + sealed interface Noise1Outcome { + class Verified( + 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 + + /** `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, + // 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) { + 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]/[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()) + 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/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/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/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/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/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/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 + } +} 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..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,8 @@ 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 import app.handlive.android.core.transport.server.ControlServer @@ -16,6 +18,10 @@ 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.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 import app.handlive.android.feature.connection.capability.SimChangeWatcher @@ -48,9 +54,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 +73,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 @@ -97,6 +104,20 @@ 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 + + /** + * 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 @@ -206,6 +227,17 @@ 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. + // A QR window opens or closes through setPairingAdvert, so the same flow restarts the Bluetooth advertising. + val qrPairing = BluetoothQrPairingHooks({ bluetoothQrWindow }, pairingAdvert, { pairingEndpoint }) + val pinPairing = BluetoothPinPairingHooks({ bluetoothPinWindow }, pairingAdvert, { pairingEndpoint }) + bluetooth.start( + runtimeScope, + controlServer, + table.sessions.map { it.keys }, + environmentVersion, + BluetoothPairingHooks(qrPairing, pinPairing), + ) simWatcher.start() } @@ -216,6 +248,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..e7ba6f6 --- /dev/null +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothAdvertising.kt @@ -0,0 +1,249 @@ +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.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 +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 + +/** + * 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). 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, + 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<*>, + pairing: BluetoothPairingHooks = BluetoothPairingHooks(), + ) { + if (appContext.getSystemService(BluetoothManager::class.java)?.adapter == null) return + val acceptor = + acceptor(server.bluetoothAdmission, server::serveBluetoothPeer, pairing, clock) { linkKeys } + val radioPeripheral = BluetoothPeripheral(AndroidBluetoothRadio(appContext), acceptor::accept, clock) + peripheral = radioPeripheral + val discoveryKeys = discoveryKeys(liveSessions) + // `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) }, + 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 + } + + companion object { + private val NEARBY_DEVICES = + listOf(Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT) + + /** + * 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 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, + 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 = { + 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) } + }, + ) + } + + private 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 + } + } +} + +/** 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á + * 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 }) + } +} + +/** + * 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/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..82acc31 --- /dev/null +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothLinkLifecycle.kt @@ -0,0 +1,45 @@ +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 -> { + // Chờ STATE_ON: chưa mở được server hay quảng bá. + } + } + } +} 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..d705a1f --- /dev/null +++ b/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheral.kt @@ -0,0 +1,132 @@ +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.flowOf +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. [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 = + 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)) + } + } + } + + @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, + 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, hints) }.exceptionOrNull() + if (failure is CancellationException) throw failure + delay(RETRY_DELAY_MILLIS) + } + } + + private suspend fun advertiseAndAccept( + linkScope: CoroutineScope, + hints: List, + ) { + val server = radio.listen() + try { + 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())) + } + } + + /** 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 ByteArray?.sameBytes(other: ByteArray?): Boolean = + if (this == null) other == null else other != null && contentEquals(other) + } +} 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..da04938 --- /dev/null +++ b/feature/connection/src/test/kotlin/app/handlive/android/feature/connection/bluetooth/BluetoothPeripheralTest.kt @@ -0,0 +1,288 @@ +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() + val first = radio.servers.single() + first.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() + } + + 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 { + val radio = FakeRadio() + val accepted = mutableListOf() + val peripheral = BluetoothPeripheral(radio, accept = { accepted += it }, clock = clock()) + peripheral.start(backgroundScope, MutableStateFlow(listOf(key1))) + runCurrent() + val server = radio.servers.single() + server.incoming.send(NoIo) + server.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 + + /** `pr_bt` của "cặp 1" trong bt-pairing.json. */ + val PR_BT: ByteArray = Hex.decode("24be2584") + } +} 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/PairingFeature.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/PairingFeature.kt index 6e1b6de..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,8 @@ 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 import app.handlive.android.feature.pairing.revoke.UnpairController @@ -78,6 +80,8 @@ 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.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/devices/DeviceListModel.kt b/feature/pairing/src/main/kotlin/app/handlive/android/feature/pairing/devices/DeviceListModel.kt index 916d824..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 @@ -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 { @@ -217,5 +217,6 @@ object DeviceListModel { PeerSession.Channel.LAN -> DeviceLink.WIFI PeerSession.Channel.RELAY -> DeviceLink.INTERNET PeerSession.Channel.USB -> DeviceLink.USB + PeerSession.Channel.BLUETOOTH -> DeviceLink.BLUETOOTH } } 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/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..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() @@ -105,6 +109,25 @@ 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 + + /** + * 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) @@ -152,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. @@ -212,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 } } 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( 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/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..84bc3ba --- /dev/null +++ b/feature/pairing/src/test/kotlin/app/handlive/android/feature/pairing/exchange/BtQrPairingLinkTest.kt @@ -0,0 +1,138 @@ +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.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 +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") }, + pairing = BluetoothPairingHooks(qr = 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) + } + } +} 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) + } +}