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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/documentation/README.md +2 -1
  3. data/documentation/admin-backup.md +82 -3
  4. data/documentation/admin-config.md +28 -1
  5. data/documentation/admin-firmware-migration.md +171 -0
  6. data/documentation/admin-firmware.md +7 -3
  7. data/documentation/admin.md +16 -0
  8. data/documentation/mesh-interface.md +46 -18
  9. data/documentation/mqtt.md +63 -71
  10. data/documentation/waypoint.md +54 -0
  11. data/lib/meshtastic/admin/backup.rb +44 -5
  12. data/lib/meshtastic/admin/channel.rb +3 -8
  13. data/lib/meshtastic/admin/config.rb +63 -1
  14. data/lib/meshtastic/admin/firmware.rb +43 -10
  15. data/lib/meshtastic/admin/validation.rb +78 -0
  16. data/lib/meshtastic/admin.rb +28 -22
  17. data/lib/meshtastic/mesh_interface.rb +123 -43
  18. data/lib/meshtastic/module_config.rb +2 -0
  19. data/lib/meshtastic/mqtt.rb +21 -12
  20. data/lib/meshtastic/version.rb +1 -1
  21. data/lib/meshtastic/waypoint.rb +85 -0
  22. data/lib/meshtastic.rb +1 -0
  23. data/spec/lib/meshtastic/admin/backup_spec.rb +153 -9
  24. data/spec/lib/meshtastic/admin/config_spec.rb +70 -1
  25. data/spec/lib/meshtastic/admin/firmware_migration_spec.rb +121 -0
  26. data/spec/lib/meshtastic/admin/firmware_spec.rb +1 -1
  27. data/spec/lib/meshtastic/admin/firmware_target_selection_spec.rb +44 -0
  28. data/spec/lib/meshtastic/admin/validation_spec.rb +89 -0
  29. data/spec/lib/meshtastic/admin_spec.rb +77 -0
  30. data/spec/lib/meshtastic/bluetooth_spec.rb +26 -1
  31. data/spec/lib/meshtastic/mesh_interface_spec.rb +54 -0
  32. data/spec/lib/meshtastic/module_config_spec.rb +1 -1
  33. data/spec/lib/meshtastic/mqtt_spec.rb +165 -0
  34. data/spec/lib/meshtastic/serial_spec.rb +26 -0
  35. data/spec/lib/meshtastic/tcp_spec.rb +29 -0
  36. data/spec/lib/meshtastic/waypoint_spec.rb +55 -0
  37. data/spec/support/channel_crypto_reference.rb +29 -0
  38. data/spec/support/firmware_release_artifact_audit.rb +39 -0
  39. metadata +11 -1
@@ -86,6 +86,21 @@ describe Meshtastic::Admin do
86
86
  expect(published.length).to eq(1)
87
87
  end
88
88
 
89
+ it 'passes named MQTT AEAD selection through the Admin boundary without promoting broker claims' do
90
+ client = MQTTClient.new(client_id: 'aabbccdd')
91
+ published = []
92
+ client.define_singleton_method(:publish) { |_topic, bytes| published << Meshtastic::ServiceEnvelope.decode(bytes) }
93
+ result = described_class.request(transport_obj: client, to: '!aabbccee', get_owner_request: true, wait: false,
94
+ channel_id: 'Admin', psks: { Admin: { psk: 'AQ==', use_aead: true } })
95
+ expect(result).not_to have_key(:delivery)
96
+ envelope = published.fetch(0)
97
+ expect(envelope.channel_id).to eq('Admin')
98
+ decoded = Meshtastic::MeshInterface.new.decrypt_packet(message: envelope.packet.to_h, channel: envelope.channel_id,
99
+ psks: { Admin: { psk: 'AQ==', use_aead: true } })
100
+ expect(Meshtastic::AdminMessage.decode(decoded[:decoded][:payload]).get_owner_request).to be(true)
101
+ expect(decoded[:security_provenance][:verification]).not_to eq(:verified)
102
+ end
103
+
89
104
  it 'sends a reboot AdminMessage on ADMIN_APP' do
90
105
  serial_obj = fake_serial_obj
91
106
  described_class.reboot(transport_obj: serial_obj, seconds: 7)
@@ -165,6 +180,65 @@ module AdminSynchronousSpecHelpers
165
180
  end
166
181
  end
167
182
 
183
+ describe Meshtastic::Admin, 'delivery provenance' do
184
+ include AdminSynchronousSpecHelpers
185
+
186
+ it 'keeps standalone or broker-origin response claims untrusted even with caller-supplied provenance' do
187
+ packet = Meshtastic::MeshPacket.new(from: 42, ack_proof_status: :ACK_PROOF_VALID, xeddsa_signed: true,
188
+ decoded: Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: 9,
189
+ payload: Meshtastic::AdminMessage.new(get_owner_response: Meshtastic::User.new).to_proto))
190
+ result = described_class.response(packet: Meshtastic::FromRadio.new(packet: packet), from: 42, request_id: 9,
191
+ security_provenance: { source: :radio, verification: :device_reported })
192
+ expect(result[:delivery]).to eq(:readback)
193
+ expect(result[:security_provenance]).to include(source: :unknown, verification: :untrusted,
194
+ reported: { ack_proof_status: :ACK_PROOF_VALID, xeddsa_signed: true })
195
+ end
196
+
197
+ it 'requires actual request ID and target rather than a local or unrelated valid proof' do
198
+ ignored = []
199
+ handle = responding_serial do |connection, packet|
200
+ [[packet.to, packet.id + 1], [connection[:my_node_num], packet.id], [123, packet.id]].each do |from, request_id|
201
+ incoming = Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: from, ack_proof_status: :ACK_PROOF_VALID,
202
+ decoded: Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: request_id)))
203
+ ignored << incoming
204
+ connection[:from_radio_queue] << incoming
205
+ end
206
+ connection[:from_radio_queue] << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: packet.to,
207
+ decoded: Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id)))
208
+ end
209
+ result = described_class.request(transport_obj: handle, to: '!aabbccdd', auto_session: false, reboot_seconds: 5)
210
+ expect(result[:delivery]).to eq(:acknowledged)
211
+ expect(ignored.map { handle[:from_radio_queue].pop(true) }).to eq(ignored)
212
+ end
213
+
214
+ it 'identifies readback from the connected radio without treating a signature as delivery proof' do
215
+ handle = responding_serial do |connection, packet|
216
+ reply = admin_reply(packet)
217
+ reply.packet.xeddsa_signed = true
218
+ reply.packet.ack_proof_status = :ACK_PROOF_VALID
219
+ connection[:from_radio_queue] << reply
220
+ end
221
+ result = described_class.request(transport_obj: handle, get_device_metadata_request: true)
222
+ expect(result[:delivery]).to eq(:readback)
223
+ expect(result[:security_provenance]).to include(source: :radio, verification: :device_reported)
224
+ end
225
+
226
+ it 'distinguishes device-reported valid proof from signed or ordinary target ACKs' do
227
+ (%i[ACK_PROOF_VALID ACK_PROOF_ABSENT ACK_PROOF_INVALID ACK_PROOF_NO_KEY] + [99]).each do |proof|
228
+ handle = responding_serial do |connection, packet|
229
+ connection[:from_radio_queue] << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(
230
+ from: packet.to, ack_proof_status: proof, xeddsa_signed: true,
231
+ decoded: Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id, payload: Meshtastic::Routing.new.to_proto)
232
+ ))
233
+ end
234
+ result = described_class.request(transport_obj: handle, to: '!aabbccdd', auto_session: false, reboot_seconds: 5)
235
+ expect(result[:delivery]).to eq(proof == :ACK_PROOF_VALID ? :device_verified : :acknowledged)
236
+ expect(result[:security_provenance]).to include(source: :radio, verification: :device_reported,
237
+ reported: { ack_proof_status: proof, xeddsa_signed: true })
238
+ end
239
+ end
240
+ end
241
+
168
242
  describe Meshtastic::Admin, 'automatic sessions' do
169
243
  include AdminSynchronousSpecHelpers
170
244
 
@@ -398,6 +472,9 @@ describe Meshtastic::Admin, 'generated schema coverage' do
398
472
  when :enum then 0
399
473
  else 1
400
474
  end
475
+ value = Meshtastic::Config.new(device: {}) if %w[set_config get_config_response].include?(field.name)
476
+ value = Meshtastic::ModuleConfig.new(mqtt: {}) if %w[set_module_config get_module_config_response].include?(field.name)
477
+ value = Meshtastic::HamParameters.new(call_sign: 'N0CALL') if field.name == 'set_ham_mode'
401
478
  message = described_class.encode(field.name.to_sym => value)
402
479
  decoded = described_class.decode(payload: message.to_proto)
403
480
  expect(decoded.payload_variant).to eq(field.name.to_sym)
@@ -3,7 +3,7 @@
3
3
  require 'spec_helper'
4
4
  require_relative '../../support/payload_fixtures'
5
5
 
6
- describe Meshtastic::Bluetooth do
6
+ describe Meshtastic::Bluetooth do # rubocop:disable Metrics/BlockLength
7
7
  include PayloadFixtures
8
8
 
9
9
  it 'decodes every application fixture received from GATT including malformed and default messages' do
@@ -17,6 +17,31 @@ describe Meshtastic::Bluetooth do
17
17
  end
18
18
  end
19
19
 
20
+ it 'preserves device security provenance and decrypts AEAD through GATT' do
21
+ handle = connect
22
+ packet = Meshtastic::MeshPacket.new(from: 123, to: 0xffffffff, id: 42,
23
+ channel: 'Custom'.bytes.reduce(0, :^) ^ 0xae,
24
+ encrypted: ['f0285b86af98b3f88815b9938ecf0ddf1693c987'].pack('H*'),
25
+ xeddsa_signed: true, ack_proof_status: :ACK_PROOF_VALID)
26
+ connection.incoming << Meshtastic::FromRadio.new(packet: packet).to_proto
27
+ received = Timeout.timeout(2) do
28
+ described_class.subscribe(bluetooth_obj: handle, psks: { Custom: { psk: Base64.strict_encode64('k' * 16), use_aead: true } }) { |message| break message }
29
+ end
30
+ expect(received.dig(:packet, :decoded, :payload)).to eq('test')
31
+ expect(received.dig(:packet, :security_provenance)).to include(source: :radio, verification: :device_reported,
32
+ reported: { xeddsa_signed: true, ack_proof_status: :ACK_PROOF_VALID }, channel_authentication: :aead)
33
+ end
34
+
35
+ it 'forwards PKI text through the real GATT ToRadio codec' do
36
+ handle = connect
37
+ described_class.send_text(bluetooth_obj: handle, text: 'private', to: '!00000002', pki_encrypted: true, public_key: 'k' * 32)
38
+ packet = Meshtastic::ToRadio.decode(connection.writes.last).packet
39
+ expect(packet.pki_encrypted).to be true
40
+ expect(packet.public_key).to eq('k' * 32)
41
+ expect(packet.decoded.payload).to eq('private')
42
+ expect(packet.encrypted).to be_empty
43
+ end
44
+
20
45
  let(:connection) do
21
46
  Class.new do
22
47
  attr_reader :writes, :incoming
@@ -2,6 +2,38 @@
2
2
 
3
3
  require 'spec_helper'
4
4
  require_relative '../../support/payload_fixtures'
5
+ require_relative '../../support/channel_crypto_reference'
6
+
7
+ describe Meshtastic::MeshInterface, 'pinned firmware CCM interoperability' do
8
+ it 'validates the independent reference against firmware RFC3610 vector 1' do
9
+ hex = ->(value) { [value].pack('H*') }
10
+ bytes = ChannelCryptoReference.encrypt(
11
+ key: hex.call('C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF'), nonce: hex.call('00000003020100A0A1A2A3A4A5'),
12
+ aad: hex.call('0001020304050607'), plain: hex.call('08090A0B0C0D0E0F101112131415161718191A1B1C1D1E'), tag_len: 8
13
+ )
14
+ expect(bytes.unpack1('H*')).to eq('588c979a61c663d2f066d0c2c0f989806d5f6b61dac38417e8d12cfdf926e0')
15
+ end
16
+
17
+ it 'matches the firmware smoke inputs using an independent reference and decodes the fixed result' do
18
+ plain = ['08011204746573744800'].pack('H*')
19
+ key = Base64.strict_decode64('1PG7OiApB1nwvP+rz05pAQ==')
20
+ encrypted = ChannelCryptoReference.encrypt(key: key, nonce: [0xaabbccdd, 0, 0x12345678, 0].pack('V4')[0, 13],
21
+ aad: [0x12345678, 0xaaaa].pack('V2'), plain: plain)
22
+ expect(encrypted.unpack1('H*')).to eq('62bb832909c076bb019632d85aac0b9db32ae40cbe95')
23
+ packet = { id: 0xaabbccdd, from: 0x12345678, to: 0xaaaa, encrypted: encrypted }
24
+ result = described_class.new.decrypt_packet(message: packet, channel: 'LongFast',
25
+ psks: { LongFast: { psk: 'AQ==', use_aead: true } })
26
+ expect(result[:decoded]).to eq(Meshtastic::Data.decode(plain).to_h)
27
+ end
28
+
29
+ it 'independently reproduces both broker broadcast golden vectors' do
30
+ { 16 => 'f0285b86af98b3f88815b9938ecf0ddf1693c987', 32 => '8640f69cc0583046e446be6d72147811991099bb' }.each do |size, expected|
31
+ bytes = ChannelCryptoReference.encrypt(key: 'k' * size, nonce: [42, 0, 123, 0].pack('V4')[0, 13],
32
+ aad: [123, 0xffffffff].pack('V2'), plain: ['0801120474657374'].pack('H*'))
33
+ expect(bytes.unpack1('H*')).to eq(expected)
34
+ end
35
+ end
36
+ end
5
37
 
6
38
  describe Meshtastic::MeshInterface do
7
39
  include PayloadFixtures
@@ -150,6 +182,15 @@ describe Meshtastic::MeshInterface do
150
182
  expect(result[:encrypted]).to eq(encrypted)
151
183
  end
152
184
 
185
+ it 'rejects duplicate named PSKs rather than silently choosing a string or symbol entry' do
186
+ packet = { from: 123, to: 0xffffffff, id: 42, channel: 'Custom'.bytes.reduce(0, :^) ^ 0xae,
187
+ encrypted: ['f0285b86af98b3f88815b9938ecf0ddf1693c987'].pack('H*') }
188
+ keys = { 'Custom' => { psk: Base64.strict_encode64('k' * 16), use_aead: true }, Custom: 'AQ==' }
189
+ result = described_class.new.decrypt_packet(message: packet, psks: keys, channel: 'Custom', source: :mqtt)
190
+ expect(result[:decoded]).to be_nil
191
+ expect(result[:decryption_error]).not_to be_nil
192
+ end
193
+
153
194
  it 'preserves already decoded PKI packets and ciphertext on invalid channel keys' do
154
195
  decoded = { decoded: { portnum: :ROUTING_APP }, pki_encrypted: true }
155
196
  expect(OpenSSL::Cipher).not_to receive(:new)
@@ -182,6 +223,19 @@ describe Meshtastic::MeshInterface do
182
223
  end.to raise_error(ArgumentError, /PKI.*radio/)
183
224
  end
184
225
 
226
+ it 'documents explicit PKI and per-channel crypto options in instance help' do
227
+ expect { described_class.new.help }.to output(/pki_encrypted:.*public_key:.*channel_id:.*use_aead/m).to_stdout
228
+ end
229
+
230
+ it 'forwards explicit PKI text options to the device without encrypting on the host' do
231
+ wire = described_class.new.send_text(from: 1, to: 2, text: 'private', psks: nil,
232
+ pki_encrypted: true, public_key: 'k' * 32)
233
+ packet = Meshtastic::ToRadio.decode(wire).packet
234
+ expect(packet.pki_encrypted).to be true
235
+ expect(packet.public_key).to eq('k' * 32)
236
+ expect(packet.decoded.payload).to eq('private')
237
+ end
238
+
185
239
  it 'rejects malformed recipient public keys' do
186
240
  expect do
187
241
  described_class.new.send_data(
@@ -39,7 +39,7 @@ describe Meshtastic::ModuleConfig do
39
39
  %i[get set].each do |operation|
40
40
  it "encodes a real Admin packet for #{operation} with #{key}" do
41
41
  connection = fake_serial_obj
42
- options = { key => connection, module_config: Meshtastic::ModuleConfig.new }
42
+ options = { key => connection, module_config: Meshtastic::ModuleConfig.new(mqtt: {}) }
43
43
  options[:bluetooth_obj] = nil
44
44
  described_class.public_send(operation, options.freeze)
45
45
  frame = connection[:written]
@@ -12,6 +12,7 @@ describe Meshtastic::MQTT do # rubocop:disable Metrics/BlockLength
12
12
  payload_cases.each do |port, bytes, _expected|
13
13
  packet = payload_radio(port, bytes).packet
14
14
  if encrypted
15
+ packet.channel = ('LongFast'.bytes + Base64.strict_decode64('1PG7OiApB1nwvP+rz05pAQ==').bytes).reduce(0, :^)
15
16
  cipher = OpenSSL::Cipher.new('AES-128-CTR')
16
17
  cipher.encrypt
17
18
  cipher.key = Base64.strict_decode64('1PG7OiApB1nwvP+rz05pAQ==')
@@ -96,6 +97,103 @@ describe Meshtastic::MQTT do # rubocop:disable Metrics/BlockLength
96
97
  end
97
98
 
98
99
  describe '.send_text' do
100
+ it 'fails closed on direct text and explicit PKI without publishing' do
101
+ client = fake_mqtt_obj
102
+ [{ to: '!00000002' }, { pki_encrypted: true, public_key: 'k' * 32 }].each do |options|
103
+ expect { described_class.send_text({ mqtt_obj: client, text: 'private' }.merge(options)) }.to raise_error(ArgumentError, /PKI|direct/i)
104
+ end
105
+ expect(client.published).to be_empty
106
+ end
107
+
108
+ it 'selects the explicit channel key and firmware hash, never the first key' do
109
+ client = fake_mqtt_obj
110
+ keys = { Wrong: Base64.strict_encode64('w' * 16), Custom: Base64.strict_encode64('k' * 32) }
111
+ described_class.send_text(mqtt_obj: client, channel_id: 'Custom', psks: keys, text: 'selected')
112
+ item = client.published.last
113
+ envelope = Meshtastic::ServiceEnvelope.decode(item[:payload])
114
+ expect(envelope.channel_id).to eq('Custom')
115
+ expect(envelope.packet.channel).to eq(('Custom'.bytes + ('k' * 32).bytes).reduce(0, :^))
116
+ expect(item[:topic]).to include('/Custom/')
117
+ client.incoming << mqtt_packet(topic: item[:topic], payload: item[:payload])
118
+ client.incoming << nil
119
+ described_class.subscribe(mqtt_obj: client, psks: keys) do |message|
120
+ expect(message.dig(:packet, :decoded, :payload)).to eq('selected')
121
+ end
122
+ expect { described_class.send_text(mqtt_obj: client, psks: keys) }.to raise_error(ArgumentError, /channel_id|ambiguous/)
123
+ end
124
+
125
+ it 'encrypts and decrypts firmware CCM vectors for both AES key sizes over the broker codec' do
126
+ # Firmware 8e6a88d CryptoEngine: L=2, M=12, nonce=id64le/from32le/00, AAD=from32le/to32le.
127
+ # Independently generated using CBC-MAC/CTR, checked against upstream RFC3610 vector 1.
128
+ { 16 => 'f0285b86af98b3f88815b9938ecf0ddf1693c987', 32 => '8640f69cc0583046e446be6d72147811991099bb' }.each do |size, vector|
129
+ client = fake_mqtt_obj
130
+ keys = { Custom: { psk: Base64.strict_encode64('k' * size), use_aead: true } }
131
+ described_class.send_text(mqtt_obj: client, from: 123, last_packet_id: 41, channel_id: 'Custom', psks: keys, text: 'test')
132
+ item = client.published.last
133
+ packet = Meshtastic::ServiceEnvelope.decode(item[:payload]).packet
134
+ expect(packet.encrypted.unpack1('H*')).to eq(vector)
135
+ expect(packet.channel).to eq(('Custom'.bytes + ('k' * size).bytes).reduce(0, :^) ^ 0xae)
136
+ client.incoming << mqtt_packet(topic: item[:topic], payload: item[:payload])
137
+ client.incoming << nil
138
+ described_class.subscribe(mqtt_obj: client, psks: keys) do |message|
139
+ expect(message.dig(:packet, :decoded, :payload)).to eq('test')
140
+ expect(message.dig(:packet, :security_provenance, :channel_authentication)).to eq(:aead)
141
+ expect(message.dig(:packet, :security_provenance, :verification)).to eq(:untrusted)
142
+ end
143
+ end
144
+ end
145
+
146
+ it 'rejects conflicting channel selections and unsupported crypto requests before publishing' do
147
+ client = fake_mqtt_obj
148
+ cases = [
149
+ { psks: { Custom: 'AQ==', 'Custom' => 'Ag==' }, channel_id: 'Custom' },
150
+ { psks: { Custom: 'AQ==' }, channel_id: 'Unknown' },
151
+ { psks: { Custom: 'AQ==' }, channel_id: 'Custom', topic: '2/e/Other/#' },
152
+ { psks: nil }, { psks: {} }, { public_key: 'k' * 32 }
153
+ ]
154
+ cases.each do |options|
155
+ expect { described_class.send_text({ mqtt_obj: client }.merge(options)) }.to raise_error(ArgumentError)
156
+ end
157
+ expect(client.published).to be_empty
158
+ end
159
+
160
+ it 'rejects misplaced mode options instead of silently publishing legacy CTR' do
161
+ client = fake_mqtt_obj
162
+ expect { described_class.send_text(mqtt_obj: client, use_aead: true, text: 'private') }.to raise_error(ArgumentError, /use_aead.*psks/)
163
+ expect { described_class.send_data(mqtt_obj: client, use_aead: true, data: Meshtastic::Data.new(portnum: :TEXT_MESSAGE_APP)) }.to raise_error(ArgumentError, /use_aead.*psks/)
164
+ expect(client.published).to be_empty
165
+ client.incoming << nil
166
+ expect { described_class.subscribe(mqtt_obj: client, use_aead: true) }.to raise_error(ArgumentError, /use_aead.*psks/)
167
+ end
168
+
169
+ it 'requires an explicit boolean mode in structured key records' do
170
+ client = fake_mqtt_obj
171
+ [{ psk: 'AQ==' }, { psk: 'AQ==', use_aed: true }, { psk: 'AQ==', use_aead: 'true' }].each do |record|
172
+ expect { described_class.send_text(mqtt_obj: client, psks: { Private: record }) }.to raise_error(ArgumentError)
173
+ end
174
+ expect(client.published).to be_empty
175
+ end
176
+
177
+ it 'accepts frozen key configuration without modifying caller options' do
178
+ keys = { LongFast: 'AQ==' }.freeze
179
+ client = fake_mqtt_obj
180
+ options = { mqtt_obj: client, psks: keys, text: 'immutable' }.freeze
181
+ described_class.send_text(options)
182
+ client.incoming << nil
183
+ described_class.subscribe(mqtt_obj: client, psks: keys)
184
+ expect(keys).to eq(LongFast: 'AQ==')
185
+ expect(options.keys).to eq(%i[mqtt_obj psks text])
186
+ end
187
+
188
+ it 'expands firmware short PSK aliases on any named channel' do
189
+ client = fake_mqtt_obj
190
+ described_class.send_text(mqtt_obj: client, channel_id: 'Custom', psks: { Custom: { psk: 'Ag==', use_aead: true } }, text: 'alias')
191
+ packet = Meshtastic::ServiceEnvelope.decode(client.published.last[:payload]).packet
192
+ key = Base64.strict_decode64('1PG7OiApB1nwvP+rz05pAQ==')
193
+ key.setbyte(15, 2)
194
+ expect(packet.channel).to eq(('Custom'.bytes + key.bytes).reduce(0, :^) ^ 0xae)
195
+ end
196
+
99
197
  it 'publishes an encrypted ServiceEnvelope without UART framing' do
100
198
  mqtt_obj = fake_mqtt_obj
101
199
  described_class.send_text(
@@ -130,6 +228,69 @@ describe Meshtastic::MQTT do # rubocop:disable Metrics/BlockLength
130
228
  end
131
229
 
132
230
  describe 'mqtt reception' do
231
+ it 'rejects authenticated packet tampering, truncated tags and mode downgrades without plaintext' do
232
+ vector = ['f0285b86af98b3f88815b9938ecf0ddf1693c987'].pack('H*')
233
+ base = { from: 123, to: 0xffffffff, id: 42, channel: 'Custom'.bytes.reduce(0, :^) ^ 0xae, encrypted: vector }
234
+ variants = vector.bytes.each_index.map do |index|
235
+ altered = vector.dup
236
+ altered.setbyte(index, altered.getbyte(index) ^ 1)
237
+ base.merge(encrypted: altered)
238
+ end
239
+ variants += %i[from to id channel].map { |field| base.merge(field => base[field] ^ 1) }
240
+ variants += [base.merge(encrypted: vector[0, 12])]
241
+ [true, false].each do |aead|
242
+ client = fake_mqtt_obj
243
+ packets = aead ? variants : [base]
244
+ packets.each do |fields|
245
+ envelope = Meshtastic::ServiceEnvelope.new(packet: Meshtastic::MeshPacket.new(fields), channel_id: 'Custom')
246
+ client.incoming << mqtt_packet(topic: 'msh/US/2/e/Custom/!0000007b', payload: envelope.to_proto)
247
+ end
248
+ client.incoming << nil
249
+ received = []
250
+ described_class.subscribe(mqtt_obj: client, psks: { Custom: { psk: Base64.strict_encode64('k' * 16), use_aead: aead } }) do |message|
251
+ received << message[:packet]
252
+ end
253
+ expect(received.size).to eq(packets.size)
254
+ received.each do |packet|
255
+ expect(packet[:decoded]).to be_nil
256
+ expect(packet[:encrypted]).to be_a(String)
257
+ expect(packet[:decryption_error]).not_to be_nil
258
+ end
259
+ end
260
+ end
261
+
262
+ it 'does not expose broker security claims even when later enrichment fails' do
263
+ client = fake_mqtt_obj
264
+ forged = { channel_id: 'LongFast', packet: { xeddsa_signed: true, ack_proof_status: 'ACK_PROOF_VALID',
265
+ public_key: 5, decoded: { portnum: 1, payload: 'hello' },
266
+ security_provenance: { source: 'radio', verification: 'verified' } } }
267
+ client.incoming << mqtt_packet(topic: 'msh/US/2/e/LongFast/!00000001', payload: JSON.generate(forged))
268
+ client.incoming << nil
269
+ received = []
270
+ begin
271
+ described_class.subscribe(mqtt_obj: client, json: true) { |message| received << message[:packet] }
272
+ rescue TypeError
273
+ # The enrichment failure must not yield a broker claim as a radio result.
274
+ end
275
+ expect(received.size).to eq(1)
276
+ expect(received.first[:xeddsa_signed]).to be_nil
277
+ expect(received.first[:ack_proof_status]).to be_nil
278
+ expect(received.first[:security_provenance]).to include(source: :mqtt, verification: :untrusted)
279
+ end
280
+
281
+ it 'labels broker verification claims as untrusted and preserves their reported values' do
282
+ client = fake_mqtt_obj
283
+ packet = Meshtastic::MeshPacket.new(decoded: Meshtastic::Data.new(portnum: :TEXT_MESSAGE_APP, payload: 'hello'), xeddsa_signed: true)
284
+ envelope = Meshtastic::ServiceEnvelope.new(packet: packet, channel_id: 'LongFast')
285
+ client.incoming << mqtt_packet(topic: 'msh/US/2/e/LongFast/!00000001', payload: envelope.to_proto)
286
+ client.incoming << nil
287
+ described_class.subscribe(mqtt_obj: client) do |message|
288
+ expect(message[:packet][:xeddsa_signed]).to be_nil
289
+ expect(message[:packet][:security_provenance]).to include(source: :mqtt, verification: :untrusted,
290
+ reported: include(xeddsa_signed: true))
291
+ end
292
+ end
293
+
133
294
  it 'does not apply a channel PSK to PKI ciphertext or fall back for an unknown channel' do
134
295
  [true, false].each do |pki|
135
296
  client = fake_mqtt_obj
@@ -214,6 +375,10 @@ describe Meshtastic::MQTT do # rubocop:disable Metrics/BlockLength
214
375
  end
215
376
 
216
377
  describe '.help' do
378
+ it 'documents channel selection and explicit per-key mode in runtime help' do
379
+ expect { described_class.help }.to output(/channel_id:.*use_aead/m).to_stdout
380
+ end
381
+
217
382
  it 'prints usage without raising' do
218
383
  expect { described_class.help }.to output(/USAGE/).to_stdout
219
384
  end
@@ -25,6 +25,22 @@ describe Meshtastic::Serial do # rubocop:disable Metrics/BlockLength
25
25
  end
26
26
  end
27
27
 
28
+ it 'preserves device security provenance and decrypts AEAD through UART framing' do
29
+ with_serial_link do |handle, device|
30
+ packet = Meshtastic::MeshPacket.new(from: 123, to: 0xffffffff, id: 42,
31
+ channel: 'Custom'.bytes.reduce(0, :^) ^ 0xae,
32
+ encrypted: ['f0285b86af98b3f88815b9938ecf0ddf1693c987'].pack('H*'),
33
+ xeddsa_signed: true, ack_proof_status: :ACK_PROOF_VALID)
34
+ device.write(radio_frame(Meshtastic::FromRadio.new(packet: packet)))
35
+ received = Timeout.timeout(2) do
36
+ described_class.subscribe(serial_obj: handle, psks: { Custom: { psk: Base64.strict_encode64('k' * 16), use_aead: true } }) { |message| break message }
37
+ end
38
+ expect(received.dig(:packet, :decoded, :payload)).to eq('test')
39
+ expect(received.dig(:packet, :security_provenance)).to include(source: :radio, verification: :device_reported,
40
+ reported: { xeddsa_signed: true, ack_proof_status: :ACK_PROOF_VALID }, channel_authentication: :aead)
41
+ end
42
+ end
43
+
28
44
  def with_serial_link(opts = {})
29
45
  PTY.open do |device, host|
30
46
  serial_obj = described_class.connect({ block_dev: host.path, want_config: false }.merge(opts))
@@ -257,6 +273,16 @@ describe Meshtastic::Serial do # rubocop:disable Metrics/BlockLength
257
273
  end
258
274
 
259
275
  describe '.send_text' do
276
+ it 'forwards PKI text requests through real ToRadio framing' do
277
+ serial_obj = fake_serial_obj
278
+ described_class.send_text(serial_obj: serial_obj, text: 'private', to: '!00000002', pki_encrypted: true, public_key: 'k' * 32)
279
+ packet = Meshtastic::ToRadio.decode(serial_obj[:written].byteslice(4..)).packet
280
+ expect(packet.pki_encrypted).to be true
281
+ expect(packet.public_key).to eq('k' * 32)
282
+ expect(packet.decoded.payload).to eq('private')
283
+ expect(packet.encrypted).to be_empty
284
+ end
285
+
260
286
  it 'rejects text exceeding the byte limit without closing the connection' do
261
287
  serial_obj = fake_serial_obj
262
288
  expect do
@@ -19,6 +19,22 @@ describe Meshtastic::TCP do
19
19
  end
20
20
  end
21
21
 
22
+ it 'preserves device security provenance and decrypts AEAD through TCP framing' do
23
+ with_tcp_link do |handle, remote|
24
+ packet = Meshtastic::MeshPacket.new(from: 123, to: 0xffffffff, id: 42,
25
+ channel: 'Custom'.bytes.reduce(0, :^) ^ 0xae,
26
+ encrypted: ['f0285b86af98b3f88815b9938ecf0ddf1693c987'].pack('H*'),
27
+ xeddsa_signed: true, ack_proof_status: :ACK_PROOF_VALID)
28
+ remote.write(radio_frame(Meshtastic::FromRadio.new(packet: packet)))
29
+ received = Timeout.timeout(2) do
30
+ described_class.subscribe(tcp_obj: handle, psks: { Custom: { psk: Base64.strict_encode64('k' * 16), use_aead: true } }) { |message| break message }
31
+ end
32
+ expect(received.dig(:packet, :decoded, :payload)).to eq('test')
33
+ expect(received.dig(:packet, :security_provenance)).to include(source: :radio, verification: :device_reported,
34
+ reported: { xeddsa_signed: true, ack_proof_status: :ACK_PROOF_VALID }, channel_authentication: :aead)
35
+ end
36
+ end
37
+
22
38
  def with_tcp_link(opts = {})
23
39
  local, remote = UNIXSocket.pair
24
40
  tcp_obj = described_class.connect({ socket: local, host: '127.0.0.1', port: 4403, want_config: false }.merge(opts))
@@ -46,6 +62,19 @@ describe Meshtastic::TCP do
46
62
  end
47
63
  end
48
64
 
65
+ it 'forwards PKI text through the real TCP ToRadio codec' do
66
+ with_tcp_link do |handle, remote|
67
+ described_class.send_text(tcp_obj: handle, text: 'private', to: '!00000002', pki_encrypted: true, public_key: 'k' * 32)
68
+ remote.read(32)
69
+ header = remote.read(4)
70
+ packet = Meshtastic::ToRadio.decode(remote.read(header.unpack('CCn').last)).packet
71
+ expect(packet.pki_encrypted).to be true
72
+ expect(packet.public_key).to eq('k' * 32)
73
+ expect(packet.decoded.payload).to eq('private')
74
+ expect(packet.encrypted).to be_empty
75
+ end
76
+ end
77
+
49
78
  it 'receives a framed FromRadio over TCP' do
50
79
  with_tcp_link do |tcp_obj, remote|
51
80
  remote.read(32)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Meshtastic::Waypoint do
6
+ it 'transmits a validated box using production radio framing and preserves security options' do
7
+ frames = []
8
+ conn = Object.new
9
+ conn.define_singleton_method(:write) do |bytes|
10
+ frames << bytes
11
+ bytes.bytesize
12
+ end
13
+ conn.define_singleton_method(:flush) { true }
14
+ handle = { serial_conn: conn, my_node_num: 42 }
15
+ waypoint = described_class.build(fields: { bounding_box: { longitude_west_i: -10, longitude_east_i: 10 }, notify_on_exit: true })
16
+ described_class.transmit(serial_obj: handle, waypoint: waypoint, to: '!aabbccdd', pki_encrypted: true, public_key: 'k' * 32, want_ack: true)
17
+ packet = Meshtastic::ToRadio.decode(frames.last.byteslice(4..)).packet
18
+ expect(packet.decoded.portnum).to eq(:WAYPOINT_APP)
19
+ expect(described_class.decode(packet.decoded.payload)).to eq(waypoint)
20
+ expect(packet).to have_attributes(pki_encrypted: true, public_key: 'k' * 32, want_ack: true)
21
+ expect { described_class.transmit(serial_obj: handle, waypoint: described_class.new(geofence_radius: 1)) }.to raise_error(ArgumentError)
22
+ expect(frames.length).to eq(1)
23
+ expect { described_class.help }.to output(/#{described_class}\.transmit/).to_stdout
24
+ end
25
+
26
+ it 'validates bounds, geometry, notification types and firmware UTF-8 byte capacity' do
27
+ invalid = [
28
+ { latitude_i: 900_000_001, longitude_i: 0 }, { latitude_i: 0, longitude_i: -1_800_000_001 },
29
+ { latitude_i: 0 }, { geofence_radius: 1 }, { geofence_radius: -1 }, { geofence_radius: 1.5 },
30
+ { notify_on_enter: 1 }, { notify_on_exit: 'true' }, { notify_favorites_only: nil },
31
+ { bounding_box: { longitude_west_i: 10, longitude_east_i: 9 } },
32
+ { bounding_box: { latitude_south_i: 1, latitude_north_i: 0 } },
33
+ { bounding_box: { latitude_north_i: 900_000_001 } }, { bounding_box: { latitude_north_i: nil } },
34
+ { name: "bad\0name" }, { name: "\xff".b },
35
+ { name: 'é' * 15 }, { description: 'x' * 100 }, { shape: :circle }
36
+ ]
37
+ invalid.each do |fields|
38
+ expect { described_class.build(fields: fields) }.to raise_error(ArgumentError), fields.inspect
39
+ end
40
+ fields = { latitude_i: 0, longitude_i: 0, geofence_radius: 10,
41
+ bounding_box: { longitude_west_i: -1_800_000_000, latitude_south_i: -900_000_000,
42
+ longitude_east_i: 1_800_000_000, latitude_north_i: 900_000_000 },
43
+ name: 'x' * 29, description: 'x' * 99 }
44
+ expect(described_class.build(fields: fields).bounding_box.latitude_north_i).to eq(900_000_000)
45
+ expect(described_class.build(fields: { bounding_box: {} }).bounding_box).to eq(Meshtastic::BoundingBox.new)
46
+ end
47
+
48
+ it 'preserves the generated protobuf class and builds a circle with notification flags' do
49
+ waypoint = described_class.build(fields: { id: 7, latitude_i: 450_000_000, longitude_i: -730_000_000,
50
+ geofence_radius: 100, notify_on_enter: true, notify_on_exit: true, notify_favorites_only: true })
51
+ expect(waypoint).to be_a(described_class)
52
+ expect(described_class.decode(waypoint.to_proto).to_h).to include(geofence_radius: 100, notify_favorites_only: true)
53
+ expect(described_class.build.to_proto).to eq(''.b)
54
+ end
55
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ # Independent test-only CBC-MAC/CTR reference, not OpenSSL's CCM implementation.
6
+ # Layout from firmware 8e6a88d06f44cad26f1e8d7cd402939ccacb9b4c
7
+ # src/mesh/aes-ccm.cpp (L=2). The specs validate this against its RFC3610 vector.
8
+ module ChannelCryptoReference
9
+ def self.encrypt(key:, nonce:, aad:, plain:, tag_len: 12)
10
+ ecb = OpenSSL::Cipher.new("aes-#{key.bytesize * 8}-ecb")
11
+ ecb.encrypt
12
+ ecb.padding = 0
13
+ ecb.key = key
14
+ encrypt = ->(block) { ecb.update(block) }
15
+ xor = ->(left, right) { left.bytes.zip(right.bytes).map { |x, y| x ^ y }.pack('C*') }
16
+ b0 = [0x40 | (((tag_len - 2) / 2) << 3) | 1].pack('C') + nonce + [plain.bytesize].pack('n')
17
+ mac = encrypt.call(b0)
18
+ [[aad.bytesize].pack('n') + aad, plain].each do |bytes|
19
+ bytes = bytes.ljust((bytes.bytesize + 15) / 16 * 16, "\0")
20
+ bytes.bytes.each_slice(16) { |block| mac = encrypt.call(xor.call(mac, block.pack('C*'))) }
21
+ end
22
+ ctr = ->(index) { encrypt.call("\1#{nonce}#{[index].pack('n')}") }
23
+ tag = xor.call(mac, ctr.call(0))[0, tag_len]
24
+ crypt = plain.bytes.each_slice(16).with_index.map do |block, index|
25
+ xor.call(block.pack('C*'), ctr.call(index + 1)[0, block.size])
26
+ end.join
27
+ crypt + tag
28
+ end
29
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'meshtastic'
4
+ require 'digest'
5
+ require 'json'
6
+
7
+ # Opt-in, parser-only audit. No installer, device, subprocess or network calls.
8
+ # Extract the named official release members into ARGV[0] before running.
9
+ root = ARGV.fetch(0)
10
+ firmware = Meshtastic::Admin::Firmware
11
+ cases = [
12
+ ['firmware-heltec-ht62-esp32c3-sx1262-2.8.1.8e6a88d.bin', '4bae94b7f5132bff199a8890094a3642c6096bf366dcdf1eb4ba84cdb2b9e290', :bin, nil],
13
+ ['firmware-heltec-ht62-esp32c3-sx1262-2.8.1.8e6a88d.factory.bin', 'da6b9789f869b6b48da7a6532e4b769be4cbb6633a61a9c90eb1d92732784d3c', :bin, 'trailing data'],
14
+ ['firmware-rak4631-2.8.1.8e6a88d.uf2', 'ba35ee9247125dba2551261446b130d861dd00478caaf9ba6377aecbf0b2aa1e', :nrf_uf2, 'outside application flash'],
15
+ ['firmware-rak4631-2.8.1.8e6a88d.hex', '718482dc2a56d8f89b8cf416c775cbb78fb3eee694f6642ab8a6545ba2e8f07b', :hex, nil],
16
+ ['firmware-rak4631-2.8.1.8e6a88d-ota.zip', '2315a758cdfcd635fc5a11341619d67787fddbdf9f0e5778364950c36f4fdeaa', :zip, 'Only application-only legacy Adafruit DFU packages'],
17
+ ['firmware-rak11310-2.8.1.8e6a88d.uf2', '941cc6d0b44ac5498a44c708705a0209509de7183fdc8862fe19a223d9b3d066', :rp_uf2, nil]
18
+ ]
19
+ results = cases.map do |name, digest, kind, expected_error|
20
+ bytes = File.binread(File.join(root, name))
21
+ raise "Release artifact hash mismatch: #{name}" unless Digest::SHA256.hexdigest(bytes) == digest
22
+
23
+ error = nil
24
+ begin
25
+ case kind
26
+ when :bin then firmware::SerialBootloader.send(:validate_image, bytes: bytes, chip_id: 5)
27
+ when :nrf_uf2 then firmware::UF2.send(:validate_image, bytes: bytes, family_id: 0xada52840)
28
+ when :rp_uf2 then firmware::UF2.send(:validate_image, bytes: bytes, family_id: 0xe48bff56, flash_size: 2_097_152)
29
+ when :hex then firmware::Hex.validate(bytes: bytes, expected_chip: :nrf52840)
30
+ when :zip then firmware::NordicDFU.send(:package, package_bytes: bytes)
31
+ end
32
+ rescue ArgumentError => e
33
+ error = e.message
34
+ end
35
+ raise "Unexpected parser result for #{name}: #{error || 'accepted'}" unless expected_error ? error&.include?(expected_error) : error.nil?
36
+
37
+ { artifact: name, bytes: bytes.bytesize, sha256: digest, parser: error ? :rejected : :accepted, reason: error, hardware: false }
38
+ end
39
+ puts JSON.pretty_generate(results)