pwn 0.5.736 → 0.5.737

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f20a2bcbd4611945405ce166826e978eabfe051ee9b1d5bc51ab07f9dde2e5d2
4
- data.tar.gz: 894c5e39df14c75ce020427d0ea9294550b17dee242c728aaa1067fadfd1d683
3
+ metadata.gz: af2001b2546112668f86a37ec9ab15c0bea62dd138188fd47b0441e2101a8622
4
+ data.tar.gz: 9281f66b1e6563d1abe0cd057b3a8a7e6de5c9712dd58e72e188f24d51acfbfc
5
5
  SHA512:
6
- metadata.gz: c512159a8249c30b3ae191d3e4d159d8f30da33b48dbc99fdb2d6e753c4811444f5db70492b8ca712a7dd26396818365c0940d225d9c9011247109a7cfd3a47f
7
- data.tar.gz: 0a9bff32912c36b10baca436caf72a18f132f9f9e4779df8f823b0aa66f785e3c905473762b85953916dbc20b51bfcae1989697ed6a57282f2d9da1d114761de
6
+ metadata.gz: e8f434c799f8cb9c67fb9d8f2f329e1e172fa1664ccfaea7bcb208febe9e3d8b42372b95210c3afb551cdb5648816f033fccdacb6897e36177a17d3b3be79a5a
7
+ data.tar.gz: 893e79ee5521e6c52558ea5cd69ac30c65b5a97e4a0cad3405da69bef72d48424714f971755880b8d7ca89168befe138d8edcc03636e124a09279ec7eb09c272
@@ -54,6 +54,33 @@ Typed at the mesh TX prompt (leading `/`). `/` or `/menu` opens a boxed curses m
54
54
 
55
55
  Typing `/` shows matching commands in the TX pane (ncurses hides Reline's dropdown). TAB still completes `/…` commands, channel names, transports, and device ids.
56
56
 
57
+ In COMPOSE, Up recalls older submitted lines and Down recalls newer ones.
58
+ Moving past the newest entry restores the draft and cursor position from before
59
+ history browsing. Recalled lines can be edited without changing saved entries.
60
+ History includes messages and slash commands, skips blank lines and consecutive
61
+ duplicates, and keeps the latest 100 entries in memory for the current pwn-mesh
62
+ session only; it is not written to disk.
63
+
64
+ ### Encrypted DMs and key discovery
65
+
66
+ On serial, Bluetooth, or TCP, a DM checks the destination's public key in the
67
+ connected radio's NodeInfo records. If missing, pwn-mesh sends its own public
68
+ NodeInfo to that destination with a response requested and waits up to 15 seconds.
69
+ Only after obtaining the destination key does it submit the text with PKI
70
+ encryption explicitly required. Both radios must be reachable over the selected
71
+ channel; this does not verify a peer's identity outside Meshtastic's key exchange.
72
+
73
+ Discovery failure sends no DM text. The most recent failed attempt is retained
74
+ in session memory; bare `/msg` retries it using the current connection and active
75
+ channel. A new DM replaces that retained attempt, and exiting the process loses
76
+ it. Retention ends after transport submission, not a delivery acknowledgement;
77
+ later radio failures are displayed separately. There is no plaintext or
78
+ channel-PSK downgrade. Missing local public-key metadata requires a reconnect.
79
+
80
+ MQTT channel broadcasts remain supported, but PKI DMs are not implemented for
81
+ the broker transport. MQTT DM attempts remain unsent with an explicit error;
82
+ switch to serial, Bluetooth, or TCP and use `/msg` to retry.
83
+
57
84
  ## Multi-line input
58
85
 
59
86
  `pwn-ai` and `pwn-asm` use a custom `PWNMultiLineInput` reader. Plain
@@ -270,11 +270,15 @@ module PWN
270
270
  next unless ch.is_a?(Hash)
271
271
 
272
272
  role = (ch[:role] || ch['role']).to_s.downcase
273
- next if %w[disabled 0].include?(role)
274
-
275
273
  idx = (ch[:index] || ch['index'] || 0).to_i
276
274
  next unless (0..7).cover?(idx)
277
275
 
276
+ # Protobuf omits the default DISABLED role from to_h.
277
+ if role.empty? || %w[disabled 0].include?(role)
278
+ by_index.delete(idx)
279
+ next
280
+ end
281
+
278
282
  settings = ch[:settings] || ch['settings'] || {}
279
283
  settings = settings.to_h if !settings.is_a?(Hash) && settings.respond_to?(:to_h)
280
284
  settings = {} unless settings.is_a?(Hash)
@@ -674,6 +678,65 @@ module PWN
674
678
  end
675
679
  end
676
680
 
681
+ def mesh_node_user(opts = {})
682
+ obj = opts[:obj]
683
+ return unless obj.is_a?(Hash)
684
+
685
+ rows = obj[:rx_mutex] ? obj[:rx_mutex].synchronize { Array(obj[:proto_data]).dup } : Array(obj[:proto_data]).dup
686
+ user = nil
687
+ rows.each do |row|
688
+ next unless row.is_a?(Hash)
689
+
690
+ info = row[:node_info] || row[:nodeInfo]
691
+ user = info[:user] if info.is_a?(Hash) && info[:num] == opts[:num]
692
+ packet = row[:packet]
693
+ next unless packet.is_a?(Hash) && packet[:from] == opts[:num]
694
+
695
+ data = packet[:decoded]
696
+ next unless data.is_a?(Hash) && %w[4 NODEINFO_APP].include?(data[:portnum].to_s)
697
+
698
+ payload = data[:payload]
699
+ user = payload.is_a?(String) ? Meshtastic::User.decode(payload).to_h : payload
700
+ end
701
+ user if user.is_a?(Hash)
702
+ end
703
+
704
+ def mesh_dm_key(opts = {})
705
+ kind = opts[:kind]
706
+ raise IOError, 'MQTT PKI DMs are unsupported; message remains unsent. Use a device transport.' if kind == :mqtt
707
+
708
+ obj = opts[:obj]
709
+ raise IOError, 'No connected radio; message remains unsent.' unless obj.is_a?(Hash)
710
+
711
+ num = opts[:to].delete_prefix('!').to_i(16)
712
+ user = mesh_node_user(obj: obj, num: num)
713
+ return user[:public_key] if user && user[:public_key].to_s.bytesize == 32
714
+
715
+ own = mesh_node_user(obj: obj, num: obj[:my_node_num])
716
+ raise IOError, 'Local radio public key unavailable; reconnect to refresh NodeInfo. Message remains unsent.' unless own && own[:public_key].to_s.bytesize == 32
717
+
718
+ timeout = Float(opts.fetch(:timeout, 15))
719
+ raise ArgumentError, 'key timeout must be between 0 and 60 seconds' unless timeout.finite? && (0..60).cover?(timeout)
720
+
721
+ mesh_ui_puts(text: "Discovering public key for #{opts[:to]} (up to #{timeout}s); DM text is held locally.")
722
+ data = Meshtastic::Data.new(portnum: :NODEINFO_APP, payload: Meshtastic::User.new(own).to_proto, want_response: true)
723
+ transport = { serial: Meshtastic::Serial, bluetooth: Meshtastic::Bluetooth, tcp: Meshtastic::TCP }.fetch(kind)
724
+ transport.send_data({ "#{kind}_obj": obj, to: opts[:to], channel: opts[:radio] || 0,
725
+ data: data, port_num: Meshtastic::PortNum::NODEINFO_APP, want_ack: true })
726
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
727
+ loop do
728
+ user = mesh_node_user(obj: obj, num: num)
729
+ return user[:public_key] if user && user[:public_key].to_s.bytesize == 32
730
+
731
+ break if obj[:closing] || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
732
+
733
+ sleep 0.05
734
+ end
735
+ raise IOError, 'Destination public key discovery timed out; message remains unsent. Retry with /msg.'
736
+ end
737
+
738
+ private :mesh_node_user, :mesh_dm_key
739
+
677
740
  def mesh_send_text(opts = {})
678
741
  env = opts[:env] || {}
679
742
  obj = opts[:obj]
@@ -687,38 +750,56 @@ module PWN
687
750
  radio = opts[:radio]
688
751
  radio = mesh_radio_index_for_name(env: env, obj: obj, name: channel_name) if radio.nil? && !channel_name.empty?
689
752
  radio = mesh_radio_channel(env: env, obj: obj) if radio.nil? && %i[serial bluetooth tcp].include?(kind)
753
+ dm_key = nil
754
+ unless mesh_broadcast?(to: dest)
755
+ raise ArgumentError, 'DM destination must be ! followed by eight hex digits' unless dest.match?(/\A![0-9a-fA-F]{8}\z/)
756
+
757
+ PWN.send(:remove_const, :MeshPendingDm) if PWN.const_defined?(:MeshPendingDm)
758
+ PWN.const_set(:MeshPendingDm, { to: dest, text: opts[:text].to_s, channel_name: channel_name })
759
+ dm_key = mesh_dm_key(obj: obj, kind: kind, to: dest, radio: radio, timeout: opts.fetch(:key_timeout, 15))
760
+ end
690
761
  chunks = mesh_text_chunks(text: opts[:text])
691
762
  chunks.each_with_index do |piece, idx|
692
763
  since = obj.is_a?(Hash) ? Array(obj[:proto_data]).size : 0
693
- case kind
694
- when :serial
695
- tx = { serial_obj: obj, to: dest, text: piece, want_ack: true }
696
- tx[:channel] = radio unless radio.nil?
697
- Meshtastic::Serial.send_text(tx)
698
- when :bluetooth
699
- tx = { bluetooth_obj: obj, to: dest, text: piece, want_ack: true }
700
- tx[:channel] = radio unless radio.nil?
701
- Meshtastic::Bluetooth.send_text(tx)
702
- when :tcp
703
- tx = { tcp_obj: obj, to: dest, text: piece }
704
- tx[:channel] = radio unless radio.nil?
705
- Meshtastic::TCP.send_text(tx)
764
+ if dm_key
765
+ bytes = Meshtastic::MeshInterface.new.send_text(from: obj[:my_node_num] || 0, to: dest, channel: radio || 0, text: piece, want_ack: true, psks: nil)
766
+ packet = Meshtastic::ToRadio.decode(bytes)
767
+ packet.packet.pki_encrypted = true
768
+ packet.packet.public_key = dm_key
769
+ transport = { serial: Meshtastic::Serial, bluetooth: Meshtastic::Bluetooth, tcp: Meshtastic::TCP }.fetch(kind)
770
+ transport.send_to_radio({ "#{kind}_obj": obj, to_radio: packet.to_proto })
706
771
  else
707
- send_psks = psks
708
- send_psks = mesh_channel_psks(env: env) if send_psks.nil? || send_psks.empty?
709
- Meshtastic::MQTT.send_text(
710
- mqtt_obj: obj,
711
- from: from,
712
- to: dest,
713
- region: mesh_mqtt_region(region: opts[:region], env: env),
714
- topic: mesh_mqtt_topic(env: env, topic: opts[:topic]),
715
- channel: channel,
716
- text: piece,
717
- psks: send_psks
718
- )
772
+ case kind
773
+ when :serial
774
+ tx = { serial_obj: obj, to: dest, text: piece, want_ack: true }
775
+ tx[:channel] = radio unless radio.nil?
776
+ Meshtastic::Serial.send_text(tx)
777
+ when :bluetooth
778
+ tx = { bluetooth_obj: obj, to: dest, text: piece, want_ack: true }
779
+ tx[:channel] = radio unless radio.nil?
780
+ Meshtastic::Bluetooth.send_text(tx)
781
+ when :tcp
782
+ tx = { tcp_obj: obj, to: dest, text: piece }
783
+ tx[:channel] = radio unless radio.nil?
784
+ Meshtastic::TCP.send_text(tx)
785
+ else
786
+ send_psks = psks
787
+ send_psks = mesh_channel_psks(env: env) if send_psks.nil? || send_psks.empty?
788
+ Meshtastic::MQTT.send_text(
789
+ mqtt_obj: obj,
790
+ from: from,
791
+ to: dest,
792
+ region: mesh_mqtt_region(region: opts[:region], env: env),
793
+ topic: mesh_mqtt_topic(env: env, topic: opts[:topic]),
794
+ channel: channel,
795
+ text: piece,
796
+ psks: send_psks
797
+ )
798
+ end
719
799
  end
720
800
  from_id = from.to_s
721
801
  from_id = mesh_self_node_id(env: env, obj: obj) if from_id.empty?
802
+ from_id = mesh_format_node_id(id: from_id)
722
803
  PWN.send(:remove_const, :MeshLastTx) if PWN.const_defined?(:MeshLastTx)
723
804
  PWN.const_set(:MeshLastTx, { from: from_id, to: dest, text: piece.to_s, at: Time.now })
724
805
  mesh_handle_rx(
@@ -727,6 +808,7 @@ module PWN
727
808
  msg: {
728
809
  packet: {
729
810
  channel: radio,
811
+ pki_encrypted: !dm_key.nil?,
730
812
  node_id_from: from_id,
731
813
  node_id_to: dest,
732
814
  decoded: { portnum: :TEXT_MESSAGE_APP, payload: piece.to_s }
@@ -738,6 +820,10 @@ module PWN
738
820
 
739
821
  mesh_wait_tx_slot(obj: obj, since: since)
740
822
  end
823
+ return unless dm_key && PWN.const_defined?(:MeshPendingDm)
824
+
825
+ PWN.send(:remove_const, :MeshPendingDm)
826
+ PWN.const_set(:MeshPendingDm, nil)
741
827
  end
742
828
 
743
829
  # Close the Meshtastic session opened by #mesh_connect.
@@ -830,6 +916,10 @@ module PWN
830
916
  end
831
917
  text = +''
832
918
  cursor = 0
919
+ history = []
920
+ history_index = 0
921
+ draft = +''
922
+ draft_cursor = 0
833
923
  while pi.config.pwn_mesh
834
924
  mesh_drain_events
835
925
  mesh_draw_input(text: text, cursor: cursor)
@@ -839,10 +929,33 @@ module PWN
839
929
  break if text.empty?
840
930
  when "\n", "\r", 10, 13, Curses::KEY_ENTER
841
931
  submitted = text.dup
932
+ unless submitted.strip.empty? || history.last == submitted
933
+ history << submitted.dup
934
+ history.shift if history.length > 100
935
+ end
936
+ history_index = history.length
937
+ draft = +''
938
+ draft_cursor = 0
842
939
  text.clear
843
940
  cursor = 0
844
941
  mesh_draw_input(text: text, cursor: cursor)
845
942
  mesh_submit(request: submitted, pry: pi)
943
+ when Curses::KEY_UP
944
+ next if history.empty? || history_index.zero?
945
+
946
+ if history_index == history.length
947
+ draft = text.dup
948
+ draft_cursor = cursor
949
+ end
950
+ history_index -= 1
951
+ text = history[history_index].dup
952
+ cursor = text.length
953
+ when Curses::KEY_DOWN
954
+ next if history_index >= history.length
955
+
956
+ history_index += 1
957
+ text = history_index == history.length ? draft.dup : history[history_index].dup
958
+ cursor = history_index == history.length ? draft_cursor : text.length
846
959
  when "\u007f", "\b", 127, 8, Curses::KEY_BACKSPACE
847
960
  if cursor.positive?
848
961
  cursor -= 1
@@ -918,7 +1031,7 @@ module PWN
918
1031
  end
919
1032
  end
920
1033
  win.setpos(3, 2)
921
- win.addstr('Enter send Tab complete /menu settings Ctrl+D back'[0, win.maxx - 4])
1034
+ win.addstr('Enter send Up/Down history Tab complete /menu settings Ctrl+D back'[0, win.maxx - 4])
922
1035
  win.refresh
923
1036
  end
924
1037
 
@@ -1199,12 +1312,24 @@ module PWN
1199
1312
  ''
1200
1313
  end
1201
1314
 
1315
+ def mesh_format_node_id(opts = {})
1316
+ id = opts[:id]
1317
+ return format('!%08x', id) if id.is_a?(Integer) && (0..0xffffffff).cover?(id)
1318
+
1319
+ text = id.to_s
1320
+ return text unless text.match?(/\A![0-9a-fA-F]{1,8}\z/)
1321
+
1322
+ format('!%08x', text.delete_prefix('!').to_i(16))
1323
+ end
1324
+
1325
+ private :mesh_format_node_id
1326
+
1202
1327
  def mesh_self_node_id(opts = {})
1203
1328
  return '!00000b0b' unless opts.is_a?(Hash)
1204
1329
 
1205
1330
  obj = opts[:obj]
1206
1331
  obj = PWN.const_get(:MeshObj) if obj.nil? && PWN.const_defined?(:MeshObj)
1207
- return "!#{obj[:my_node_num].to_i.to_s(16)}" if obj.is_a?(Hash) && !obj[:my_node_num].nil?
1332
+ return mesh_format_node_id(id: obj[:my_node_num].to_i) if obj.is_a?(Hash) && !obj[:my_node_num].nil?
1208
1333
 
1209
1334
  '!00000b0b'
1210
1335
  end
@@ -1212,7 +1337,7 @@ module PWN
1212
1337
  def mesh_decorate_local_id(opts = {})
1213
1338
  return '' unless opts.is_a?(Hash)
1214
1339
 
1215
- id = opts[:id].to_s
1340
+ id = mesh_format_node_id(id: opts[:id])
1216
1341
  return id if id.empty?
1217
1342
 
1218
1343
  self_id = mesh_self_node_id(env: opts[:env], obj: opts[:obj])
@@ -1225,7 +1350,7 @@ module PWN
1225
1350
  return true unless opts.is_a?(Hash)
1226
1351
 
1227
1352
  psk = opts[:psk].to_s.strip
1228
- psk.empty? || %w[none default aq==].include?(psk.downcase)
1353
+ psk.empty? || %w[none default aq==].include?(psk.downcase) || psk == '1PG7OiApB1nwvP+rz05pAQ=='
1229
1354
  end
1230
1355
 
1231
1356
  def mesh_channel_securely_encrypted?(opts = {})
@@ -1281,7 +1406,17 @@ module PWN
1281
1406
 
1282
1407
  packet = msg[:packet].is_a?(Hash) ? msg[:packet] : msg
1283
1408
  decoded = packet[:decoded]
1284
- return unless decoded.is_a?(Hash) && mesh_text_app?(portnum: decoded[:portnum])
1409
+ return unless decoded.is_a?(Hash)
1410
+
1411
+ if %w[5 ROUTING_APP].include?(decoded[:portnum].to_s)
1412
+ routing = decoded[:payload]
1413
+ routing = Meshtastic::Routing.decode(routing).to_h if routing.is_a?(String)
1414
+ reason = routing[:error_reason] if routing.is_a?(Hash)
1415
+ reason = Meshtastic::Routing::Error.lookup(reason) || reason if reason.is_a?(Integer)
1416
+ mesh_ui_puts(text: "TX failed: packet #{decoded[:request_id]}: #{reason}") if reason && !%w[0 NONE].include?(reason.to_s)
1417
+ return
1418
+ end
1419
+ return unless mesh_text_app?(portnum: decoded[:portnum])
1285
1420
 
1286
1421
  env = mesh_env_hash
1287
1422
  idx = packet[:channel] || packet['channel']
@@ -1300,9 +1435,11 @@ module PWN
1300
1435
  return if rx_text.strip.empty?
1301
1436
 
1302
1437
  from_id = packet[:node_id_from].to_s
1303
- from_id = "!#{packet[:from].to_i.to_s(16)}" if from_id.empty? && packet[:from]
1438
+ from_id = packet[:from] if from_id.empty? && packet[:from]
1439
+ from_id = mesh_format_node_id(id: from_id)
1304
1440
  to = packet[:node_id_to].to_s
1305
- to = "!#{packet[:to].to_i.to_s(16)}" if to.empty? && packet[:to]
1441
+ to = packet[:to] if to.empty? && packet[:to]
1442
+ to = mesh_format_node_id(id: to)
1306
1443
  unless opts[:local]
1307
1444
  last = PWN.const_defined?(:MeshLastTx) ? PWN.const_get(:MeshLastTx) : nil
1308
1445
  if last.is_a?(Hash) &&
@@ -1332,13 +1469,15 @@ module PWN
1332
1469
  state = PWN.const_defined?(:MeshRxState) ? PWN.const_get(:MeshRxState) : {}
1333
1470
  ts = Time.now.strftime('%H:%M:%S')
1334
1471
  color = opts[:local] ? 23 : 21
1335
- current_line = "#{ts} #{from.strip} #{dest_label}\n#{rx_text}"
1472
+ secure = packet[:pki_encrypted] == true || mesh_channel_securely_encrypted?(env: env, channel: channel_name)
1473
+ security_icon = secure ? '🔒' : '🔍'
1474
+ current_line = "#{ts} #{security_icon} #{from.strip} #{dest_label}\n#{rx_text}"
1336
1475
  unless state[:last_line] == current_line
1337
1476
  rx_body_win = PWN.const_get(:MeshRxBodyWin)
1338
1477
  mutex.synchronize do
1339
1478
  width = [rx_body_win.maxx - 2, 1].max
1340
1479
  rx_body_win.attron(Curses.color_pair(color) | Curses::A_BOLD)
1341
- rx_body_win.addstr(" #{ts} #{from.strip} · #{dest_label}\n")
1480
+ rx_body_win.addstr(" #{ts} #{security_icon} #{from.strip} · #{dest_label}\n")
1342
1481
  rx_body_win.attroff(Curses.color_pair(color) | Curses::A_BOLD)
1343
1482
  rx_body_win.attron(Curses.color_pair(24))
1344
1483
  mesh_wrap_text(text: rx_text, width: width - 1).each do |line|
@@ -1669,11 +1808,16 @@ module PWN
1669
1808
  def pwn_mesh_run_msg(opts = {})
1670
1809
  env = opts[:env] || mesh_env_hash
1671
1810
  tokens = Array(opts[:args]).map(&:to_s)
1811
+ if tokens.empty? && PWN.const_defined?(:MeshPendingDm) && PWN::MeshPendingDm
1812
+ pending = PWN::MeshPendingDm
1813
+ tokens = [pending[:to], pending[:text]]
1814
+ end
1672
1815
  names = mesh_channel_names(env: env)
1673
1816
  dest = nil
1674
1817
  channel_name = ''
1675
1818
  if tokens[0].to_s.match?(/\A![0-9a-fA-F]{8}\z/)
1676
1819
  dest = tokens.shift
1820
+ channel_name = env.dig(:channel, :active).to_s
1677
1821
  elsif names.any? { |n| n.casecmp?(tokens[0].to_s) }
1678
1822
  channel_name = names.find { |n| n.casecmp?(tokens[0].to_s) }.to_s
1679
1823
  tokens.shift
@@ -1688,6 +1832,7 @@ module PWN
1688
1832
  raise ArgumentError, 'usage: /msg [!nodeid|channel] <text>' if dest.to_s.empty? || text.empty?
1689
1833
 
1690
1834
  channel_name = PWN.const_get(:MeshLastChannel).to_s if channel_name.empty? && PWN.const_defined?(:MeshLastChannel)
1835
+ channel_name = env.dig(:channel, :active).to_s if channel_name.empty?
1691
1836
  ch = env[:channel] || {}
1692
1837
  slot = ch[channel_name.to_sym] || ch[channel_name] || {}
1693
1838
  obj = PWN.const_defined?(:MeshObj) ? PWN.const_get(:MeshObj) : nil
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.736'
4
+ VERSION = '0.5.737'
5
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'meshtastic'
4
5
 
5
6
  describe PWN::Plugins::REPL, 'mesh packet channel routing' do
6
7
  let(:mesh_env) do
@@ -42,6 +43,18 @@ describe PWN::Plugins::REPL, 'mesh packet channel routing' do
42
43
  end
43
44
  end
44
45
 
46
+ it 'does not route LongFast to disabled protobuf slots with omitted roles' do
47
+ mesh_env[:channel][:LongFast].delete(:radio_index)
48
+ radio[:proto_data] = [
49
+ Meshtastic::FromRadio.new(channel: Meshtastic::Channel.new(index: 1, role: :SECONDARY, settings: { name: 'LongFast' })).to_h,
50
+ Meshtastic::FromRadio.new(channel: Meshtastic::Channel.new(index: 7, role: :DISABLED)).to_h
51
+ ]
52
+ expect(described_class.send(:mesh_radio_index_for_name, env: mesh_env, obj: radio, name: 'LongFast')).to eq(1)
53
+ expect(described_class.send(:mesh_device_channel_meta, obj: radio).keys).to eq([1])
54
+ radio[:proto_data] << Meshtastic::FromRadio.new(channel: Meshtastic::Channel.new(index: 1, role: :DISABLED)).to_h
55
+ expect(described_class.send(:mesh_device_channel_meta, obj: radio)).to be_empty
56
+ end
57
+
45
58
  it 'paints and replies on the packet radio slot instead of the selected channel' do
46
59
  win = double('rx', maxx: 80)
47
60
  allow(win).to receive(:attron)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'meshtastic'
4
5
 
5
6
  describe PWN::Plugins::REPL, 'mesh msg command' do
6
7
  let(:mesh_env) do
@@ -51,6 +52,22 @@ describe PWN::Plugins::REPL, 'mesh msg command' do
51
52
  expect(described_class.pwn_mesh_complete(target: '/', line: '/')).not_to include('/dm')
52
53
  end
53
54
 
55
+ it 'encodes a named MQTT channel message before receiving anything' do
56
+ broker = double('broker', client_id: '!00000b0b')
57
+ PWN.send(:remove_const, :MeshObj)
58
+ PWN.const_set(:MeshObj, broker)
59
+ envelope = nil
60
+ allow(broker).to receive(:publish) do |_topic, bytes|
61
+ envelope = Meshtastic::ServiceEnvelope.decode(bytes)
62
+ end
63
+
64
+ described_class.send(:pwn_mesh_run_msg, env: mesh_env, args: %w[LongFast hello])
65
+
66
+ expect(envelope.packet.to).to eq(0xffffffff)
67
+ expect(envelope.packet.channel).to eq(8)
68
+ expect(envelope.channel_id).to eq('LongFast')
69
+ end
70
+
54
71
  it 'addresses a named channel with /msg <channel> text' do
55
72
  mesh_env[:channel][:LongFast] = { psk: 'cHdu', radio_index: 1, topic: '2/e/LongFast/#' }
56
73
  allow(described_class).to receive(:mesh_send_text)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'meshtastic'
5
+
6
+ describe PWN::Plugins::REPL, 'mesh DM transport replay' do
7
+ it 'encodes /msg on the active slot and paints a decoded receiver packet through Serial.subscribe' do
8
+ env = { transport: 'serial', channel: { active: 'LongFast', LongFast: { radio_index: 1, psk: 'AQ==' } } }
9
+ sender = { my_node_num: 0xaabbccdd, proto_data: [{ node_info: { num: 0xbbccddee, user: { public_key: 'b' * 32 } } }] }
10
+ stub_const('PWN::MeshPendingDm', nil)
11
+ receiver = { my_node_num: 0xbbccddee, from_radio_queue: Queue.new }
12
+ stub_const('PWN::MeshTransport', :serial)
13
+ stub_const('PWN::MeshObj', sender)
14
+ stub_const('PWN::MeshLastChannel', '')
15
+ stub_const('PWN::MeshLastTx', nil)
16
+ stub_const('PWN::MeshLastDm', nil)
17
+ allow(described_class).to receive(:mesh_env_hash).and_return(env)
18
+ packet = nil
19
+ allow(Meshtastic::Serial).to receive(:send_to_radio) do |opts|
20
+ packet = Meshtastic::ToRadio.decode(opts[:to_radio]).packet
21
+ end
22
+
23
+ described_class.pwn_mesh_dispatch_slash!(request: '/msg !bbccddee hello receiver', env: env, pry: :fixture)
24
+ expect(packet.to).to eq(receiver[:my_node_num])
25
+ expect(packet.channel).to eq(1)
26
+ expect(packet.decoded.payload).to eq('hello receiver')
27
+
28
+ # Replay the firmware's decoded FromRadio boundary, not RF delivery.
29
+ receiver[:from_radio_queue] << Meshtastic::FromRadio.decode(Meshtastic::FromRadio.new(packet: packet).to_proto)
30
+ receiver[:from_radio_queue].close
31
+ PWN.send(:remove_const, :MeshObj)
32
+ PWN.const_set(:MeshObj, receiver)
33
+ PWN.send(:remove_const, :MeshLastTx)
34
+ PWN.const_set(:MeshLastTx, nil)
35
+ win = double('conversation', maxx: 60, attron: nil, attroff: nil, addstr: nil, refresh: nil)
36
+ stub_const('PWN::MeshRxBodyWin', win)
37
+ stub_const('PWN::MeshMutex', Mutex.new)
38
+ stub_const('PWN::MeshRxState', {})
39
+ allow(Curses).to receive(:color_pair).and_return(0)
40
+
41
+ described_class.send(
42
+ :mesh_subscribe,
43
+ env: env, obj: receiver, psks: {},
44
+ on_message: proc { |msg| described_class.send(:mesh_handle_rx, msg: msg) }
45
+ )
46
+
47
+ expect(win).to have_received(:addstr).with(" hello receiver\n")
48
+ expect(win).to have_received(:addstr).with(a_string_including('!aabbccdd', '!bbccddee (ME)'))
49
+ end
50
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::Plugins::REPL, 'mesh compose history' do
6
+ it 'recalls submissions with Up/Down and restores the unfinished draft and cursor' do
7
+ config = double('config', pwn_mesh: true)
8
+ pry = double('pry', config: config)
9
+ keys = ['first', "\n", '/status', "\n", 'draft', Curses::KEY_LEFT,
10
+ Curses::KEY_UP, Curses::KEY_UP, Curses::KEY_UP,
11
+ Curses::KEY_DOWN, Curses::KEY_DOWN, Curses::KEY_DOWN]
12
+ frames = []
13
+ allow(described_class).to receive(:mesh_drain_events)
14
+ allow(described_class).to receive(:mesh_submit)
15
+ allow(described_class).to receive(:mesh_draw_input) { |opts| frames << [opts[:text].dup, opts[:cursor]] }
16
+ reader = proc do
17
+ allow(config).to receive(:pwn_mesh).and_return(false) if keys.empty?
18
+ keys.shift
19
+ end
20
+ described_class.send(:mesh_console_loop, pry: pry, getch: reader)
21
+ expected = [
22
+ ['/status', 7], ['first', 5], ['first', 5],
23
+ ['/status', 7], ['draft', 4], ['draft', 4]
24
+ ]
25
+ expect(frames.last(6)).to eq(expected)
26
+ expect(described_class).to have_received(:mesh_submit).with(request: 'first', pry: pry)
27
+ expect(described_class).to have_received(:mesh_submit).with(request: '/status', pry: pry)
28
+ end
29
+
30
+ it 'edits recalled text without mutating history and skips empty submissions' do
31
+ config = double('config', pwn_mesh: true)
32
+ pry = double('pry', config: config)
33
+ keys = [Curses::KEY_UP, Curses::KEY_DOWN, 'hello', "\n", "\n",
34
+ Curses::KEY_UP, '!', Curses::KEY_DOWN, Curses::KEY_UP]
35
+ frames = []
36
+ allow(described_class).to receive(:mesh_drain_events)
37
+ allow(described_class).to receive(:mesh_submit)
38
+ allow(described_class).to receive(:mesh_draw_input) { |opts| frames << opts[:text].dup }
39
+ reader = proc do
40
+ allow(config).to receive(:pwn_mesh).and_return(false) if keys.empty?
41
+ keys.shift
42
+ end
43
+ described_class.send(:mesh_console_loop, pry: pry, getch: reader)
44
+ expect(frames).to include('hello!')
45
+ expect(frames.last).to eq('hello')
46
+ end
47
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'meshtastic'
5
+
6
+ describe PWN::Plugins::REPL, 'mesh DM key exchange' do
7
+ let(:local_user) { { id: '!aabbccdd', public_key: 'a' * 32 } }
8
+ let(:remote_user) { { id: '!bbccddee', public_key: 'b' * 32 } }
9
+ let(:obj) { { my_node_num: 0xaabbccdd, proto_data: [{ node_info: { num: 0xaabbccdd, user: local_user } }] } }
10
+ let(:env) { { transport: 'auto', channel: { active: 'LongFast', LongFast: { radio_index: 1, psk: 'AQ==' } } } }
11
+
12
+ before do
13
+ stub_const('PWN::MeshPendingDm', nil)
14
+ allow(described_class).to receive(:mesh_ui_puts)
15
+ allow(described_class).to receive(:mesh_handle_rx)
16
+ end
17
+
18
+ %i[serial bluetooth tcp].each do |kind|
19
+ it "exchanges NodeInfo then sends a PKI-enforced DM over #{kind}" do
20
+ stub_const('PWN::MeshTransport', kind)
21
+ transport = kind == :bluetooth ? Meshtastic::Bluetooth : Meshtastic::Serial
22
+ packets = []
23
+ allow(transport).to receive(:send_to_radio) do |opts|
24
+ packet = Meshtastic::ToRadio.decode(opts[:to_radio]).packet
25
+ packets << packet
26
+ if packet.decoded.portnum == :NODEINFO_APP
27
+ expect(packet.decoded.want_response).to eq(true)
28
+ expect(Meshtastic::User.decode(packet.decoded.payload).public_key).to eq('a' * 32)
29
+ obj[:proto_data] << { packet: { from: 0xbbccddee, decoded: { portnum: 4, payload: Meshtastic::User.new(remote_user).to_proto } } }
30
+ end
31
+ end
32
+ described_class.send(:mesh_send_text, env: env, obj: obj, to: '!bbccddee', text: 'private', channel_name: 'LongFast')
33
+ expect(packets.map { |p| p.decoded.portnum }).to eq(%i[NODEINFO_APP TEXT_MESSAGE_APP])
34
+ expect(packets.last.pki_encrypted).to eq(true)
35
+ expect(packets.last.public_key).to eq('b' * 32)
36
+ expect(packets.last.to).to eq(0xbbccddee)
37
+ expect(PWN::MeshPendingDm).to be_nil
38
+ end
39
+ end
40
+
41
+ it 'retains the unsent text and never transmits it after discovery times out' do
42
+ stub_const('PWN::MeshTransport', :serial)
43
+ allow(Meshtastic::Serial).to receive(:send_data)
44
+ expect(Meshtastic::Serial).not_to receive(:send_text)
45
+ expect(Meshtastic::Serial).not_to receive(:send_to_radio)
46
+ expect do
47
+ described_class.send(:mesh_send_text, env: env, obj: obj, to: '!bbccddee', text: 'private', key_timeout: 0)
48
+ end.to raise_error(IOError, /public key.*unsent/i)
49
+ expect(PWN::MeshPendingDm[:text]).to eq('private')
50
+ end
51
+
52
+ it 'retries the retained message with bare /msg after the key becomes available' do
53
+ stub_const('PWN::MeshTransport', :serial)
54
+ stub_const('PWN::MeshObj', obj)
55
+ stub_const('PWN::MeshPendingDm', { to: '!bbccddee', text: 'retained text' })
56
+ stub_const('PWN::MeshLastTx', nil)
57
+ obj[:proto_data] << { node_info: { num: 0xbbccddee, user: remote_user } }
58
+ expect(Meshtastic::Serial).not_to receive(:send_data)
59
+ expect(Meshtastic::Serial).to receive(:send_to_radio) do |opts|
60
+ packet = Meshtastic::ToRadio.decode(opts[:to_radio]).packet
61
+ expect(packet.pki_encrypted).to eq(true)
62
+ expect(packet.decoded.payload).to eq('retained text')
63
+ end
64
+ described_class.send(:pwn_mesh_run_msg, env: env, args: [])
65
+ expect(PWN::MeshPendingDm).to be_nil
66
+ end
67
+
68
+ it 'does not use another node public key as the destination key' do
69
+ stub_const('PWN::MeshTransport', :serial)
70
+ obj[:proto_data] << { node_info: { num: 0xccddeeff, user: remote_user } }
71
+ allow(Meshtastic::Serial).to receive(:send_data)
72
+ expect do
73
+ described_class.send(:mesh_send_text, env: env, obj: obj, to: '!bbccddee', text: 'private', key_timeout: 0)
74
+ end.to raise_error(IOError, /timed out/)
75
+ end
76
+
77
+ it 'refuses MQTT DMs without publishing a legacy encrypted or plaintext fallback' do
78
+ stub_const('PWN::MeshTransport', :mqtt)
79
+ expect(Meshtastic::MQTT).not_to receive(:send_text)
80
+ expect do
81
+ described_class.send(:mesh_send_text, env: env, obj: double('broker'), to: '!bbccddee', text: 'private')
82
+ end.to raise_error(IOError, /MQTT.*PKI/)
83
+ expect(PWN::MeshPendingDm[:text]).to eq('private')
84
+ end
85
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::Plugins::REPL, 'mesh node ID formatting' do
6
+ it 'preserves broadcast and non-node labels while formatting valid node values' do
7
+ { 0 => '!00000000', 0xffffffff => '!ffffffff', '!ABCDEF1' => '!0abcdef1',
8
+ '!ffffffff' => '!ffffffff', 'LongFast' => 'LongFast', '!nothex' => '!nothex' }.each do |id, expected|
9
+ expect(described_class.send(:mesh_format_node_id, id: id)).to eq(expected)
10
+ end
11
+ end
12
+
13
+ it 'pads the local numeric node ID to eight hex digits' do
14
+ expect(described_class.send(:mesh_self_node_id, obj: { my_node_num: 0xabcdef1 })).to eq('!0abcdef1')
15
+ expect(described_class.send(:mesh_self_node_id, obj: { my_node_num: 0xb0b })).to eq('!00000b0b')
16
+ end
17
+
18
+ it 'pads transport-enriched IDs before painting and remembering a DM sender' do
19
+ stub_const('PWN::MeshObj', { my_node_num: 0xb0b })
20
+ stub_const('PWN::MeshLastDm', nil)
21
+ stub_const('PWN::MeshLastTx', nil)
22
+ stub_const('PWN::MeshRxState', {})
23
+ stub_const('PWN::MeshMutex', Mutex.new)
24
+ win = double('conversation', maxx: 80, attron: nil, attroff: nil, addstr: nil, refresh: nil)
25
+ stub_const('PWN::MeshRxBodyWin', win)
26
+ allow(Curses).to receive(:color_pair).and_return(0)
27
+ allow(described_class).to receive(:mesh_maybe_dispatch_to_pwn_ai)
28
+ allow(described_class).to receive(:mesh_env_hash).and_return({ transport: 'mqtt', channel: {} })
29
+ described_class.send(:mesh_handle_rx, msg: { packet: {
30
+ node_id_from: '!abcdef1', node_id_to: '!b0b',
31
+ decoded: { portnum: 1, payload: 'hello' }
32
+ } })
33
+ expect(win).to have_received(:addstr).with(a_string_including('!0abcdef1', '!00000b0b (ME)'))
34
+ expect(PWN::MeshLastDm).to eq('!0abcdef1')
35
+ end
36
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'meshtastic'
4
5
 
5
6
  describe PWN::Plugins::REPL, 'mesh incoming paint' do
6
7
  let(:mesh_env) do
@@ -23,6 +24,28 @@ describe PWN::Plugins::REPL, 'mesh incoming paint' do
23
24
  PWN::Env[:plugins][:meshtastic] = @prev_mesh if PWN::Env[:plugins].is_a?(Hash)
24
25
  end
25
26
 
27
+ [5, :ROUTING_APP].each do |port|
28
+ it "shows a radio DM failure for routing port #{port} instead of silently dropping it" do
29
+ allow(described_class).to receive(:mesh_ui_puts)
30
+ routing = Meshtastic::Routing.new(error_reason: :PKI_SEND_FAIL_PUBLIC_KEY)
31
+ payload = port == 5 ? routing.to_proto : routing.to_h
32
+ described_class.send(:mesh_handle_rx, msg: {
33
+ packet: { decoded: { portnum: port, request_id: 42, payload: payload } }
34
+ })
35
+ expect(described_class).to have_received(:mesh_ui_puts).with(
36
+ text: a_string_including('TX failed', '42', 'PKI_SEND_FAIL_PUBLIC_KEY')
37
+ )
38
+ end
39
+ end
40
+
41
+ it 'does not label a successful routing response as a transmission failure' do
42
+ allow(described_class).to receive(:mesh_ui_puts)
43
+ described_class.send(:mesh_handle_rx, msg: {
44
+ packet: { decoded: { portnum: 5, request_id: 42, payload: Meshtastic::Routing.new.to_proto } }
45
+ })
46
+ expect(described_class).not_to have_received(:mesh_ui_puts)
47
+ end
48
+
26
49
  it 'paints incoming RX in yellow with sender id and channel name or DM destination id' do
27
50
  win = double('rx', maxx: 80)
28
51
  allow(win).to receive(:attron)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::Plugins::REPL, 'mesh message security indicators' do
6
+ [true, false].each do |local|
7
+ [
8
+ ['private channel', 'cHdu', false, '🔒'],
9
+ ['public channel', 'AQ==', false, '🔍'],
10
+ ['expanded public key', '1PG7OiApB1nwvP+rz05pAQ==', false, '🔍'],
11
+ ['unknown encryption', '', false, '🔍'],
12
+ ['PKI packet', 'AQ==', true, '🔒']
13
+ ].each do |label, psk, pki, icon|
14
+ it "paints #{icon} for #{local ? 'sent' : 'received'} #{label}" do
15
+ env = { transport: 'mqtt', channel: { active: 'LongFast', LongFast: { psk: psk } } }
16
+ allow(described_class).to receive(:mesh_env_hash).and_return(env)
17
+ allow(described_class).to receive(:mesh_maybe_dispatch_to_pwn_ai)
18
+ stub_const('PWN::MeshLastTx', nil)
19
+ stub_const('PWN::MeshLastChannel', nil)
20
+ stub_const('PWN::MeshMutex', Mutex.new)
21
+ stub_const('PWN::MeshRxState', {})
22
+ win = double('conversation', maxx: 80, attron: nil, attroff: nil, addstr: nil, refresh: nil)
23
+ stub_const('PWN::MeshRxBodyWin', win)
24
+ allow(Curses).to receive(:color_pair).and_return(0)
25
+ described_class.send(
26
+ :mesh_handle_rx,
27
+ local: local, channel_name: 'LongFast',
28
+ msg: { packet: { node_id_from: '!aabbccdd', node_id_to: '!ffffffff', pki_encrypted: pki,
29
+ decoded: { portnum: 1, payload: 'security test' } } }
30
+ )
31
+ expect(win).to have_received(:addstr).with(a_string_including(icon, '!aabbccdd', 'LongFast'))
32
+ expect(Curses).to have_received(:color_pair).with(local ? 23 : 21).twice
33
+ end
34
+ end
35
+ end
36
+ end
@@ -497,7 +497,7 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
497
497
 
498
498
  it 'uses the named device channel index instead of forcing slot zero' do
499
499
  require 'meshtastic'
500
- obj = { proto_data: [{ channel: { index: 3, settings: { name: 'LongFast' } } }] }
500
+ obj = { proto_data: [{ channel: { index: 3, role: :SECONDARY, settings: { name: 'LongFast' } } }] }
501
501
  env = { transport: 'serial', channel: { active: 'LongFast', LongFast: { channel_num: 99 } } }
502
502
  expect(Meshtastic::Serial).to receive(:send_text).with(hash_including(channel: 3))
503
503
  described_class.send(:mesh_send_text, env: env, obj: obj, channel: 99, text: 'slot three')
@@ -507,8 +507,8 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
507
507
  require 'meshtastic'
508
508
  obj = {
509
509
  proto_data: [
510
- { channel: { index: 0, settings: { name: '' } } },
511
- { channel: { index: 2, settings: { name: 'LongFast' } } }
510
+ { channel: { index: 0, role: :PRIMARY, settings: { name: '' } } },
511
+ { channel: { index: 2, role: :SECONDARY, settings: { name: 'LongFast' } } }
512
512
  ]
513
513
  }
514
514
  env = {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pwn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.736
4
+ version: 0.5.737
5
5
  platform: ruby
6
6
  authors:
7
7
  - 0day Inc.
@@ -6915,8 +6915,13 @@ files:
6915
6915
  - spec/lib/pwn/plugins/repl_mesh_console_spec.rb
6916
6916
  - spec/lib/pwn/plugins/repl_mesh_dispatch_spec.rb
6917
6917
  - spec/lib/pwn/plugins/repl_mesh_dm_spec.rb
6918
+ - spec/lib/pwn/plugins/repl_mesh_dm_transport_spec.rb
6919
+ - spec/lib/pwn/plugins/repl_mesh_history_spec.rb
6920
+ - spec/lib/pwn/plugins/repl_mesh_key_exchange_spec.rb
6918
6921
  - spec/lib/pwn/plugins/repl_mesh_layout_spec.rb
6922
+ - spec/lib/pwn/plugins/repl_mesh_node_id_spec.rb
6919
6923
  - spec/lib/pwn/plugins/repl_mesh_rx_paint_spec.rb
6924
+ - spec/lib/pwn/plugins/repl_mesh_security_paint_spec.rb
6920
6925
  - spec/lib/pwn/plugins/repl_pwn_vault_spec.rb
6921
6926
  - spec/lib/pwn/plugins/repl_spec.rb
6922
6927
  - spec/lib/pwn/plugins/rop_spec.rb