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,66 +3,144 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # True-air + detector-fallback decoder for ZigBee.
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
+ # IEEE 802.15.4 O-QPSK (2.4 GHz ZigBee/Thread) true-air decoder.
7
+ #
8
+ # 2 Mchip/s half-sine O-QPSK MSK, so I/Q → PWN::FFI::Liquid
9
+ # gmskdem (BT=0.5) at 2 Msps → chip stream. Each 4-bit symbol maps
10
+ # to a 32-chip PN sequence (Table 73, IEEE 802.15.4-2011); soft-
11
+ # correlate every 32 chips against the 16 sequences → symbols →
12
+ # nibbles → bytes. Hunt SHR (4×0x00 preamble + SFD 0xA7) → PHR len
13
+ # → MHR (FCF/seq/PAN/addr) → FCS (CRC-16-KERMIT). Emits per-frame
14
+ # {pan_id:, src:, dst:, frame_type:, len:, fcs_ok:}.
9
15
  module ZigBee
10
- # Streaming I/Q energy/burst demod for Base.run_iq.
16
+ CHIP_RATE = 2_000_000
17
+ # 16 × 32-chip PN sequences (symbol 0..15). Each row is one 32-bit
18
+ # word; chips are LSB-first (c0 = bit0).
19
+ PN32 = [
20
+ 0xD9C3522E, 0xED9C3522, 0x2ED9C352, 0x22ED9C35,
21
+ 0x522ED9C3, 0x3522ED9C, 0xC3522ED9, 0x9C3522ED,
22
+ 0x8C96077B, 0xB8C96077, 0x7B8C9607, 0x77B8C960,
23
+ 0x077B8C96, 0x6077B8C9, 0x96077B8C, 0xC96077B8
24
+ ].freeze
25
+ PN_CHIPS = PN32.map { |w| Array.new(32) { |i| (w >> i) & 1 } }.freeze
26
+ SFD = 0xA7
27
+ FRAME_TYPE = { 0 => 'Beacon', 1 => 'Data', 2 => 'ACK', 3 => 'MAC-Cmd' }.freeze
28
+
29
+ # Streaming O-QPSK/MSK chip demod for Base.run_iq — I/Q → 802.15.4 MPDU.
11
30
  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
31
+ def initialize(rate:, channel: nil)
32
+ @rate = rate.to_f
33
+ @channel = channel
34
+ @chips = []
35
+ @seen = {}
23
36
  end
24
37
 
25
38
  def feed_iq(samples, rate: nil, &)
26
39
  @rate = rate.to_f if rate
27
- m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
28
- hop = [(@rate / 1000.0).round, 1].max
40
+ chips = PWN::SDR::Decoder::DSP.gfsk_slice(
41
+ iq: samples, rate: @rate, baud: CHIP_RATE, bt: 0.5
42
+ )
43
+ @chips.concat(chips)
44
+ scan(&) if block_given?
45
+ @chips.shift(@chips.length - 8192) if @chips.length > 65_536
46
+ end
47
+
48
+ private
49
+
50
+ def correlate_symbol(win)
51
+ best = [0, -1]
52
+ PN_CHIPS.each_with_index do |pn, sym|
53
+ # ±1 correlation, ignore chip0/16 (Q-branch alignment tolerance)
54
+ s = 0
55
+ 32.times { |i| s += (win[i] == pn[i] ? 1 : -1) }
56
+ best = [sym, s] if s > best[1]
57
+ end
58
+ best
59
+ end
60
+
61
+ def chips_to_bytes(chips)
62
+ nsym = chips.length / 32
63
+ syms = Array.new(nsym) { |i| correlate_symbol(chips[i * 32, 32]).first }
64
+ # two nibbles → byte, LSB-nibble first (symbol 2n = low nibble)
65
+ out = []
66
+ syms.each_slice(2) { |lo, hi| out << ((hi.to_i << 4) | lo.to_i) if hi }
67
+ out
68
+ end
69
+
70
+ def scan(&)
71
+ # sliding-align on 32-chip boundary until 4 consecutive sym-0
29
72
  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
73
+ while i <= @chips.length - (32 * 12)
74
+ ok = true
75
+ 4.times do |p|
76
+ sym, sc = correlate_symbol(@chips[i + (p * 32), 32])
77
+ ok &&= sym.zero? && sc >= 20
78
+ break unless ok
79
+ end
80
+ if ok
81
+ # SFD (0xA7 = symbols 0x7, 0xA)
82
+ s5, = correlate_symbol(@chips[i + (8 * 32), 32])
83
+ s6, = correlate_symbol(@chips[i + (9 * 32), 32])
84
+ if s5 == 0x7 && s6 == 0xA
85
+ yield_frame(i, &)
86
+ # skip past SHR
87
+ i += 32 * 10
88
+ next
43
89
  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?
63
90
  end
64
- i += hop
91
+ i += 1
65
92
  end
93
+ @chips.shift([i - 32, 0].max) if i > 32
94
+ end
95
+
96
+ def yield_frame(shr_i)
97
+ phr_i = shr_i + (32 * 10)
98
+ return if phr_i + (32 * 2) > @chips.length
99
+
100
+ phr = chips_to_bytes(@chips[phr_i, 32 * 2]).first.to_i & 0x7F
101
+ body_i = phr_i + (32 * 2)
102
+ return if body_i + (phr * 32 * 2) > @chips.length
103
+
104
+ bytes = chips_to_bytes(@chips[body_i, phr * 32 * 2])
105
+ return if bytes.length < 3
106
+
107
+ fcs_rx = bytes[-2].to_i | (bytes[-1].to_i << 8)
108
+ fcs_ok = PWN::SDR::Decoder::DSP.crc16(
109
+ bytes: bytes[0, bytes.length - 2], poly: 0x1021,
110
+ init: 0x0000, refin: true, refout: true
111
+ ) == fcs_rx
112
+ fcf = bytes[0].to_i | (bytes[1].to_i << 8)
113
+ ftype = fcf & 0x7
114
+ seq = bytes[2]
115
+ dst_mode = (fcf >> 10) & 0x3
116
+ src_mode = (fcf >> 14) & 0x3
117
+ j = 3
118
+ pan = dst = src = nil
119
+ if dst_mode.positive?
120
+ pan = bytes[j, 2]&.reverse&.map { |b| format('%02X', b) }&.join
121
+ j += 2
122
+ dl = dst_mode == 3 ? 8 : 2
123
+ dst = bytes[j, dl]&.reverse&.map { |b| format('%02X', b) }&.join
124
+ j += dl
125
+ end
126
+ if src_mode.positive?
127
+ j += 2 unless (fcf >> 6).allbits?(1) # PAN-ID compression
128
+ sl = src_mode == 3 ? 8 : 2
129
+ src = bytes[j, sl]&.reverse&.map { |b| format('%02X', b) }&.join
130
+ end
131
+ key = "#{pan}:#{src}:#{seq}"
132
+ return if @seen[key]
133
+
134
+ @seen[key] = true
135
+ @seen.shift if @seen.length > 128
136
+ yield(
137
+ protocol: 'ZigBee', event: 'mpdu', modulation: 'O-QPSK',
138
+ channel: @channel, len: phr, seq: seq,
139
+ frame_type: FRAME_TYPE[ftype] || ftype, fcf: format('%04X', fcf),
140
+ pan_id: pan, dst: dst, src: src, fcs_ok: fcs_ok,
141
+ payload_hex: bytes.map { |b| format('%02X', b) }.join,
142
+ summary: "802.15.4 #{FRAME_TYPE[ftype] || ftype} PAN=#{pan} #{src}→#{dst} seq=#{seq} len=#{phr} FCS=#{fcs_ok ? 'OK' : 'BAD'}"
143
+ )
66
144
  end
67
145
  end
68
146
 
@@ -73,33 +151,28 @@ module PWN
73
151
 
74
152
  public_class_method def self.decode(opts = {})
75
153
  freq_obj = opts[:freq_obj]
76
-
77
- rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_000_000).to_i
78
- proto = 'ZigBee'
79
- demod = DemodIQ.new(
80
- rate: rate, protocol: proto, modulation: 'O-QPSK',
81
- extra: { threshold: 8.0 }
82
- )
154
+ hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
155
+ ch = ((hz - 2_405_000_000) / 5_000_000).round + 11
156
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 4_000_000).to_i
83
157
  PWN::SDR::Decoder::Base.run_iq(
84
158
  freq_obj: freq_obj,
85
- protocol: proto,
159
+ protocol: 'ZigBee',
86
160
  sample_rate: rate,
87
161
  source: opts[:source],
88
162
  file: opts[:file],
89
- demod: demod,
90
- threshold: 8.0,
91
- note: 'O-QPSK 250 kbit/s DSSS true-air I/Q path reports frame bursts on the tuned channel.',
92
- describe: proc { |b| { modulation: 'O-QPSK', symbol_rate: 62_500, classification: b[:duration_ms] < 5 ? 'ACK' : 'MAC-frame' } }
163
+ demod: DemodIQ.new(rate: rate, channel: ch),
164
+ note: 'O-QPSK 2 Mcps ≡ MSK — I/Q→gmskdem→32-chip PN correlate→SHR/SFD→PHR/MHR/FCS.',
165
+ describe: proc { |b| { modulation: 'O-QPSK', channel: ch, classification: b[:duration_ms] < 5 ? 'ACK' : 'MAC-frame' } }
93
166
  )
94
167
  end
95
168
 
96
169
  public_class_method def self.parse_line(opts = {})
97
170
  line = opts[:line].to_s
98
171
  out = { protocol: 'ZigBee' }
99
- out[:pan] = ::Regexp.last_match(1) if line =~ /PAN[:= ]+([0-9A-Fa-f]+)/i
100
- out[:src] = ::Regexp.last_match(1) if line =~ /src[:= ]+([0-9A-Fa-f:]+)/i
101
- out[:dst] = ::Regexp.last_match(1) if line =~ /dst[:= ]+([0-9A-Fa-f:]+)/i
102
- out[:cmd] = ::Regexp.last_match(1) if line =~ /\b(Beacon|Data|ACK|Cmd)\b/i
172
+ out[:pan] = ::Regexp.last_match(1) if line =~ /PAN[:= ]+([0-9A-Fa-f]+)/i
173
+ out[:src] = ::Regexp.last_match(1) if line =~ /src[:= ]+([0-9A-Fa-f:]+)/i
174
+ out[:dst] = ::Regexp.last_match(1) if line =~ /dst[:= ]+([0-9A-Fa-f:]+)/i
175
+ out[:cmd] = ::Regexp.last_match(1) if line =~ /\b(Beacon|Data|ACK|Cmd)\b/i
103
176
  out[:summary] = "ZigBee #{out[:cmd]} PAN=#{out[:pan]} #{out[:src]}→#{out[:dst]}"
104
177
  out.compact
105
178
  end
@@ -115,7 +188,7 @@ module PWN
115
188
  #{self}.decode(
116
189
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
117
190
  source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
118
- file: 'optional - .cu8/.cs16 capture'
191
+ file: 'optional - .cu8/.cs16 capture (≥4 Msps)'
119
192
  )
120
193
 
121
194
  #{self}.authors
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.627'
4
+ VERSION = '0.5.628'
5
5
  end
data/lib/pwn.rb CHANGED
@@ -18,6 +18,7 @@ module PWN
18
18
  autoload :Config, 'pwn/config'
19
19
  autoload :Cron, 'pwn/cron'
20
20
  autoload :Memory, 'pwn/memory'
21
+ autoload :MemoryIndex, 'pwn/memory_index'
21
22
  autoload :Sessions, 'pwn/sessions'
22
23
  autoload :Driver, 'pwn/driver'
23
24
  autoload :FFI, 'pwn/ffi'
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::MemoryIndex do
6
+ it 'should display information for authors' do
7
+ authors_response = PWN::MemoryIndex
8
+ expect(authors_response).to respond_to :authors
9
+ end
10
+
11
+ it 'should display information for existing help method' do
12
+ help_response = PWN::MemoryIndex
13
+ expect(help_response).to respond_to :help
14
+ end
15
+ end