pwn 0.5.621 → 0.5.626

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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/Gemfile +1 -1
  4. data/README.md +7 -1
  5. data/bin/pwn_gqrx_scanner +78 -9
  6. data/documentation/Agent-Tool-Registry.md +3 -3
  7. data/documentation/Configuration.md +301 -47
  8. data/documentation/Cron.md +5 -3
  9. data/documentation/Diagrams.md +1 -1
  10. data/documentation/Extrospection.md +472 -26
  11. data/documentation/FFI.md +72 -8
  12. data/documentation/Home.md +4 -4
  13. data/documentation/How-PWN-Works.md +7 -3
  14. data/documentation/Mistakes.md +9 -0
  15. data/documentation/SDR.md +83 -10
  16. data/documentation/Skills-Memory-Learning.md +101 -11
  17. data/documentation/diagrams/agent-tool-registry.svg +56 -55
  18. data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
  19. data/documentation/diagrams/aws-cloud-security.svg +25 -25
  20. data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
  21. data/documentation/diagrams/code-scanning-sast.svg +25 -25
  22. data/documentation/diagrams/cron-scheduling.svg +23 -23
  23. data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
  24. data/documentation/diagrams/dot/extrospection-world-awareness.dot +33 -6
  25. data/documentation/diagrams/dot/hardware-hacking.dot +3 -1
  26. data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -2
  27. data/documentation/diagrams/dot/mistakes-negative-feedback.dot +4 -2
  28. data/documentation/diagrams/dot/overall-pwn-architecture.dot +3 -3
  29. data/documentation/diagrams/dot/persistence-filesystem.dot +3 -3
  30. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +10 -4
  31. data/documentation/diagrams/dot/sdr-radio-flow.dot +39 -16
  32. data/documentation/diagrams/driver-framework.svg +13 -13
  33. data/documentation/diagrams/extrospection-world-awareness.svg +383 -177
  34. data/documentation/diagrams/fuzzing-workflow.svg +24 -24
  35. data/documentation/diagrams/hardware-hacking.svg +84 -69
  36. data/documentation/diagrams/history-to-drivers.svg +18 -18
  37. data/documentation/diagrams/memory-skills-detailed.svg +137 -120
  38. data/documentation/diagrams/mistakes-negative-feedback.svg +152 -137
  39. data/documentation/diagrams/network-infra-testing.svg +27 -27
  40. data/documentation/diagrams/overall-pwn-architecture.svg +97 -97
  41. data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
  42. data/documentation/diagrams/persistence-filesystem.svg +100 -94
  43. data/documentation/diagrams/plugin-ecosystem.svg +35 -35
  44. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +254 -207
  45. data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
  46. data/documentation/diagrams/reporting-pipeline.svg +18 -18
  47. data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
  48. data/documentation/diagrams/sdr-radio-flow.svg +223 -91
  49. data/documentation/diagrams/sessions-cron-automation.svg +18 -18
  50. data/documentation/diagrams/swarm-multi-agent.svg +39 -39
  51. data/documentation/diagrams/web-application-testing.svg +26 -26
  52. data/documentation/diagrams/zero-day-research-flow.svg +25 -25
  53. data/documentation/pwn-ai-Agent.md +16 -10
  54. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  55. data/lib/pwn/ai/agent/assembly.rb +1 -1
  56. data/lib/pwn/ai/agent/btc.rb +1 -1
  57. data/lib/pwn/ai/agent/burp_suite.rb +1 -1
  58. data/lib/pwn/ai/agent/extrospection.rb +2562 -57
  59. data/lib/pwn/ai/agent/gqrx.rb +2 -2
  60. data/lib/pwn/ai/agent/hacker_one.rb +1 -1
  61. data/lib/pwn/ai/agent/learning.rb +16 -16
  62. data/lib/pwn/ai/agent/loop.rb +1 -1
  63. data/lib/pwn/ai/agent/{introspection.rb → reflect.rb} +12 -12
  64. data/lib/pwn/ai/agent/sast.rb +1 -1
  65. data/lib/pwn/ai/agent/tools/extrospection.rb +311 -8
  66. data/lib/pwn/ai/agent/tools/learning.rb +7 -7
  67. data/lib/pwn/ai/agent/transparent_browser.rb +1 -1
  68. data/lib/pwn/ai/agent/vuln_gen.rb +2 -2
  69. data/lib/pwn/ai/agent.rb +1 -1
  70. data/lib/pwn/ai/open_ai.rb +10 -6
  71. data/lib/pwn/config.rb +5 -4
  72. data/lib/pwn/ffi/adalm_pluto.rb +498 -0
  73. data/lib/pwn/ffi/fftw.rb +192 -0
  74. data/lib/pwn/ffi/hack_rf.rb +193 -0
  75. data/lib/pwn/ffi/liquid.rb +244 -0
  76. data/lib/pwn/ffi/rtl_sdr.rb +210 -0
  77. data/lib/pwn/ffi/soapy_sdr.rb +177 -0
  78. data/lib/pwn/ffi/stdio.rb +9 -1
  79. data/lib/pwn/ffi/volk.rb +241 -0
  80. data/lib/pwn/ffi.rb +45 -2
  81. data/lib/pwn/plugins/burp_suite.rb +2 -2
  82. data/lib/pwn/sast/pom_version.rb +2 -2
  83. data/lib/pwn/sast/test_case_engine.rb +1 -1
  84. data/lib/pwn/sdr/decoder/adsb.rb +201 -12
  85. data/lib/pwn/sdr/decoder/apt.rb +19 -5
  86. data/lib/pwn/sdr/decoder/base.rb +314 -1
  87. data/lib/pwn/sdr/decoder/bluetooth.rb +91 -18
  88. data/lib/pwn/sdr/decoder/dect.rb +81 -23
  89. data/lib/pwn/sdr/decoder/dsp.rb +229 -15
  90. data/lib/pwn/sdr/decoder/flex.rb +23 -6
  91. data/lib/pwn/sdr/decoder/gps.rb +80 -18
  92. data/lib/pwn/sdr/decoder/gsm.rb +83 -28
  93. data/lib/pwn/sdr/decoder/iridium.rb +80 -18
  94. data/lib/pwn/sdr/decoder/lora.rb +84 -28
  95. data/lib/pwn/sdr/decoder/lte.rb +80 -18
  96. data/lib/pwn/sdr/decoder/morse.rb +23 -6
  97. data/lib/pwn/sdr/decoder/p25.rb +90 -31
  98. data/lib/pwn/sdr/decoder/pager.rb +23 -6
  99. data/lib/pwn/sdr/decoder/pocsag.rb +23 -6
  100. data/lib/pwn/sdr/decoder/rds.rb +219 -52
  101. data/lib/pwn/sdr/decoder/rfid.rb +81 -27
  102. data/lib/pwn/sdr/decoder/rtl433.rb +85 -27
  103. data/lib/pwn/sdr/decoder/rtty.rb +23 -6
  104. data/lib/pwn/sdr/decoder/wifi.rb +86 -32
  105. data/lib/pwn/sdr/decoder/zigbee.rb +88 -35
  106. data/lib/pwn/sdr/decoder.rb +6 -5
  107. data/lib/pwn/sdr/frequency_allocation.rb +124 -81
  108. data/lib/pwn/sdr/gqrx.rb +1513 -165
  109. data/lib/pwn/version.rb +1 -1
  110. data/spec/lib/pwn/ai/agent/extrospection_spec.rb +96 -0
  111. data/spec/lib/pwn/ai/agent/{introspection_spec.rb → reflect_spec.rb} +3 -3
  112. data/spec/lib/pwn/ai/agent/tools/extrospection_spec.rb +8 -24
  113. data/spec/lib/pwn/ffi/adalm_pluto_spec.rb +37 -0
  114. data/spec/lib/pwn/ffi/fftw_spec.rb +26 -0
  115. data/spec/lib/pwn/ffi/hack_rf_spec.rb +25 -0
  116. data/spec/lib/pwn/ffi/liquid_spec.rb +42 -0
  117. data/spec/lib/pwn/ffi/rtl_sdr_spec.rb +24 -0
  118. data/spec/lib/pwn/ffi/soapy_sdr_spec.rb +25 -0
  119. data/spec/lib/pwn/ffi/volk_spec.rb +36 -0
  120. data/spec/lib/pwn/sdr/decoder/base_iq_spec.rb +65 -0
  121. data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +50 -4
  122. data/spec/lib/pwn/sdr/decoder/rds_spec.rb +45 -4
  123. data/third_party/pwn_rdoc.jsonl +279 -30
  124. metadata +21 -5
@@ -11,7 +11,7 @@ module PWN
11
11
  # Shared, 100 % Ruby-native pipeline plumbing for every
12
12
  # PWN::SDR::Decoder::* module.
13
13
  #
14
- # Two entry points, neither of which shell out to any external binary:
14
+ # Three entry points, none of which shell out to any external binary:
15
15
  #
16
16
  # run_native — Bind the GQRX 48 kHz s16le mono UDP audio tap, unpack
17
17
  # the samples with PWN::SDR::Decoder::DSP, hand each
@@ -21,6 +21,14 @@ module PWN
21
21
  # pretty-printed, JSONL-logged, and shown on the
22
22
  # spinner. [ENTER] stops cleanly.
23
23
  #
24
+ # run_iq — True-air path. Opens a real SDR front-end via
25
+ # PWN::FFI::{RTLSdr,HackRF,AdalmPluto,SoapySDR} (or
26
+ # reads a capture file), streams interleaved I/Q into
27
+ # a demod that responds to `#feed_iq(iq, rate:, &emit)`
28
+ # (or `#feed` after optional FM-demod). Falls back to
29
+ # run_detector when no hardware/file source is present
30
+ # so the operator still gets structured output.
31
+ #
24
32
  # run_detector — For protocols whose bit-rate/bandwidth cannot be
25
33
  # recovered from a 48 kHz demodulated-audio tap (GSM,
26
34
  # LTE, ADS-B, WiFi, LoRa, GPS, DECT, ZigBee, Bluetooth,
@@ -302,6 +310,299 @@ module PWN
302
310
  end
303
311
  end
304
312
 
313
+ # Supported Method Parameters::
314
+ # src = PWN::SDR::Decoder::Base.resolve_iq_source(
315
+ # freq_obj: 'required',
316
+ # source: 'optional - :auto|:rtlsdr|:hackrf|:adalm_pluto|:soapy|:file',
317
+ # sample_rate: 'optional - desired rate Hz',
318
+ # file: 'optional - path to .cu8/.cs16/.iq capture'
319
+ # )
320
+ # Returns { kind:, rate_hz:, ... } handle, or nil if nothing available.
321
+
322
+ public_class_method def self.resolve_iq_source(opts = {})
323
+ freq_obj = opts[:freq_obj] || {}
324
+ want = (opts[:source] || freq_obj[:iq_source] || :auto).to_s.downcase.to_sym
325
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_048_000).to_i
326
+ file = opts[:file] || freq_obj[:iq_file]
327
+ freq_hz = begin
328
+ PWN::SDR.hz_to_i(freq: freq_obj[:freq])
329
+ rescue StandardError
330
+ freq_obj[:freq].to_i
331
+ end
332
+
333
+ try = lambda do |kind|
334
+ case kind
335
+ when :file
336
+ return nil if file.to_s.empty? || !File.file?(file.to_s)
337
+
338
+ fmt = opts[:iq_format] || freq_obj[:iq_format]
339
+ fmt ||= file.to_s.end_with?('.cs16', '.sc16') ? :cs16 : :cu8
340
+ { kind: :file, path: file.to_s, format: fmt.to_sym, rate_hz: rate, freq_hz: freq_hz }
341
+ when :rtlsdr
342
+ return nil unless PWN::FFI.available?(mod: :RTLSdr)
343
+ return nil if PWN::FFI::RTLSdr.list_devices.empty?
344
+
345
+ dev = PWN::FFI::RTLSdr.open(index: (opts[:index] || 0).to_i)
346
+ PWN::FFI::RTLSdr.configure(
347
+ device: dev, freq_hz: freq_hz, rate_hz: rate,
348
+ gain_db: opts[:gain_db] || freq_obj[:gain_db],
349
+ ppm: opts[:ppm] || freq_obj[:ppm] || 0
350
+ )
351
+ { kind: :rtlsdr, device: dev, rate_hz: rate, freq_hz: freq_hz, format: :cu8 }
352
+ when :hackrf
353
+ return nil unless PWN::FFI.available?(mod: :HackRF)
354
+
355
+ dev = PWN::FFI::HackRF.open(serial: opts[:serial])
356
+ PWN::FFI::HackRF.configure(
357
+ device: dev, freq_hz: freq_hz, rate_hz: rate,
358
+ lna_gain: opts[:lna_gain] || 16,
359
+ vga_gain: opts[:vga_gain] || 20,
360
+ amp: opts[:amp]
361
+ )
362
+ { kind: :hackrf, device: dev, rate_hz: rate, freq_hz: freq_hz, format: :cs8 }
363
+ when :adalm_pluto, :pluto
364
+ return nil unless PWN::FFI.available?(mod: :AdalmPluto)
365
+
366
+ ctx = PWN::FFI::AdalmPluto.open(uri: opts[:uri] || freq_obj[:pluto_uri])
367
+ PWN::FFI::AdalmPluto.configure(
368
+ context: ctx, freq_hz: freq_hz, rate_hz: rate,
369
+ gain_db: opts[:gain_db] || freq_obj[:gain_db]
370
+ )
371
+ handle = PWN::FFI::AdalmPluto.start_rx(
372
+ context: ctx,
373
+ samples: (opts[:chunk_samples] || 262_144).to_i
374
+ )
375
+ { kind: :adalm_pluto, context: ctx, handle: handle, rate_hz: rate, freq_hz: freq_hz, format: :cs16 }
376
+ when :soapy
377
+ return nil unless PWN::FFI.available?(mod: :SoapySDR)
378
+
379
+ # Discovery only today — Soapy streaming is layered later.
380
+ devs = PWN::FFI::SoapySDR.list_devices
381
+ return nil if devs.empty?
382
+
383
+ { kind: :soapy, devices: devs, rate_hz: rate, freq_hz: freq_hz, format: :cf32, streaming: false }
384
+ end
385
+ rescue StandardError
386
+ nil
387
+ end
388
+
389
+ order =
390
+ case want
391
+ when :auto
392
+ %i[file rtlsdr adalm_pluto hackrf soapy]
393
+ else
394
+ [want]
395
+ end
396
+ order.each do |k|
397
+ h = try.call(k)
398
+ return h if h
399
+ end
400
+ nil
401
+ end
402
+
403
+ # Supported Method Parameters::
404
+ # chunk = PWN::SDR::Decoder::Base.read_iq_chunk(source: handle, bytes: 262_144)
405
+ # Returns raw binary String in the source's native format, or nil on EOF.
406
+
407
+ public_class_method def self.read_iq_chunk(opts = {})
408
+ src = opts[:source]
409
+ bytes = (opts[:bytes] || 262_144).to_i
410
+ raise 'ERROR: :source required' unless src.is_a?(Hash)
411
+
412
+ case src[:kind]
413
+ when :file
414
+ src[:io] ||= File.open(src[:path], 'rb')
415
+ data = src[:io].read(bytes)
416
+ data.to_s.empty? ? nil : data
417
+ when :rtlsdr
418
+ PWN::FFI::RTLSdr.read_sync(device: src[:device], bytes: bytes)
419
+ when :adalm_pluto
420
+ PWN::FFI::AdalmPluto.read_sync(handle: src[:handle])
421
+ when :hackrf, :soapy
422
+ # HackRF is callback-based; Soapy streaming layered later.
423
+ nil
424
+ end
425
+ rescue StandardError
426
+ nil
427
+ end
428
+
429
+ # Supported Method Parameters::
430
+ # iq = PWN::SDR::Decoder::Base.unpack_iq(source: handle, data: raw_string)
431
+ # → interleaved Array<Float> [I0,Q0,…]
432
+
433
+ public_class_method def self.unpack_iq(opts = {})
434
+ src = opts[:source] || {}
435
+ data = opts[:data].to_s
436
+ case (src[:format] || :cu8).to_sym
437
+ when :cs16 then PWN::SDR::Decoder::DSP.unpack_cs16le(data: data)
438
+ when :cs8
439
+ # HackRF signed 8-bit interleaved I/Q
440
+ data.unpack('c*').map { |v| v / 128.0 }
441
+ else # :cu8 / unknown
442
+ PWN::SDR::Decoder::DSP.unpack_cu8(data: data)
443
+ end
444
+ end
445
+
446
+ # Supported Method Parameters::
447
+ # PWN::SDR::Decoder::Base.close_iq_source(source: handle)
448
+
449
+ public_class_method def self.close_iq_source(opts = {})
450
+ src = opts[:source]
451
+ return unless src.is_a?(Hash)
452
+
453
+ case src[:kind]
454
+ when :file
455
+ src[:io]&.close
456
+ when :rtlsdr
457
+ PWN::FFI::RTLSdr.close(device: src[:device]) if src[:device]
458
+ when :adalm_pluto
459
+ PWN::FFI::AdalmPluto.stop_rx(handle: src[:handle]) if src[:handle]
460
+ PWN::FFI::AdalmPluto.close(context: src[:context]) if src[:context]
461
+ when :hackrf
462
+ PWN::FFI::HackRF.close(device: src[:device]) if src[:device]
463
+ end
464
+ rescue StandardError
465
+ nil
466
+ ensure
467
+ src[:io] = nil if src.is_a?(Hash)
468
+ end
469
+
470
+ # Supported Method Parameters::
471
+ # PWN::SDR::Decoder::Base.run_iq(
472
+ # freq_obj: 'required - freq_obj Hash',
473
+ # protocol: 'required - short name',
474
+ # demod: 'required - object with #feed_iq(iq, rate:, &emit)
475
+ # OR #feed(samples, &emit) when fm_demod:true',
476
+ # sample_rate: 'optional - Hz (default 2_048_000)',
477
+ # source: 'optional - :auto|:rtlsdr|:hackrf|:adalm_pluto|:file',
478
+ # file: 'optional - path to capture',
479
+ # fm_demod: 'optional - FM-demod I/Q→audio then #feed (default false)',
480
+ # chunk_bytes: 'optional - bytes per read (default 262144)',
481
+ # fallback: 'optional - :detector|:raise|:silent (default :detector)',
482
+ # note: 'optional - shown once when falling back',
483
+ # describe: 'optional - Proc for detector fallback'
484
+ # )
485
+
486
+ public_class_method def self.run_iq(opts = {})
487
+ freq_obj = opts[:freq_obj]
488
+ protocol = opts[:protocol] || 'SIGNAL'
489
+ demod = opts[:demod]
490
+ rate = (opts[:sample_rate] || 2_048_000).to_i
491
+ fm_demod = opts[:fm_demod] ? true : false
492
+ chunk_b = (opts[:chunk_bytes] || 262_144).to_i
493
+ fallback = (opts[:fallback] || :detector).to_sym
494
+
495
+ raise 'ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)
496
+ raise 'ERROR: :demod required' if demod.nil?
497
+
498
+ src = resolve_iq_source(
499
+ freq_obj: freq_obj,
500
+ source: opts[:source],
501
+ sample_rate: rate,
502
+ file: opts[:file],
503
+ gain_db: opts[:gain_db],
504
+ uri: opts[:uri],
505
+ index: opts[:index],
506
+ chunk_samples: chunk_b / 4
507
+ )
508
+
509
+ unless src && (src[:kind] != :soapy || src[:streaming] != false)
510
+ case fallback
511
+ when :raise
512
+ raise 'ERROR: no I/Q source available (RTL-SDR / ADALM-Pluto / HackRF / file)'
513
+ when :silent
514
+ return nil
515
+ else
516
+ return run_detector(
517
+ freq_obj: freq_obj,
518
+ protocol: protocol,
519
+ note: opts[:note] || "No I/Q source — falling back to energy detector. Plug in RTL-SDR/Pluto or pass iq_file: for true-air decode of #{protocol}.",
520
+ threshold: opts[:threshold],
521
+ describe: opts[:describe]
522
+ )
523
+ end
524
+ end
525
+
526
+ rate = src[:rate_hz] if src[:rate_hz]
527
+ log_obj = strip_freq_obj(freq_obj: freq_obj).merge(
528
+ iq_source: src[:kind], iq_rate: rate, iq_format: src[:format]
529
+ )
530
+
531
+ puts JSON.pretty_generate(log_obj)
532
+ puts "\n*** #{protocol} Decoder (true-air I/Q via #{src[:kind]}) ***"
533
+ puts 'Press [ENTER] to continue to next frequency...'
534
+
535
+ spinner, max_len = build_spinner(
536
+ banner: "INFO: Air-decoding #{protocol} @ #{rate} Hz from #{src[:kind]}"
537
+ )
538
+ log_file = log_path(protocol: protocol)
539
+ current_line = "Streaming I/Q from #{src[:kind]}..."
540
+ iq_q = Queue.new
541
+ stop = false
542
+
543
+ reader_thread = Thread.new do
544
+ loop do
545
+ break if stop
546
+
547
+ raw = read_iq_chunk(source: src, bytes: chunk_b)
548
+ break if raw.nil?
549
+
550
+ iq_q.push(raw) if raw.bytesize.positive?
551
+ end
552
+ rescue StandardError => e
553
+ current_line = "iq-reader: #{e.class}: #{e.message}"
554
+ ensure
555
+ iq_q.push(:eof)
556
+ end
557
+
558
+ decoder_thread = Thread.new do
559
+ emit = proc do |msg|
560
+ next unless msg.is_a?(Hash)
561
+
562
+ final = log_obj.merge(decoded_at: Time.now.strftime('%Y-%m-%d %H:%M:%S%z')).merge(msg)
563
+ spinner.stop
564
+ puts JSON.pretty_generate(final)
565
+ spinner.auto_spin
566
+ File.open(log_file, 'a') { |f| f.puts("#{JSON.generate(final)},") }
567
+ disp = (msg[:summary] || msg[:raw] || msg.values.compact.first).to_s
568
+ current_line = disp[0...max_len]
569
+ end
570
+
571
+ loop do
572
+ raw = iq_q.pop
573
+ break if raw == :eof || stop
574
+ next unless raw.is_a?(String)
575
+
576
+ iq = unpack_iq(source: src, data: raw)
577
+ if fm_demod && demod.respond_to?(:feed)
578
+ audio = PWN::SDR::Decoder::DSP.fm_demod_iq(iq: iq)
579
+ demod.feed(audio, &emit)
580
+ elsif demod.respond_to?(:feed_iq)
581
+ demod.feed_iq(iq, rate: rate, &emit)
582
+ elsif demod.respond_to?(:feed)
583
+ # magnitude envelope as last-resort real signal
584
+ demod.feed(PWN::SDR::Decoder::DSP.mag_sq(iq: iq).map { |v| Math.sqrt(v) }, &emit)
585
+ else
586
+ raise 'ERROR: demod must respond to #feed_iq or #feed'
587
+ end
588
+ end
589
+ rescue StandardError => e
590
+ current_line = "iq-demod: #{e.class}: #{e.message}"
591
+ end
592
+
593
+ wait_for_enter(spinner: spinner, title_ref: -> { current_line })
594
+ stop = true
595
+ spinner.success('Air-decode stopped')
596
+ rescue StandardError => e
597
+ spinner&.error("Air-decode failed: #{e.message}") if defined?(spinner)
598
+ raise
599
+ ensure
600
+ stop = true if defined?(stop)
601
+ [reader_thread, decoder_thread].compact.each { |th| th.kill if th&.alive? }
602
+ close_iq_source(source: src) if defined?(src) && src
603
+ spinner&.stop if defined?(spinner)
604
+ end
605
+
305
606
  # Author(s):: 0day Inc. <support@0dayinc.com>
306
607
 
307
608
  public_class_method def self.authors
@@ -319,6 +620,17 @@ module PWN
319
620
  rate: 'optional - UDP sample rate (default 48000)'
320
621
  )
321
622
 
623
+ #{self}.run_iq(
624
+ freq_obj: 'required - freq_obj Hash',
625
+ protocol: 'required - short protocol name',
626
+ demod: 'required - #feed_iq(iq, rate:, &emit) or #feed',
627
+ sample_rate: 'optional - Hz (default 2_048_000)',
628
+ source: 'optional - :auto|:rtlsdr|:hackrf|:adalm_pluto|:file',
629
+ file: 'optional - .cu8/.cs16 capture path',
630
+ fm_demod: 'optional - FM-demod then #feed (default false)',
631
+ fallback: 'optional - :detector|:raise|:silent (default :detector)'
632
+ )
633
+
322
634
  #{self}.run_detector(
323
635
  freq_obj: 'required - freq_obj from PWN::SDR::GQRX.init_freq',
324
636
  protocol: 'required - short protocol name',
@@ -327,6 +639,7 @@ module PWN
327
639
  describe: 'optional - Proc { |burst| Hash } extra fields'
328
640
  )
329
641
 
642
+ #{self}.resolve_iq_source(freq_obj:, source: :auto, sample_rate:, file:)
330
643
  #{self}.match_line?(line: str, matcher: Regexp|String|Array)
331
644
 
332
645
  #{self}.authors
@@ -3,12 +3,75 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # Pure-Ruby Bluetooth Classic (BR/EDR) & BLE activity detector.
7
- #
8
- # 1 Mbit/s GFSK with 79 (BR/EDR) or 40 (BLE) FHSS channels — native
9
- # mode reports per-channel hop bursts and derives channel index from
10
- # freq_obj. `parse_line` retained for offline text analysis.
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.
11
9
  module Bluetooth
10
+ # Streaming I/Q energy/burst demod for Base.run_iq.
11
+ 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
+ end
24
+
25
+ def feed_iq(samples, rate: nil, &)
26
+ @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?
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
43
+ 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
+ end
64
+ i += hop
65
+ end
66
+ end
67
+
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)
72
+ end
73
+ end
74
+
12
75
  # Supported Method Parameters::
13
76
  # PWN::SDR::Decoder::Bluetooth.decode(
14
77
  # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
@@ -19,20 +82,32 @@ module PWN
19
82
  hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
20
83
  ble = freq_obj[:ble] || freq_obj[:mode].to_s.casecmp('ble').zero?
21
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)
22
- PWN::SDR::Decoder::Base.run_detector(
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
+ )
92
+ PWN::SDR::Decoder::Base.run_iq(
23
93
  freq_obj: freq_obj,
24
- protocol: ble ? 'BLE' : 'BT-BR/EDR',
25
- note: '1 Mbit/s GFSK FHSS — native mode reports single-channel hop bursts only.',
94
+ protocol: proto,
95
+ sample_rate: rate,
96
+ source: opts[:source],
97
+ file: opts[:file],
98
+ demod: demod,
26
99
  threshold: 9.0,
100
+ note: '1 Mbit/s GFSK FHSS — true-air I/Q path reports hop-burst density per tuned channel.',
27
101
  describe: proc { |b|
28
- { modulation: 'GFSK', channel: ch, hop_slots: (b[:duration_ms] / 0.625).round, classification: ble && [37, 38, 39].include?(ch) ? 'BLE-advertising' : 'data-hop' }
102
+ { modulation: 'GFSK', channel: begin
103
+ b[:channel]
104
+ rescue StandardError
105
+ nil
106
+ end, hop_slots: (b[:duration_ms] / 0.625).round }
29
107
  }
30
108
  )
31
109
  end
32
110
 
33
- # Supported Method Parameters::
34
- # PWN::SDR::Decoder::Bluetooth.parse_line(line: 'systime=... LAP=9e8b33 ...')
35
-
36
111
  public_class_method def self.parse_line(opts = {})
37
112
  line = opts[:line].to_s
38
113
  out = { protocol: 'Bluetooth' }
@@ -52,16 +127,14 @@ module PWN
52
127
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
53
128
  end
54
129
 
55
- # Display Usage for this Module
56
-
57
130
  public_class_method def self.help
58
- puts "USAGE (ruby-native detector, no external binaries):
131
+ puts "USAGE (true-air I/Q via PWN::FFI + detector fallback):
59
132
  #{self}.decode(
60
- freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
133
+ freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
134
+ source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
135
+ file: 'optional - .cu8/.cs16 capture'
61
136
  )
62
137
 
63
- #{self}.parse_line(line: 'systime=... ch=37 LAP=9e8b33 ...')
64
-
65
138
  #{self}.authors
66
139
  "
67
140
  end
@@ -3,12 +3,69 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # Pure-Ruby DECT (1.88–1.90 GHz EU / 1.92–1.93 GHz US) activity detector.
7
- #
8
- # 1.152 Mbit/s GFSK across 10 TDMA carriers — native mode reports
9
- # slot bursts (417 μs) and derives carrier index from freq_obj.
10
- # `parse_line` retained for offline text analysis.
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.
11
9
  module DECT
10
+ # Streaming I/Q energy/burst demod for Base.run_iq.
11
+ 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
+ end
24
+
25
+ def feed_iq(samples, rate: nil, &)
26
+ @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?
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
43
+ 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
+ end
64
+ i += hop
65
+ end
66
+ end
67
+ end
68
+
12
69
  # Supported Method Parameters::
13
70
  # PWN::SDR::Decoder::DECT.decode(
14
71
  # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
@@ -16,23 +73,26 @@ module PWN
16
73
 
17
74
  public_class_method def self.decode(opts = {})
18
75
  freq_obj = opts[:freq_obj]
19
- hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
20
- ch = ((1_897_344_000 - hz) / 1_728_000.0).round.clamp(0, 9)
21
- PWN::SDR::Decoder::Base.run_detector(
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
+ )
83
+ PWN::SDR::Decoder::Base.run_iq(
22
84
  freq_obj: freq_obj,
23
- protocol: 'DECT',
24
- note: '1.152 Mbit/s GFSK 24-slot TDMA — native mode reports slot bursts and carrier index.',
85
+ protocol: proto,
86
+ sample_rate: rate,
87
+ source: opts[:source],
88
+ file: opts[:file],
89
+ demod: demod,
25
90
  threshold: 7.0,
26
- describe: proc { |b|
27
- slots = (b[:duration_ms] / 0.417).round
28
- { modulation: 'GFSK', carrier: ch, tdma_slots: slots, classification: slots >= 24 ? 'FP-beacon-frame' : 'PP-burst' }
29
- }
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' } }
30
93
  )
31
94
  end
32
95
 
33
- # Supported Method Parameters::
34
- # PWN::SDR::Decoder::DECT.parse_line(line: 'RFPI: 01 23 45 67 89 slot 4 carrier 3 RSSI -55')
35
-
36
96
  public_class_method def self.parse_line(opts = {})
37
97
  line = opts[:line].to_s
38
98
  out = { protocol: 'DECT' }
@@ -51,16 +111,14 @@ module PWN
51
111
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
52
112
  end
53
113
 
54
- # Display Usage for this Module
55
-
56
114
  public_class_method def self.help
57
- puts "USAGE (ruby-native detector, no external binaries):
115
+ puts "USAGE (true-air I/Q via PWN::FFI + detector fallback):
58
116
  #{self}.decode(
59
- freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
117
+ freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
118
+ source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
119
+ file: 'optional - .cu8/.cs16 capture'
60
120
  )
61
121
 
62
- #{self}.parse_line(line: 'RFPI: 01 23 45 67 89 slot 4 carrier 3')
63
-
64
122
  #{self}.authors
65
123
  "
66
124
  end