Module: PWN::SDR::Decoder::DECT

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

Overview

DECT P00 control and P32 B-field descrambling, ETSI EN 300 175-3 sections 6.2/7.1. https://www.etsi.org/deliver/etsi_EN/300100_300199/30017503/02.08.01_60/en_30017503v020801p.pdf

1.152 Mbit/s GFSK, 24-slot / 10 ms TDMA. Continuous Ruby FM/NRZ symbol recovery → hunt 32-bit S-field (16-bit preamble + 16-bit sync 0xE98A FP / 0x1675 PP) → A-field (64 bits: 8-bit header + 40-bit tail + 16-bit R-CRC) → RFPI extraction on Nt/Qt tails. Emits role:, slot_est:, crc_ok:.

Defined Under Namespace

Classes: DemodIQ

Constant Summary collapse

SYNC_FP =
0xAAAAE98A
SYNC_PP =
0x55551675
BAUD =
1_152_000
RCRC_POLY =

R-CRC-16 poly x^16+x^10+x^8+x^7+x^3+1 = 0x0589, init 0x0000.

0x0589
A_TA =
{ 0 => 'Ct', 1 => 'Ct', 2 => 'Nt', 3 => 'Nt', 4 => 'Qt', 5 => 'combined', 6 => 'Mt', 7 => 'Pt' }.freeze

Class Method Summary collapse

Class Method Details

.authorsObject

Author(s)

0day Inc. [email protected]



241
242
243
# File 'lib/pwn/sdr/decoder/dect.rb', line 241

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

.decode(opts = {}) ⇒ Object

Realtime options forwarded to Base: on_frame (Hash callback), output (writable IO), interactive (default true), duration (seconds), stop (callable), queue_size (bounded chunks), log_file (path or false).

Raises:

  • (ArgumentError)


198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/pwn/sdr/decoder/dect.rb', line 198

public_class_method def self.decode(opts = {})
  raise ArgumentError, 'supported packets are :p00 and :p32' unless i[p00 p32].include?(opts.fetch(:packet, :p00))

  freq_obj = opts[:freq_obj]
  hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
  # EU: carrier 0 = 1897.344 MHz, step 1.728 MHz down; US 1.9296 GHz.
  carrier = ((1_897_344_000 - hz) / 1_728_000.0).round
  rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_304_000).to_i
  PWN::SDR::Decoder::Base.run_iq(
    **opts,
    freq_obj: freq_obj,
    protocol: 'DECT',
    sample_rate: rate,
    source: opts[:source],
    file: opts[:file],
    demod: DemodIQ.new(rate: rate, carrier: carrier, packet: opts.fetch(:packet, :p00), encrypted: opts[:encrypted],
                       frame_number: opts[:frame_number], b_format: opts.fetch(:b_format, :unprotected)),
    fallback: :raise,
    note: '1.152 Mbit/s GFSK — I/Q→gmskdem→S-field 0xE98A→A-field/RFPI/R-CRC.',
    describe: proc { |b| { modulation: 'GFSK', tdma_slots: (b[:duration_ms] / 0.417).round } }
  )
end

.detect(opts = {}) ⇒ Object

Energy observations only; never substituted for protocol decoding.



222
223
224
225
# File 'lib/pwn/sdr/decoder/dect.rb', line 222

public_class_method def self.detect(opts = {})
  Base.run_detector(opts.merge(protocol: 'DECT',
                               note: 'Energy detector only; use .decode for supported protocol frames.'))
end

.helpObject



245
246
247
248
249
250
251
252
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
# File 'lib/pwn/sdr/decoder/dect.rb', line 245

public_class_method def self.help
  puts "USAGE:
    # Supported: packet: :p00 control (default), or :p32 full-slot framing.
    # R-CRC protects A-field; X-CRC covers ONLY 80 selected scrambled B bits.
    # encrypted: true/false/nil supplies external connection context; nil is unknown.
    # P32 frame_number: 0..15 enables descrambling. A callable supplies each packet's frame.
    # A constant frame number applies to EVERY packet; no automatic multiframe tracking.
    # b_format: :unprotected, :multisubfield (4x64+16 CRC), :singlesubfield (304+16 CRC).
    # Format/encryption are caller-owned connection context, not inferred from BA alone.
    # No DSC decryption, encoded IPX/FEC, E/U channel demux, DLC reassembly or speech codec.
    # Descramble a P32 B-field and verify configured protected subfields.
    #{self}.parse_b_field(bits: 'required - 320 bits', frame_number: 'required - TDMA frame 0..15', encrypted: false, b_format: :multisubfield)
    # Role assumes the observed sync polarity; spectral inversion can swap FP/PP.
    #{self}.detect(freq_obj: 'required', on_frame: 'optional callback')

    # Run decode and return its result
    #{self}.decode(
      freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
      on_frame: 'optional - callback receiving each emitted Hash',
      output: 'optional - writable IO (default stdout)',
      interactive: 'optional - false disables ENTER input',
      duration: 'optional - finite seconds to run',
      stop: 'optional - callable returning true to stop',
      queue_size: 'optional - bounded pending chunks (default 8)',
      log_file: 'optional - JSONL path or false to disable logging',
      sample_rate: 'optional - sample rate value consumed by #decode',
      source: 'optional - source value consumed by #decode',
      encrypted: 'optional - true or false for known connection encryption, nil if unknown',
      frame_number: 'optional - constant TDMA frame 0..15 or per-packet callable',
      file: 'optional - filesystem path'
    )

    # Run parse line and return its result
    #{self}.parse_line(
      line: 'optional - line value consumed by #parse_line'
    )

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

.parse_b_field(opts = {}) ⇒ Object

EN 300 175-3 6.2.4 and 6.2.1.3: externally supplied frame/connection context.

Raises:

  • (ArgumentError)


148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/pwn/sdr/decoder/dect.rb', line 148

public_class_method def self.parse_b_field(opts = {})
  bits = opts[:bits]
  frame = opts[:frame_number]
  format = opts.fetch(:b_format, :unprotected)
  raise ArgumentError, 'B-field must contain 320 bits' unless bits.is_a?(Array) && bits.length == 320 && bits.all? { |b| [0, 1].include?(b) }
  raise ArgumentError, 'frame_number must be 0..15' unless frame.is_a?(Integer) && (0..15).cover?(frame)
  raise ArgumentError, 'unsupported B-field format' unless i[unprotected multisubfield singlesubfield].include?(format)

  register = 24 | (frame & 7)
  invert = 1
  clear = bits.map do |bit|
    output = bit ^ (register >> 4) ^ invert
    invert ^= 1 if register == 31
    feedback = ((register >> 1) ^ (register >> 4)) & 1
    register = ((register << 1) & 31) | feedback
    output
  end
  hex = ->(data) { DSP.bytes_from_bits(bits: data).pack('C*').unpack1('H*').upcase }
  result = { frame_number: frame, b_format: format, payload_hex: nil, b_crc_ok: nil,
             descrambled_payload_hex: hex.call(clear), payload_state: 'encryption-unknown' }
  if opts[:encrypted] != false
    return result.merge(payload_state: opts[:encrypted] ? 'encrypted' : 'encryption-unknown', ciphertext_hex: hex.call(clear))
  end

  subfields = if format == :unprotected
                [clear]
              else
                clear.each_slice(format == :multisubfield ? 80 : 320).to_a
              end
  if format != :unprotected
    return nil unless subfields.all? do |field|
      crc = DSP.crc16(bytes: DSP.bytes_from_bits(bits: field[0...-16]), poly: RCRC_POLY, init: 0) ^ 1
      crc == DSP.bits_to_int(bits: field[-16, 16])
    end

    subfields = subfields.map { |field| field[0...-16] }
  end
  result.merge(payload_state: 'clear', payload_hex: hex.call(subfields.flatten),
               subfields: subfields.map { |field| { payload_hex: hex.call(field) } },
               b_crc_ok: format == :unprotected ? nil : true)
end

.parse_line(opts = {}) ⇒ Object



227
228
229
230
231
232
233
234
235
236
237
# File 'lib/pwn/sdr/decoder/dect.rb', line 227

public_class_method def self.parse_line(opts = {})
  line = opts[:line].to_s
  out  = { protocol: 'DECT' }
  out[:rfpi]    = ::Regexp.last_match(1).delete(' ') if line =~ /RFPI[:=]?\s*((?:[0-9A-Fa-f]{2}\s*){5})/
  out[:slot]    = ::Regexp.last_match(1) if line =~ /slot\s*(\d+)/i
  out[:carrier] = ::Regexp.last_match(1) if line =~ /carrier\s*(\d+)/i
  out[:rssi]    = ::Regexp.last_match(1) if line =~ /RSSI[:=]?\s*(-?\d+)/i
  out[:role]    = ::Regexp.last_match(1) if line =~ /\b(FP|PP)\b/
  out[:summary] = "DECT RFPI=#{out[:rfpi]} slot=#{out[:slot]} carrier=#{out[:carrier]}"
  out.compact
end