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 +4 -4
- data/documentation/pwn-REPL.md +27 -0
- data/lib/pwn/plugins/repl/mesh.rb +228 -43
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +13 -0
- data/spec/lib/pwn/plugins/repl_mesh_dm_spec.rb +17 -0
- data/spec/lib/pwn/plugins/repl_mesh_dm_transport_spec.rb +50 -0
- data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +47 -0
- data/spec/lib/pwn/plugins/repl_mesh_key_exchange_spec.rb +85 -0
- data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +29 -0
- data/spec/lib/pwn/plugins/repl_mesh_node_id_spec.rb +36 -0
- data/spec/lib/pwn/plugins/repl_mesh_rx_paint_spec.rb +23 -0
- data/spec/lib/pwn/plugins/repl_mesh_security_paint_spec.rb +36 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +3 -3
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af2001b2546112668f86a37ec9ab15c0bea62dd138188fd47b0441e2101a8622
|
|
4
|
+
data.tar.gz: 9281f66b1e6563d1abe0cd057b3a8a7e6de5c9712dd58e72e188f24d51acfbfc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e8f434c799f8cb9c67fb9d8f2f329e1e172fa1664ccfaea7bcb208febe9e3d8b42372b95210c3afb551cdb5648816f033fccdacb6897e36177a17d3b3be79a5a
|
|
7
|
+
data.tar.gz: 893e79ee5521e6c52558ea5cd69ac30c65b5a97e4a0cad3405da69bef72d48424714f971755880b8d7ca89168befe138d8edcc03636e124a09279ec7eb09c272
|
data/documentation/pwn-REPL.md
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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]
|
|
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)
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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,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.
|
|
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
|