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
|
@@ -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)
|