pwn 0.5.620 → 0.5.621

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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/documentation/Agent-Tool-Registry.md +2 -2
  4. data/documentation/Cron.md +13 -0
  5. data/documentation/Diagrams.md +1 -1
  6. data/documentation/Extrospection.md +71 -5
  7. data/documentation/Home.md +2 -2
  8. data/documentation/How-PWN-Works.md +1 -1
  9. data/documentation/Mistakes.md +2 -1
  10. data/documentation/Persistence.md +2 -1
  11. data/documentation/Skills-Memory-Learning.md +1 -0
  12. data/documentation/Transparent-Browser.md +18 -1
  13. data/documentation/diagrams/agent-tool-registry.svg +55 -54
  14. data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
  15. data/documentation/diagrams/aws-cloud-security.svg +25 -25
  16. data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
  17. data/documentation/diagrams/code-scanning-sast.svg +25 -25
  18. data/documentation/diagrams/cron-scheduling.svg +23 -23
  19. data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
  20. data/documentation/diagrams/dot/extrospection-world-awareness.dot +29 -8
  21. data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -0
  22. data/documentation/diagrams/dot/overall-pwn-architecture.dot +2 -2
  23. data/documentation/diagrams/dot/persistence-filesystem.dot +2 -1
  24. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +10 -4
  25. data/documentation/diagrams/driver-framework.svg +13 -13
  26. data/documentation/diagrams/extrospection-world-awareness.svg +229 -99
  27. data/documentation/diagrams/fuzzing-workflow.svg +24 -24
  28. data/documentation/diagrams/hardware-hacking.svg +18 -18
  29. data/documentation/diagrams/history-to-drivers.svg +18 -18
  30. data/documentation/diagrams/memory-skills-detailed.svg +124 -101
  31. data/documentation/diagrams/mistakes-negative-feedback.svg +55 -55
  32. data/documentation/diagrams/network-infra-testing.svg +27 -27
  33. data/documentation/diagrams/overall-pwn-architecture.svg +85 -85
  34. data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
  35. data/documentation/diagrams/persistence-filesystem.svg +94 -85
  36. data/documentation/diagrams/plugin-ecosystem.svg +35 -35
  37. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +226 -180
  38. data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
  39. data/documentation/diagrams/reporting-pipeline.svg +18 -18
  40. data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
  41. data/documentation/diagrams/sdr-radio-flow.svg +31 -31
  42. data/documentation/diagrams/sessions-cron-automation.svg +18 -18
  43. data/documentation/diagrams/swarm-multi-agent.svg +39 -39
  44. data/documentation/diagrams/web-application-testing.svg +26 -26
  45. data/documentation/diagrams/zero-day-research-flow.svg +25 -25
  46. data/documentation/pwn-ai-Agent.md +1 -1
  47. data/lib/pwn/ai/agent/extrospection.rb +433 -4
  48. data/lib/pwn/ai/agent/tools/extrospection.rb +88 -3
  49. data/lib/pwn/sdr/decoder/adsb.rb +14 -33
  50. data/lib/pwn/sdr/decoder/apt.rb +97 -36
  51. data/lib/pwn/sdr/decoder/base.rb +258 -171
  52. data/lib/pwn/sdr/decoder/bluetooth.rb +13 -20
  53. data/lib/pwn/sdr/decoder/dect.rb +13 -27
  54. data/lib/pwn/sdr/decoder/dsp.rb +396 -0
  55. data/lib/pwn/sdr/decoder/flex.rb +177 -210
  56. data/lib/pwn/sdr/decoder/gps.rb +12 -21
  57. data/lib/pwn/sdr/decoder/gsm.rb +21 -73
  58. data/lib/pwn/sdr/decoder/iridium.rb +11 -26
  59. data/lib/pwn/sdr/decoder/lora.rb +16 -23
  60. data/lib/pwn/sdr/decoder/lte.rb +11 -32
  61. data/lib/pwn/sdr/decoder/morse.rb +95 -21
  62. data/lib/pwn/sdr/decoder/p25.rb +19 -16
  63. data/lib/pwn/sdr/decoder/pager.rb +22 -38
  64. data/lib/pwn/sdr/decoder/pocsag.rb +175 -68
  65. data/lib/pwn/sdr/decoder/rfid.rb +24 -33
  66. data/lib/pwn/sdr/decoder/rtl433.rb +21 -30
  67. data/lib/pwn/sdr/decoder/rtty.rb +100 -27
  68. data/lib/pwn/sdr/decoder/wifi.rb +21 -42
  69. data/lib/pwn/sdr/decoder/zigbee.rb +18 -40
  70. data/lib/pwn/sdr/decoder.rb +8 -0
  71. data/lib/pwn/version.rb +1 -1
  72. data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +15 -0
  73. data/third_party/pwn_rdoc.jsonl +24 -3
  74. metadata +3 -1
@@ -3,20 +3,21 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # POCSAG (CCIR Radiopaging Code No. 1) decoder for pager networks.
6
+ # Pure-Ruby POCSAG (CCIR Radiopaging Code No. 1) decoder.
7
7
  #
8
- # Pipeline (identical shape to PWN::SDR::Decoder::Flex):
9
- # GQRX 48 kHz s16le UDP audio sox 22 050 Hz → multimon-ng.
10
- #
11
- # multimon-ng emits lines of the form:
12
- # POCSAG1200: Address: 123456 Function: 3 Alpha: FIRE ALARM ZONE 4
13
- # POCSAG512: Address: 000042 Function: 0 Numeric: 5551234
14
- # POCSAG2400: Address: 987654 Function: 1 Skyper: ...
15
- # POCSAG1200: Address: 123456 Function: 2 Alpha: <partial><EOT>
16
- #
17
- # Each line is parsed into { protocol, baud, address/capcode, function,
18
- # function_desc, type, type_desc, message } and JSON-logged.
8
+ # GQRX supplies NBFM-discriminator audio on its 48 kHz UDP tap; for a
9
+ # 2-FSK pager channel that is already an NRZ baseband whose sign
10
+ # encodes the bit. This module NRZ-slices at 512/1200/2400 baud,
11
+ # locks onto the 32-bit Frame Sync Codeword (0x7CD215D8), then walks
12
+ # each 8-frame batch of BCH(31,21)+parity codewords, extracting
13
+ # address (RIC/capcode + function bits) and message codewords
14
+ # (numeric BCD or 7-bit ASCII). No `multimon-ng`, no `sox`.
19
15
  module POCSAG
16
+ FSC = 0x7CD215D8
17
+ IDLE_CW = 0x7A89C197
18
+ BAUDS = [1200, 512, 2400].freeze
19
+ BCD_TABLE = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', 'U', ' ', '-', ')', '('].freeze
20
+
20
21
  FUNCTION_DESC = {
21
22
  0 => 'Numeric (Tone/A)',
22
23
  1 => 'Tone only (B)',
@@ -24,73 +25,173 @@ module PWN
24
25
  3 => 'Alphanumeric (D)'
25
26
  }.freeze
26
27
 
27
- TYPE_DESC = {
28
- 'Alpha' => 'Alphanumeric text (7-bit ASCII)',
29
- 'Numeric' => 'Numeric-only (BCD)',
30
- 'Skyper' => 'Skyper network encoded',
31
- 'Tone' => 'Tone-only alert (no message body)'
32
- }.freeze
28
+ # Streaming POCSAG demodulator fed by Base.run_native.
29
+ class Demod
30
+ def initialize(rate: 48_000)
31
+ @rate = rate
32
+ @buf = []
33
+ @baud = nil
34
+ @inv = false
35
+ @carry_bits = []
36
+ end
33
37
 
34
- # Supported Method Parameters::
35
- # PWN::SDR::Decoder::POCSAG.decode(
36
- # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
37
- # )
38
+ def feed(samples, &)
39
+ @buf.concat(samples)
40
+ max = (@rate * 3.5).to_i
41
+ @buf.shift(@buf.length - max) if @buf.length > max
42
+ return if @buf.length < @rate # need ≥1 s to lock
38
43
 
39
- public_class_method def self.decode(opts = {})
40
- freq_obj = opts[:freq_obj]
44
+ try_lock unless @baud
45
+ return unless @baud
41
46
 
42
- # -e hide empty messages
43
- # -u heuristically prune unlikely decodes (cuts BCH false-positives)
44
- # -p emit partially received messages (still useful intel)
45
- # -f alpha force Alpha framing when function bits are ambiguous
46
- # mirrors the behaviour operators expect from Flex ALN.
47
- decode_cmd = 'multimon-ng -q -t raw -e -u -p -f alpha ' \
48
- '-a POCSAG512 -a POCSAG1200 -a POCSAG2400 -'
47
+ bits = PWN::SDR::Decoder::DSP.nrz_slice(samples: @buf, rate: @rate, baud: @baud, invert: @inv)
48
+ @buf.clear
49
+ bits = @carry_bits + bits
50
+ @carry_bits = POCSAG.decode_bits(bits: bits, baud: @baud, &)
51
+ end
49
52
 
50
- PWN::SDR::Decoder::Base.run_pipeline(
51
- freq_obj: freq_obj,
52
- protocol: 'POCSAG',
53
- required_bins: %w[sox multimon-ng],
54
- decode_cmd: decode_cmd,
55
- line_match: /^POCSAG(?:512|1200|2400):/,
56
- parser: proc { |line| parse_line(line: line) }
57
- )
53
+ private
54
+
55
+ # Try each baud × polarity until FSC (≤2 bit errors) is found.
56
+ def try_lock
57
+ hit = BAUDS.product([false, true]).find do |bd, inv|
58
+ bits = PWN::SDR::Decoder::DSP.nrz_slice(samples: @buf, rate: @rate, baud: bd, invert: inv)
59
+ PWN::SDR::Decoder::DSP.find_sync(bits: bits, pattern: FSC, width: 32, max_err: 2)
60
+ end
61
+ @baud, @inv = hit if hit
62
+ end
58
63
  end
59
64
 
60
65
  # Supported Method Parameters::
61
- # PWN::SDR::Decoder::POCSAG.parse_line(line: 'POCSAG1200: Address: ...')
66
+ # carry = PWN::SDR::Decoder::POCSAG.decode_bits(bits: [...], baud: 1200) { |msg| ... }
67
+ # Returns the trailing (unconsumed) bits so the caller can prepend
68
+ # them to the next chunk for streaming continuity.
69
+
70
+ public_class_method def self.decode_bits(opts = {})
71
+ bits = opts[:bits] || []
72
+ baud = opts[:baud]
73
+ i = 0
74
+ pending = nil
75
+ flush = proc do
76
+ yield assemble(pending: pending, baud: baud) if pending && block_given?
77
+ pending = nil
78
+ end
79
+ loop do
80
+ idx = PWN::SDR::Decoder::DSP.find_sync(bits: bits, pattern: FSC, width: 32, max_err: 2, from: i)
81
+ break unless idx
82
+
83
+ i = idx + 32
84
+ # One batch = 8 frames × 2 codewords × 32 bits = 512 bits
85
+ 8.times do |frame|
86
+ 2.times do
87
+ break if i + 32 > bits.length
88
+
89
+ cw = PWN::SDR::Decoder::DSP.bits_to_int(bits: bits[i, 32])
90
+ i += 32
91
+ next if [IDLE_CW, FSC].include?(cw)
92
+
93
+ if cw.nobits?(0x80000000)
94
+ flush.call
95
+ addr18 = (cw >> 13) & 0x3FFFF
96
+ func = (cw >> 11) & 0x3
97
+ ric = (addr18 << 3) | frame
98
+ pending = { ric: ric, func: func, msg_words: [] }
99
+ elsif pending
100
+ pending[:msg_words] << ((cw >> 11) & 0xFFFFF)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ flush.call
106
+ tail_from = [bits.length - 576, 0].max
107
+ bits[tail_from..] || []
108
+ end
62
109
 
63
- public_class_method def self.parse_line(opts = {})
64
- line = opts[:line].to_s
65
- out = { protocol: 'POCSAG', raw_inspected: line.inspect }
110
+ # Supported Method Parameters::
111
+ # h = PWN::SDR::Decoder::POCSAG.assemble(pending: {ric:,func:,msg_words:}, baud: 1200)
112
+
113
+ public_class_method def self.assemble(opts = {})
114
+ pending = opts[:pending]
115
+ baud = opts[:baud]
116
+ return {} unless pending
117
+
118
+ words = pending[:msg_words] || []
119
+ func = pending[:func]
120
+ type, text =
121
+ if words.empty?
122
+ ['Tone', nil]
123
+ elsif func == 3
124
+ ['Alpha', alpha_decode(words: words)]
125
+ else
126
+ ['Numeric', numeric_decode(words: words)]
127
+ end
128
+ out = {
129
+ protocol: 'POCSAG',
130
+ baud: baud,
131
+ address: pending[:ric],
132
+ capcode: pending[:ric].to_s.rjust(7, '0'),
133
+ function: func,
134
+ function_desc: FUNCTION_DESC[func] || 'Unknown',
135
+ type: type,
136
+ message: text
137
+ }.compact
138
+ summary = ["POCSAG#{baud}", "RIC=#{out[:capcode]}", "F#{func}(#{out[:function_desc]})"]
139
+ summary << "#{type}: #{text}" if text
140
+ out[:summary] = summary.join(' ')
141
+ out
142
+ end
66
143
 
67
- if (m = line.match(/^POCSAG(\d+):/))
68
- out[:baud] = m[1].to_i
69
- end
70
- if (m = line.match(/Address:\s*(\d+)/i))
71
- out[:address] = m[1].to_i
72
- out[:capcode] = m[1].rjust(7, '0')
144
+ # Supported Method Parameters::
145
+ # str = PWN::SDR::Decoder::POCSAG.numeric_decode(words: [Integer, ...])
146
+
147
+ public_class_method def self.numeric_decode(opts = {})
148
+ words = opts[:words] || []
149
+ out = +''
150
+ words.each do |w|
151
+ 5.times do |d|
152
+ nib = (w >> (16 - (d * 4))) & 0xF
153
+ # POCSAG BCD nibbles are bit-reversed within each 4-bit group
154
+ rev = ((nib & 1) << 3) | ((nib & 2) << 1) | ((nib & 4) >> 1) | ((nib & 8) >> 3)
155
+ out << BCD_TABLE[rev]
156
+ end
73
157
  end
74
- if (m = line.match(/Function:\s*(\d+)/i))
75
- fn = m[1].to_i
76
- out[:function] = fn
77
- out[:function_desc] = FUNCTION_DESC[fn] || 'Unknown'
158
+ out.gsub(/ +$/, '')
159
+ end
160
+
161
+ # Supported Method Parameters::
162
+ # str = PWN::SDR::Decoder::POCSAG.alpha_decode(words: [Integer, ...])
163
+
164
+ public_class_method def self.alpha_decode(opts = {})
165
+ words = opts[:words] || []
166
+ bitstream = []
167
+ words.each do |w|
168
+ 19.downto(0) { |b| bitstream << ((w >> b) & 1) }
78
169
  end
79
- if (m = line.match(/\b(Alpha|Numeric|Skyper|Tone):\s*(.*)$/i))
80
- type = m[1].capitalize
81
- out[:type] = type
82
- out[:type_desc] = TYPE_DESC[type] || 'Unknown'
83
- out[:type_payload] = m[2].to_s.gsub(/<[A-Z]{2,4}>/, '').strip
84
- out[:message] = out[:type_payload]
170
+ out = +''
171
+ bitstream.each_slice(7) do |ch|
172
+ break if ch.length < 7
173
+
174
+ # 7-bit ASCII, LSB first within each character
175
+ code = ch.each_with_index.sum { |b, i| b << i }
176
+ next if code.zero? || code == 0x03 || code == 0x17
177
+
178
+ out << (code.between?(0x20, 0x7E) ? code.chr : '.')
85
179
  end
180
+ out.strip
181
+ end
86
182
 
87
- bits = []
88
- bits << "POCSAG#{out[:baud]}" if out[:baud]
89
- bits << "RIC=#{out[:capcode]}" if out[:capcode]
90
- bits << "F#{out[:function]}(#{out[:function_desc]})" if out[:function]
91
- bits << "#{out[:type]}: #{out[:message]}" if out[:type]
92
- out[:summary] = bits.join(' ')
93
- out
183
+ # Supported Method Parameters::
184
+ # PWN::SDR::Decoder::POCSAG.decode(
185
+ # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
186
+ # )
187
+
188
+ public_class_method def self.decode(opts = {})
189
+ freq_obj = opts[:freq_obj]
190
+ PWN::SDR::Decoder::Base.run_native(
191
+ freq_obj: freq_obj,
192
+ protocol: 'POCSAG',
193
+ demod: Demod.new
194
+ )
94
195
  end
95
196
 
96
197
  # Author(s):: 0day Inc. <support@0dayinc.com>
@@ -102,12 +203,18 @@ module PWN
102
203
  # Display Usage for this Module
103
204
 
104
205
  public_class_method def self.help
105
- puts "USAGE:
206
+ puts "USAGE (ruby-native, no external binaries):
106
207
  #{self}.decode(
107
208
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
108
209
  )
109
210
 
110
- #{self}.parse_line(line: 'POCSAG1200: Address: 123456 Function: 3 Alpha: HELLO')
211
+ #{self}.decode_bits(bits: [0,1,...], baud: 1200) { |msg| ... }
212
+ #{self}.assemble(pending: {ric:, func:, msg_words:[]}, baud: 1200)
213
+ #{self}.numeric_decode(words: [Integer, ...])
214
+ #{self}.alpha_decode(words: [Integer, ...])
215
+
216
+ NOTE: Set GQRX to Narrow FM. Baud (512/1200/2400) and NRZ
217
+ polarity are auto-detected from the FSC 0x7CD215D8.
111
218
 
112
219
  #{self}.authors
113
220
  "
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'shellwords'
4
-
5
3
  module PWN
6
4
  module SDR
7
5
  module Decoder
8
- # RFID decoder covering LF (125/134 kHz), HF (13.56 MHz) and UHF
9
- # (860–960 MHz EPC Gen2). Near-field ASK/load-modulation is not
10
- # recoverable from GQRX audio, so this drives a Proxmark3 (LF/HF) via
11
- # `pm3 -c '...'` or `nfc-list` (HF), and `rtl_433` in flex/analyzer mode
12
- # for UHF backscatter whichever tool is present.
6
+ # Pure-Ruby RFID activity detector for LF (125/134 kHz), HF
7
+ # (13.56 MHz) and UHF (860–960 MHz EPC Gen2).
8
+ #
9
+ # Near-field ASK/load-modulation on LF/HF requires an inductive
10
+ # coupler (not an SDR antenna); UHF backscatter is 40–640 kbps ASK.
11
+ # Native mode reports reader-carrier presence and tag-response
12
+ # bursts by band. `parse_line` retained for offline text analysis.
13
13
  module RFID
14
14
  # Supported Method Parameters::
15
15
  # PWN::SDR::Decoder::RFID.decode(
@@ -18,30 +18,24 @@ module PWN
18
18
 
19
19
  public_class_method def self.decode(opts = {})
20
20
  freq_obj = opts[:freq_obj]
21
- raise 'ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)
22
-
23
21
  hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
24
-
25
- direct_cmd, bins, proto =
26
- if hz < 1_000_000
27
- ["pm3 -c 'lf search'", %w[pm3], 'RFID-LF']
28
- elsif hz.between?(13_000_000, 14_000_000)
29
- if PWN::SDR::Decoder::Base.bin_available?(bin: 'pm3')
30
- ["pm3 -c 'hf search'", %w[pm3], 'RFID-HF']
31
- else
32
- ['nfc-list -v', %w[nfc-list], 'RFID-HF']
33
- end
34
- else
35
- ["rtl_433 -f #{hz} -A -F json", %w[rtl_433], 'RFID-UHF']
36
- end
37
-
38
- PWN::SDR::Decoder::Base.run_pipeline(
22
+ band = if hz < 1_000_000 then 'LF'
23
+ elsif hz.between?(13_000_000, 14_000_000) then 'HF'
24
+ else 'UHF'
25
+ end
26
+ PWN::SDR::Decoder::Base.run_detector(
39
27
  freq_obj: freq_obj,
40
- protocol: proto,
41
- required_bins: bins,
42
- direct_cmd: direct_cmd,
43
- line_match: /(UID|EPC|TAG|ATQA|SAK|EM4|HID|ISO|Chipset|"model")/i,
44
- parser: proc { |line| parse_line(line: line) }
28
+ protocol: "RFID-#{band}",
29
+ note: 'Native mode reports reader-carrier and tag-backscatter bursts by band.',
30
+ threshold: 6.0,
31
+ describe: proc { |b|
32
+ kind = case band
33
+ when 'LF' then b[:duration_ms] > 100 ? 'reader-CW' : 'EM4x/HID-response'
34
+ when 'HF' then b[:duration_ms] > 5 ? 'ISO14443-REQA/frame' : 'ISO15693-slot'
35
+ else b[:duration_ms] > 20 ? 'reader-Query' : 'EPC-backscatter'
36
+ end
37
+ { band: band, modulation: 'ASK/load-mod', classification: kind }
38
+ }
45
39
  )
46
40
  end
47
41
 
@@ -69,14 +63,11 @@ module PWN
69
63
  # Display Usage for this Module
70
64
 
71
65
  public_class_method def self.help
72
- puts "USAGE:
66
+ puts "USAGE (ruby-native detector, no external binaries):
73
67
  #{self}.decode(
74
68
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
75
69
  )
76
70
 
77
- NOTE: LF/HF need a Proxmark3 (`pm3`) or libnfc reader; UHF uses
78
- `rtl_433 -A`. Band chosen automatically from freq_obj[:freq].
79
-
80
71
  #{self}.parse_line(line: 'UID: 04 A1 B2 C3 D4 E5 F6 SAK: 08 Mifare Classic 1K')
81
72
 
82
73
  #{self}.authors
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'shellwords'
5
4
 
6
5
  module PWN
7
6
  module SDR
8
7
  module Decoder
9
- # Generic ISM/keyfob/sensor decoder backed by `rtl_433`.
8
+ # Pure-Ruby ISM/keyfob/sensor activity detector for the 315 / 390 /
9
+ # 433.92 / 868 / 915 MHz device zoo.
10
10
  #
11
- # Covers the OOK/ASK/FSK device zoo on 300–315 / 390 / 433.92 / 868 /
12
- # 902–928 MHz: car/garage keyfobs, TPMS, weather stations, utility
13
- # meters, doorbells, alarm PIRs, etc. rtl_433 owns the SDR directly and
14
- # emits one JSON object per decoded frame (`-F json`), which this module
15
- # merges verbatim into the freq_obj log line.
11
+ # The upstream `rtl_433` binary carries ~250 device-specific protocol
12
+ # dissectors; re-implementing that library is out of scope. This
13
+ # module instead characterises OOK/ASK/FSK bursts natively (count,
14
+ # duration, gap, peak dBFS) enough to fingerprint a keyfob press,
15
+ # a periodic weather-station beacon, or a TPMS chirp — without
16
+ # invoking any external binary. `parse_line` still accepts rtl_433
17
+ # `-F json` output for offline analysis.
16
18
  module RTL433
17
19
  # Supported Method Parameters::
18
20
  # PWN::SDR::Decoder::RTL433.decode(
@@ -21,24 +23,18 @@ module PWN
21
23
 
22
24
  public_class_method def self.decode(opts = {})
23
25
  freq_obj = opts[:freq_obj]
24
- raise 'ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)
25
-
26
- hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
27
- gain = freq_obj[:rf_gain]
28
- sdr_args = freq_obj[:sdr_args].to_s
29
-
30
- cmd = ['rtl_433', '-f', hz.to_s, '-F', 'json', '-M', 'level', '-M', 'protocol']
31
- cmd.push('-g', gain.to_s) if gain
32
- cmd.push('-d', sdr_args) unless sdr_args.empty?
33
- direct_cmd = Shellwords.join(cmd)
34
-
35
- PWN::SDR::Decoder::Base.run_pipeline(
26
+ PWN::SDR::Decoder::Base.run_detector(
36
27
  freq_obj: freq_obj,
37
- protocol: 'RTL433',
38
- required_bins: %w[rtl_433],
39
- direct_cmd: direct_cmd,
40
- line_match: /^\s*{/,
41
- parser: proc { |line| parse_line(line: line) }
28
+ protocol: 'ISM-433',
29
+ note: 'Native OOK/FSK burst characteriser (no rtl_433 binary). Feed captured `rtl_433 -F json` lines to .parse_line for per-device decode.',
30
+ threshold: 10.0,
31
+ describe: proc { |b|
32
+ kind = if b[:duration_ms] < 20 then 'keyfob/OOK-short'
33
+ elsif b[:duration_ms] < 120 then 'sensor/OOK-packet'
34
+ else 'FSK-continuous'
35
+ end
36
+ { modulation: 'OOK/ASK/FSK', classification: kind }
37
+ }
42
38
  )
43
39
  end
44
40
 
@@ -53,7 +49,6 @@ module PWN
53
49
  { unparsed: line }
54
50
  end
55
51
  out = { protocol: 'RTL433' }.merge(h)
56
-
57
52
  bits = []
58
53
  bits << out[:model].to_s if out[:model]
59
54
  bits << "id=#{out[:id]}" if out[:id]
@@ -75,15 +70,11 @@ module PWN
75
70
  # Display Usage for this Module
76
71
 
77
72
  public_class_method def self.help
78
- puts "USAGE:
73
+ puts "USAGE (ruby-native detector, no external binaries):
79
74
  #{self}.decode(
80
75
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
81
76
  )
82
77
 
83
- NOTE: Requires `rtl_433`. Owns the SDR directly (pass
84
- freq_obj[:sdr_args] like ':1' or 'driver=hackrf' to select
85
- a device other than the one GQRX is holding).
86
-
87
78
  #{self}.parse_line(line: '{\"model\":\"Acurite-Tower\",\"id\":1234,...}')
88
79
 
89
80
  #{self}.authors
@@ -3,11 +3,102 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # RTTY (Radioteletype, ITA2/Baudot) decoder for the amateur RTTY
7
- # sub-bands (rtty20/rtty40/rtty80). GQRX supplies FM-demodulated audio
8
- # containing the classic 170 Hz-shift 45.45-baud mark/space tones;
9
- # `minimodem` recovers the 5-bit Baudot stream and prints ASCII.
6
+ # Pure-Ruby RTTY (Radioteletype, ITA2/Baudot) decoder.
7
+ #
8
+ # Amateur RTTY is 45.45 baud 2-FSK with a 170 Hz shift; on USB the
9
+ # convention is mark 2125 Hz, space 2295 Hz in the demodulated
10
+ # audio. This module runs a per-symbol Goertzel on both tones,
11
+ # frames 1-start / 5-data / 1.5-stop asynchronously, and decodes
12
+ # ITA2 via DSP::BAUDOT_LTRS/FIGS. No `minimodem`, no `sox`.
10
13
  module RTTY
14
+ # Streaming 2-FSK Baudot demodulator fed by Base.run_native.
15
+ class Demod
16
+ def initialize(rate: 48_000, baud: 45.45, mark_hz: 2125.0, space_hz: 2295.0)
17
+ @rate = rate
18
+ @baud = baud
19
+ @mark_hz = mark_hz
20
+ @space_hz = space_hz
21
+ @spb = rate / baud
22
+ @buf = []
23
+ @figs = false
24
+ @line = +''
25
+ @idle_bits = 0
26
+ end
27
+
28
+ def feed(samples, &)
29
+ @buf.concat(samples)
30
+ # keep at most ~2 s of audio buffered
31
+ @buf.shift(@buf.length - (@rate * 2)) if @buf.length > @rate * 2
32
+ demod_buffer(&)
33
+ end
34
+
35
+ private
36
+
37
+ def bit_at(offset)
38
+ a = offset.floor
39
+ b = (offset + @spb).floor
40
+ win = @buf[a...b]
41
+ pm = PWN::SDR::Decoder::DSP.goertzel(samples: win, rate: @rate, freq: @mark_hz)
42
+ ps = PWN::SDR::Decoder::DSP.goertzel(samples: win, rate: @rate, freq: @space_hz)
43
+ pm >= ps ? 1 : 0
44
+ end
45
+
46
+ # Async framing: idle = mark(1), start bit = space(0), 5 data bits
47
+ # LSB-first, ≥1 stop bit = mark(1).
48
+ def demod_buffer(&)
49
+ char_span = (@spb * 7.5).ceil
50
+ pos = 0.0
51
+ while @buf.length - pos > char_span
52
+ if bit_at(pos) == 1
53
+ pos += @spb
54
+ @idle_bits += 1
55
+ flush_line(&) if @idle_bits > 20 && !@line.empty?
56
+ next
57
+ end
58
+ @idle_bits = 0
59
+ # start bit found — sample 5 data bits at their centres
60
+ data = Array.new(5) { |i| bit_at(pos + (@spb * (i + 1))) }
61
+ stop = bit_at(pos + (@spb * 6))
62
+ pos += @spb * 7.5
63
+ next unless stop == 1
64
+
65
+ handle_code(data.each_with_index.sum { |b, i| b << i }, &)
66
+ end
67
+ consumed = pos.floor
68
+ @buf.shift(consumed) if consumed.positive?
69
+ end
70
+
71
+ def handle_code(code, &)
72
+ case code
73
+ when 31 then @figs = false
74
+ when 27 then @figs = true
75
+ else
76
+ tbl = @figs ? PWN::SDR::Decoder::DSP::BAUDOT_FIGS : PWN::SDR::Decoder::DSP::BAUDOT_LTRS
77
+ c = tbl[code]
78
+ return unless c
79
+
80
+ if ["\n", "\r"].include?(c)
81
+ flush_line(&)
82
+ else
83
+ @line << c
84
+ flush_line(&) if @line.length >= 80
85
+ end
86
+ end
87
+ end
88
+
89
+ def flush_line
90
+ return if @line.strip.empty?
91
+
92
+ out = { protocol: 'RTTY', baud: @baud, shift_hz: (@space_hz - @mark_hz).round, text: @line.dup }
93
+ if (m = @line.match(/\bDE\s+([A-Z0-9]{1,3}\d[A-Z]{1,4})\b/))
94
+ out[:callsign] = m[1]
95
+ end
96
+ out[:summary] = "RTTY #{@line.strip}"[0, 120]
97
+ yield out
98
+ @line = +''
99
+ end
100
+ end
101
+
11
102
  # Supported Method Parameters::
12
103
  # PWN::SDR::Decoder::RTTY.decode(
13
104
  # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
@@ -15,32 +106,13 @@ module PWN
15
106
 
16
107
  public_class_method def self.decode(opts = {})
17
108
  freq_obj = opts[:freq_obj]
18
-
19
- # minimodem reads s16le mono from stdin at the rate we hand it; keep
20
- # sox at 22 050 Hz (Base default) and tell minimodem the same.
21
- PWN::SDR::Decoder::Base.run_pipeline(
109
+ PWN::SDR::Decoder::Base.run_native(
22
110
  freq_obj: freq_obj,
23
111
  protocol: 'RTTY',
24
- required_bins: %w[sox minimodem],
25
- decode_cmd: 'minimodem --rx --quiet --rtty -R 22050 -f -',
26
- line_match: /\S/,
27
- parser: proc { |line| parse_line(line: line) }
112
+ demod: Demod.new
28
113
  )
29
114
  end
30
115
 
31
- # Supported Method Parameters::
32
- # PWN::SDR::Decoder::RTTY.parse_line(line: 'RYRYRY DE W1AW')
33
-
34
- public_class_method def self.parse_line(opts = {})
35
- line = opts[:line].to_s.strip
36
- out = { protocol: 'RTTY', text: line }
37
- if (m = line.match(/\bDE\s+([A-Z0-9]{1,3}\d[A-Z]{1,4})\b/))
38
- out[:callsign] = m[1]
39
- end
40
- out[:summary] = "RTTY #{line}"[0, 120]
41
- out
42
- end
43
-
44
116
  # Author(s):: 0day Inc. <support@0dayinc.com>
45
117
 
46
118
  public_class_method def self.authors
@@ -50,12 +122,13 @@ module PWN
50
122
  # Display Usage for this Module
51
123
 
52
124
  public_class_method def self.help
53
- puts "USAGE:
125
+ puts "USAGE (ruby-native 2-FSK ITA2, no external binaries):
54
126
  #{self}.decode(
55
127
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
56
128
  )
57
129
 
58
- #{self}.parse_line(line: 'RYRYRY CQ CQ DE W1AW')
130
+ NOTE: Set GQRX to USB, tune so mark≈2125 Hz / space≈2295 Hz in
131
+ the audio passband. 45.45 baud, 170 Hz shift, 1N5+1.5.
59
132
 
60
133
  #{self}.authors
61
134
  "