pwn 0.5.735 → 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: d8f7823f226f8196c1c33bda916bb5ffde0d6ca22b0c47b3d7b5518ca85e9a8b
4
- data.tar.gz: f069d75fa0a0e678aff23e9255cdd5ea13e4b3821a93fa6a1a0fd86e5648171c
3
+ metadata.gz: af2001b2546112668f86a37ec9ab15c0bea62dd138188fd47b0441e2101a8622
4
+ data.tar.gz: 9281f66b1e6563d1abe0cd057b3a8a7e6de5c9712dd58e72e188f24d51acfbfc
5
5
  SHA512:
6
- metadata.gz: 4795ce1673e60efc6919b72aa25cd0d1058a740234fdccb55efb104708d9940a32ca77d0f600195662dd543d5d20403435313d7c3746266d01fc761b3af8e4ae
7
- data.tar.gz: 1fb653643bcd942b89a7e220009a5b05714950382f1598581bd859e8c2f8c9f64c5c60e37570a02d2c6d4e31831a8978dead7793766c529a48373fe6acd5b31b
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
@@ -88,9 +88,10 @@ module PWN
88
88
  PWN.const_set(:MeshColors, [20, 23, 21])
89
89
  PWN.const_set(:MeshLastColor, 20)
90
90
 
91
- mesh_tx_rows = 5
92
- mesh_header_rows = 5
93
- body_height = [Curses.lines - mesh_tx_rows - mesh_header_rows, 4].max
91
+ layout = PWN::Plugins::REPL.send(:mesh_layout, lines: Curses.lines, cols: Curses.cols)
92
+ mesh_tx_rows = layout[:tx]
93
+ mesh_header_rows = layout[:header]
94
+ body_height = layout[:body]
94
95
  rx_header_win = Curses::Window.new(mesh_header_rows, Curses.cols, 0, 0)
95
96
  rx_header_win.scrollok(false)
96
97
  rx_header_win.nodelay = true
@@ -111,14 +112,18 @@ module PWN
111
112
  frame.addstr(' CONVERSATION ')
112
113
  frame.refresh
113
114
  PWN.const_set(:MeshRxFrameWin, frame)
114
- rx_body_win = Curses::Window.new(body_height - 2, Curses.cols - 4, body_start_row + 1, 2)
115
+ rx_body_win = Curses::Window.new(
116
+ [body_height - 2, 1].max,
117
+ [Curses.cols - 4, 1].max,
118
+ body_start_row + (body_height > 2 ? 1 : 0),
119
+ Curses.cols > 4 ? 2 : 0
120
+ )
115
121
  rx_body_win.scrollok(true)
116
122
  rx_body_win.nodelay = true
117
123
  rx_body_win.refresh
118
124
  PWN.const_set(:MeshRxBodyWin, rx_body_win)
119
125
 
120
- tx_height = mesh_header_rows + body_height
121
- tx_win = Curses::Window.new(mesh_tx_rows, Curses.cols, tx_height, 0)
126
+ tx_win = Curses::Window.new(mesh_tx_rows, Curses.cols, layout[:tx_top], 0)
122
127
  tx_win.scrollok(false)
123
128
  tx_win.nodelay = true
124
129
  PWN::Plugins::REPL.send(:mesh_box!, win: tx_win)
@@ -165,6 +170,41 @@ module PWN
165
170
  t == :auto ? PWN_MESH_TRANSPORTS.first : t
166
171
  end
167
172
 
173
+ # Row counts for header, CONVERSATION, and COMPOSE so the TUI fits the terminal.
174
+ def mesh_layout(opts = {})
175
+ lines = opts[:lines]
176
+ cols = opts[:cols]
177
+ lines = lines.nil? ? Curses.lines : Integer(lines)
178
+ cols = cols.nil? ? Curses.cols : Integer(cols)
179
+ lines = 1 if lines < 1
180
+ cols = 1 if cols < 1
181
+ header = 5
182
+ tx = 5
183
+ min_header = 3
184
+ min_tx = 3
185
+ min_body = 3
186
+ body = lines - header - tx
187
+ while body < min_body && header > min_header
188
+ header -= 1
189
+ body = lines - header - tx
190
+ end
191
+ while body < min_body && tx > min_tx
192
+ tx -= 1
193
+ body = lines - header - tx
194
+ end
195
+ body = [body, 1].max
196
+ overflow = header + body + tx - lines
197
+ body -= overflow if overflow.positive? && body > 1
198
+ body = 1 if body < 1
199
+ {
200
+ header: header,
201
+ body: body,
202
+ tx: tx,
203
+ cols: cols,
204
+ tx_top: header + body
205
+ }
206
+ end
207
+
168
208
  # Explicit Unicode avoids ACS falling back to ASCII on some terminals.
169
209
  def mesh_box!(opts = {})
170
210
  win = opts[:win]
@@ -230,11 +270,15 @@ module PWN
230
270
  next unless ch.is_a?(Hash)
231
271
 
232
272
  role = (ch[:role] || ch['role']).to_s.downcase
233
- next if %w[disabled 0].include?(role)
234
-
235
273
  idx = (ch[:index] || ch['index'] || 0).to_i
236
274
  next unless (0..7).cover?(idx)
237
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
+
238
282
  settings = ch[:settings] || ch['settings'] || {}
239
283
  settings = settings.to_h if !settings.is_a?(Hash) && settings.respond_to?(:to_h)
240
284
  settings = {} unless settings.is_a?(Hash)
@@ -634,6 +678,65 @@ module PWN
634
678
  end
635
679
  end
636
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
+
637
740
  def mesh_send_text(opts = {})
638
741
  env = opts[:env] || {}
639
742
  obj = opts[:obj]
@@ -647,38 +750,56 @@ module PWN
647
750
  radio = opts[:radio]
648
751
  radio = mesh_radio_index_for_name(env: env, obj: obj, name: channel_name) if radio.nil? && !channel_name.empty?
649
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
650
761
  chunks = mesh_text_chunks(text: opts[:text])
651
762
  chunks.each_with_index do |piece, idx|
652
763
  since = obj.is_a?(Hash) ? Array(obj[:proto_data]).size : 0
653
- case kind
654
- when :serial
655
- tx = { serial_obj: obj, to: dest, text: piece, want_ack: true }
656
- tx[:channel] = radio unless radio.nil?
657
- Meshtastic::Serial.send_text(tx)
658
- when :bluetooth
659
- tx = { bluetooth_obj: obj, to: dest, text: piece, want_ack: true }
660
- tx[:channel] = radio unless radio.nil?
661
- Meshtastic::Bluetooth.send_text(tx)
662
- when :tcp
663
- tx = { tcp_obj: obj, to: dest, text: piece }
664
- tx[:channel] = radio unless radio.nil?
665
- 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 })
666
771
  else
667
- send_psks = psks
668
- send_psks = mesh_channel_psks(env: env) if send_psks.nil? || send_psks.empty?
669
- Meshtastic::MQTT.send_text(
670
- mqtt_obj: obj,
671
- from: from,
672
- to: dest,
673
- region: mesh_mqtt_region(region: opts[:region], env: env),
674
- topic: mesh_mqtt_topic(env: env, topic: opts[:topic]),
675
- channel: channel,
676
- text: piece,
677
- psks: send_psks
678
- )
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
679
799
  end
680
800
  from_id = from.to_s
681
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)
682
803
  PWN.send(:remove_const, :MeshLastTx) if PWN.const_defined?(:MeshLastTx)
683
804
  PWN.const_set(:MeshLastTx, { from: from_id, to: dest, text: piece.to_s, at: Time.now })
684
805
  mesh_handle_rx(
@@ -687,6 +808,7 @@ module PWN
687
808
  msg: {
688
809
  packet: {
689
810
  channel: radio,
811
+ pki_encrypted: !dm_key.nil?,
690
812
  node_id_from: from_id,
691
813
  node_id_to: dest,
692
814
  decoded: { portnum: :TEXT_MESSAGE_APP, payload: piece.to_s }
@@ -698,6 +820,10 @@ module PWN
698
820
 
699
821
  mesh_wait_tx_slot(obj: obj, since: since)
700
822
  end
823
+ return unless dm_key && PWN.const_defined?(:MeshPendingDm)
824
+
825
+ PWN.send(:remove_const, :MeshPendingDm)
826
+ PWN.const_set(:MeshPendingDm, nil)
701
827
  end
702
828
 
703
829
  # Close the Meshtastic session opened by #mesh_connect.
@@ -790,6 +916,10 @@ module PWN
790
916
  end
791
917
  text = +''
792
918
  cursor = 0
919
+ history = []
920
+ history_index = 0
921
+ draft = +''
922
+ draft_cursor = 0
793
923
  while pi.config.pwn_mesh
794
924
  mesh_drain_events
795
925
  mesh_draw_input(text: text, cursor: cursor)
@@ -799,10 +929,33 @@ module PWN
799
929
  break if text.empty?
800
930
  when "\n", "\r", 10, 13, Curses::KEY_ENTER
801
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
802
939
  text.clear
803
940
  cursor = 0
804
941
  mesh_draw_input(text: text, cursor: cursor)
805
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
806
959
  when "\u007f", "\b", 127, 8, Curses::KEY_BACKSPACE
807
960
  if cursor.positive?
808
961
  cursor -= 1
@@ -878,7 +1031,7 @@ module PWN
878
1031
  end
879
1032
  end
880
1033
  win.setpos(3, 2)
881
- 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])
882
1035
  win.refresh
883
1036
  end
884
1037
 
@@ -1159,12 +1312,24 @@ module PWN
1159
1312
  ''
1160
1313
  end
1161
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
+
1162
1327
  def mesh_self_node_id(opts = {})
1163
1328
  return '!00000b0b' unless opts.is_a?(Hash)
1164
1329
 
1165
1330
  obj = opts[:obj]
1166
1331
  obj = PWN.const_get(:MeshObj) if obj.nil? && PWN.const_defined?(:MeshObj)
1167
- 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?
1168
1333
 
1169
1334
  '!00000b0b'
1170
1335
  end
@@ -1172,7 +1337,7 @@ module PWN
1172
1337
  def mesh_decorate_local_id(opts = {})
1173
1338
  return '' unless opts.is_a?(Hash)
1174
1339
 
1175
- id = opts[:id].to_s
1340
+ id = mesh_format_node_id(id: opts[:id])
1176
1341
  return id if id.empty?
1177
1342
 
1178
1343
  self_id = mesh_self_node_id(env: opts[:env], obj: opts[:obj])
@@ -1185,7 +1350,7 @@ module PWN
1185
1350
  return true unless opts.is_a?(Hash)
1186
1351
 
1187
1352
  psk = opts[:psk].to_s.strip
1188
- psk.empty? || %w[none default aq==].include?(psk.downcase)
1353
+ psk.empty? || %w[none default aq==].include?(psk.downcase) || psk == '1PG7OiApB1nwvP+rz05pAQ=='
1189
1354
  end
1190
1355
 
1191
1356
  def mesh_channel_securely_encrypted?(opts = {})
@@ -1241,7 +1406,17 @@ module PWN
1241
1406
 
1242
1407
  packet = msg[:packet].is_a?(Hash) ? msg[:packet] : msg
1243
1408
  decoded = packet[:decoded]
1244
- 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])
1245
1420
 
1246
1421
  env = mesh_env_hash
1247
1422
  idx = packet[:channel] || packet['channel']
@@ -1260,9 +1435,11 @@ module PWN
1260
1435
  return if rx_text.strip.empty?
1261
1436
 
1262
1437
  from_id = packet[:node_id_from].to_s
1263
- 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)
1264
1440
  to = packet[:node_id_to].to_s
1265
- 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)
1266
1443
  unless opts[:local]
1267
1444
  last = PWN.const_defined?(:MeshLastTx) ? PWN.const_get(:MeshLastTx) : nil
1268
1445
  if last.is_a?(Hash) &&
@@ -1292,13 +1469,15 @@ module PWN
1292
1469
  state = PWN.const_defined?(:MeshRxState) ? PWN.const_get(:MeshRxState) : {}
1293
1470
  ts = Time.now.strftime('%H:%M:%S')
1294
1471
  color = opts[:local] ? 23 : 21
1295
- 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}"
1296
1475
  unless state[:last_line] == current_line
1297
1476
  rx_body_win = PWN.const_get(:MeshRxBodyWin)
1298
1477
  mutex.synchronize do
1299
1478
  width = [rx_body_win.maxx - 2, 1].max
1300
1479
  rx_body_win.attron(Curses.color_pair(color) | Curses::A_BOLD)
1301
- rx_body_win.addstr(" #{ts} #{from.strip} · #{dest_label}\n")
1480
+ rx_body_win.addstr(" #{ts} #{security_icon} #{from.strip} · #{dest_label}\n")
1302
1481
  rx_body_win.attroff(Curses.color_pair(color) | Curses::A_BOLD)
1303
1482
  rx_body_win.attron(Curses.color_pair(24))
1304
1483
  mesh_wrap_text(text: rx_text, width: width - 1).each do |line|
@@ -1629,11 +1808,16 @@ module PWN
1629
1808
  def pwn_mesh_run_msg(opts = {})
1630
1809
  env = opts[:env] || mesh_env_hash
1631
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
1632
1815
  names = mesh_channel_names(env: env)
1633
1816
  dest = nil
1634
1817
  channel_name = ''
1635
1818
  if tokens[0].to_s.match?(/\A![0-9a-fA-F]{8}\z/)
1636
1819
  dest = tokens.shift
1820
+ channel_name = env.dig(:channel, :active).to_s
1637
1821
  elsif names.any? { |n| n.casecmp?(tokens[0].to_s) }
1638
1822
  channel_name = names.find { |n| n.casecmp?(tokens[0].to_s) }.to_s
1639
1823
  tokens.shift
@@ -1648,6 +1832,7 @@ module PWN
1648
1832
  raise ArgumentError, 'usage: /msg [!nodeid|channel] <text>' if dest.to_s.empty? || text.empty?
1649
1833
 
1650
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?
1651
1836
  ch = env[:channel] || {}
1652
1837
  slot = ch[channel_name.to_sym] || ch[channel_name] || {}
1653
1838
  obj = PWN.const_defined?(:MeshObj) ? PWN.const_get(:MeshObj) : nil
@@ -1808,7 +1993,7 @@ module PWN
1808
1993
  false
1809
1994
  end
1810
1995
 
1811
- private :mesh_transport, :mesh_bound_transport, :mesh_box!, :mesh_mqtt_region, :mesh_mqtt_topic, :mesh_active_psks, :mesh_device_channel_meta, :mesh_device_channels
1996
+ private :mesh_transport, :mesh_bound_transport, :mesh_layout, :mesh_box!, :mesh_mqtt_region, :mesh_mqtt_topic, :mesh_active_psks, :mesh_device_channel_meta, :mesh_device_channels
1812
1997
  private :mesh_radio_channel, :mesh_radio_index_for_name, :mesh_channel_name_for_index, :mesh_unassigned_slot_map, :mesh_psk_b64, :mesh_psk_same?, :mesh_env_channel_name_for_psk, :mesh_whitelist_name_for_index, :mesh_channel_name_from_topic, :mesh_link_label
1813
1998
  private :mesh_connect_one, :mesh_connect, :mesh_mqtt_tls?, :mesh_subscribe, :mesh_text_payload_max, :mesh_payload_fits?, :mesh_text_chunks, :mesh_tx_row_ready?, :mesh_wait_tx_slot, :mesh_send_text, :mesh_disconnect, :mesh_compose_send, :mesh_channel_names, :mesh_env_hash
1814
1999
  private :mesh_submit, :mesh_console_loop, :mesh_drain_events, :mesh_draw_input, :mesh_wrap_text, :mesh_ui_puts, :mesh_menu_root, :mesh_list_devices
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.735'
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,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::Plugins::REPL, 'mesh terminal layout' do
6
+ it 'keeps a 5-row header and 5-row compose on a 24-line terminal' do
7
+ layout = described_class.send(:mesh_layout, lines: 24, cols: 80)
8
+ expect(layout[:header]).to eq(5)
9
+ expect(layout[:tx]).to eq(5)
10
+ expect(layout[:body]).to eq(14)
11
+ expect(layout[:tx_top]).to eq(19)
12
+ end
13
+
14
+ it 'shrinks conversation so header, conversation and compose fit a 12-line terminal' do
15
+ layout = described_class.send(:mesh_layout, lines: 12, cols: 40)
16
+ expect(layout[:header] + layout[:body] + layout[:tx]).to eq(12)
17
+ expect(layout[:tx_top] + layout[:tx]).to eq(12)
18
+ expect(layout[:header]).to be >= 3
19
+ expect(layout[:tx]).to be >= 3
20
+ expect(layout[:body]).to be >= 3
21
+ end
22
+
23
+ it 'keeps compose on-screen on a 10-line terminal by shrinking conversation first' do
24
+ layout = described_class.send(:mesh_layout, lines: 10, cols: 40)
25
+ expect(layout[:header] + layout[:body] + layout[:tx]).to eq(10)
26
+ expect(layout[:tx_top]).to be < 10
27
+ expect(layout[:tx_top] + layout[:tx]).to eq(10)
28
+ end
29
+ 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.735
4
+ version: 0.5.737
5
5
  platform: ruby
6
6
  authors:
7
7
  - 0day Inc.
@@ -6915,7 +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
6921
+ - spec/lib/pwn/plugins/repl_mesh_layout_spec.rb
6922
+ - spec/lib/pwn/plugins/repl_mesh_node_id_spec.rb
6918
6923
  - spec/lib/pwn/plugins/repl_mesh_rx_paint_spec.rb
6924
+ - spec/lib/pwn/plugins/repl_mesh_security_paint_spec.rb
6919
6925
  - spec/lib/pwn/plugins/repl_pwn_vault_spec.rb
6920
6926
  - spec/lib/pwn/plugins/repl_spec.rb
6921
6927
  - spec/lib/pwn/plugins/rop_spec.rb