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
@@ -1,101 +1,93 @@
1
1
  # Meshtastic::MQTT
2
2
 
3
- Broker client. The host encrypts and decrypts with channel PSKs. Default public broker is `mqtt.meshtastic.org` (port 1883, user `meshdev`).
3
+ Broker client using protobuf `ServiceEnvelope`. The host performs channel PSK encryption/decryption; MQTT does **not** provide public-key direct messaging. A broker publish is submission, not RF delivery or a verified ACK.
4
4
 
5
- `channel:` on MQTT is the integer hash seen in envelopes, not the radio channel index (that is Serial/Bluetooth/TCP).
6
-
7
- ## Methods
8
-
9
- - `connect` — returns an `MQTT::Client`
10
- - `subscribe` — blocking loop; yields a hash per envelope, or pretty-prints without a block. Disconnects on exit.
11
- - `send_text` — publishes a `ServiceEnvelope` (raises if text exceeds `Meshtastic::Constants::DATA_PAYLOAD_LEN`)
12
- - `disconnect`
13
- - `help` / `authors`
14
-
15
- ### `connect`
5
+ ## Connect and subscribe
16
6
 
17
7
  ```ruby
8
+ require 'meshtastic'
9
+
18
10
  mqtt_obj = Meshtastic::MQTT.connect(
19
- host: 'mqtt.meshtastic.org',
20
- port: 1883,
21
- tls: false,
22
- username: 'meshdev',
23
- password: 'large4cats',
24
- client_id: nil, # default: random 4-byte hex
25
- keep_alive: 15,
26
- ack_timeout: 30
11
+ host: 'mqtt.meshtastic.org', port: 1883, tls: false,
12
+ username: 'meshdev', password: 'large4cats',
13
+ client_id: 'aabbccdd', keep_alive: 15, ack_timeout: 30
27
14
  )
28
- ```
29
15
 
30
- ### `subscribe`
16
+ # Use your own Base64 16- or 32-byte channel key, not this public alias.
17
+ keys = {
18
+ LongFast: 'AQ==', # backward-compatible AES-CTR
19
+ Private: { psk: 'AQ==', use_aead: true } # firmware AES-CCM, public example key
20
+ }
31
21
 
32
- ```ruby
33
22
  Meshtastic::MQTT.subscribe(
34
- mqtt_obj: mqtt_obj,
35
- root_topic: 'msh',
36
- region: 'US',
37
- topic: '2/e/LongFast/#',
38
- psks: { LongFast: 'AQ==' },
39
- qos: 0,
40
- exclude: nil, # comma-delimited substrings to hide
41
- include: nil, # all listed substrings must appear
42
- gps_metadata: false,
43
- include_raw: false
44
- ) do |message|
45
- puts message.inspect
23
+ mqtt_obj: mqtt_obj, root_topic: 'msh', region: 'US',
24
+ topic: '2/e/Private/#', psks: keys, qos: 0,
25
+ include: nil, exclude: nil, gps_metadata: false, include_raw: false
26
+ ) do |envelope|
27
+ puts envelope[:packet].inspect
46
28
  end
47
29
  ```
48
30
 
49
- `include: '_APP, LongFast'` keeps only messages whose flattened inspect contains both strings.
31
+ `subscribe` is blocking and disconnects on exit. `include`/`exclude` are comma-delimited substrings. `json: true` accepts JSON envelopes but does not make their security claims trustworthy. Binary protobuf is the normal interoperable path.
50
32
 
51
- ### `send_text`
33
+ The exact `ServiceEnvelope.channel_id` selects the key. If absent, the penultimate MQTT topic segment is used. Unknown names never fall back to LongFast or the first key. Encrypted packets must have the firmware hash matching the configured name, expanded key and mode. Already-decoded envelopes bypass host decryption and are not authenticated by it.
52
34
 
53
- ```ruby
54
- require 'meshtastic'
35
+ ## Send text or application Data
55
36
 
56
- mqtt_obj = Meshtastic::MQTT.connect
57
- client_id = "!#{mqtt_obj.client_id}"
37
+ ```ruby
58
38
  Meshtastic::MQTT.send_text(
59
- mqtt_obj: mqtt_obj,
60
- from: client_id,
61
- to: '!ffffffff',
62
- root_topic: 'msh',
63
- region: 'US',
64
- topic: '2/e/LongFast/#',
65
- channel: 93,
66
- text: 'Hello, World!',
67
- psks: { LongFast: 'AQ==' }
39
+ mqtt_obj: mqtt_obj, from: '!aabbccdd', to: '!ffffffff',
40
+ channel_id: 'Private', psks: keys,
41
+ text: 'Hello', want_ack: false, hop_limit: 3
42
+ )
43
+
44
+ Meshtastic::MQTT.send_data(
45
+ mqtt_obj: mqtt_obj, from: '!aabbccdd',
46
+ channel_id: 'Private', psks: keys,
47
+ data: Meshtastic::Data.new(portnum: :TEXT_MESSAGE_APP, payload: 'Hello')
68
48
  )
69
49
  ```
70
50
 
71
- Default `channel` is `6`. The value that actually works is the integer in a received envelope for that mesh channel. Subscribe with `include: '!YOUR_CLIENT_ID'` while sending a test message from the official app:
51
+ - `channel_id:` selects the exact channel name. It may be omitted only for a single configured key; omitted `psks` retains the public LongFast CTR default. Multiple keys without a selector, duplicate string/symbol names, unknown selectors, and conflicting publication topics raise `ArgumentError` before publish.
52
+ - `psks:` maps names to Base64 strings (legacy CTR), or `{ psk: base64_string, use_aead: true/false }`. `use_aead` belongs **inside each key record**, not at the top level. Structured records require exactly `psk` and a boolean `use_aead`; omitted/misspelled modes and top-level `use_aead` are rejected rather than silently selecting CTR. No mode probing or AEAD-to-CTR retry occurs.
53
+ - Keys must decode to 16 or 32 bytes, or a nonzero one-byte firmware PSK alias. Aliases 1–255 expand using the firmware default key and last-byte adjustment on **any** channel name. Empty/zero aliases, malformed keys and other lengths are rejected; unlike firmware, arbitrary short keys are not silently zero-padded. Resolve secondary-channel inherited keys before supplying them here. Public aliases do not provide confidentiality.
54
+ - The packet channel hash is computed, not guessed: XOR all UTF-8 channel-name bytes and expanded key bytes, then XOR `0xAE` for AEAD. Legacy numeric `channel:` remains accepted but does not override this computed MQTT hash. On radio transports, `channel:` is still a device slot.
55
+ - The default publish path is `msh/US/2/e/<selected channel>/<from>`. Supply `root_topic`, `region`, or `topic: '2/e/Private/#'` to customize. A topic must identify the selected channel.
56
+ - `send_text` and text `send_data` reject nonbroadcast destinations, even without `pki_encrypted`. Explicit `pki_encrypted: true` or recipient `public_key:` also raise. Use Serial/Bluetooth/TCP with device-owned PKI for direct messages. Non-text channel-addressed application Data remains supported, but is not PKI.
57
+ - `psks: nil` and `{}` cannot publish plaintext. `send_text` enforces the payload byte limit. Large encoded Data plus AEAD overhead still must fit the receiving firmware's RF limits; publish success does not prove airtime acceptance.
58
+ - Do not reuse a packet ID with the same sender/key. Normal sends generate IDs; `last_packet_id:` exists for controlled sequencing/testing, not replay protection. The host does not maintain a durable nonce/replay database.
59
+
60
+ `disconnect(mqtt_obj:)`, `help`, and `authors` are also available.
61
+
62
+ ## Security provenance
63
+
64
+ Every enriched packet has host-created `:security_provenance`:
72
65
 
73
66
  ```ruby
74
- mqtt_obj = Meshtastic::MQTT.connect
75
- Meshtastic::MQTT.subscribe(
76
- mqtt_obj: mqtt_obj,
77
- root_topic: 'msh',
78
- region: 'US',
79
- topic: '2/e/LongFast/#',
80
- psks: { LongFast: 'AQ==' },
81
- include: '!c0ffee00'
82
- ) do |message|
83
- puts message.inspect
84
- end
67
+ {
68
+ source: :mqtt,
69
+ verification: :untrusted,
70
+ reported: { xeddsa_signed: true, ack_proof_status: :ACK_PROOF_VALID },
71
+ channel_authentication: :aead # only after a successful local CCM check
72
+ }
85
73
  ```
86
74
 
87
- Example envelope (placeholders):
75
+ Broker `xeddsa_signed` and `ack_proof_status` are removed from the top-level packet hash and retained only under `reported`. They are untrusted broker assertions, including on already-decoded packets, encrypted packets and failed-enrichment paths. A broker-supplied `security_provenance` is overwritten. `include_raw` is wire evidence, not a trusted verification result.
88
76
 
89
- ```
90
- {packet: {from: 3237997296, to: 4294967295, channel: 93, id: 1, rx_time: 1735689600, rx_snr: 0.0, hop_limit: 3, want_ack: false, priority: :HIGH, rx_rssi: 0, delayed: :NO_DELAY, via_mqtt: false, hop_start: 3, public_key: "", pki_encrypted: false, next_hop: 0, relay_node: 0, tx_after: 0, decoded: {portnum: :TEXT_MESSAGE_APP, payload: "WHAT IS MY channel VALUE?", want_response: false, dest: 0, source: 0, request_id: 0, reply_id: 0, emoji: 0, bitfield: 0}, encrypted: :decrypted, topic: "msh/US/2/e/LongFast/!c0ffee00", node_id_from: "!c0ffee00", node_id_to: "!ffffffff", rx_time_utc: "2025-01-01 00:00:00 UTC"}, channel_id: "LongFast", gateway_id: "!c0ffee00"}
91
- ```
77
+ Successful CCM proves possession of the shared channel key, **not** sender identity, XEdDSA signature validity, ACK proof validity, freshness or radio delivery. CTR decoding sets `channel_authentication: :none`; protobuf parsing is not authentication. Failures preserve ciphertext and add `decryption_error` without publishing partial plaintext. PKI ciphertext is never attempted with PSK crypto.
78
+
79
+ ## Pinned firmware interoperability
80
+
81
+ Implemented against release **v2.8.1.8e6a88d**:
82
+
83
+ - Firmware [`8e6a88d06f44cad26f1e8d7cd402939ccacb9b4c`](https://github.com/meshtastic/firmware/tree/8e6a88d06f44cad26f1e8d7cd402939ccacb9b4c): `src/mesh/Channels.cpp`, `CryptoEngine.cpp/.h`, `aes-ccm.cpp`, and `test/test_crypto/test_main.cpp`.
84
+ - Protobuf [`8f97d66a63ce10cfb12f94203e361691647f5ad3`](https://github.com/meshtastic/protobufs/tree/8f97d66a63ce10cfb12f94203e361691647f5ad3): existing `ChannelSettings.use_aead`, `MeshPacket.xeddsa_signed` and `ack_proof_status` schemas.
92
85
 
93
- Use `channel: 93` from that dump when publishing.
86
+ CTR uses AES-128/256 with the 16-byte nonce `packet_id64le || from32le || zero32`. Channel AEAD uses AES-128/256-CCM, **13-byte nonce** `packet_id64le || from32le || 00`, **12-byte tag**, and AAD `from32le || to32le`. Wire bytes are `ciphertext || tag`; there is no PKI extra nonce. Relayed hop fields are not AAD. Packet IDs in MeshPacket are 32-bit and occupy the low four bytes of the 64-bit nonce field.
94
87
 
95
- `AQ==` is expanded to the well-known LongFast PSK. Pass a Base64 channel key for private channels.
88
+ Ruby OpenSSL must provide AES-CCM, `iv_len=`, `auth_tag_len=`, `ccm_data_len=`, and authenticated decrypt. Unsupported crypto fails, never downgrades. No new gem/native extension dependency was added. Tests use fixed independently calculated CCM vectors, upstream RFC3610 validation, real ServiceEnvelope/FromRadio codecs, fake broker/GATT, PTY UART and UNIXSocket TCP; no hardware or live broker was used.
96
89
 
97
90
  ## Related
98
91
 
99
- - [Meshtastic::MeshInterface](mesh-interface.md) (`via: :mqtt`)
100
- - [Meshtastic::Portnums](portnums.md)
101
- - Protobuf: `ServiceEnvelope`, `MapReport` in [protobufs.md](protobufs.md)
92
+ - [MeshInterface](mesh-interface.md)
93
+ - [Protobuf catalog](protobufs.md)
@@ -0,0 +1,54 @@
1
+ # Meshtastic::Waypoint
2
+
3
+ `Waypoint` remains the generated protobuf **class**. `build(fields:)` and `transmit` add validated waypoint/geofence helpers without replacing its constructor, descriptor, encoder, or decoder. All methods accept one options Hash; `help` and `authors` expose usage and attribution.
4
+
5
+ ## Circle and box examples
6
+
7
+ ```ruby
8
+ circle = Meshtastic::Waypoint.build(fields: {
9
+ id: 7, name: 'Meeting point',
10
+ latitude_i: 450_000_000, longitude_i: -730_000_000,
11
+ geofence_radius: 100,
12
+ notify_on_enter: true, notify_on_exit: true,
13
+ notify_favorites_only: true
14
+ })
15
+
16
+ box = Meshtastic::Waypoint.build(fields: {
17
+ id: 8, name: 'Search area',
18
+ bounding_box: {
19
+ longitude_west_i: -731_000_000, latitude_south_i: 449_000_000,
20
+ longitude_east_i: -729_000_000, latitude_north_i: 451_000_000
21
+ },
22
+ notify_on_enter: true
23
+ })
24
+
25
+ Meshtastic::Waypoint.transmit(serial_obj: connection, waypoint: circle, want_ack: true)
26
+ # Alternative: pass fields: directly, not both waypoint and fields.
27
+ ```
28
+
29
+ Coordinates are **integer decimal degrees × 10,000,000**, not radians, floating-point degrees, or map pixels. Latitude must be within ±900,000,000 and longitude within ±1,800,000,000. Supply waypoint latitude and longitude together, including explicit zero for equator/prime-meridian locations. A positive radius requires that center. `geofence_radius` is uint32 meters; zero means no circular fence.
30
+
31
+ `bounding_box` uses exactly `longitude_west_i`, `latitude_south_i`, `longitude_east_i`, `latitude_north_i` (west/south/east/north), with south ≤ north and west ≤ east. Antimeridian-spanning boxes must be split; they are not silently reordered. An explicitly empty box is valid proto3 and denotes all-zero bounds. Circle and box may coexist, as allowed by the pinned schema.
32
+
33
+ Notification flags are booleans: `notify_on_enter`, `notify_on_exit`, `notify_favorites_only`. Favorite status is resolved **locally on each receiving device**. These are firmware/client notification instructions, not a host tracking service or a guarantee that every device will alert.
34
+
35
+ ## Other fields and validation
36
+
37
+ Only exact generated `Waypoint` fields are accepted under `fields:`. In addition to the geometry/notification fields, these are `id`, `expire` (Unix seconds), `locked_to` (editing node number, zero for unrestricted), `name`, `description`, and `icon` (Unicode designator integer). There is no invented `shape`, `radius`, `receive_only`, or `geofence` wire field. The helper rejects unknown fields, invalid types, out-of-range numbers, nil values, geographic bounds violations, incomplete centers, and reversed boxes before transmission.
38
+
39
+ Pinned nanopb storage allows **29 UTF-8 bytes for name and 99 for description**, leaving the terminating byte inside the schema's 30/100-byte storage buffers. Overlength text, embedded NUL, and invalid UTF-8 are rejected, never truncated. `build` without fields and a decoded empty proto3 message are valid default waypoints; absence of payload bytes does not mean a malformed known protobuf payload.
40
+
41
+ ## Transmission and limits
42
+
43
+ `transmit(waypoint:)` revalidates even an object made directly with `Waypoint.new`, then submits `Data(portnum: :WAYPOINT_APP)` through `Meshtastic.deliver_data`. Use the existing non-Admin transport keywords: `serial_obj`, `bluetooth_obj`, `tcp_obj`, or `mqtt_obj`. Shared transport options such as destination, channel, hop limit, packet ID, `want_ack`, `pki_encrypted`, and `public_key` pass through unchanged. For MQTT use `channel_id` and `psks` per the [MQTT](mqtt.md) documentation; MQTT PKI remains unsupported. Do not supply multiple transport handles.
44
+
45
+ Submission is not an RF receipt or notification confirmation. Signing reports, ACK proofs, and broker claims are different evidence; see [Admin delivery results](admin.md#delivery-evidence-and-trust-boundary-v2818e6a88d). Geofence handling and UI notifications depend on the receiver's firmware/build. No live radio verification was performed.
46
+
47
+ ## Pinned contract
48
+
49
+ Firmware **v2.8.1.8e6a88d**, commit `8e6a88d06f44cad26f1e8d7cd402939ccacb9b4c`, pins protobuf commit `8f97d66a63ce10cfb12f94203e361691647f5ad3`:
50
+
51
+ - [Waypoint and BoundingBox schema](https://github.com/meshtastic/protobufs/blob/8f97d66a63ce10cfb12f94203e361691647f5ad3/meshtastic/mesh.proto)
52
+ - [Firmware string capacities](https://github.com/meshtastic/protobufs/blob/8f97d66a63ce10cfb12f94203e361691647f5ad3/meshtastic/mesh.options)
53
+
54
+ Related: [Position](position.md), [Admin::Config](admin-config.md).
@@ -13,7 +13,7 @@ module Meshtastic
13
13
  # Host-side snapshots, not firmware filesystem backup commands.
14
14
  module Backup
15
15
  FORMAT = 'meshtastic-admin-backup'
16
- WARNING = 'Contains secrets returned by firmware. Firmware may redact secrets; absent/default values are not proof of completeness. Owner identity and hardware metadata are excluded. ACKs do not prove persistence.'
16
+ WARNING = 'Contains secrets returned by firmware. Firmware may redact secrets; absent/default values are not proof of completeness. Owner id, MAC and hardware metadata are excluded, NOT security private/public identity keys. ACKs do not prove persistence.'
17
17
  CONFIG_TYPES = %i[DEVICE_CONFIG POSITION_CONFIG POWER_CONFIG NETWORK_CONFIG DISPLAY_CONFIG LORA_CONFIG BLUETOOTH_CONFIG SECURITY_CONFIG].freeze
18
18
  MODULE_TYPES = %i[MQTT_CONFIG SERIAL_CONFIG EXTNOTIF_CONFIG STOREFORWARD_CONFIG RANGETEST_CONFIG TELEMETRY_CONFIG CANNEDMSG_CONFIG AUDIO_CONFIG REMOTEHARDWARE_CONFIG NEIGHBORINFO_CONFIG AMBIENTLIGHTING_CONFIG DETECTIONSENSOR_CONFIG PAXCOUNTER_CONFIG STATUSMESSAGE_CONFIG TRAFFICMANAGEMENT_CONFIG TAK_CONFIG MESHBEACON_CONFIG].freeze
19
19
  OWNER_FIELDS = %w[long_name short_name is_licensed].freeze
@@ -127,6 +127,20 @@ module Meshtastic
127
127
  end
128
128
  end
129
129
  entries = import_plan(document: document, format: opts.fetch(:format, :auto), path: opts[:path])
130
+ identity = entries.any? do |entry|
131
+ security = entry[:message].security if entry[:section] == 'config'
132
+ security && (!security.private_key.empty? || !security.public_key.empty?)
133
+ end
134
+ raise ArgumentError, 'backup contains security identity keys; explicitly select identity_keys: :restore or remove the entire security record offline' if identity && opts.fetch(:identity_keys, :reject) != :restore
135
+
136
+ reconnect = identity || entries.any? { |entry| entry[:section] == 'owner' || (entry[:section] == 'config' && %w[LORA_CONFIG SECURITY_CONFIG NETWORK_CONFIG BLUETOOTH_CONFIG].include?(entry[:slot])) }
137
+ privacy = entries.any? { |entry| privacy_sensitive?(entry) }
138
+ raise ArgumentError, 'backup changes location, sharing or telemetry settings; explicitly select allow_privacy_changes: true' if privacy && !opts.fetch(:allow_privacy_changes, false)
139
+
140
+ warnings = [WARNING]
141
+ warnings << 'Privacy: restored location, channel precision, MQTT sharing or enabled telemetry may disclose location, traffic or device, environment, air quality, power and health metrics. Firmware may clamp public-channel precision; values are never silently stripped by this client.' if privacy
142
+ warnings << 'Identity or connection settings may change the node number or connection. Reconnect, rediscover the target, and compare fresh configuration before claiming persistence; this API does not reconnect or retarget automatically.' if reconnect
143
+ warnings << 'Security public keys may be derived from private keys by firmware; absent private keys preserve an existing identity, not a request to reset it.' if entries.any? { |entry| entry[:section] == 'config' && entry[:slot] == 'SECURITY_CONFIG' }
130
144
  plan = entries.each_with_index.sort_by do |entry, index|
131
145
  priority = if entry[:section] == 'config'
132
146
  { 'LORA_CONFIG' => 20, 'BLUETOOTH_CONFIG' => 21, 'NETWORK_CONFIG' => 22, 'SECURITY_CONFIG' => 23 }.fetch(entry[:slot], 0)
@@ -136,7 +150,7 @@ module Meshtastic
136
150
  [priority, index]
137
151
  end.map(&:first)
138
152
  report = { status: :dry_run, planned: plan.length, attempted: 0, acknowledged: 0, readback_confirmed: 0, persistence_verified: false,
139
- transaction: :not_requested, records: [], plan: plan.map { |entry| entry.slice(:section, :slot) }, warnings: [WARNING] }
153
+ transaction: :not_requested, records: [], plan: plan.map { |entry| entry.slice(:section, :slot) }, warnings: warnings, reconnect_required: reconnect }
140
154
  return report if opts.fetch(:dry_run, false)
141
155
 
142
156
  failure = nil
@@ -174,6 +188,23 @@ module Meshtastic
174
188
  report
175
189
  end
176
190
 
191
+ private_class_method def self.privacy_sensitive?(opts = {})
192
+ message = opts[:message]
193
+ case opts[:section]
194
+ when 'fixed_position' then true
195
+ when 'config' then opts[:slot] == 'POSITION_CONFIG'
196
+ when 'channel'
197
+ settings = message.settings
198
+ settings && (settings.uplink_enabled || settings.module_settings&.position_precision.to_i.positive?)
199
+ when 'module_config'
200
+ return message.mqtt.enabled || message.mqtt.map_reporting_enabled if message.mqtt
201
+
202
+ telemetry = message.telemetry
203
+ telemetry && %w[device_telemetry_enabled environment_measurement_enabled air_quality_enabled power_measurement_enabled health_measurement_enabled].any? { |field| telemetry[field] }
204
+ else false
205
+ end
206
+ end
207
+
177
208
  # A timed-out mutation is never resent; only a fresh getter can resolve it.
178
209
  private_class_method def self.apply_entry(opts = {})
179
210
  entry = opts[:entry]
@@ -345,6 +376,7 @@ module Meshtastic
345
376
  end
346
377
  raise ArgumentError, 'DeviceProfile contains no restorable fields' if entries.empty?
347
378
 
379
+ entries.each { |entry| Validation.validate(message: entry[:message]) if entry[:message].respond_to?(:to_proto) }
348
380
  entries
349
381
  rescue Google::Protobuf::ParseError, TypeError, RangeError, URI::InvalidURIError
350
382
  raise ArgumentError, 'invalid DeviceProfile protobuf or channel URL', cause: nil
@@ -422,6 +454,7 @@ module Meshtastic
422
454
  end
423
455
  validate_proto_json(value: record['value'], descriptor: klass.descriptor)
424
456
  message = klass.decode_json(JSON.generate(record['value']), ignore_unknown_fields: false)
457
+ Validation.validate(message: message)
425
458
  if %w[config module_config].include?(section)
426
459
  types = section == 'config' ? CONFIG_TYPES : MODULE_TYPES
427
460
  fields = klass.descriptor.map(&:name)
@@ -461,14 +494,16 @@ module Meshtastic
461
494
  private_class_method def self.validate_options(opts = {})
462
495
  options = opts[:options]
463
496
  allowed = %i[transport_obj to timeout channel hop_limit path]
464
- allowed += opts[:operation] == :export ? %i[format config_types module_config_types channel_indexes include_owner include_ui] : %i[backup format dry_run edit_transaction verify]
497
+ allowed += opts[:operation] == :export ? %i[format config_types module_config_types channel_indexes include_owner include_ui] : %i[backup format dry_run edit_transaction verify identity_keys allow_privacy_changes]
498
+ raise ArgumentError, 'identity_keys must be :reject (default) or :restore' if options.key?(:identity_keys) && !%i[reject restore].include?(options[:identity_keys])
499
+
465
500
  allowed += %i[fixed_position include_ringtone include_canned_messages] if opts[:operation] == :export && options[:format] == :device_profile
466
501
  raise ArgumentError, 'unknown backup options' unless (options.keys - allowed).empty?
467
502
 
468
503
  formats = opts[:operation] == :export ? %i[json device_profile] : %i[auto json device_profile]
469
504
  raise ArgumentError, "format must be one of #{formats.join(', ')}" if options.key?(:format) && !formats.include?(options[:format])
470
505
 
471
- %i[include_owner include_ui include_ringtone include_canned_messages dry_run edit_transaction verify].each do |key|
506
+ %i[include_owner include_ui include_ringtone include_canned_messages dry_run edit_transaction verify allow_privacy_changes].each do |key|
472
507
  raise ArgumentError, 'backup flags must be boolean' if options.key?(key) && ![true, false].include?(options[key])
473
508
  end
474
509
  timeout = options.fetch(:timeout, 10)
@@ -542,7 +577,9 @@ module Meshtastic
542
577
  path: 'optional - existing JSON or binary DeviceProfile .cfg file; mutually exclusive with backup',
543
578
  backup: 'optional - versioned JSON Hash, JSON String or binary DeviceProfile String; mutually exclusive with path',
544
579
  format: 'optional - :auto (default), :json or :device_profile; cfg paths select binary, other content is validated',
545
- dry_run: 'optional - validate and plan without any radio requests; default false',
580
+ identity_keys: 'optional - :reject (default) rejects any nonempty security private/public identity key; :restore sends unchanged; never strips keys, including in dry runs',
581
+ allow_privacy_changes: 'optional - boolean default false; explicitly consent to position/fixed-position, positive channel precision/uplink, enabled MQTT/map reporting or telemetry (device, environment, air quality, power, health) restoration',
582
+ dry_run: 'optional - validate and plan without any radio requests; default false; consent flags still required',
546
583
  edit_transaction: 'optional - begin/commit on firmware known to support edits; default false',
547
584
  verify: 'optional - additional fresh comparison after completed writes/commit; timeout recovery reads occur regardless; not durability proof; default false',
548
585
  timeout: 'optional - positive finite seconds per request; default 10',
@@ -551,6 +588,8 @@ module Meshtastic
551
588
  hop_limit: 'optional - mesh transport hop limit; default Admin behavior'
552
589
  )
553
590
  # Contains secrets; firmware may redact values. Never logs document contents.
591
+ # Firmware v2.8.1.8e6a88d semantics; owner metadata exclusion does not exclude security identity keys.
592
+ # Reports reconnect_required when identity/connection settings may change. No automatic reconnect or target discovery; persistence_verified remains false.
554
593
  # Display the module authors.
555
594
  #{self}.authors
556
595
  "
@@ -25,10 +25,7 @@ module Meshtastic
25
25
  value = field.subtype.msgclass.new(value) if value.is_a?(Hash) && field.subtype
26
26
  settings[field.name] = value
27
27
  end
28
- raise ArgumentError, 'PSK must contain 0, 1, 16, or 32 raw bytes' unless [0, 1, 16, 32].include?(settings.psk.bytesize)
29
- raise ArgumentError, 'channel name must be fewer than 12 bytes' unless settings.name.bytesize < 12
30
-
31
- settings
28
+ Validation.validate(message: settings)
32
29
  end
33
30
 
34
31
  public_class_method def self.build(opts = {})
@@ -38,10 +35,8 @@ module Meshtastic
38
35
 
39
36
  channel.index = index
40
37
  channel.role = opts[:role] if opts[:role]
41
- raise ArgumentError, 'role must be PRIMARY, SECONDARY, or DISABLED' unless %i[PRIMARY SECONDARY DISABLED].include?(channel.role)
42
-
43
38
  channel.settings = build_settings(opts.merge(settings: opts.fetch(:settings, channel.settings)))
44
- channel
39
+ Validation.validate(message: channel)
45
40
  end
46
41
 
47
42
  public_class_method def self.get(opts = {})
@@ -82,7 +77,7 @@ module Meshtastic
82
77
  channel_set = Meshtastic::ChannelSet.decode(Base64.urlsafe_decode64(uri.fragment))
83
78
  raise ArgumentError, 'invalid channel URL' unless (1..8).cover?(channel_set.settings.length)
84
79
 
85
- channel_set
80
+ Validation.validate(message: channel_set)
86
81
  rescue URI::InvalidURIError, Google::Protobuf::ParseError, ArgumentError, TypeError
87
82
  raise ArgumentError, 'invalid channel URL'
88
83
  end
@@ -100,6 +100,49 @@ module Meshtastic
100
100
  set(opts.merge(config: Meshtastic::Config.new(device_ui: opts.fetch(:device_ui))))
101
101
  end
102
102
 
103
+ public_class_method def self.set_packet_signature_policy(opts = {})
104
+ policy = opts.fetch(:policy)
105
+ enum = Meshtastic::Config::SecurityConfig::PacketSignaturePolicy
106
+ valid = policy.is_a?(Symbol) ? enum.resolve(policy) : (policy.is_a?(Integer) && enum.lookup(policy))
107
+ raise ArgumentError, 'policy must be a known PacketSignaturePolicy symbol or integer' unless valid
108
+
109
+ section = Meshtastic::Config::SecurityConfig.new(packet_signature_policy: policy)
110
+ Admin::Validation.validate(message: Meshtastic::AdminMessage.new(set_config: Meshtastic::Config.new(security: section)))
111
+ update_field(opts.except(:policy).merge(section: :security, config_type: :SECURITY_CONFIG, field: :packet_signature_policy,
112
+ value: section.packet_signature_policy))
113
+ end
114
+
115
+ public_class_method def self.set_receive_only(opts = {})
116
+ enabled = opts.fetch(:enabled)
117
+ raise ArgumentError, 'enabled must be true or false' unless [true, false].include?(enabled)
118
+ raise ArgumentError, 'receive-only requires a local radio connection' if Admin.transport_type(opts) == :mqtt
119
+
120
+ connection = opts.fetch(:transport_obj)
121
+ target = opts.fetch(:to, connection[:my_node_num])
122
+ target = target.delete_prefix('!').to_i(16) if target.is_a?(String) && target.match?(/\A![0-9a-fA-F]{8}\z/)
123
+ raise ArgumentError, 'receive-only requires the local node for reliable readback and recovery' unless target && target == connection[:my_node_num]
124
+
125
+ update_field(opts.except(:enabled).merge(section: :lora, config_type: :LORA_CONFIG, field: :tx_enabled, value: !enabled))
126
+ end
127
+
128
+ private_class_method def self.update_field(opts = {})
129
+ section = opts.fetch(:section)
130
+ field = opts.fetch(:field)
131
+ value = opts.fetch(:value)
132
+ options = opts.except(:section, :field, :value, :config_type, :wait, :request_id).merge(wait: true)
133
+ query = options.merge(get_config_request: opts.fetch(:config_type))
134
+ before = Admin.request(query).fetch(:value)
135
+ raise IOError, "expected #{section} configuration readback" unless before.is_a?(Meshtastic::Config) && before.payload_variant == section
136
+
137
+ intended = Meshtastic::Config.decode(before.to_proto)
138
+ intended.public_send(section).public_send("#{field}=", value)
139
+ acknowledgment = Admin.request(options.merge(set_config: intended))
140
+ readback = Admin.request(query)
141
+ actual = readback.fetch(:value)
142
+ matched = actual.is_a?(Meshtastic::Config) && actual.payload_variant == section && actual.public_send(section).public_send(field) == value
143
+ { acknowledgment: acknowledgment, readback: readback, matched: matched, reboot_requested: false }
144
+ end
145
+
103
146
  public_class_method def self.authors
104
147
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
105
148
  end
@@ -115,7 +158,7 @@ module Meshtastic
115
158
  # Write a Config protobuf to the node.
116
159
  #{self}.set(
117
160
  transport_obj: 'required - connected Serial, Bluetooth, TCP handle or MQTT client',
118
- config: 'required - Meshtastic::Config protobuf to write'
161
+ config: 'required - Config protobuf with exactly one selected section; empty outer messages and unknown enum values are rejected; device_ui uses dedicated store operation'
119
162
  )
120
163
 
121
164
  # Request DEVICE_CONFIG from the node.
@@ -204,6 +247,25 @@ module Meshtastic
204
247
  # Write device UI configuration to node.
205
248
  #{self}.set_device_ui(device_ui: 'required - DeviceUIConfig protobuf or field hash')
206
249
 
250
+ # Change signature policy preserving other security fields with fresh readback.
251
+ #{self}.set_packet_signature_policy(
252
+ transport_obj: 'required - connected radio handle with receive queue; MQTT unsupported',
253
+ policy: 'required - PACKET_SIGNATURE_POLICY_COMPATIBLE, BALANCED or STRICT enum symbol with full prefix, or known integer',
254
+ to: 'optional - target node, defaults to connected radio; accepts shared Admin request options',
255
+ timeout: 'optional - per-request positive deadline seconds; default ten'
256
+ )
257
+
258
+ # Set local LoRa transmit enablement with fresh readback.
259
+ #{self}.set_receive_only(
260
+ transport_obj: 'required - local connected radio with receive queue; remote targets and MQTT rejected',
261
+ enabled: 'required - true disables LoRa transmission, false enables it; preserves other LoRa fields',
262
+ to: 'optional - must equal the connected local node number',
263
+ timeout: 'optional - per-request positive deadline seconds; default ten'
264
+ )
265
+ # Helpers return acknowledgment, readback, matched and reboot_requested false.
266
+ # They do not request reboot; firmware may itself reboot/disconnect after settings changes.
267
+ # Timeout propagates without replay; reconnect and read back before retrying mutations.
268
+
207
269
  # Print the AUTHOR(S) string for this module.
208
270
  #{self}.authors
209
271
  "
@@ -125,8 +125,11 @@ module Meshtastic
125
125
  public_class_method def self.verify_reboot(opts = {})
126
126
  validate_verification(opts.merge({}))
127
127
  handle = nil
128
- transport = { tcp: Meshtastic::TCP, bluetooth: Meshtastic::Bluetooth, serial: Meshtastic::Serial }.fetch(opts.fetch(:transport))
129
- key = { tcp: :tcp_obj, bluetooth: :bluetooth_obj, serial: :serial_obj }.fetch(opts.fetch(:transport))
128
+ handle_type = nil
129
+ transports = { tcp: Meshtastic::TCP, bluetooth: Meshtastic::Bluetooth, serial: Meshtastic::Serial }
130
+ keys = { tcp: :tcp_obj, bluetooth: :bluetooth_obj, serial: :serial_obj }
131
+ transport = transports.fetch(opts.fetch(:transport))
132
+ key = keys.fetch(opts.fetch(:transport))
130
133
  Timeout.timeout(opts.fetch(:timeout, 60)) do
131
134
  sleep opts.fetch(:reboot_delay, 3)
132
135
  begin
@@ -135,6 +138,9 @@ module Meshtastic
135
138
  else
136
139
  transport.connect(opts.fetch(:connection).merge(want_config: true))
137
140
  end
141
+ handle_type = Admin.transport_type(transport_obj: handle)
142
+ raise ArgumentError, 'Reconnect transport does not match selected transport' unless handle_type == opts.fetch(:transport)
143
+
138
144
  transport.wait_for_config(key => handle, timeout: opts.fetch(:timeout, 60))
139
145
  rescue IOError, SystemCallError
140
146
  transport.disconnect(key => handle) if handle
@@ -142,25 +148,50 @@ module Meshtastic
142
148
  sleep 0.25
143
149
  retry
144
150
  end
145
- reply = Admin.request(transport_obj: handle, get_device_metadata_request: true, timeout: opts.fetch(:timeout, 60))
151
+ node = handle[:my_node_num]
152
+ identity = verify_identity(opts.merge(handle: handle, node: node))
153
+ reply = Admin.request(transport_obj: handle, to: node, get_device_metadata_request: true, timeout: opts.fetch(:timeout, 60))
146
154
  metadata = reply.fetch(:value).to_h
147
- raise IOError, "Firmware version mismatch: #{metadata[:firmware_version].inspect}" unless metadata[:firmware_version] == opts.fetch(:expected_version)
148
- raise IOError, 'Post-reboot node identity mismatch' if opts[:expected_node] && handle[:my_node_num] != opts[:expected_node]
155
+ raise IOError, 'Firmware version mismatch' unless metadata[:firmware_version] == opts.fetch(:expected_version)
156
+ raise IOError, 'Post-reboot node changed during verification' unless handle[:my_node_num] == node
149
157
 
150
- { status: :boot_verified, firmware_version: metadata[:firmware_version], node_num: handle[:my_node_num], metadata: metadata }
158
+ { status: :boot_verified, firmware_version: metadata[:firmware_version], node_num: node, node_id: format('!%08x', node), metadata: metadata }.merge(identity)
151
159
  end
152
160
  ensure
153
- transport.disconnect(key => handle) if handle
161
+ transports.fetch(handle_type).disconnect(keys.fetch(handle_type) => handle) if handle && transports.key?(handle_type)
162
+ end
163
+
164
+ private_class_method def self.verify_identity(opts = {})
165
+ node = opts[:node]
166
+ raise IOError, 'Missing valid local node identity' unless node.is_a?(Integer) && (1...0xffffffff).cover?(node)
167
+ raise IOError, 'Post-reboot node identity mismatch' if opts[:expected_node] && node != opts[:expected_node]
168
+ return {} unless opts[:expected_public_key]
169
+
170
+ reply = Admin.request(transport_obj: opts[:handle], to: node, get_owner_request: true, timeout: opts.fetch(:timeout, 60))
171
+ raise IOError, 'Post-reboot public-key identity mismatch' unless reply.fetch(:value).public_key == opts[:expected_public_key]
172
+
173
+ result = { identity: :public_key_continuity }
174
+ return result unless opts[:previous_node]
175
+
176
+ result.merge(previous_node_num: opts[:previous_node], previous_node_id: format('!%08x', opts[:previous_node]), node_changed: node != opts[:previous_node])
154
177
  end
155
178
 
156
179
  private_class_method def self.validate_verification(opts = {})
157
180
  raise ArgumentError, 'verify must be a Hash of verify_reboot options' unless opts.is_a?(Hash)
158
181
 
159
- allowed = %i[transport connection expected_version expected_node reconnect timeout reboot_delay]
182
+ allowed = %i[transport connection expected_version expected_node expected_public_key previous_node reconnect timeout reboot_delay]
160
183
  raise ArgumentError, 'Unknown reboot verification option' unless (opts.keys - allowed).empty?
161
184
  raise ArgumentError, 'transport must be :tcp, :bluetooth or :serial' unless %i[tcp bluetooth serial].include?(opts[:transport])
162
185
  raise ArgumentError, 'expected_version must be a nonempty firmware version String' unless opts[:expected_version].is_a?(String) && !opts[:expected_version].empty?
163
- raise ArgumentError, 'expected_node must be a numeric node ID' if opts.key?(:expected_node) && !(opts[:expected_node].is_a?(Integer) && (1..0xffffffff).cover?(opts[:expected_node]))
186
+
187
+ %i[expected_node previous_node].each do |name|
188
+ raise ArgumentError, "#{name} must be a unicast numeric node ID" if opts.key?(name) && !(opts[name].is_a?(Integer) && (1...0xffffffff).cover?(opts[name]))
189
+ end
190
+ if opts.key?(:expected_public_key)
191
+ key = opts[:expected_public_key]
192
+ raise ArgumentError, 'expected_public_key must be 32 nonzero raw bytes' unless key.is_a?(String) && key.bytesize == 32 && key.bytes.any?(&:positive?)
193
+ end
194
+ raise ArgumentError, 'previous_node requires expected_public_key continuity' if opts.key?(:previous_node) && !opts[:expected_public_key]
164
195
 
165
196
  timeout = opts.fetch(:timeout, 60)
166
197
  delay = opts.fetch(:reboot_delay, 3)
@@ -300,7 +331,9 @@ module Meshtastic
300
331
  transport: 'required - :tcp, :bluetooth or :serial for the restarted application',
301
332
  connection: 'optional - connect options Hash with explicit host/address/block_dev; required without reconnect',
302
333
  expected_version: 'required - exact firmware version reported by the intended application',
303
- expected_node: 'optional - numeric node identity to verify after restart',
334
+ expected_node: 'optional - strict numeric post-restart node identity; never relaxed by key continuity',
335
+ expected_public_key: 'optional - trusted pre-upgrade public identity, 32 raw bytes; compared with fresh local owner reply',
336
+ previous_node: 'optional - pre-upgrade numeric node for changed mapping output; requires expected_public_key',
304
337
  reconnect: 'optional - callable accepting options Hash and returning a newly connected transport handle',
305
338
  timeout: 'optional - entire reboot/reconnect/config/metadata deadline seconds, default 60',
306
339
  reboot_delay: 'optional - initial wait for loader restart, default 3 seconds'
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Meshtastic
4
+ module Admin
5
+ # Firmware v2.8.1.8e6a88d: reject lossy writes rather than emulate clamping.
6
+ module Validation
7
+ public_class_method def self.validate(opts = {})
8
+ message = opts.fetch(:message)
9
+ message.class.descriptor.each do |field|
10
+ value = message[field.name]
11
+ values = field.label == :repeated ? value : [value]
12
+ values.each do |item|
13
+ if field.type == :enum
14
+ known = item.is_a?(Symbol) ? field.subtype.lookup_name(item) : field.subtype.lookup_value(item)
15
+ raise ArgumentError, "unknown enum for #{field.name}" if known.nil?
16
+ elsif field.type == :message && item
17
+ validate(message: item)
18
+ end
19
+ end
20
+ end
21
+ if message.is_a?(Meshtastic::Config::SecurityConfig)
22
+ keys = [message.private_key, message.public_key] + message.admin_key.to_a
23
+ raise ArgumentError, 'security keys must contain zero or 32 bytes; at most three admin keys' unless keys.all? { |key| [0, 32].include?(key.bytesize) } && message.admin_key.length <= 3
24
+ end
25
+ if message.is_a?(Meshtastic::Channel)
26
+ raise ArgumentError, 'channel index must be from 0 through 7' unless message.index.between?(0, 7)
27
+ elsif message.is_a?(Meshtastic::ChannelSettings)
28
+ raise ArgumentError, 'PSK must contain 0, 1, 16, or 32 raw bytes' unless [0, 1, 16, 32].include?(message.psk.bytesize)
29
+ raise ArgumentError, 'channel name must be fewer than 12 UTF-8 bytes without NUL' unless message.name.valid_encoding? && message.name.bytesize < 12 && !message.name.include?("\0")
30
+ end
31
+ if message.is_a?(Meshtastic::Config) || message.is_a?(Meshtastic::ModuleConfig)
32
+ raise ArgumentError, 'exactly one config section is required' unless message.payload_variant
33
+ raise ArgumentError, 'sessionkey is request-only; device_ui requires store_ui_config' if %i[sessionkey device_ui].include?(message.payload_variant)
34
+ end
35
+ owner(message: message) if message.is_a?(Meshtastic::User)
36
+ ham(message: message.set_ham_mode) if message.is_a?(Meshtastic::AdminMessage) && (message.payload_variant == :set_ham_mode)
37
+ message
38
+ end
39
+
40
+ private_class_method def self.owner(opts = {})
41
+ message = opts[:message]
42
+ name(value: message.long_name, label: 'owner long_name', limit: 24)
43
+ name(value: message.short_name, label: 'owner short_name', limit: 4)
44
+ end
45
+
46
+ private_class_method def self.ham(opts = {})
47
+ message = opts[:message]
48
+ name(value: message.call_sign, label: 'ham call_sign', limit: 7)
49
+ raise ArgumentError, 'ham call_sign is required' if message.call_sign.empty?
50
+
51
+ name(value: message.long_name, label: 'ham long_name', limit: 14)
52
+ name(value: message.short_name, label: 'ham short_name', limit: 4)
53
+ composed = message.long_name.empty? ? message.call_sign : "#{message.call_sign}//#{message.long_name}"
54
+ name(value: composed, label: 'ham composed owner long_name', limit: 24)
55
+ end
56
+
57
+ private_class_method def self.name(opts = {})
58
+ value = opts[:value]
59
+ valid = value.encoding == Encoding::UTF_8 && value.valid_encoding? && !value.include?("\0") && value.bytesize <= opts[:limit]
60
+ valid &&= value.empty? || !value.match?(/\A[ \t\r\n\v\f]*\z/)
61
+ raise ArgumentError, "#{opts[:label]} must be nonblank UTF-8 without NUL, at most #{opts[:limit]} bytes (empty means omitted)" unless valid
62
+ end
63
+
64
+ public_class_method def self.authors
65
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
66
+ end
67
+
68
+ public_class_method def self.help
69
+ puts "USAGE:
70
+ # Validate semantics without changing data or contacting hardware.
71
+ #{self}.validate(message: 'required - protobuf message to validate against firmware v2.8.1.8e6a88d')
72
+ # Display the module authors.
73
+ #{self}.authors
74
+ "
75
+ end
76
+ end
77
+ end
78
+ end