meshtastic 0.0.187 → 0.0.188
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/README.md +2 -1
- data/documentation/admin-backup.md +82 -3
- data/documentation/admin-config.md +28 -1
- data/documentation/admin-firmware-migration.md +171 -0
- data/documentation/admin-firmware.md +7 -3
- data/documentation/admin.md +16 -0
- data/documentation/mesh-interface.md +46 -18
- data/documentation/mqtt.md +63 -71
- data/documentation/waypoint.md +54 -0
- data/lib/meshtastic/admin/backup.rb +44 -5
- data/lib/meshtastic/admin/channel.rb +3 -8
- data/lib/meshtastic/admin/config.rb +63 -1
- data/lib/meshtastic/admin/firmware.rb +43 -10
- data/lib/meshtastic/admin/validation.rb +78 -0
- data/lib/meshtastic/admin.rb +28 -22
- data/lib/meshtastic/mesh_interface.rb +123 -43
- data/lib/meshtastic/module_config.rb +2 -0
- data/lib/meshtastic/mqtt.rb +21 -12
- data/lib/meshtastic/version.rb +1 -1
- data/lib/meshtastic/waypoint.rb +85 -0
- data/lib/meshtastic.rb +1 -0
- data/spec/lib/meshtastic/admin/backup_spec.rb +153 -9
- data/spec/lib/meshtastic/admin/config_spec.rb +70 -1
- data/spec/lib/meshtastic/admin/firmware_migration_spec.rb +121 -0
- data/spec/lib/meshtastic/admin/firmware_spec.rb +1 -1
- data/spec/lib/meshtastic/admin/firmware_target_selection_spec.rb +44 -0
- data/spec/lib/meshtastic/admin/validation_spec.rb +89 -0
- data/spec/lib/meshtastic/admin_spec.rb +77 -0
- data/spec/lib/meshtastic/bluetooth_spec.rb +26 -1
- data/spec/lib/meshtastic/mesh_interface_spec.rb +54 -0
- data/spec/lib/meshtastic/module_config_spec.rb +1 -1
- data/spec/lib/meshtastic/mqtt_spec.rb +165 -0
- data/spec/lib/meshtastic/serial_spec.rb +26 -0
- data/spec/lib/meshtastic/tcp_spec.rb +29 -0
- data/spec/lib/meshtastic/waypoint_spec.rb +55 -0
- data/spec/support/channel_crypto_reference.rb +29 -0
- data/spec/support/firmware_release_artifact_audit.rb +39 -0
- metadata +11 -1
data/lib/meshtastic/admin.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'timeout'
|
|
4
4
|
require 'monitor'
|
|
5
5
|
require 'meshtastic/admin_pb'
|
|
6
|
+
require 'meshtastic/admin/validation'
|
|
6
7
|
require 'meshtastic/mqtt'
|
|
7
8
|
|
|
8
9
|
module Meshtastic
|
|
@@ -19,7 +20,7 @@ module Meshtastic
|
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
SKIP = %i[
|
|
22
|
-
message transport_obj to from channel want_ack hop_limit
|
|
23
|
+
message transport_obj to from channel channel_id want_ack hop_limit
|
|
23
24
|
want_response port_num data via psks seconds owner long_name short_name index config_type
|
|
24
25
|
module_config_type path node_num lat lon altitude time channel_settings channel_pb
|
|
25
26
|
config module_config messages ringtone scale location event event_code kb_char touch_x
|
|
@@ -70,7 +71,7 @@ module Meshtastic
|
|
|
70
71
|
|
|
71
72
|
message.session_passkey = passkey
|
|
72
73
|
end
|
|
73
|
-
message
|
|
74
|
+
Validation.validate(message: message)
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
public_class_method def self.send(opts = {})
|
|
@@ -86,11 +87,7 @@ module Meshtastic
|
|
|
86
87
|
message.session_passkey = acquire_session(opts.merge(connection: connection, target: destination))
|
|
87
88
|
end
|
|
88
89
|
want_response = opts.fetch(:want_response) { message.payload_variant.to_s.match?(/\Aget_.*_request\z/) }
|
|
89
|
-
data = Meshtastic::Data.new(
|
|
90
|
-
portnum: :ADMIN_APP,
|
|
91
|
-
payload: message.to_proto,
|
|
92
|
-
want_response: want_response
|
|
93
|
-
)
|
|
90
|
+
data = Meshtastic::Data.new(portnum: :ADMIN_APP, payload: message.to_proto, want_response: want_response)
|
|
94
91
|
delivery = opts.except(:transport_obj).merge(data: data, port_num: Meshtastic::PortNum::ADMIN_APP, to: destination)
|
|
95
92
|
delivery[:"#{type}_obj"] = opts[:transport_obj]
|
|
96
93
|
delivery[:from] = 0 if connection && !opts.key?(:from)
|
|
@@ -176,10 +173,17 @@ module Meshtastic
|
|
|
176
173
|
[opts[:target], opts[:connection][:my_node_num]].include?(packet.from)
|
|
177
174
|
reason = Meshtastic::Routing.decode(packet.decoded.payload).error_reason
|
|
178
175
|
raise RoutingError.new(reason, opts[:request_id], packet.from) if reason != :NONE && [opts[:target], opts[:connection][:my_node_num]].include?(packet.from)
|
|
179
|
-
|
|
176
|
+
if reason == :NONE && packet.from == opts[:target] && opts[:variant] == :routing
|
|
177
|
+
return { variant: :routing, value: reason, request_id: opts[:request_id], from: packet.from,
|
|
178
|
+
delivery: packet.ack_proof_status == :ACK_PROOF_VALID ? :device_verified : :acknowledged,
|
|
179
|
+
security_provenance: { source: :radio, verification: :device_reported, reported: { ack_proof_status: packet.ack_proof_status, xeddsa_signed: packet.xeddsa_signed } } }
|
|
180
|
+
end
|
|
180
181
|
end
|
|
181
182
|
reply = response(packet: incoming, request_id: opts[:request_id], from: opts[:target])
|
|
182
|
-
|
|
183
|
+
if reply && reply[:variant] == opts[:variant]
|
|
184
|
+
reply[:security_provenance].merge!(source: :radio, verification: :device_reported)
|
|
185
|
+
return reply
|
|
186
|
+
end
|
|
183
187
|
|
|
184
188
|
deferred << incoming
|
|
185
189
|
end
|
|
@@ -227,8 +231,9 @@ module Meshtastic
|
|
|
227
231
|
return nil unless variant.to_s.end_with?('_response')
|
|
228
232
|
|
|
229
233
|
{
|
|
230
|
-
message: message, variant: variant, value: message.public_send(variant),
|
|
231
|
-
session_passkey: message.session_passkey, request_id: packet.decoded.request_id, from: packet.from
|
|
234
|
+
message: message, variant: variant, value: message.public_send(variant), delivery: :readback,
|
|
235
|
+
session_passkey: message.session_passkey, request_id: packet.decoded.request_id, from: packet.from,
|
|
236
|
+
security_provenance: { source: :unknown, verification: :untrusted, reported: { ack_proof_status: packet.ack_proof_status, xeddsa_signed: packet.xeddsa_signed } }
|
|
232
237
|
}
|
|
233
238
|
end
|
|
234
239
|
|
|
@@ -338,12 +343,8 @@ module Meshtastic
|
|
|
338
343
|
end
|
|
339
344
|
|
|
340
345
|
public_class_method def self.send_input_event(opts = {})
|
|
341
|
-
event = opts[:event] || Meshtastic::AdminMessage::InputEvent.new(
|
|
342
|
-
|
|
343
|
-
kb_char: opts[:kb_char].to_i,
|
|
344
|
-
touch_x: opts[:touch_x].to_i,
|
|
345
|
-
touch_y: opts[:touch_y].to_i
|
|
346
|
-
)
|
|
346
|
+
event = opts[:event] || Meshtastic::AdminMessage::InputEvent.new(event_code: opts[:event_code].to_i, kb_char: opts[:kb_char].to_i,
|
|
347
|
+
touch_x: opts[:touch_x].to_i, touch_y: opts[:touch_y].to_i)
|
|
347
348
|
send(opts.merge(send_input_event: event))
|
|
348
349
|
end
|
|
349
350
|
|
|
@@ -471,7 +472,8 @@ module Meshtastic
|
|
|
471
472
|
channel: 'optional - routing channel index (default zero)',
|
|
472
473
|
want_ack: 'optional - request routing acknowledgment, distinct from an admin reply',
|
|
473
474
|
hop_limit: 'optional - maximum radio hop count',
|
|
474
|
-
|
|
475
|
+
channel_id: 'optional - exact MQTT channel name selecting one PSK and explicit AEAD mode',
|
|
476
|
+
psks: 'optional - MQTT name to Base64 legacy PSK or {psk: Base64, use_aead: boolean} map; not public-key admin authorization',
|
|
475
477
|
pki_encrypted: 'optional - enable device-owned PKI on serial BLE or TCP; unsupported over MQTT',
|
|
476
478
|
public_key: 'optional - recipient public-key bytes for radio PKI',
|
|
477
479
|
last_packet_id: 'optional - previous packet number used by transport packet generation',
|
|
@@ -488,6 +490,10 @@ module Meshtastic
|
|
|
488
490
|
timeout: 'optional - positive finite total response budget in seconds including session acquisition (default ten)'
|
|
489
491
|
)
|
|
490
492
|
|
|
493
|
+
# Results distinguish :acknowledged, :device_verified and :readback.
|
|
494
|
+
# :device_verified requires a target/request-correlated ACK_PROOF_VALID from the radio queue.
|
|
495
|
+
# security_provenance retains raw proof/signature reports; verification is device_reported, never host-verified.
|
|
496
|
+
|
|
491
497
|
# Decode protobuf bytes or an incoming admin packet.
|
|
492
498
|
#{self}.decode(
|
|
493
499
|
payload: 'optional - serialized AdminMessage bytes when packet is omitted',
|
|
@@ -526,8 +532,8 @@ module Meshtastic
|
|
|
526
532
|
# Set the node owner User protobuf.
|
|
527
533
|
#{self}.set_owner(
|
|
528
534
|
owner: 'optional - Meshtastic::User protobuf to send as set_owner',
|
|
529
|
-
long_name: 'optional - owner long name
|
|
530
|
-
short_name: 'optional - owner short name
|
|
535
|
+
long_name: 'optional - owner long name, at most 24 UTF-8 bytes; empty means unchanged on firmware v2.8.1.8e6a88d',
|
|
536
|
+
short_name: 'optional - owner short name, at most four UTF-8 bytes; empty means unchanged'
|
|
531
537
|
)
|
|
532
538
|
|
|
533
539
|
# Request the node owner User protobuf.
|
|
@@ -643,11 +649,11 @@ module Meshtastic
|
|
|
643
649
|
# Enable ham-radio identity parameters.
|
|
644
650
|
#{self}.set_ham_mode(
|
|
645
651
|
ham: 'optional - HamParameters protobuf to send',
|
|
646
|
-
call_sign: 'optional -
|
|
652
|
+
call_sign: 'optional - required within ham parameters, nonblank callsign at most seven UTF-8 bytes',
|
|
647
653
|
tx_power: 'optional - transmit power when ham is omitted',
|
|
648
654
|
frequency: 'optional - frequency in MHz when ham is omitted',
|
|
649
655
|
short_name: 'optional - short name when ham is omitted',
|
|
650
|
-
long_name: 'optional -
|
|
656
|
+
long_name: 'optional - ham suffix at most fourteen UTF-8 bytes; composed CALL//suffix must fit 24 bytes; rejected, never truncated'
|
|
651
657
|
)
|
|
652
658
|
|
|
653
659
|
# Remove a node from the local nodedb by number.
|
|
@@ -113,14 +113,14 @@ module Meshtastic
|
|
|
113
113
|
def get_cipher_keys(opts = {})
|
|
114
114
|
psks = opts[:psks]
|
|
115
115
|
|
|
116
|
-
psks.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
psks[key] = replaced_psk
|
|
121
|
-
end
|
|
116
|
+
psks.to_h do |name, value|
|
|
117
|
+
entry = value.is_a?(Hash) ? value.dup : { psk: value }
|
|
118
|
+
psk = entry[:psk]
|
|
119
|
+
raise ArgumentError, 'PSK must be a Base64 string' unless psk.is_a?(String)
|
|
122
120
|
|
|
123
|
-
|
|
121
|
+
entry[:psk] = psk.tr('-_', '+/').ljust(psk.length + ((4 - (psk.length % 4)) % 4), '=')
|
|
122
|
+
[name, value.is_a?(Hash) ? entry : entry[:psk]]
|
|
123
|
+
end
|
|
124
124
|
rescue StandardError => e
|
|
125
125
|
raise e
|
|
126
126
|
end
|
|
@@ -212,6 +212,8 @@ module Meshtastic
|
|
|
212
212
|
# psks: 'optional - hash of :channel => psk key value pairs (default: { LongFast: "AQ==" })'
|
|
213
213
|
# )
|
|
214
214
|
def send_packet(opts = {})
|
|
215
|
+
raise ArgumentError, 'use_aead belongs inside each psks channel record' if opts.key?(:use_aead)
|
|
216
|
+
|
|
215
217
|
mesh_packet = opts[:mesh_packet]
|
|
216
218
|
from = opts[:from] ||= '!00000b0b'
|
|
217
219
|
# from_hex = from.delete('!').bytes.map { |b| b.to_s(16).rjust(2, '0') }.join if from.is_a?(String)
|
|
@@ -245,6 +247,9 @@ module Meshtastic
|
|
|
245
247
|
# my_info = Meshtastic::FromRadio.my_info
|
|
246
248
|
# wait_connected if to != my_info.my_node_num && my_info.is_a(Meshtastic::Deviceonly::MyInfo)
|
|
247
249
|
|
|
250
|
+
raise ArgumentError, 'MQTT does not support recipient public_key PKI encryption' if via == :mqtt && opts[:public_key] && !opts[:pki_encrypted]
|
|
251
|
+
raise ArgumentError, 'MQTT direct text requires unsupported PKI encryption; use a radio transport' if via == :mqtt && to != 0xffffffff && mesh_packet.decoded&.portnum == :TEXT_MESSAGE_APP
|
|
252
|
+
|
|
248
253
|
mesh_packet.from = from
|
|
249
254
|
mesh_packet.to = to
|
|
250
255
|
mesh_packet.channel = channel
|
|
@@ -258,26 +263,24 @@ module Meshtastic
|
|
|
258
263
|
raise ArgumentError, 'recipient public_key must contain 32 bytes' unless mesh_packet.public_key.bytesize == 32
|
|
259
264
|
end
|
|
260
265
|
|
|
261
|
-
#
|
|
262
|
-
|
|
263
|
-
# ServiceEnvelope payload is pre-encrypted for the mesh.
|
|
266
|
+
# Select by exact name, never Hash insertion order when there are multiple keys.
|
|
267
|
+
selected_name = opts[:channel_id]&.to_s
|
|
264
268
|
if psks && !psks.empty?
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
mesh_packet.encrypted = encrypted_payload
|
|
269
|
+
raise ArgumentError, 'Ambiguous duplicate channel names in psks' unless psks.keys.map(&:to_s).uniq.size == psks.size
|
|
270
|
+
|
|
271
|
+
selected_name ||= psks.keys.first.to_s if psks.size == 1
|
|
272
|
+
raise ArgumentError, 'channel_id required for ambiguous PSKs' unless selected_name
|
|
273
|
+
raise ArgumentError, 'MQTT topic conflicts with selected channel_id' if via == :mqtt && opts[:topic] && opts[:topic].split('/')[-2] != selected_name
|
|
274
|
+
|
|
275
|
+
psk = psks[selected_name] || psks[selected_name.to_sym]
|
|
276
|
+
raise ArgumentError, 'No PSK for selected channel_id' unless psk
|
|
277
|
+
|
|
278
|
+
config = channel_crypto(name: selected_name, value: psk)
|
|
279
|
+
mesh_packet.channel = config[:hash] if via == :mqtt
|
|
280
|
+
mesh_packet.encrypted = crypt_channel(config: config, message: mesh_packet.to_h,
|
|
281
|
+
bytes: mesh_packet.decoded.to_proto, encrypt: true)
|
|
282
|
+
elsif via == :mqtt
|
|
283
|
+
raise ArgumentError, 'MQTT requires an explicit channel PSK; plaintext fallback is disabled'
|
|
281
284
|
end
|
|
282
285
|
# puts mesh_packet.to_h
|
|
283
286
|
|
|
@@ -290,8 +293,7 @@ module Meshtastic
|
|
|
290
293
|
when :mqtt
|
|
291
294
|
service_envelope = Meshtastic::ServiceEnvelope.new
|
|
292
295
|
service_envelope.packet = mesh_packet
|
|
293
|
-
|
|
294
|
-
service_envelope.channel_id = psks.keys.first
|
|
296
|
+
service_envelope.channel_id = selected_name
|
|
295
297
|
service_envelope.gateway_id = "!#{from.to_s(16).downcase}"
|
|
296
298
|
send_to_mqtt(service_envelope: service_envelope)
|
|
297
299
|
else
|
|
@@ -315,6 +317,8 @@ module Meshtastic
|
|
|
315
317
|
# psks: 'optional - hash of :channel => psk key value pairs (default: { LongFast: "AQ==" })'
|
|
316
318
|
# )
|
|
317
319
|
def send_data(opts = {})
|
|
320
|
+
raise ArgumentError, 'use_aead belongs inside each psks channel record' if opts.key?(:use_aead)
|
|
321
|
+
|
|
318
322
|
# Send a text message to a node
|
|
319
323
|
from = opts[:from] ||= '!00000b0b'
|
|
320
324
|
# from_hex = from.delete('!').bytes.map { |b| b.to_s(16).rjust(2, '0') }.join if from.is_a?(String)
|
|
@@ -366,6 +370,8 @@ module Meshtastic
|
|
|
366
370
|
hop_limit: hop_limit,
|
|
367
371
|
pki_encrypted: opts.fetch(:pki_encrypted, false),
|
|
368
372
|
public_key: opts[:public_key],
|
|
373
|
+
channel_id: opts[:channel_id],
|
|
374
|
+
topic: opts[:topic],
|
|
369
375
|
psks: psks
|
|
370
376
|
)
|
|
371
377
|
rescue StandardError => e
|
|
@@ -387,6 +393,8 @@ module Meshtastic
|
|
|
387
393
|
# psks: 'optional - hash of :channel => psk key value pairs (default: { LongFast: "AQ==" })'
|
|
388
394
|
# )
|
|
389
395
|
def send_text(opts = {})
|
|
396
|
+
raise ArgumentError, 'use_aead belongs inside each psks channel record' if opts.key?(:use_aead)
|
|
397
|
+
|
|
390
398
|
# Send a text message to a node
|
|
391
399
|
from = opts[:from] ||= '!00000b0b'
|
|
392
400
|
# from_hex = from.delete('!').bytes.map { |b| b.to_s(16).rjust(2, '0') }.join if from.is_a?(String)
|
|
@@ -443,6 +451,10 @@ module Meshtastic
|
|
|
443
451
|
hop_limit: hop_limit,
|
|
444
452
|
port_num: port_num,
|
|
445
453
|
on_response: on_response,
|
|
454
|
+
pki_encrypted: opts.fetch(:pki_encrypted, false),
|
|
455
|
+
public_key: opts[:public_key],
|
|
456
|
+
channel_id: opts[:channel_id],
|
|
457
|
+
topic: opts[:topic],
|
|
446
458
|
psks: psks
|
|
447
459
|
)
|
|
448
460
|
rescue StandardError => e
|
|
@@ -544,10 +556,71 @@ module Meshtastic
|
|
|
544
556
|
|
|
545
557
|
private :decode_application_payload
|
|
546
558
|
|
|
559
|
+
# Firmware v2.8.1.8e6a88d: Channels::generateHash and CryptoEngine CCM.
|
|
560
|
+
def channel_crypto(opts = {})
|
|
561
|
+
value = opts[:value]
|
|
562
|
+
entry = value.is_a?(Hash) ? value : { psk: value, use_aead: false }
|
|
563
|
+
raise ArgumentError, 'Channel records require psk and explicit use_aead only' unless entry.keys.sort == %i[psk use_aead]
|
|
564
|
+
|
|
565
|
+
aead = entry[:use_aead]
|
|
566
|
+
raise ArgumentError, 'use_aead must be true or false' unless [true, false].include?(aead)
|
|
567
|
+
|
|
568
|
+
encoded = entry[:psk]
|
|
569
|
+
raise ArgumentError, 'PSK must be a Base64 string' unless encoded.is_a?(String)
|
|
570
|
+
|
|
571
|
+
encoded = encoded.tr('-_', '+/').ljust(encoded.length + ((4 - (encoded.length % 4)) % 4), '=')
|
|
572
|
+
key = Base64.strict_decode64(encoded)
|
|
573
|
+
if key.bytesize == 1 && key.getbyte(0).positive?
|
|
574
|
+
index = key.getbyte(0)
|
|
575
|
+
key = Base64.strict_decode64('1PG7OiApB1nwvP+rz05pAQ==')
|
|
576
|
+
key.setbyte(15, (key.getbyte(15) + index - 1) & 0xff)
|
|
577
|
+
end
|
|
578
|
+
raise ArgumentError, 'PSK must contain 16 or 32 bytes (or a nonzero one-byte firmware alias)' unless [16, 32].include?(key.bytesize)
|
|
579
|
+
|
|
580
|
+
{ key: key, aead: aead, hash: (opts[:name].to_s.bytes + key.bytes).reduce(0, :^) ^ (aead ? 0xae : 0) }
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def crypt_channel(opts = {})
|
|
584
|
+
config = opts[:config]
|
|
585
|
+
message = opts[:message]
|
|
586
|
+
encrypt = opts[:encrypt]
|
|
587
|
+
bytes = opts[:bytes]
|
|
588
|
+
aead = config[:aead]
|
|
589
|
+
key = config[:key]
|
|
590
|
+
cipher = OpenSSL::Cipher.new("AES-#{key.bytesize * 8}-#{aead ? 'CCM' : 'CTR'}")
|
|
591
|
+
encrypt ? cipher.encrypt : cipher.decrypt
|
|
592
|
+
if aead
|
|
593
|
+
raise ArgumentError, 'Truncated AEAD ciphertext' if !encrypt && bytes.bytesize <= 12
|
|
594
|
+
|
|
595
|
+
cipher.iv_len = 13
|
|
596
|
+
cipher.auth_tag_len = 12
|
|
597
|
+
unless encrypt
|
|
598
|
+
cipher.auth_tag = bytes.byteslice(-12, 12)
|
|
599
|
+
bytes = bytes.byteslice(0, bytes.bytesize - 12)
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
cipher.key = key
|
|
603
|
+
nonce = [message.fetch(:id, 0), 0, message.fetch(:from, 0), 0].pack('V4')
|
|
604
|
+
cipher.iv = aead ? nonce.byteslice(0, 13) : nonce
|
|
605
|
+
if aead
|
|
606
|
+
cipher.ccm_data_len = bytes.bytesize
|
|
607
|
+
cipher.auth_data = [message.fetch(:from, 0), message.fetch(:to, 0)].pack('V2')
|
|
608
|
+
end
|
|
609
|
+
result = (bytes.empty? ? ''.b : cipher.update(bytes)) + cipher.final
|
|
610
|
+
result += cipher.auth_tag(12) if aead && encrypt
|
|
611
|
+
result
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
private :channel_crypto, :crypt_channel
|
|
615
|
+
|
|
547
616
|
# Decode only the selected channel key; never reinterpret PKI as PSK crypto.
|
|
548
617
|
# AES-CTR parsing is not authentication or proof of origin.
|
|
549
618
|
def decrypt_packet(opts = {})
|
|
550
619
|
message = opts[:message]
|
|
620
|
+
source = opts.fetch(:source, :radio)
|
|
621
|
+
reported = message.slice(:xeddsa_signed, :ack_proof_status)
|
|
622
|
+
message[:security_provenance] = { source: source, verification: source == :radio ? :device_reported : :untrusted, reported: reported }
|
|
623
|
+
reported.each_key { |key| message.delete(key) } unless source == :radio
|
|
551
624
|
return message if message[:decoded] || !message.key?(:encrypted)
|
|
552
625
|
|
|
553
626
|
if message[:pki_encrypted]
|
|
@@ -557,32 +630,29 @@ module Meshtastic
|
|
|
557
630
|
|
|
558
631
|
channel = opts[:channel]
|
|
559
632
|
psks = opts[:psks] || {}
|
|
633
|
+
raise ArgumentError, 'Ambiguous duplicate channel names in psks' unless psks.keys.map(&:to_s).uniq.size == psks.size
|
|
634
|
+
|
|
560
635
|
psk = psks[channel] || psks[channel.to_s] || psks[channel.to_s.to_sym] unless channel.nil?
|
|
561
636
|
if channel.nil?
|
|
562
637
|
# Encrypted radio packets carry an eight-bit name/key XOR hash, not a slot.
|
|
563
638
|
candidates = psks.select do |name, value|
|
|
564
|
-
|
|
565
|
-
[16, 32].include?(bytes.bytesize) && (name.to_s.bytes + bytes.bytes).reduce(0, :^) == message.fetch(:channel, 0)
|
|
639
|
+
channel_crypto(name: name, value: value)[:hash] == message.fetch(:channel, 0)
|
|
566
640
|
rescue ArgumentError
|
|
567
641
|
false
|
|
568
642
|
end
|
|
569
|
-
psk = candidates.
|
|
643
|
+
channel, psk = candidates.first if candidates.size == 1
|
|
570
644
|
end
|
|
571
645
|
unless psk
|
|
572
646
|
message[:decryption_error] = 'No PSK for the selected channel'
|
|
573
647
|
return message
|
|
574
648
|
end
|
|
575
649
|
|
|
576
|
-
|
|
577
|
-
raise ArgumentError, '
|
|
650
|
+
config = channel_crypto(name: channel, value: psk)
|
|
651
|
+
raise ArgumentError, 'Channel hash does not match selected key and mode' if opts[:source] == :mqtt && config[:hash] != message.fetch(:channel, 0)
|
|
578
652
|
|
|
579
|
-
|
|
580
|
-
cipher.decrypt
|
|
581
|
-
cipher.key = key
|
|
582
|
-
cipher.iv = [message.fetch(:id, 0), 0, message.fetch(:from, 0), 0].pack('V4')
|
|
583
|
-
ciphertext = message[:encrypted]
|
|
584
|
-
plaintext = (ciphertext.empty? ? ''.b : cipher.update(ciphertext)) + cipher.final
|
|
653
|
+
plaintext = crypt_channel(config: config, message: message, bytes: message[:encrypted], encrypt: false)
|
|
585
654
|
message[:decoded] = Meshtastic::Data.decode(plaintext).to_h
|
|
655
|
+
message[:security_provenance][:channel_authentication] = config[:aead] ? :aead : :none
|
|
586
656
|
message[:encrypted] = :decrypted
|
|
587
657
|
message
|
|
588
658
|
rescue ArgumentError, OpenSSL::Cipher::CipherError, Google::Protobuf::ParseError
|
|
@@ -623,7 +693,10 @@ module Meshtastic
|
|
|
623
693
|
channel: 'optional - Channel (Default: 0)',
|
|
624
694
|
want_ack: 'optional - Want Acknowledgement (Default: false)',
|
|
625
695
|
hop_limit: 'optional - Hop Limit (Default: 3)',
|
|
626
|
-
|
|
696
|
+
pki_encrypted: 'optional - radio-owned PKI; requires psks: nil and a 32-byte recipient public_key',
|
|
697
|
+
public_key: 'optional - raw recipient key forwarded to the radio without host encryption',
|
|
698
|
+
channel_id: 'optional - exact channel name; required when multiple PSKs are supplied',
|
|
699
|
+
psks: 'optional - names to Base64 keys or { psk: Base64, use_aead: true/false }; nil delegates to radio'
|
|
627
700
|
)
|
|
628
701
|
|
|
629
702
|
#{self}.send_data(
|
|
@@ -636,7 +709,10 @@ module Meshtastic
|
|
|
636
709
|
want_ack: 'optional - Want Acknowledgement (Default: false)',
|
|
637
710
|
hop_limit: 'optional - Hop Limit (Default: 3)',
|
|
638
711
|
port_num: 'optional - (Default: Meshtastic::PortNum::PRIVATE_APP)',
|
|
639
|
-
|
|
712
|
+
pki_encrypted: 'optional - radio-owned PKI; requires psks: nil and a 32-byte recipient public_key',
|
|
713
|
+
public_key: 'optional - raw recipient key forwarded to the radio without host encryption',
|
|
714
|
+
channel_id: 'optional - exact channel name; required when multiple PSKs are supplied',
|
|
715
|
+
psks: 'optional - names to Base64 keys or { psk: Base64, use_aead: true/false }; nil delegates to radio'
|
|
640
716
|
)
|
|
641
717
|
|
|
642
718
|
#{self}.send_text(
|
|
@@ -650,7 +726,10 @@ module Meshtastic
|
|
|
650
726
|
want_response: 'optional - Want Response (Default: false)',
|
|
651
727
|
hop_limit: 'optional - Hop Limit (Default: 3)',
|
|
652
728
|
on_response: 'optional - Callback on Response',
|
|
653
|
-
|
|
729
|
+
pki_encrypted: 'optional - radio-owned PKI; requires psks: nil and a 32-byte recipient public_key',
|
|
730
|
+
public_key: 'optional - raw recipient key forwarded to the radio without host encryption',
|
|
731
|
+
channel_id: 'optional - exact channel name; required when multiple PSKs are supplied',
|
|
732
|
+
psks: 'optional - names to Base64 keys or { psk: Base64, use_aead: true/false }; nil delegates to radio'
|
|
654
733
|
)
|
|
655
734
|
|
|
656
735
|
# Binary codecs: Unishox2, ATAK V1/V2, Forwarder, Reticulum and PayloadFormats.
|
|
@@ -664,7 +743,8 @@ module Meshtastic
|
|
|
664
743
|
|
|
665
744
|
#{self}.decrypt_packet(
|
|
666
745
|
message: 'required - decoded MeshPacket hash, updated in place',
|
|
667
|
-
psks: 'required -
|
|
746
|
+
psks: 'required - names to Base64 keys or { psk: Base64, use_aead: true/false }',
|
|
747
|
+
source: 'optional - :radio (device reports) or :mqtt (untrusted broker claims)',
|
|
668
748
|
channel: 'optional - exact MQTT channel name; omitted selects unique radio channel hash'
|
|
669
749
|
)
|
|
670
750
|
|
|
@@ -41,6 +41,8 @@ module Meshtastic
|
|
|
41
41
|
# Legacy serial_obj:, bluetooth_obj:, tcp_obj:, mqtt_obj: remain accepted.
|
|
42
42
|
# Supply one non-nil connection only; mixed connection options are rejected.
|
|
43
43
|
# Remote setters inherit Admin automatic sessions, not persistence readback.
|
|
44
|
+
# Firmware v2.8.1.8e6a88d: exactly one selected module section is required.
|
|
45
|
+
# Empty outer messages and unknown enum values fail before any radio I/O.
|
|
44
46
|
|
|
45
47
|
# Run the authors class method for this module.
|
|
46
48
|
#{self}.authors
|
data/lib/meshtastic/mqtt.rb
CHANGED
|
@@ -72,16 +72,17 @@ module Meshtastic
|
|
|
72
72
|
|
|
73
73
|
public_class_method def self.subscribe(opts = {})
|
|
74
74
|
mqtt_obj = opts[:mqtt_obj]
|
|
75
|
+
raise ArgumentError, 'use_aead belongs inside each psks channel record' if opts.key?(:use_aead)
|
|
76
|
+
|
|
75
77
|
root_topic = opts[:root_topic] ||= 'msh'
|
|
76
78
|
region = opts[:region] ||= 'US'
|
|
77
79
|
topic = opts[:topic] ||= '2/e/LongFast/#'
|
|
78
|
-
#
|
|
80
|
+
# Exact channel-name selection; never probe alternative keys or modes.
|
|
79
81
|
|
|
80
82
|
public_psk = '1PG7OiApB1nwvP+rz05pAQ=='
|
|
81
83
|
psks = opts[:psks] ||= { LongFast: public_psk }
|
|
82
84
|
raise 'ERROR: psks parameter must be a hash of :channel_id => psk key value pairs' unless psks.is_a?(Hash)
|
|
83
85
|
|
|
84
|
-
psks[:LongFast] = public_psk if psks[:LongFast] == 'AQ=='
|
|
85
86
|
mui = Meshtastic::MeshInterface.new
|
|
86
87
|
psks = mui.get_cipher_keys(psks: psks)
|
|
87
88
|
|
|
@@ -121,6 +122,10 @@ module Meshtastic
|
|
|
121
122
|
next unless decoded_payload_hash[:packet].is_a?(Hash)
|
|
122
123
|
|
|
123
124
|
message = decoded_payload_hash[:packet] if decoded_payload_hash.keys.include?(:packet)
|
|
125
|
+
mui.decrypt_packet(
|
|
126
|
+
message: message, psks: psks, source: :mqtt,
|
|
127
|
+
channel: decoded_payload_hash[:channel_id].to_s.empty? ? raw_topic.split('/')[-2].to_s : decoded_payload_hash[:channel_id]
|
|
128
|
+
)
|
|
124
129
|
message[:topic] = raw_topic
|
|
125
130
|
message[:node_id_from] = "!#{message[:from].to_i.to_s(16)}"
|
|
126
131
|
message[:node_id_to] = "!#{message[:to].to_i.to_s(16)}"
|
|
@@ -137,11 +142,6 @@ module Meshtastic
|
|
|
137
142
|
message[:public_key] = Base64.strict_encode64(raw_public_key)
|
|
138
143
|
end
|
|
139
144
|
|
|
140
|
-
mui.decrypt_packet(
|
|
141
|
-
message: message, psks: psks,
|
|
142
|
-
channel: decoded_payload_hash[:channel_id].to_s.empty? ? raw_topic.split('/')[-2].to_s : decoded_payload_hash[:channel_id]
|
|
143
|
-
)
|
|
144
|
-
|
|
145
145
|
if message[:decoded]
|
|
146
146
|
# payload = Meshtastic::Data.decode(message[:decoded][:payload]).to_h
|
|
147
147
|
payload = message[:decoded][:payload]
|
|
@@ -228,12 +228,13 @@ module Meshtastic
|
|
|
228
228
|
# psks: 'optional - hash of :channel_id => psk key value pairs (default: { LongFast: "AQ==" })'
|
|
229
229
|
# )
|
|
230
230
|
public_class_method def self.send_text(opts = {})
|
|
231
|
+
opts = opts.dup
|
|
231
232
|
mqtt_obj = opts[:mqtt_obj]
|
|
232
233
|
opts[:from] ||= mqtt_obj.client_id
|
|
233
234
|
opts[:to] ||= '!ffffffff'
|
|
234
235
|
opts[:root_topic] ||= 'msh'
|
|
235
236
|
opts[:region] ||= 'US'
|
|
236
|
-
opts[:topic] ||=
|
|
237
|
+
opts[:topic] ||= "2/e/#{opts[:channel_id] || (opts[:psks]&.size == 1 ? opts[:psks].keys.first : 'LongFast')}/#"
|
|
237
238
|
opts[:topic] = opts[:topic].to_s.gsub('/#', '')
|
|
238
239
|
opts[:channel] ||= 6
|
|
239
240
|
absolute_topic = "#{opts[:root_topic]}/#{opts[:region]}/#{opts[:topic]}/#{opts[:from]}"
|
|
@@ -260,7 +261,7 @@ module Meshtastic
|
|
|
260
261
|
opts[:to] ||= '!ffffffff'
|
|
261
262
|
opts[:root_topic] ||= 'msh'
|
|
262
263
|
opts[:region] ||= 'US'
|
|
263
|
-
opts[:topic] ||=
|
|
264
|
+
opts[:topic] ||= "2/e/#{opts[:channel_id] || (opts[:psks]&.size == 1 ? opts[:psks].keys.first : 'LongFast')}/#"
|
|
264
265
|
opts[:topic] = opts[:topic].to_s.gsub('/#', '')
|
|
265
266
|
opts[:channel] ||= 6
|
|
266
267
|
absolute_topic = "#{opts[:root_topic]}/#{opts[:region]}/#{opts[:topic]}/#{opts[:from]}"
|
|
@@ -314,7 +315,7 @@ module Meshtastic
|
|
|
314
315
|
root_topic: 'optional - value for root_topic passed into subscribe',
|
|
315
316
|
region: 'optional - value for region passed into subscribe',
|
|
316
317
|
topic: 'optional - value for topic passed into subscribe',
|
|
317
|
-
psks: 'optional -
|
|
318
|
+
psks: 'optional - channel names to Base64 keys or { psk: Base64, use_aead: true/false }; configured mode only',
|
|
318
319
|
qos: 'optional - value for qos passed into subscribe',
|
|
319
320
|
json: 'optional - value for json passed into subscribe',
|
|
320
321
|
exclude: 'optional - value for exclude passed into subscribe',
|
|
@@ -331,7 +332,11 @@ module Meshtastic
|
|
|
331
332
|
root_topic: 'optional - value for root_topic passed into send_text',
|
|
332
333
|
region: 'optional - value for region passed into send_text',
|
|
333
334
|
topic: 'optional - value for topic passed into send_text',
|
|
334
|
-
|
|
335
|
+
channel_id: 'optional - exact PSK channel name; required for multiple keys',
|
|
336
|
+
psks: 'optional - channel names to Base64 keys or { psk: Base64, use_aead: true/false }',
|
|
337
|
+
pki_encrypted: 'optional - unsupported on MQTT; raises rather than downgrades',
|
|
338
|
+
public_key: 'optional - unsupported recipient PKI key; raises on MQTT',
|
|
339
|
+
channel: 'optional - legacy value ignored; firmware hash is computed from selected name, key and mode',
|
|
335
340
|
via: 'optional - value for via passed into send_text',
|
|
336
341
|
text: 'optional - value for text passed into send_text'
|
|
337
342
|
)
|
|
@@ -345,7 +350,11 @@ module Meshtastic
|
|
|
345
350
|
root_topic: 'optional - value for root_topic passed into send_data',
|
|
346
351
|
region: 'optional - value for region passed into send_data',
|
|
347
352
|
topic: 'optional - value for topic passed into send_data',
|
|
348
|
-
|
|
353
|
+
channel_id: 'optional - exact PSK channel name; required for multiple keys',
|
|
354
|
+
psks: 'optional - channel names to Base64 keys or { psk: Base64, use_aead: true/false }',
|
|
355
|
+
pki_encrypted: 'optional - unsupported on MQTT; raises rather than downgrades',
|
|
356
|
+
public_key: 'optional - unsupported recipient PKI key; raises on MQTT',
|
|
357
|
+
channel: 'optional - legacy value ignored; firmware hash is computed from selected name, key and mode',
|
|
349
358
|
via: 'optional - value for via passed into send_data'
|
|
350
359
|
)
|
|
351
360
|
|
data/lib/meshtastic/version.rb
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'meshtastic/mesh_pb'
|
|
4
|
+
|
|
5
|
+
module Meshtastic
|
|
6
|
+
class Waypoint
|
|
7
|
+
public_class_method def self.build(opts = {})
|
|
8
|
+
fields = opts.fetch(:fields, {})
|
|
9
|
+
raise ArgumentError, 'fields must be a Waypoint field Hash' unless fields.is_a?(Hash)
|
|
10
|
+
raise ArgumentError, 'nil fields are not valid waypoint values' if fields.values.any?(&:nil?)
|
|
11
|
+
|
|
12
|
+
box = fields[:bounding_box] || fields['bounding_box']
|
|
13
|
+
raise ArgumentError, 'bounding_box coordinates cannot be nil' if box.is_a?(Hash) && box.values.any?(&:nil?)
|
|
14
|
+
|
|
15
|
+
waypoint = new(fields)
|
|
16
|
+
raise ArgumentError, 'latitude_i and longitude_i must be supplied together' if !waypoint.has_latitude_i? != !waypoint.has_longitude_i?
|
|
17
|
+
raise ArgumentError, 'a circular geofence requires latitude_i and longitude_i' if waypoint.geofence_radius.positive? && !waypoint.has_latitude_i?
|
|
18
|
+
|
|
19
|
+
validate_coordinates(fields: { latitude_i: waypoint.latitude_i, longitude_i: waypoint.longitude_i })
|
|
20
|
+
if waypoint.bounding_box
|
|
21
|
+
box = waypoint.bounding_box
|
|
22
|
+
validate_coordinates(fields: { latitude_south_i: box.latitude_south_i, latitude_north_i: box.latitude_north_i,
|
|
23
|
+
longitude_west_i: box.longitude_west_i, longitude_east_i: box.longitude_east_i })
|
|
24
|
+
raise ArgumentError, 'bounding_box requires south <= north and west <= east; split antimeridian regions' unless box.latitude_south_i <= box.latitude_north_i && box.longitude_west_i <= box.longitude_east_i
|
|
25
|
+
end
|
|
26
|
+
{ name: 29, description: 99 }.each do |field, maximum|
|
|
27
|
+
text = waypoint.public_send(field)
|
|
28
|
+
raise ArgumentError, "#{field} must be valid NUL-free UTF-8 and at most #{maximum} bytes" unless text.valid_encoding? && !text.include?("\0") && text.bytesize <= maximum
|
|
29
|
+
end
|
|
30
|
+
waypoint
|
|
31
|
+
rescue TypeError, RangeError, EncodingError => e
|
|
32
|
+
raise ArgumentError, e.message
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private_class_method def self.validate_coordinates(opts = {})
|
|
36
|
+
opts.fetch(:fields).each do |field, value|
|
|
37
|
+
maximum = field.to_s.start_with?('latitude') ? 900_000_000 : 1_800_000_000
|
|
38
|
+
raise ArgumentError, "#{field} is outside geographic bounds" unless value.is_a?(Integer) && value.between?(-maximum, maximum)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
public_class_method def self.transmit(opts = {})
|
|
43
|
+
waypoint = opts[:waypoint]
|
|
44
|
+
raise ArgumentError, 'waypoint must be a Waypoint protobuf' if waypoint && !waypoint.is_a?(self)
|
|
45
|
+
raise ArgumentError, 'supply waypoint or fields, not both' if waypoint && opts.key?(:fields)
|
|
46
|
+
|
|
47
|
+
waypoint = build(fields: waypoint ? waypoint.to_h : opts.fetch(:fields, {}))
|
|
48
|
+
data = Meshtastic::Data.new(portnum: :WAYPOINT_APP, payload: waypoint.to_proto)
|
|
49
|
+
Meshtastic.deliver_data(opts.except(:waypoint, :fields).merge(data: data, port_num: Meshtastic::PortNum::WAYPOINT_APP))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
public_class_method def self.authors
|
|
53
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
public_class_method def self.help
|
|
57
|
+
puts "USAGE:
|
|
58
|
+
# Build a waypoint or geofence protobuf.
|
|
59
|
+
#{self}.build(fields: 'optional - exact Waypoint field hash; coordinates are integer degrees times 10000000; default empty message')
|
|
60
|
+
# fields: latitude_i and longitude_i must be paired, within +/-90 and +/-180 degrees scaled by 10000000.
|
|
61
|
+
# geofence_radius is uint32 meters, positive requires center; zero means no circle.
|
|
62
|
+
# bounding_box uses longitude_west_i, latitude_south_i, longitude_east_i, latitude_north_i.
|
|
63
|
+
# Require west <= east and south <= north; circle and box can coexist.
|
|
64
|
+
# notify_on_enter, notify_on_exit and notify_favorites_only are booleans resolved on receivers.
|
|
65
|
+
# name and description allow 29 and 99 UTF-8 bytes respectively, without embedded NUL.
|
|
66
|
+
# id, expire, locked_to and icon retain the exact protobuf integer semantics.
|
|
67
|
+
# Send a validated waypoint over WAYPOINT_APP.
|
|
68
|
+
#{self}.transmit(
|
|
69
|
+
waypoint: 'optional - Waypoint protobuf to validate and transmit instead of fields',
|
|
70
|
+
fields: 'optional - exact Waypoint fields as accepted by build; default empty message',
|
|
71
|
+
serial_obj: 'optional - connected serial handle; choose exactly one transport',
|
|
72
|
+
bluetooth_obj: 'optional - connected Bluetooth handle',
|
|
73
|
+
tcp_obj: 'optional - connected TCP handle',
|
|
74
|
+
mqtt_obj: 'optional - connected MQTT client; accepts channel_id and psks',
|
|
75
|
+
to: 'optional - destination !eighthex node ID; defaults to broadcast',
|
|
76
|
+
want_ack: 'optional - request routing acknowledgment, not proof of delivery',
|
|
77
|
+
pki_encrypted: 'optional - radio-owned PKI encryption; MQTT PKI unsupported',
|
|
78
|
+
public_key: 'optional - recipient public-key bytes for radio PKI'
|
|
79
|
+
)
|
|
80
|
+
# Print the module author information.
|
|
81
|
+
#{self}.authors
|
|
82
|
+
"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/meshtastic.rb
CHANGED