From 6779fae1f45e7e723b9177c1c67d6497f02770b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20V=C3=A1zquez=20Blanco?= Date: Thu, 10 Sep 2026 16:25:33 +0200 Subject: [PATCH] bluetooth_vsc_csr: Add basic command support AI-Assisted: no --- .config/codespell_ignore.txt | 1 + scapy/contrib/bluetooth_vsc_csr.py | 160 +++++++++++++++++++++++++++++ test/contrib/bluetooth_vsc_csr.uts | 118 +++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 scapy/contrib/bluetooth_vsc_csr.py create mode 100644 test/contrib/bluetooth_vsc_csr.uts diff --git a/.config/codespell_ignore.txt b/.config/codespell_ignore.txt index e5d65af4708..3c94fb1c6ab 100644 --- a/.config/codespell_ignore.txt +++ b/.config/codespell_ignore.txt @@ -36,6 +36,7 @@ negociate optiona ot potatoe +recal referer requestor ro diff --git a/scapy/contrib/bluetooth_vsc_csr.py b/scapy/contrib/bluetooth_vsc_csr.py new file mode 100644 index 00000000000..810981e934b --- /dev/null +++ b/scapy/contrib/bluetooth_vsc_csr.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# +# scapy.contrib.description = CSR/BlueCore Bluetooth HCI Vendor-Specific Commands +# scapy.contrib.status = loads +# +# Information sources: +# - BlueZ ``tools/csr.h`` / ``tools/csr.c`` (BCCMD framing + varid list) +# - BlueZ ``tools/parser/csr.c`` (BCCMD PDU dissection) + +from scapy.packet import Packet, bind_layers +from scapy.fields import ( + ByteField, + LEShortField, + LEShortEnumField, + XStrField, +) + +from scapy.layers.bluetooth import ( + HCI_Command_Hdr, + HCI_Event_Vendor, +) + +# BCCMD PDU type (first header word). +_csr_bccmd_pdu_type = { + 0x0000: "getreq", + 0x0001: "getresp", + 0x0002: "setreq", +} + +# BCCMD varids (BlueZ ``csr.h``). +# High nibble is an operation class: 0x2xxx read-only info, 0x3xxx iterators +# and parameterised gets, 0x4xxx valueless actions (resets/halts/radio), +# 0x5xxx test, 0x6xxx config, 0x7003 PS-key door. +_csr_varid = { + 0x000b: "ps_clr_all", + 0x000c: "ps_factory_set", + 0x082d: "ps_clr_all_stores", + 0x2801: "bc01_status", + 0x2819: "buildid", + 0x281a: "chipver", + 0x281b: "chiprev", + 0x2825: "interface_version", + 0x282a: "rand", + 0x282c: "max_crypt_key_length", + 0x2836: "chipanarev", + 0x2838: "buildid_loader", + 0x2c00: "bt_clock", + 0x3005: "ps_next", + 0x3006: "ps_size", + 0x3008: "crypt_key_length", + 0x3009: "piconet_instance", + 0x300a: "get_clr_evt", + 0x300b: "get_next_builddef", + 0x3012: "ps_memory_type", + 0x301c: "read_build_name", + 0x4001: "cold_reset", + 0x4002: "warm_reset", + 0x4003: "cold_halt", + 0x4004: "warm_halt", + 0x4005: "init_bt_stack", + 0x4006: "activate_bt_stack", + 0x4007: "enable_tx", + 0x4008: "disable_tx", + 0x4009: "recal", + 0x400d: "ps_factory_restore", + 0x400e: "ps_factory_restore_all", + 0x400f: "ps_defrag_reset", + 0x4010: "kill_vm_application", + 0x4011: "hopping_on", + 0x4012: "cancel_page", + 0x4818: "ps_clr", + 0x481c: "map_sco_pcm", + 0x482e: "single_chan", + 0x5004: "radiotest", + 0x500c: "ps_clr_stores", + 0x6000: "no_variable", + 0x6802: "config_uart", + 0x6805: "panic_arg", + 0x6806: "fault_arg", + 0x6827: "max_tx_power", + 0x682b: "default_tx_power", + 0x7003: "ps", +} + + +def _bccmd_set_length(p): + """ + Fill in the BCCMD ``length`` word (total PDU size in 16-bit words: all + bytes after the ``channel`` byte, divided by 2). + """ + total_words = (len(p) - 1) // 2 + return p[:3] + total_words.to_bytes(2, "little") + p[5:] + + +class HCI_Cmd_VSC_CSR_BCCMD(Packet): + """ + CSR BCCMD command (opcode 0xFC00). + """ + name = "CSR BCCMD" + fields_desc = [ + ByteField("channel", 0xC2), + LEShortEnumField("pdu_type", 0x0000, _csr_bccmd_pdu_type), + LEShortField("length", None), + LEShortField("seqno", 0), + LEShortEnumField("varid", 0, _csr_varid), + LEShortField("status", 0), + XStrField("value", b"\x00" * 8), + ] + + def post_build(self, p, pay): + p += pay + if self.length is None: + p = _bccmd_set_length(p) + return p + + +class HCI_Event_VSC_CSR_BCCMD(HCI_Event_Vendor): + """ + CSR BCCMD response, carried in the HCI vendor-specific event (code 0xFF). + + Registered as an ``HCI_Event_Vendor`` handler, so it replaces the generic + vendor event whenever the body looks like a BCCMD PDU (see ``check``). + """ + name = "CSR BCCMD response" + match_subclass = True + fields_desc = [ + ByteField("channel", 0xC2), + LEShortEnumField("pdu_type", 0x0001, _csr_bccmd_pdu_type), + LEShortField("length", None), + LEShortField("seqno", 0), + LEShortEnumField("varid", 0, _csr_varid), + LEShortField("status", 0), + XStrField("value", b""), + ] + + @classmethod + def check(cls, body): + """ + Checks if the given 0xFF vendor-event body is a BCCMD PDU: a 0xC2 + channel byte followed by the 5-word (10-byte) header. + """ + return len(body) >= 11 and body[0] == 0xC2 + + def post_build(self, p, pay): + p += pay + if self.length is None: + p = _bccmd_set_length(p) + return p + + +bind_layers(HCI_Command_Hdr, HCI_Cmd_VSC_CSR_BCCMD, ogf=0x3F, ocf=0x000) + +# The BCCMD reply rides the generic 0xFF vendor event, which is shared with +# other vendors. Rather than rebinding that event code (split_layers), register +# the response as a handler so it is only used when the body looks like a BCCMD +# PDU (see its ``check``). This lets the CSR contrib coexist with other vendor +# contribs that use the same event code. +HCI_Event_Vendor.register_handler(HCI_Event_VSC_CSR_BCCMD) diff --git a/test/contrib/bluetooth_vsc_csr.uts b/test/contrib/bluetooth_vsc_csr.uts new file mode 100644 index 00000000000..3152b79bad0 --- /dev/null +++ b/test/contrib/bluetooth_vsc_csr.uts @@ -0,0 +1,118 @@ +% CSR/BlueCore Bluetooth Vendor-Specific Command (VSC) layer tests + ++ Load the CSR VSC contrib module + += Import the module (opt-in layer) +from scapy.layers.bluetooth import * +load_contrib("bluetooth_vsc_csr") +from scapy.contrib.bluetooth_vsc_csr import * + + ++ CSR BCCMD command (opcode 0xFC00) + += BCCMD shares OGF 0x3F / OCF 0x000, i.e. opcode 0xFC00 +cmd = HCI_Command_Hdr() / HCI_Cmd_VSC_CSR_BCCMD(pdu_type="getreq", seqno=1, varid=0x2819) +assert cmd.ogf == 0x3f +assert cmd.ocf == 0x000 +assert cmd.opcode == 0xfc00 + += GETREQ build with auto-computed length word +# The 8-byte default value area makes a 19-byte PDU: all bytes after the channel +# byte are 18, i.e. 9 little-endian words, so length is auto-filled to 0x0009. +cmd = HCI_Command_Hdr() / HCI_Cmd_VSC_CSR_BCCMD(pdu_type="getreq", seqno=1, varid=0x2819) +r = raw(cmd) +# 00fc(op) 13(plen) c2(channel) 0000(getreq) 0900(length) 0100(seqno) +# 1928(varid buildid) 0000(status) 0000000000000000(value) +assert r == bytes.fromhex("00fc13c2000009000100192800000000000000000000") +p = HCI_Command_Hdr(r) +assert HCI_Cmd_VSC_CSR_BCCMD in p +b = p[HCI_Cmd_VSC_CSR_BCCMD] +assert b.channel == 0xc2 +assert b.pdu_type == 0x0000 +assert b.length == 9 +assert b.seqno == 1 +assert b.varid == 0x2819 +assert b.status == 0 +assert b.value == b"\x00" * 8 + += The length word counts every 16-bit word after the channel byte +# Drop the value area entirely: 11-byte PDU -> (11 - 1) / 2 = 5 words. +cmd = HCI_Cmd_VSC_CSR_BCCMD(pdu_type="getreq", seqno=1, varid=0x2819, value=b"") +r = raw(cmd) +assert r == bytes.fromhex("c200000500010019280000") +assert HCI_Cmd_VSC_CSR_BCCMD(r).length == 5 + += An explicit length is preserved, not recomputed +cmd = HCI_Cmd_VSC_CSR_BCCMD(pdu_type="getreq", seqno=1, varid=0x2819, length=0x1234) +assert HCI_Cmd_VSC_CSR_BCCMD(raw(cmd)).length == 0x1234 + += The pdu_type and varid enums resolve to names +cmd = HCI_Cmd_VSC_CSR_BCCMD(pdu_type="setreq", varid=0x7003) +assert cmd.pdu_type == 0x0002 +assert cmd.varid == 0x7003 +assert cmd.sprintf("%pdu_type%") == "setreq" +assert cmd.sprintf("%varid%") == "ps" + += A GETREQ carrying a non-default value payload (PS-key door) round-trips +# The PS-key door (varid 0x7003) puts [pskey, nwords, stores] at the head of the +# value area; the contrib carries it as opaque bytes. +val = bytes.fromhex("010004000000") + b"\x00" * 8 +cmd = HCI_Cmd_VSC_CSR_BCCMD(pdu_type="getreq", seqno=0x42, varid=0x7003, value=val) +p = HCI_Cmd_VSC_CSR_BCCMD(raw(cmd)) +assert p.varid == 0x7003 +assert p.seqno == 0x42 +assert p.value == val +# length = (1 channel excluded) -> 10 header bytes + 14 value bytes = 24 -> 12 words +assert p.length == 12 + + ++ CSR BCCMD response (HCI vendor event, code 0xFF) + += The response is registered as an HCI_Event_Vendor handler +# The 0xFF event code is shared across vendors, so the CSR response is registered +# as a handler (its check() claims only BCCMD-shaped bodies) instead of rebound. +assert HCI_Event_VSC_CSR_BCCMD in HCI_Event_Vendor.registered_handlers +assert issubclass(HCI_Event_VSC_CSR_BCCMD, HCI_Event_Vendor) + += check() claims a 0xC2-led PDU of at least 11 bytes and nothing else +assert HCI_Event_VSC_CSR_BCCMD.check(b"\xc2" + b"\x00" * 10) +assert not HCI_Event_VSC_CSR_BCCMD.check(b"\xc2\x00") # too short +assert not HCI_Event_VSC_CSR_BCCMD.check(b"\x99" + b"\x00" * 10) # wrong channel +assert not HCI_Event_VSC_CSR_BCCMD.check(b"") + += A GETRESP vendor event dissects through the handler (buildid = 0x22bb) +# 04(evt) ff(vendor) 13(plen) c2(channel) 0100(getresp) 0900(length) 0100(seqno) +# 1928(varid buildid) 0000(status) bb22000000000000(value) +raw_evt = bytes.fromhex("04ff13c201000900010019280000bb22000000000000") +evt = HCI_Hdr(raw_evt) +assert HCI_Event_VSC_CSR_BCCMD in evt +r = evt[HCI_Event_VSC_CSR_BCCMD] +assert r.channel == 0xc2 +assert r.pdu_type == 0x0001 +assert r.get_field("pdu_type").i2repr(r, r.pdu_type) == "getresp" +assert r.length == 9 +assert r.seqno == 1 +assert r.varid == 0x2819 +assert r.status == 0 +assert r.value == bytes.fromhex("bb22000000000000") +# match_subclass keeps the handler reachable as the generic vendor event +assert HCI_Event_Vendor in evt +assert evt[HCI_Event_Vendor] is r + += Dissection is idempotent and rebuild reproduces the wire bytes +assert raw(HCI_Hdr(raw_evt)) == raw_evt +# rebuild from parsed fields with auto-length reproduces the PDU body +body = raw_evt[3:] +r2 = HCI_Event_VSC_CSR_BCCMD(channel=r.channel, pdu_type=r.pdu_type, + seqno=r.seqno, varid=r.varid, status=r.status, + value=r.value) +assert raw(r2) == body + += A vendor event that is not a BCCMD PDU falls back to the generic event +# No registered check claims it (channel byte is not 0xC2), so the body stays in +# HCI_Event_Vendor.data and other vendor contribs remain free to claim it. +p = HCI_Event_Hdr(b"\xff\x02\x99\xaa") +assert type(p[HCI_Event_Vendor]) is HCI_Event_Vendor +assert HCI_Event_VSC_CSR_BCCMD not in p +assert p[HCI_Event_Vendor].data == b"\x99\xaa" +assert raw(p) == b"\xff\x02\x99\xaa"