Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e1ee0f0
test(android): cover cpace.json for the CPace-X25519-SHA512 primitive
xuandung38 Oct 29, 2026
5265c41
feat(android): implement the CPace-X25519-SHA512 primitive
xuandung38 Oct 29, 2026
a771c28
style(android): keep Field25519 and CPaceX25519SHA512 within ktlint/d…
xuandung38 Oct 29, 2026
7826f2b
feat(protocol): A7.1 Bluetooth link records, frames and constants
xuandung38 Oct 29, 2026
a3fc695
feat(crypto): A7.1 Bluetooth link keys and the Noise_NNpsk0 responder
xuandung38 Oct 29, 2026
c69e73a
feat(transport): A7.1 Bluetooth link core
xuandung38 Oct 29, 2026
950e1c7
feat(connection): A7.1 Bluetooth advertising and wiring
xuandung38 Oct 29, 2026
3946d36
style(connection): keep the A7.1 Bluetooth files within ktlint rules
xuandung38 Oct 29, 2026
93b41e3
test(android): cover QR pairing over Bluetooth (PAIR-01 flow B)
xuandung38 Oct 29, 2026
efb0f5f
feat(android): pair over Bluetooth with a QR code (PAIR-01 flow B)
xuandung38 Oct 29, 2026
055a138
test(android): cover PIN pairing over Bluetooth (PAIR-01 flow C)
xuandung38 Oct 29, 2026
9b59e17
feat(android): pair over Bluetooth with a PIN through CPace (PAIR-01 …
xuandung38 Oct 29, 2026
ad07e55
test(android): cover Bluetooth link as its own connection status
xuandung38 Oct 30, 2026
1d13bdb
feat(android): show Connected via Bluetooth instead of the Wi-Fi label
xuandung38 Oct 30, 2026
56960c2
style(pairing): remove blank lines between when conditions
xuandung38 Oct 30, 2026
3900d8b
chore(release): bump version to 0.2.0
xuandung38 Oct 30, 2026
ed8e41f
docs(changelog): correct Phase 7 0.2.0 entry to match shipped code
xuandung38 Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<base64>` values separated by commas.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ByteArray, ByteArray> {
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<ByteArray, ByteArray> {
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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<Byte>(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)
}
}
Loading
Loading