pwn 0.5.627 → 0.5.628

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,73 +3,147 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # True-air + detector-fallback decoder for Bluetooth.
7
- # Prefers PWN::FFI I/Q (RTL-SDR / ADALM-Pluto / HackRF / capture file)
8
- # via Base.run_iq; degrades to Base.run_detector with no hardware.
6
+ # Bluetooth LE (& BR/EDR sync-trailer) true-air decoder.
7
+ #
8
+ # I/Q → PWN::FFI::Liquid.gmsk_demod (or DSP.fm_demod_iq→NRZ) at
9
+ # 1 Mbit/s → hunt LSB-first Access Address (adv = 0x8E89BED6) →
10
+ # dewhiten (7-bit LFSR seeded ch|0x40) → PDU header (type/len) →
11
+ # AdvA (6 bytes) → CRC-24 (poly 0x65B, init 0x555555). Emits per-PDU
12
+ # {access_addr:, pdu_type:, adv_addr:, crc_ok:} — ubertooth-parity
13
+ # advertising sniff with no external binary.
9
14
  module Bluetooth
10
- # Streaming I/Q energy/burst demod for Base.run_iq.
15
+ BLE_ADV_AA = 0x8E89BED6
16
+ BLE_CRC_POLY = 0x65B
17
+ BLE_CRC_INIT = 0x555555
18
+ BLE_PDU_TYPE = {
19
+ 0 => 'ADV_IND', 1 => 'ADV_DIRECT_IND', 2 => 'ADV_NONCONN_IND',
20
+ 3 => 'SCAN_REQ', 4 => 'SCAN_RSP', 5 => 'CONNECT_IND',
21
+ 6 => 'ADV_SCAN_IND', 7 => 'ADV_EXT_IND'
22
+ }.freeze
23
+ BLE_ADV_CHANNELS = { 37 => 2_402_000_000, 38 => 2_426_000_000, 39 => 2_480_000_000 }.freeze
24
+ # BR/EDR: 64-bit sync word derived from LAP; general-inquiry LAP=0x9E8B33.
25
+ GIAC_LAP = 0x9E8B33
26
+
27
+ # Streaming BLE GFSK demod for Base.run_iq — I/Q → advertising PDUs.
11
28
  class DemodIQ
12
- def initialize(rate:, protocol:, modulation:, extra: {})
13
- @rate = rate.to_f
14
- @protocol = protocol
15
- @modulation = modulation
16
- @extra = extra
17
- @floor = nil
18
- @in_burst = false
19
- @burst_t0 = nil
20
- @peak = -200.0
21
- @burst_n = 0
22
- @threshold = (extra[:threshold] || 8.0).to_f
29
+ BAUD = 1_000_000
30
+
31
+ def initialize(rate:, channel: 37, ble: true)
32
+ @rate = rate.to_f
33
+ @channel = channel
34
+ @ble = ble
35
+ @bits = []
36
+ @seen = {}
23
37
  end
24
38
 
25
39
  def feed_iq(samples, rate: nil, &)
26
40
  @rate = rate.to_f if rate
27
- m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
28
- hop = [(@rate / 1000.0).round, 1].max
41
+ # Try both polarities — GFSK sign depends on tuner spectral inversion.
42
+ new_bits = PWN::SDR::Decoder::DSP.gfsk_slice(
43
+ iq: samples, rate: @rate, baud: BAUD, bt: 0.5
44
+ )
45
+ @bits.concat(new_bits)
46
+ scan(&) if block_given?
47
+ @bits.shift(@bits.length - 4096) if @bits.length > 65_536
48
+ end
49
+
50
+ private
51
+
52
+ # 32-bit AA is transmitted LSB-first on the air.
53
+ AA_BITS = Array.new(32) { |i| (BLE_ADV_AA >> i) & 1 }
54
+ AA_INV = AA_BITS.map { |b| b ^ 1 }.freeze
55
+
56
+ def scan
29
57
  i = 0
30
- while i < m2.length
31
- win = m2[i, hop]
32
- break if win.nil? || win.empty?
33
-
34
- ms = win.sum / win.length
35
- lvl = ms.positive? ? (10.0 * Math.log10(ms)) : -120.0
36
- @floor = @floor.nil? ? lvl : ((@floor * 0.98) + (lvl * 0.02))
37
- delta = lvl - @floor
38
- if delta >= @threshold
39
- unless @in_burst
40
- @in_burst = true
41
- @burst_t0 = Time.now
42
- @peak = lvl
58
+ while i <= @bits.length - (32 + 16 + 24)
59
+ inv = nil
60
+ inv = false if match_at?(i, AA_BITS)
61
+ inv = true if inv.nil? && match_at?(i, AA_INV)
62
+ unless inv.nil?
63
+ pdu = decode_pdu(@bits[(i + 32)..], inv)
64
+ if pdu
65
+ key = "#{pdu[:adv_addr]}:#{pdu[:pdu_type]}:#{pdu[:crc_rx]}"
66
+ unless @seen[key]
67
+ @seen[key] = true
68
+ @seen.shift if @seen.length > 256
69
+ yield pdu
70
+ end
71
+ i += 32 + ((2 + pdu[:length].to_i + 3) * 8)
72
+ next
43
73
  end
44
- @peak = lvl if lvl > @peak
45
- elsif @in_burst
46
- @in_burst = false
47
- @burst_n += 1
48
- dur_ms = ((Time.now - @burst_t0) * 1000).round
49
- msg = {
50
- protocol: @protocol, event: 'burst', source: 'iq',
51
- burst_no: @burst_n, peak_dbfs: @peak.round(1),
52
- floor_dbfs: @floor.round(1), delta_db: (@peak - @floor).round(1),
53
- duration_ms: dur_ms, modulation: @modulation,
54
- sample_rate: @rate.to_i
55
- }.merge(@extra.except(:threshold))
56
- # protocol-specific enrichment
57
- msg.merge!(self.class.enrich(msg: msg)) if self.class.respond_to?(:enrich)
58
- msg[:summary] = format(
59
- '%<p>s IQ-burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms',
60
- p: @protocol, n: @burst_n, pk: @peak, d: @peak - @floor, ms: dur_ms
61
- )
62
- yield msg if block_given?
63
74
  end
64
- i += hop
75
+ i += 1
65
76
  end
77
+ @bits.shift([i - 32, 0].max) if i > 32
78
+ end
79
+
80
+ def match_at?(idx, pat, max_err: 2)
81
+ err = 0
82
+ j = 0
83
+ while j < pat.length
84
+ err += 1 if @bits[idx + j] != pat[j]
85
+ return false if err > max_err
86
+
87
+ j += 1
88
+ end
89
+ true
90
+ end
91
+
92
+ def decode_pdu(stream, inv)
93
+ stream = stream.map { |b| b ^ 1 } if inv
94
+ hdr_b = PWN::SDR::Decoder::DSP.bytes_from_bits(bits: stream[0, 16], lsb_first: true)
95
+ hdr = PWN::SDR::Decoder::DSP.whiten_lfsr(
96
+ bytes: hdr_b, poly: 0x11, init: (@channel & 0x3F) | 0x40, width: 7
97
+ )
98
+ return nil if hdr.length < 2
99
+
100
+ ptype = hdr[0] & 0x0F
101
+ len = hdr[1] & 0xFF
102
+ return nil if len > 255 || (2 + len + 3) * 8 > stream.length
103
+
104
+ body_b = PWN::SDR::Decoder::DSP.bytes_from_bits(
105
+ bits: stream[0, (2 + len + 3) * 8], lsb_first: true
106
+ )
107
+ dewh = PWN::SDR::Decoder::DSP.whiten_lfsr(
108
+ bytes: body_b, poly: 0x11, init: (@channel & 0x3F) | 0x40, width: 7
109
+ )
110
+ payload = dewh[2, len] || []
111
+ crc_rx = dewh[2 + len, 3] || []
112
+ crc_ok = Bluetooth.ble_crc24(bytes: dewh[0, 2 + len]) ==
113
+ (crc_rx[0].to_i | (crc_rx[1].to_i << 8) | (crc_rx[2].to_i << 16))
114
+ adv_addr = payload.length >= 6 ? payload[0, 6].reverse.map { |b| format('%02X', b) }.join(':') : nil
115
+ {
116
+ protocol: @ble ? 'BLE' : 'BT-BR/EDR', event: 'pdu',
117
+ modulation: 'GFSK', channel: @channel,
118
+ access_addr: format('%08X', BLE_ADV_AA),
119
+ pdu_type: BLE_PDU_TYPE[ptype] || ptype,
120
+ tx_add: (hdr[0] >> 6) & 1, rx_add: (hdr[0] >> 7) & 1,
121
+ length: len, adv_addr: adv_addr, crc_ok: crc_ok,
122
+ crc_rx: crc_rx.map { |b| format('%02X', b) }.join,
123
+ payload_hex: payload.map { |b| format('%02X', b) }.join,
124
+ summary: "BLE #{BLE_PDU_TYPE[ptype] || ptype} AdvA=#{adv_addr} len=#{len} ch=#{@channel} crc=#{crc_ok ? 'OK' : 'BAD'}"
125
+ }
66
126
  end
127
+ end
67
128
 
68
- # Protocol-specific enrichment of an IQ-burst message (channel TBD).
69
- public_class_method def self.enrich(opts = {})
70
- msg = opts[:msg] || {}
71
- msg.merge(channel: nil)
129
+ # Supported Method Parameters::
130
+ # crc = PWN::SDR::Decoder::Bluetooth.ble_crc24(bytes: Array<Integer>)
131
+ # BLE CRC-24 (LSB-first LFSR, poly 0x65B, init 0x555555).
132
+
133
+ public_class_method def self.ble_crc24(opts = {})
134
+ bytes = opts[:bytes] || []
135
+ reg = BLE_CRC_INIT
136
+ bytes.each do |byte|
137
+ 8.times do |i|
138
+ b = (byte >> i) & 1
139
+ fb = (reg ^ b) & 1
140
+ reg >>= 1
141
+ reg ^= (BLE_CRC_POLY << 0) | 0xB4C000 if fb == 1
142
+ reg &= 0xFFFFFF
143
+ end
72
144
  end
145
+ # Register holds CRC LSB-first — return as-is (matched LSB-first on air)
146
+ reg
73
147
  end
74
148
 
75
149
  # Supported Method Parameters::
@@ -80,31 +154,20 @@ module PWN
80
154
  public_class_method def self.decode(opts = {})
81
155
  freq_obj = opts[:freq_obj]
82
156
  hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
83
- ble = freq_obj[:ble] || freq_obj[:mode].to_s.casecmp('ble').zero?
84
- ch = ble ? ((hz - 2_402_000_000) / 2_000_000).clamp(0, 39) : ((hz - 2_402_000_000) / 1_000_000).clamp(0, 78)
85
-
86
- rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_000_000).to_i
87
- proto = ble ? 'BLE' : 'BT-BR/EDR'
88
- demod = DemodIQ.new(
89
- rate: rate, protocol: proto, modulation: 'GFSK',
90
- extra: { threshold: 9.0 }
91
- )
157
+ ble = freq_obj[:ble] || freq_obj[:mode].to_s.casecmp('ble').zero? || true
158
+ # Nearest BLE advertising channel unless caller forces one.
159
+ ch = opts[:channel] ||
160
+ BLE_ADV_CHANNELS.min_by { |_, f| (f - hz).abs }&.first || 37
161
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 4_000_000).to_i
92
162
  PWN::SDR::Decoder::Base.run_iq(
93
163
  freq_obj: freq_obj,
94
- protocol: proto,
164
+ protocol: ble ? 'BLE' : 'BT-BR/EDR',
95
165
  sample_rate: rate,
96
166
  source: opts[:source],
97
167
  file: opts[:file],
98
- demod: demod,
99
- threshold: 9.0,
100
- note: '1 Mbit/s GFSK FHSS true-air I/Q path reports hop-burst density per tuned channel.',
101
- describe: proc { |b|
102
- { modulation: 'GFSK', channel: begin
103
- b[:channel]
104
- rescue StandardError
105
- nil
106
- end, hop_slots: (b[:duration_ms] / 0.625).round }
107
- }
168
+ demod: DemodIQ.new(rate: rate, channel: ch, ble: ble),
169
+ note: '1 Mbit/s GFSK — I/Q→gmskdem→AA 0x8E89BED6→dewhiten→PDU/AdvA/CRC-24.',
170
+ describe: proc { |b| { modulation: 'GFSK', channel: ch, hop_slots: (b[:duration_ms] / 0.625).round } }
108
171
  )
109
172
  end
110
173
 
@@ -132,9 +195,12 @@ module PWN
132
195
  #{self}.decode(
133
196
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
134
197
  source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
135
- file: 'optional - .cu8/.cs16 capture'
198
+ file: 'optional - .cu8/.cs16 capture (≥2 Msps)',
199
+ channel: 'optional - BLE adv channel 37|38|39 (default nearest to freq)'
136
200
  )
137
201
 
202
+ #{self}.ble_crc24(bytes: [..])
203
+
138
204
  #{self}.authors
139
205
  "
140
206
  end
@@ -3,66 +3,96 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # True-air + detector-fallback decoder for DECT.
7
- # Prefers PWN::FFI I/Q (RTL-SDR / ADALM-Pluto / HackRF / capture file)
8
- # via Base.run_iq; degrades to Base.run_detector with no hardware.
6
+ # DECT (ETSI EN 300 175) true-air decoder.
7
+ #
8
+ # 1.152 Mbit/s GFSK, 24-slot / 10 ms TDMA. I/Q PWN::FFI::Liquid
9
+ # gmskdem (or DSP.fm_demod_iq→NRZ) → hunt 32-bit S-field
10
+ # (16-bit preamble + 16-bit sync 0xE98A FP / 0x1675 PP) → A-field
11
+ # (64 bits: 8-bit header + 40-bit tail + 16-bit R-CRC) → RFPI
12
+ # extraction on Nt/Qt tails. Emits {rfpi:, role:, slot_est:, crc_ok:}.
9
13
  module DECT
10
- # Streaming I/Q energy/burst demod for Base.run_iq.
14
+ SYNC_FP = 0xAAAAE98A
15
+ SYNC_PP = 0x55551675
16
+ BAUD = 1_152_000
17
+ # R-CRC-16 poly x^16+x^10+x^8+x^7+x^3+1 = 0x0589, init 0x0000.
18
+ RCRC_POLY = 0x0589
19
+ A_TA = { 0 => 'Ct', 1 => 'Ct', 2 => 'Nt', 3 => 'Nt', 4 => 'Qt', 5 => 'Nt', 6 => 'Mt', 7 => 'Pt' }.freeze
20
+
21
+ # Streaming DECT GFSK demod for Base.run_iq — I/Q → RFPI/A-field.
11
22
  class DemodIQ
12
- def initialize(rate:, protocol:, modulation:, extra: {})
13
- @rate = rate.to_f
14
- @protocol = protocol
15
- @modulation = modulation
16
- @extra = extra
17
- @floor = nil
18
- @in_burst = false
19
- @burst_t0 = nil
20
- @peak = -200.0
21
- @burst_n = 0
22
- @threshold = (extra[:threshold] || 8.0).to_f
23
+ def initialize(rate:, carrier: nil)
24
+ @rate = rate.to_f
25
+ @carrier = carrier
26
+ @bits = []
27
+ @seen = {}
23
28
  end
24
29
 
25
30
  def feed_iq(samples, rate: nil, &)
26
31
  @rate = rate.to_f if rate
27
- m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
28
- hop = [(@rate / 1000.0).round, 1].max
29
- i = 0
30
- while i < m2.length
31
- win = m2[i, hop]
32
- break if win.nil? || win.empty?
32
+ [false, true].each do |inv|
33
+ nb = PWN::SDR::Decoder::DSP.gfsk_slice(
34
+ iq: samples, rate: @rate, baud: BAUD, bt: 0.5, invert: inv
35
+ )
36
+ scan(nb, inv, &) if block_given?
37
+ end
38
+ end
39
+
40
+ private
33
41
 
34
- ms = win.sum / win.length
35
- lvl = ms.positive? ? (10.0 * Math.log10(ms)) : -120.0
36
- @floor = @floor.nil? ? lvl : ((@floor * 0.98) + (lvl * 0.02))
37
- delta = lvl - @floor
38
- if delta >= @threshold
39
- unless @in_burst
40
- @in_burst = true
41
- @burst_t0 = Time.now
42
- @peak = lvl
42
+ def scan(bits, inv)
43
+ @bits.concat(bits)
44
+ fp = Array.new(32) { |i| (SYNC_FP >> (31 - i)) & 1 }
45
+ pp = Array.new(32) { |i| (SYNC_PP >> (31 - i)) & 1 }
46
+ i = 0
47
+ while i <= @bits.length - (32 + 64)
48
+ role = nil
49
+ role = 'FP' if err(@bits, i, fp) <= 3
50
+ role ||= 'PP' if err(@bits, i, pp) <= 3
51
+ if role
52
+ a = @bits[i + 32, 64]
53
+ hdr = PWN::SDR::Decoder::DSP.bits_to_int(bits: a[0, 8])
54
+ tail = a[8, 40]
55
+ rcrc = PWN::SDR::Decoder::DSP.bits_to_int(bits: a[48, 16])
56
+ bytes = PWN::SDR::Decoder::DSP.bytes_from_bits(bits: a[0, 48])
57
+ calc = PWN::SDR::Decoder::DSP.crc16(bytes: bytes, poly: RCRC_POLY, init: 0x0000)
58
+ # DECT R-CRC XORs the final register with 0x0001
59
+ crc_ok = ((calc ^ 0x0001) & 0xFFFF) == rcrc
60
+ ta = (hdr >> 5) & 0x7
61
+ rfpi = A_TA[ta] == 'Nt' ? tail[0, 40] : nil
62
+ rfpi_hex = rfpi ? format('%010X', PWN::SDR::Decoder::DSP.bits_to_int(bits: rfpi)) : nil
63
+ key = "#{role}:#{rfpi_hex}:#{format('%02X', hdr)}"
64
+ unless @seen[key] && crc_ok
65
+ @seen[key] = true if crc_ok
66
+ @seen.shift if @seen.length > 128
67
+ yield(
68
+ protocol: 'DECT', event: 'a_field', role: role,
69
+ modulation: 'GFSK', header: format('%02X', hdr),
70
+ ta: ta, ta_name: A_TA[ta], rfpi: rfpi_hex,
71
+ tail_hex: format('%010X', PWN::SDR::Decoder::DSP.bits_to_int(bits: tail)),
72
+ rcrc: format('%04X', rcrc), crc_ok: crc_ok,
73
+ carrier: @carrier, polarity_inverted: inv,
74
+ summary: "DECT #{role} TA=#{A_TA[ta]}#{" RFPI=#{rfpi_hex}" if rfpi_hex} R-CRC=#{crc_ok ? 'OK' : 'BAD'}"
75
+ )
43
76
  end
44
- @peak = lvl if lvl > @peak
45
- elsif @in_burst
46
- @in_burst = false
47
- @burst_n += 1
48
- dur_ms = ((Time.now - @burst_t0) * 1000).round
49
- msg = {
50
- protocol: @protocol, event: 'burst', source: 'iq',
51
- burst_no: @burst_n, peak_dbfs: @peak.round(1),
52
- floor_dbfs: @floor.round(1), delta_db: (@peak - @floor).round(1),
53
- duration_ms: dur_ms, modulation: @modulation,
54
- sample_rate: @rate.to_i
55
- }.merge(@extra.except(:threshold))
56
- # protocol-specific enrichment
57
- msg.merge!(self.class.enrich(msg)) if self.class.respond_to?(:enrich)
58
- msg[:summary] = format(
59
- '%<p>s IQ-burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms',
60
- p: @protocol, n: @burst_n, pk: @peak, d: @peak - @floor, ms: dur_ms
61
- )
62
- yield msg if block_given?
77
+ i += 32 + 64 + 320 # skip past B-field
78
+ else
79
+ i += 1
63
80
  end
64
- i += hop
65
81
  end
82
+ @bits.shift([i - 32, 0].max) if i > 32
83
+ @bits.shift(@bits.length - 8192) if @bits.length > 65_536
84
+ end
85
+
86
+ def err(bits, idx, pat)
87
+ e = 0
88
+ j = 0
89
+ while j < pat.length
90
+ e += 1 if bits[idx + j] != pat[j]
91
+ return 99 if e > 3
92
+
93
+ j += 1
94
+ end
95
+ e
66
96
  end
67
97
  end
68
98
 
@@ -73,23 +103,19 @@ module PWN
73
103
 
74
104
  public_class_method def self.decode(opts = {})
75
105
  freq_obj = opts[:freq_obj]
76
-
77
- rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_000_000).to_i
78
- proto = 'DECT'
79
- demod = DemodIQ.new(
80
- rate: rate, protocol: proto, modulation: 'GFSK',
81
- extra: { threshold: 7.0 }
82
- )
106
+ hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
107
+ # EU: carrier 0 = 1897.344 MHz, step 1.728 MHz down; US 1.9296 GHz.
108
+ carrier = ((1_897_344_000 - hz) / 1_728_000.0).round
109
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_304_000).to_i
83
110
  PWN::SDR::Decoder::Base.run_iq(
84
111
  freq_obj: freq_obj,
85
- protocol: proto,
112
+ protocol: 'DECT',
86
113
  sample_rate: rate,
87
114
  source: opts[:source],
88
115
  file: opts[:file],
89
- demod: demod,
90
- threshold: 7.0,
91
- note: '1.152 Mbit/s GFSK 24-slot TDMA true-air I/Q path reports slot bursts.',
92
- describe: proc { |b| { modulation: 'GFSK', tdma_slots: (b[:duration_ms] / 0.417).round, classification: (b[:duration_ms] / 0.417).round >= 24 ? 'FP-beacon-frame' : 'PP-burst' } }
116
+ demod: DemodIQ.new(rate: rate, carrier: carrier),
117
+ note: '1.152 Mbit/s GFSK — I/Q→gmskdem→S-field 0xE98A→A-field/RFPI/R-CRC.',
118
+ describe: proc { |b| { modulation: 'GFSK', tdma_slots: (b[:duration_ms] / 0.417).round } }
93
119
  )
94
120
  end
95
121
 
@@ -116,7 +142,7 @@ module PWN
116
142
  #{self}.decode(
117
143
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
118
144
  source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
119
- file: 'optional - .cu8/.cs16 capture'
145
+ file: 'optional - .cu8/.cs16 capture (≥2.304 Msps)'
120
146
  )
121
147
 
122
148
  #{self}.authors