Module: PWN::SDR::Decoder::Tempest

Defined in:
lib/pwn/sdr/decoder/tempest.rb

Overview

Pure-Ruby TEMPEST / Van Eck raster decoder. Recovers a greyscale candidate raster from AM on a display pixel-clock harmonic captured as I/Q (RTL-SDR, HackRF, Pluto, Soapy, or a .cu8/.cs16 file). Magnitude envelope is resampled to one sample per pixel, packed into H×V lines using a VESA timing, and written as Netpbm P5 PGM. No TempestSDR / GNU Radio dependency. Assumes known timing and frame origin; no automatic sync acquisition, drift tracking, or proof that the raster is a monitor image. Sample-and-hold interpolation cannot recover pixel detail absent from the capture bandwidth.

Defined Under Namespace

Classes: Demod

Constant Summary collapse

MODES =
{
  'vga_640x480_60' => {
    h_active: 640, h_total: 800, v_active: 480, v_total: 525,
    refresh: 60.0, pixel_clock: 25_175_000
  },
  'svga_800x600_60' => {
    h_active: 800, h_total: 1_056, v_active: 600, v_total: 628,
    refresh: 60.0, pixel_clock: 40_000_000
  },
  'xga_1024x768_60' => {
    h_active: 1_024, h_total: 1_344, v_active: 768, v_total: 806,
    refresh: 60.0, pixel_clock: 65_000_000
  },
  'hd_1280x720_60' => {
    h_active: 1_280, h_total: 1_650, v_active: 720, v_total: 750,
    refresh: 60.0, pixel_clock: 74_250_000
  }
}.freeze

Class Method Summary collapse

Class Method Details

.authorsObject

Author(s)

0day Inc. [email protected]



247
248
249
# File 'lib/pwn/sdr/decoder/tempest.rb', line 247

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <[email protected]>\n"
end

.decode(opts = {}) ⇒ Object



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/pwn/sdr/decoder/tempest.rb', line 229

public_class_method def self.decode(opts = {})
  freq_obj = opts[:freq_obj]
  raise 'ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)

  demod = Demod.new(opts.merge(continuous: opts.fetch(:continuous, !opts.key?(:frames))))
  PWN::SDR::Decoder::Base.run_iq(opts.merge(
                                   freq_obj: freq_obj,
                                   protocol: 'TEMPEST',
                                   fallback: :raise,
                                   demod: demod,
                                   sample_rate: (opts[:sample_rate] || freq_obj[:iq_rate] || 2_048_000).to_i,
                                   fm_demod: false,
                                   note: 'TEMPEST magnitude raster preview requires known timing and alignment; not automatic monitor recovery.'
                                 ))
end

.detect(opts = {}) ⇒ Object

Energy detection only; does not identify or decode Tempest payloads.

Supported Method Parameters

Tempest.detect(freq_obj: Hash, threshold: 8.0, on_frame: Proc)



221
222
223
224
225
226
227
# File 'lib/pwn/sdr/decoder/tempest.rb', line 221

public_class_method def self.detect(opts = {})
  Base.run_detector(opts.merge(
                      protocol: 'TEMPEST',
                      note: 'Energy detection only; no protocol payload decoding.',
                      describe: proc { |_burst| { event: 'detection', capability: 'energy-detection', decoded: false } }
                    ))
end

.helpObject

Display Usage for this Module



253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/pwn/sdr/decoder/tempest.rb', line 253

public_class_method def self.help
  puts "USAGE:
    # Detect energy only (not protocol payloads); accepts Base runner controls.
    #{self}.detect(freq_obj: {}, threshold: 8.0, on_frame: nil)
    # Return VESA timing hashes (or one named mode).
    #{self}.modes(
      name: 'optional - Symbol/String mode key such as :vga_640x480_60'
    )

    # Merge a named mode with operator overrides into one timing Hash.
    #{self}.resolve_timing(
      mode: 'optional - Symbol/String key from #modes (default vga_640x480_60)',
      h_active: 'optional - visible pixels per line (overrides mode)',
      h_total: 'optional - samples per line including blanking',
      v_active: 'optional - visible lines per frame',
      v_total: 'optional - lines per frame including blanking',
      refresh: 'optional - frames per second'
    )

    # Rebuild a greyscale PGM from an in-memory I/Q capture (no radio).
    #{self}.reconstruct(
      iq: 'required - interleaved I/Q Array<Float> [I0,Q0,I1,Q1,…]',
      sample_rate: 'required - capture sample rate in Hz',
      mode: 'optional - VESA mode key from #modes',
      h_active: 'optional - visible pixels per line',
      h_total: 'optional - samples per line including blanking',
      v_active: 'optional - visible lines per frame',
      v_total: 'optional - lines per frame including blanking',
      refresh: 'optional - frames per second (default from mode)',
      frames: 'optional - number of frames to write (default 1)',
      out_path: 'optional - destination .pgm path (default /tmp/tempest_*.pgm)'
    )

    # Live-decode video emanations from GQRX freq_obj / SDR I/Q / capture file.
    #{self}.decode(
      freq_obj: 'required - freq_obj Hash from PWN::SDR::GQRX.init_freq',
      mode: 'optional - VESA mode key from #modes (default vga_640x480_60)',
      h_active: 'optional - visible pixels per line',
      h_total: 'optional - samples per line including blanking',
      v_active: 'optional - visible lines per frame',
      v_total: 'optional - lines per frame including blanking',
      refresh: 'optional - frames per second',
      frames: 'optional - maximum frame updates (default continuous; use stop/duration to end stream)',
      out_path: 'optional - destination .pgm path',
      source: 'optional - :auto|:rtlsdr|:hackrf|:adalm_pluto|:soapy|:file',
      file: 'optional - path to .cu8/.cs16/.iq capture',
      sample_rate: 'optional - I/Q rate Hz (default freq_obj[:iq_rate] or 2_048_000)',
      continuous: 'optional - update latest PGM indefinitely (default true without frames)',
      on_frame: 'optional - callback receiving each frame Hash after atomic PGM update',
      output: 'optional - IO for immediate JSONL events (default stdout)',
      interactive: 'optional - stop on ENTER (default true)',
      duration: 'optional - maximum stream seconds',
      stop: 'optional - callable cancellation predicate',
      queue_size: 'optional - bounded Base input queue size',
      log_file: 'optional - JSONL log path or false to disable'
    )

    # Print the AUTHOR(S) string for this module.
    #{self}.authors
  "
  constants.sort
end

.modes(opts = {}) ⇒ Object

Supported Method Parameters

modes = PWN::SDR::Decoder::Tempest.modes( name: 'optional - Symbol/String mode key to return one timing Hash' )



144
145
146
147
148
149
150
151
152
153
# File 'lib/pwn/sdr/decoder/tempest.rb', line 144

public_class_method def self.modes(opts = {})
  name = opts[:name]
  return MODES.dup if name.nil? || name.to_s.empty?

  key = name.to_s.downcase
  timing = MODES[key]
  raise "ERROR: unknown TEMPEST mode #{name.inspect}. Supported: #{MODES.keys.join(', ')}" unless timing

  timing
end

.reconstruct(opts = {}) ⇒ Object

Supported Method Parameters

result = PWN::SDR::Decoder::Tempest.reconstruct( iq: 'required - interleaved I/Q Array [I0,Q0,I1,Q1,…]', sample_rate: 'required - capture sample rate in Hz', mode: 'optional - VESA mode key from #modes', h_active: 'optional - visible pixels per line', h_total: 'optional - samples per line including blanking', v_active: 'optional - visible lines per frame', v_total: 'optional - lines per frame including blanking', refresh: 'optional - frames per second (default from mode)', frames: 'optional - number of frames to write (default 1)', out_path: 'optional - destination .pgm path (default /tmp/tempest_*.pgm)' )



190
191
192
193
194
195
196
197
198
199
200
# File 'lib/pwn/sdr/decoder/tempest.rb', line 190

public_class_method def self.reconstruct(opts = {})
  iq = opts[:iq]
  raise 'ERROR: :iq is required (interleaved I/Q Array<Float>)' unless iq.is_a?(Array) && iq.length >= 2

  rate = opts[:sample_rate].to_f
  raise 'ERROR: :sample_rate must be a positive Hz value' unless rate.positive?

  demod = Demod.new(opts)
  demod.feed_iq(iq, rate: rate)
  demod.finish
end

.resolve_timing(opts = {}) ⇒ Object

Supported Method Parameters

timing = PWN::SDR::Decoder::Tempest.resolve_timing( mode: 'optional - Symbol/String key from #modes (default vga_640x480_60)', h_active: 'optional - visible pixels per line (overrides mode)', h_total: 'optional - samples per line including blanking', v_active: 'optional - visible lines per frame', v_total: 'optional - lines per frame including blanking', refresh: 'optional - frames per second' )



165
166
167
168
169
170
171
172
173
174
# File 'lib/pwn/sdr/decoder/tempest.rb', line 165

public_class_method def self.resolve_timing(opts = {})
  base = modes(name: opts[:mode] || 'vga_640x480_60')
  {
    h_active: (opts[:h_active] || base[:h_active]).to_i,
    h_total: (opts[:h_total] || base[:h_total]).to_i,
    v_active: (opts[:v_active] || base[:v_active]).to_i,
    v_total: (opts[:v_total] || base[:v_total]).to_i,
    refresh: (opts[:refresh] || base[:refresh]).to_f
  }
end