vnode 0.1.9__tar.gz → 0.2.3__tar.gz

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.
@@ -1,23 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vnode
3
- Version: 0.1.9
3
+ Version: 0.2.3
4
4
  Summary: Virtual Meshtastic node using mudp transport and meshdb storage.
5
5
  License-Expression: GPL-3.0-only
6
6
  License-File: LICENSE
7
7
  Author: Ben Lipsey
8
8
  Author-email: ben@pdxlocations.com
9
- Requires-Python: >=3.9,<3.15
9
+ Requires-Python: >=3.10,<3.15
10
10
  Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.9
12
11
  Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: XEdDSA (>=1.2,<2)
17
17
  Requires-Dist: cryptography (>=43,<45)
18
- Requires-Dist: meshdb (>=0.1.9)
18
+ Requires-Dist: meshdb (>=0.3.0,<0.4)
19
19
  Requires-Dist: meshtastic (>=2.7,<3.0)
20
- Requires-Dist: mudp (>=1.1.5)
20
+ Requires-Dist: mudp (>=1.6.0,<2)
21
21
  Project-URL: Homepage, https://github.com/pdxlocations/vnode
22
22
  Project-URL: Issues, https://github.com/pdxlocations/vnode/issues
23
23
  Description-Content-Type: text/markdown
@@ -46,13 +46,20 @@ If you are working from this repository instead:
46
46
 
47
47
  ## Config
48
48
 
49
- Edit [node.json](node.json).
49
+ Edit `node.json` (created locally and excluded from version control).
50
50
 
51
51
  If `node.json` does not exist yet, the runtime will create it automatically from
52
52
  [example-node.json](example-node.json).
53
53
 
54
- If the template leaves `node_id` blank, the runtime will generate and persist a random
55
- Meshtastic-style node ID when it creates or first loads `node.json`.
54
+ The runtime derives `node_id` from the IEEE CRC-32 of its X25519 public key,
55
+ matching Meshtastic 2.8 identity binding. It is a read-only property of
56
+ `NodeConfig`, not a JSON setting. Existing `node_id` fields are removed on
57
+ load while preserving the private key and configured names. Changing the
58
+ private key changes the node ID and starts a new node database; the old database
59
+ is preserved. With meshdb 0.3, each owner file uses channel-scoped tables for
60
+ node records, broadcasts, positions, and telemetry, plus per-peer DM tables.
61
+ Existing contact records are copied from the legacy owner node table on startup;
62
+ old message and telemetry tables remain intact.
56
63
  If `long_name` or `short_name` are blank, the runtime will also generate them from the
57
64
  last 4 hex characters of the node ID, for example `Meshtastic 691c` and `691c`.
58
65
 
@@ -62,6 +69,18 @@ the private key is blank.
62
69
  The derived public key is written automatically to `node.public.key` next to
63
70
  `node.json`. That file is generated by the runtime and should not need manual edits.
64
71
 
72
+ Broadcasts and directed nodeinfo/position packets carry XEdDSA signatures using
73
+ the existing X25519 identity key and the Meshtastic 2.8.1 signing format.
74
+ Traceroutes and packets that cannot fit a signature within the 255-byte radio
75
+ frame remain unsigned. Direct messages retain X25519/AES-CCM encryption.
76
+
77
+ Signed nodeinfo lets compatible receivers authenticate first contact without a
78
+ handshake or a running vnode listener. Nodeinfo includes the fixed-length MAC
79
+ field required by firmware's protobuf encoder so UDP-to-LoRa gateways can
80
+ re-encode it without invalidating the signature. The iOS app may need to be
81
+ closed and reopened to display the **Signed Node** badge after first receipt.
82
+ This badge is separate from manual contact/key verification.
83
+
65
84
  The `position` section in `node.json` controls periodic position broadcasts. Set
66
85
  `position.enabled` to `true`, then set `position.latitude` and `position.longitude`, and adjust
67
86
  `position.position_interval_seconds` to control how often the node sends `POSITION_APP`
@@ -136,6 +155,15 @@ Use these as small runnable references for common tasks:
136
155
 
137
156
  See [examples/README.md](examples/README.md) for the full list.
138
157
 
158
+ ## Tests
159
+
160
+ ```bash
161
+ .venv/bin/python -m unittest discover -s vnode/tests -v
162
+ ```
163
+
164
+ The signing tests cover identity migration, key rotation, the firmware signing
165
+ layout, tamper rejection, radio frame limits, and nodeinfo gateway re-encoding.
166
+
139
167
  ## License
140
168
 
141
169
  GPL-3.0-only. See [`LICENSE`](LICENSE).
@@ -22,13 +22,20 @@ If you are working from this repository instead:
22
22
 
23
23
  ## Config
24
24
 
25
- Edit [node.json](node.json).
25
+ Edit `node.json` (created locally and excluded from version control).
26
26
 
27
27
  If `node.json` does not exist yet, the runtime will create it automatically from
28
28
  [example-node.json](example-node.json).
29
29
 
30
- If the template leaves `node_id` blank, the runtime will generate and persist a random
31
- Meshtastic-style node ID when it creates or first loads `node.json`.
30
+ The runtime derives `node_id` from the IEEE CRC-32 of its X25519 public key,
31
+ matching Meshtastic 2.8 identity binding. It is a read-only property of
32
+ `NodeConfig`, not a JSON setting. Existing `node_id` fields are removed on
33
+ load while preserving the private key and configured names. Changing the
34
+ private key changes the node ID and starts a new node database; the old database
35
+ is preserved. With meshdb 0.3, each owner file uses channel-scoped tables for
36
+ node records, broadcasts, positions, and telemetry, plus per-peer DM tables.
37
+ Existing contact records are copied from the legacy owner node table on startup;
38
+ old message and telemetry tables remain intact.
32
39
  If `long_name` or `short_name` are blank, the runtime will also generate them from the
33
40
  last 4 hex characters of the node ID, for example `Meshtastic 691c` and `691c`.
34
41
 
@@ -38,6 +45,18 @@ the private key is blank.
38
45
  The derived public key is written automatically to `node.public.key` next to
39
46
  `node.json`. That file is generated by the runtime and should not need manual edits.
40
47
 
48
+ Broadcasts and directed nodeinfo/position packets carry XEdDSA signatures using
49
+ the existing X25519 identity key and the Meshtastic 2.8.1 signing format.
50
+ Traceroutes and packets that cannot fit a signature within the 255-byte radio
51
+ frame remain unsigned. Direct messages retain X25519/AES-CCM encryption.
52
+
53
+ Signed nodeinfo lets compatible receivers authenticate first contact without a
54
+ handshake or a running vnode listener. Nodeinfo includes the fixed-length MAC
55
+ field required by firmware's protobuf encoder so UDP-to-LoRa gateways can
56
+ re-encode it without invalidating the signature. The iOS app may need to be
57
+ closed and reopened to display the **Signed Node** badge after first receipt.
58
+ This badge is separate from manual contact/key verification.
59
+
41
60
  The `position` section in `node.json` controls periodic position broadcasts. Set
42
61
  `position.enabled` to `true`, then set `position.latitude` and `position.longitude`, and adjust
43
62
  `position.position_interval_seconds` to control how often the node sends `POSITION_APP`
@@ -112,6 +131,15 @@ Use these as small runnable references for common tasks:
112
131
 
113
132
  See [examples/README.md](examples/README.md) for the full list.
114
133
 
134
+ ## Tests
135
+
136
+ ```bash
137
+ .venv/bin/python -m unittest discover -s vnode/tests -v
138
+ ```
139
+
140
+ The signing tests cover identity migration, key rotation, the firmware signing
141
+ layout, tamper rejection, radio frame limits, and nodeinfo gateway re-encoding.
142
+
115
143
  ## License
116
144
 
117
145
  GPL-3.0-only. See [`LICENSE`](LICENSE).
@@ -1,18 +1,19 @@
1
1
  [project]
2
2
  name = "vnode"
3
- version = "0.1.9"
3
+ version = "0.2.3"
4
4
  description = "Virtual Meshtastic node using mudp transport and meshdb storage."
5
5
  authors = [
6
6
  {name = "Ben Lipsey",email = "ben@pdxlocations.com"}
7
7
  ]
8
8
  license = "GPL-3.0-only"
9
9
  readme = "README.md"
10
- requires-python = ">=3.9,<3.15"
10
+ requires-python = ">=3.10,<3.15"
11
11
  dependencies = [
12
12
  "cryptography>=43,<45",
13
- "meshdb>=0.1.9",
13
+ "XEdDSA>=1.2,<2",
14
+ "meshdb>=0.3.0,<0.4",
14
15
  "meshtastic>=2.7,<3.0",
15
- "mudp>=1.1.5",
16
+ "mudp>=1.6.0,<2",
16
17
  ]
17
18
 
18
19
  [project.urls]
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- import secrets
5
4
  from dataclasses import asdict, dataclass, field
6
5
  from pathlib import Path
7
6
  from typing import Any, Dict, List, Optional, Union
@@ -30,7 +29,7 @@ class ChannelConfig:
30
29
 
31
30
  @dataclass
32
31
  class UdpConfig:
33
- mcast_group: str = "224.0.0.69"
32
+ mcast_group: str = "239.0.0.69"
34
33
  mcast_port: int = 4403
35
34
 
36
35
 
@@ -47,7 +46,6 @@ class SecurityConfig:
47
46
 
48
47
  @dataclass
49
48
  class NodeConfig:
50
- node_id: str = ""
51
49
  long_name: str = "Virtual Meshtastic Node"
52
50
  short_name: str = "VND"
53
51
  hw_model: Union[str, int] = "ANDROID_SIM"
@@ -61,6 +59,13 @@ class NodeConfig:
61
59
  meshdb: MeshDbConfig = field(default_factory=MeshDbConfig)
62
60
  security: SecurityConfig = field(default_factory=SecurityConfig)
63
61
 
62
+ @property
63
+ def node_id(self) -> str:
64
+ from .crypto import b64_decode, derive_public_key, node_id_from_public_key
65
+
66
+ public_key = derive_public_key(b64_decode(self.security.private_key))
67
+ return f"!{node_id_from_public_key(public_key):08x}"
68
+
64
69
  @classmethod
65
70
  def load(cls, path: Union[str, Path]) -> "NodeConfig":
66
71
  config_path = Path(path)
@@ -75,7 +80,6 @@ class NodeConfig:
75
80
  security_payload = dict(payload.get("security", {}))
76
81
  security_payload.pop("enabled", None)
77
82
  return cls(
78
- node_id=str(payload.get("node_id", cls.node_id)),
79
83
  long_name=str(payload.get("long_name", cls.long_name)),
80
84
  short_name=str(payload.get("short_name", cls.short_name)),
81
85
  hw_model=payload.get("hw_model", cls.hw_model),
@@ -107,7 +111,11 @@ class NodeConfig:
107
111
  return
108
112
 
109
113
  template_path = next(
110
- (candidate for candidate in cls._example_config_candidates(config_path) if candidate.exists()),
114
+ (
115
+ candidate
116
+ for candidate in cls._example_config_candidates(config_path)
117
+ if candidate.exists()
118
+ ),
111
119
  None,
112
120
  )
113
121
  if template_path is None:
@@ -120,24 +128,12 @@ class NodeConfig:
120
128
  cls._populate_generated_defaults(payload)
121
129
  security = payload.setdefault("security", {})
122
130
  security.pop("public_key", None)
123
- if not str(security.get("private_key", "")).strip():
124
- from .crypto import b64_encode, generate_keypair
125
-
126
- _public_key, private_key = generate_keypair()
127
- security["private_key"] = b64_encode(private_key)
128
131
 
129
132
  config_path.write_text(
130
133
  json.dumps(payload, indent=2, sort_keys=False) + "\n",
131
134
  encoding="utf-8",
132
135
  )
133
136
 
134
- @staticmethod
135
- def _generate_node_id() -> str:
136
- while True:
137
- value = secrets.randbits(32)
138
- if value not in (0, 0xFFFFFFFF):
139
- return f"!{value:08x}"
140
-
141
137
  @staticmethod
142
138
  def _generated_names(node_id: str) -> Dict[str, str]:
143
139
  suffix = str(node_id).strip().lower()[-4:]
@@ -148,11 +144,27 @@ class NodeConfig:
148
144
 
149
145
  @classmethod
150
146
  def _populate_generated_defaults(cls, payload: Dict[str, Any]) -> bool:
147
+ from .crypto import (
148
+ b64_decode,
149
+ b64_encode,
150
+ derive_public_key,
151
+ generate_keypair,
152
+ node_id_from_public_key,
153
+ )
154
+
151
155
  changed = False
152
- if not str(payload.get("node_id", "")).strip():
153
- payload["node_id"] = cls._generate_node_id()
156
+ security = payload.setdefault("security", {})
157
+ private_key = str(security.get("private_key", "")).strip()
158
+ if not private_key:
159
+ _, private_bytes = generate_keypair()
160
+ private_key = security["private_key"] = b64_encode(private_bytes)
161
+ changed = True
162
+ public_key = derive_public_key(b64_decode(private_key))
163
+ node_id = f"!{node_id_from_public_key(public_key):08x}"
164
+ if "node_id" in payload:
165
+ del payload["node_id"]
154
166
  changed = True
155
- generated_names = cls._generated_names(str(payload["node_id"]))
167
+ generated_names = cls._generated_names(node_id)
156
168
  if not str(payload.get("long_name", "")).strip():
157
169
  payload["long_name"] = generated_names["long_name"]
158
170
  changed = True
@@ -2,10 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  import base64
4
4
  import secrets
5
+ import struct
6
+ import zlib
5
7
  from dataclasses import dataclass
6
8
  from hashlib import sha256
7
9
  from typing import Optional, Tuple
8
10
 
11
+ import xeddsa
12
+
9
13
  from cryptography.hazmat.primitives import serialization
10
14
  from cryptography.hazmat.primitives.asymmetric.x25519 import (
11
15
  X25519PrivateKey,
@@ -37,12 +41,67 @@ def generate_keypair() -> Tuple[bytes, bytes]:
37
41
 
38
42
 
39
43
  def derive_public_key(private_key: bytes) -> bytes:
40
- return X25519PrivateKey.from_private_bytes(private_key).public_key().public_bytes(
41
- encoding=serialization.Encoding.Raw,
42
- format=serialization.PublicFormat.Raw,
44
+ return (
45
+ X25519PrivateKey.from_private_bytes(private_key)
46
+ .public_key()
47
+ .public_bytes(
48
+ encoding=serialization.Encoding.Raw,
49
+ format=serialization.PublicFormat.Raw,
50
+ )
51
+ )
52
+
53
+
54
+ def node_id_from_public_key(public_key: bytes) -> int:
55
+ """Meshtastic 2.8 identity binding (IEEE CRC-32 of the X25519 key)."""
56
+ if len(public_key) != 32:
57
+ raise ValueError("Public key must be 32 bytes")
58
+ return zlib.crc32(public_key) & 0xFFFFFFFF
59
+
60
+
61
+ def signing_buffer(from_node: int, packet_id: int, to_node: int, data) -> bytes:
62
+ """Firmware v1 signing layout; relay-mutated fields are deliberately excluded."""
63
+ has_bitfield = data.HasField("bitfield")
64
+ flags = int(data.want_response) | (int(has_bitfield) << 1)
65
+ return struct.pack(
66
+ "<B8IB",
67
+ 1,
68
+ from_node,
69
+ packet_id,
70
+ to_node,
71
+ int(data.portnum),
72
+ data.request_id,
73
+ data.reply_id,
74
+ data.emoji,
75
+ data.bitfield if has_bitfield else 0,
76
+ flags,
77
+ ) + bytes(data.payload)
78
+
79
+
80
+ def sign_data(
81
+ private_key: bytes, from_node: int, packet_id: int, to_node: int, data
82
+ ) -> bytes:
83
+ """Sign with the existing X25519 scalar, normalizing the Edwards sign bit to zero."""
84
+ scalar = bytearray(private_key)
85
+ if len(scalar) != 32:
86
+ raise ValueError("Private key must be 32 bytes")
87
+ scalar[0] &= 248
88
+ scalar[31] = (scalar[31] & 127) | 64
89
+ normalized = xeddsa.priv_force_sign(bytes(scalar), False)
90
+ return xeddsa.ed25519_priv_sign(
91
+ normalized, signing_buffer(from_node, packet_id, to_node, data)
43
92
  )
44
93
 
45
94
 
95
+ def attach_signature(data, signature: bytes) -> None:
96
+ """Field 10 is preserved as an unknown field by pre-2.8 protobuf packages."""
97
+ if len(signature) != 64:
98
+ raise ValueError("XEdDSA signature must be 64 bytes")
99
+ if "xeddsa_signature" in data.DESCRIPTOR.fields_by_name:
100
+ data.xeddsa_signature = signature
101
+ else:
102
+ data.MergeFromString(b"\x52\x40" + signature)
103
+
104
+
46
105
  def build_nonce(packet_id: int, from_node: int, extra_nonce: int) -> bytes:
47
106
  nonce = bytearray(16)
48
107
  nonce[0:8] = int(packet_id).to_bytes(8, "little", signed=False)
@@ -66,7 +125,11 @@ class PkiEnvelope:
66
125
  extra_nonce: int
67
126
 
68
127
  def pack(self) -> bytes:
69
- return self.ciphertext + self.tag + self.extra_nonce.to_bytes(4, "little", signed=False)
128
+ return (
129
+ self.ciphertext
130
+ + self.tag
131
+ + self.extra_nonce.to_bytes(4, "little", signed=False)
132
+ )
70
133
 
71
134
 
72
135
  def encrypt_dm(
@@ -78,7 +141,9 @@ def encrypt_dm(
78
141
  plaintext: bytes,
79
142
  extra_nonce: Optional[int] = None,
80
143
  ) -> bytes:
81
- nonce_value = secrets.randbits(32) if extra_nonce is None else int(extra_nonce) & 0xFFFFFFFF
144
+ nonce_value = (
145
+ secrets.randbits(32) if extra_nonce is None else int(extra_nonce) & 0xFFFFFFFF
146
+ )
82
147
  key = build_shared_key(sender_private_key, receiver_public_key)
83
148
  nonce = build_nonce(packet_id, from_node, nonce_value)
84
149
  encrypted = AESCCM(key, tag_length=8).encrypt(nonce, plaintext, None)
@@ -1,5 +1,4 @@
1
1
  {
2
- "node_id": "",
3
2
  "long_name": "",
4
3
  "short_name": "",
5
4
  "hw_model": "ANDROID_SIM",
@@ -22,7 +21,7 @@
22
21
  "psk": "AQ=="
23
22
  },
24
23
  "udp": {
25
- "mcast_group": "224.0.0.69",
24
+ "mcast_group": "239.0.0.69",
26
25
  "mcast_port": 4403
27
26
  },
28
27
  "meshdb": {
@@ -12,13 +12,25 @@ import meshdb
12
12
  from google.protobuf.message import DecodeError
13
13
  from meshtastic import BROADCAST_ADDR, BROADCAST_NUM, LOCAL_ADDR, config_pb2, mesh_pb2, portnums_pb2, protocols
14
14
  from mudp import UDPPacketStream
15
- from mudp.encryption import encrypt_packet as mudp_encrypt_packet
15
+ from mudp.encryption import encrypt_packet as mudp_encrypt_packet, generate_hash
16
16
  from mudp.reliability import is_ack, is_nak, publish_ack, register_pending_ack, send_ack
17
17
  from mudp.singleton import conn, node as mudp_node
18
18
  from pubsub import pub
19
19
 
20
20
  from .config import NodeConfig
21
- from .crypto import b64_decode, b64_encode, decrypt_dm, derive_public_key, encrypt_dm, generate_keypair
21
+ from .crypto import (
22
+ attach_signature,
23
+ b64_decode,
24
+ b64_encode,
25
+ decrypt_dm,
26
+ derive_public_key,
27
+ encrypt_dm,
28
+ sign_data,
29
+ )
30
+
31
+ RADIO_HEADER_BYTES = 16
32
+ MAX_RADIO_FRAME_BYTES = 255
33
+ ENCODED_SIGNATURE_BYTES = 66
22
34
 
23
35
  PKI_DISALLOWED_PORTNUMS = {
24
36
  portnums_pb2.PortNum.TRACEROUTE_APP,
@@ -78,38 +90,28 @@ class VirtualNode:
78
90
  self.node_num = parse_node_id(self.config.node_id)
79
91
  self.role_num = resolve_role(self.config.role)
80
92
  self.meshdb_path = str((self.base_dir / self.config.meshdb.path).resolve())
93
+ # Keep the owner database filename while using meshdb 0.2's channel tables.
94
+ self._db_scope = {
95
+ "channel": generate_hash(self.config.channel.name, self.config.channel.psk),
96
+ "channel_name": self.config.channel.name,
97
+ "storage_name": str(self.node_num),
98
+ }
81
99
  self.stream: Optional[UDPPacketStream] = None
82
100
  self._stop = threading.Event()
83
101
  self._broadcast_thread: Optional[threading.Thread] = None
84
102
  self._message_id = random.getrandbits(32)
85
- self._public_key_b64 = ""
103
+ self._public_key_b64 = b64_encode(
104
+ derive_public_key(b64_decode(self.config.security.private_key))
105
+ )
86
106
  self._last_nodeinfo_sent_monotonic = 0.0
87
107
  self._last_position_reply_monotonic = 0.0
88
108
  self._last_nodeinfo_seen: Dict[int, int] = {}
89
109
  self._response_handlers: Dict[int, Dict[str, Any]] = {}
90
110
 
91
- self._ensure_security_keys()
92
111
  self._write_public_key_file()
93
112
  self._seed_owner_record()
94
113
  self._configure_mudp_globals()
95
114
 
96
- def _ensure_security_keys(self) -> None:
97
- private_key = self.config.security.private_key.strip()
98
- changed = False
99
-
100
- if private_key:
101
- self._public_key_b64 = b64_encode(derive_public_key(b64_decode(private_key)))
102
- else:
103
- public_bytes, private_bytes = generate_keypair()
104
- self._public_key_b64 = b64_encode(public_bytes)
105
- private_key = b64_encode(private_bytes)
106
- changed = True
107
-
108
- if changed:
109
- self.config.security.private_key = private_key
110
- self.config.security.public_key = ""
111
- self.config.save(self.config_path)
112
-
113
115
  def _write_public_key_file(self) -> None:
114
116
  if not self._public_key_b64:
115
117
  return
@@ -118,7 +120,39 @@ class VirtualNode:
118
120
  def _seed_owner_record(self) -> None:
119
121
  Path(self.meshdb_path).mkdir(parents=True, exist_ok=True)
120
122
  meshdb.set_default_db_path(self.meshdb_path)
121
- meshdb.NodeDB(self.node_num, self.meshdb_path).upsert(
123
+ node_db = meshdb.NodeDB(self.node_num, self.meshdb_path, **self._db_scope)
124
+ node_db.ensure_table()
125
+ # Import legacy contacts once; later deletions must not resurrect them.
126
+ with node_db.connect() as con:
127
+ legacy_table = f"{self.node_num}_nodedb"
128
+ exists = con.execute(
129
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
130
+ (legacy_table,),
131
+ ).fetchone()
132
+ con.execute(
133
+ "CREATE TABLE IF NOT EXISTS vnode_migrations (name TEXT PRIMARY KEY)"
134
+ )
135
+ imported = con.execute(
136
+ "SELECT 1 FROM vnode_migrations WHERE name='channel_contacts'"
137
+ ).fetchone()
138
+ if exists and not imported:
139
+ old_columns = {
140
+ row[1]
141
+ for row in con.execute(f'PRAGMA table_info("{legacy_table}")')
142
+ }
143
+ columns = [
144
+ row[1]
145
+ for row in con.execute(f"PRAGMA table_info({node_db.table})")
146
+ if row[1] in old_columns
147
+ ]
148
+ names = ", ".join(f'"{name}"' for name in columns)
149
+ con.execute(
150
+ f'INSERT OR IGNORE INTO {node_db.table} ({names}) SELECT {names} FROM "{legacy_table}"'
151
+ )
152
+ con.execute(
153
+ "INSERT INTO vnode_migrations (name) VALUES ('channel_contacts')"
154
+ )
155
+ node_db.upsert(
122
156
  node_num=self.node_num,
123
157
  long_name=self.config.long_name,
124
158
  short_name=self.config.short_name,
@@ -220,16 +254,23 @@ class VirtualNode:
220
254
  def getPublicKey(self):
221
255
  return b64_decode(self._public_key_b64) if self._public_key_b64 else None
222
256
 
223
- def getMyUser(self):
257
+ def _build_user(self) -> mesh_pb2.User:
224
258
  user = mesh_pb2.User(
225
259
  id=self.config.node_id,
226
260
  long_name=self.config.long_name,
227
261
  short_name=self.config.short_name,
262
+ # Nanopb always emits this fixed-length field, even when all zero.
263
+ # Include it before signing so gateway re-encoding preserves payload bytes.
264
+ macaddr=bytes(6),
228
265
  hw_model=resolve_hw_model(self.config.hw_model),
229
266
  )
230
267
  user.role = resolve_role(self.config.role)
231
268
  if self._public_key_b64:
232
269
  user.public_key = b64_decode(self._public_key_b64)
270
+ return user
271
+
272
+ def getMyUser(self):
273
+ user = self._build_user()
233
274
  return MessageToDict(user, preserving_proto_field_name=False, use_integers_for_enums=False)
234
275
 
235
276
  def getMyNodeInfo(self):
@@ -367,8 +408,13 @@ class VirtualNode:
367
408
  if getattr(conn, "socket", None) is None:
368
409
  conn.setup_multicast(self.config.udp.mcast_group, int(self.config.udp.mcast_port))
369
410
 
370
- def send_nodeinfo(self, destination: int = BROADCAST_NUM) -> int:
371
- return self._send_nodeinfo(destination)
411
+ def send_nodeinfo(
412
+ self,
413
+ destination: int = BROADCAST_NUM,
414
+ *,
415
+ want_response: bool = False,
416
+ ) -> int:
417
+ return self._send_nodeinfo(destination, want_response=want_response)
372
418
 
373
419
  def _send_nodeinfo(
374
420
  self,
@@ -376,22 +422,16 @@ class VirtualNode:
376
422
  *,
377
423
  request_id: Optional[int] = None,
378
424
  want_ack: bool = False,
425
+ want_response: bool = False,
379
426
  ) -> int:
380
- user = mesh_pb2.User(
381
- id=self.config.node_id,
382
- long_name=self.config.long_name,
383
- short_name=self.config.short_name,
384
- hw_model=resolve_hw_model(self.config.hw_model),
385
- )
386
- user.role = resolve_role(self.config.role)
387
- if self._public_key_b64:
388
- user.public_key = b64_decode(self._public_key_b64)
427
+ user = self._build_user()
389
428
 
390
429
  data = mesh_pb2.Data()
391
430
  data.portnum = portnums_pb2.PortNum.NODEINFO_APP
392
431
  data.payload = user.SerializeToString()
393
432
  data.source = self.node_num
394
433
  data.dest = int(destination)
434
+ data.want_response = bool(want_response)
395
435
  if request_id is not None:
396
436
  data.request_id = int(request_id)
397
437
  packet_id = self._send_data(
@@ -615,12 +655,45 @@ class VirtualNode:
615
655
  plaintext=data.SerializeToString(),
616
656
  )
617
657
  else:
658
+ signable = (
659
+ destination == BROADCAST_NUM
660
+ or data.portnum
661
+ in (
662
+ portnums_pb2.PortNum.NODEINFO_APP,
663
+ portnums_pb2.PortNum.POSITION_APP,
664
+ )
665
+ or (
666
+ self.config.is_licensed
667
+ and data.portnum != portnums_pb2.PortNum.ROUTING_APP
668
+ )
669
+ ) and data.portnum != portnums_pb2.PortNum.TRACEROUTE_APP
670
+ # Sign a copy so a caller can safely reuse its Data object. Field 10 costs
671
+ # 66 bytes; the radio frame permits 255 bytes including its 16-byte header.
672
+ data_copy = mesh_pb2.Data()
673
+ data_copy.CopyFrom(data)
674
+ if "xeddsa_signature" in data_copy.DESCRIPTOR.fields_by_name:
675
+ data_copy.ClearField("xeddsa_signature")
676
+ if signable and (
677
+ data_copy.ByteSize() + ENCODED_SIGNATURE_BYTES + RADIO_HEADER_BYTES
678
+ <= MAX_RADIO_FRAME_BYTES
679
+ ):
680
+ attach_signature(
681
+ data_copy,
682
+ sign_data(
683
+ b64_decode(self.config.security.private_key),
684
+ self.node_num,
685
+ packet.id,
686
+ int(destination),
687
+ data_copy,
688
+ ),
689
+ )
618
690
  packet.encrypted = mudp_encrypt_packet(
619
691
  self.config.channel.name,
620
692
  self.config.channel.psk,
621
693
  packet,
622
- data,
694
+ data_copy,
623
695
  )
696
+ data = data_copy
624
697
 
625
698
  raw_packet = packet.SerializeToString()
626
699
  register_pending_ack(packet, raw_packet)
@@ -760,7 +833,7 @@ class VirtualNode:
760
833
 
761
834
  def _known_node_count(self) -> int:
762
835
  try:
763
- node_db = meshdb.NodeDB(self.node_num, self.meshdb_path)
836
+ node_db = meshdb.NodeDB(self.node_num, self.meshdb_path, **self._db_scope)
764
837
  node_db.ensure_table()
765
838
  with node_db.connect() as con:
766
839
  row = con.execute(f"SELECT COUNT(*) FROM {node_db.table}").fetchone()
@@ -778,7 +851,8 @@ class VirtualNode:
778
851
  stored_packet = mesh_pb2.MeshPacket()
779
852
  stored_packet.CopyFrom(packet)
780
853
  stored_packet.rx_time = int(time.time())
781
- stored_packet.transport_mechanism = mesh_pb2.MeshPacket.TransportMechanism.TRANSPORT_MULTICAST_UDP
854
+ if "transport_mechanism" in stored_packet.DESCRIPTOR.fields_by_name:
855
+ stored_packet.transport_mechanism = mesh_pb2.MeshPacket.TransportMechanism.TRANSPORT_MULTICAST_UDP
782
856
  stored_packet.decoded.CopyFrom(data)
783
857
  self._persist_packet(stored_packet)
784
858
 
@@ -788,6 +862,7 @@ class VirtualNode:
788
862
  normalized,
789
863
  node_database_number=self.node_num,
790
864
  db_path=self.meshdb_path,
865
+ **self._db_scope,
791
866
  )
792
867
 
793
868
  def _publish_receive(self, packet: mesh_pb2.MeshPacket) -> None:
@@ -922,7 +997,12 @@ class VirtualNode:
922
997
  text = str(destination).strip()
923
998
  if text.startswith("!"):
924
999
  return parse_node_id(text)
925
- resolved = meshdb.get_node_num(text, owner_node_num=self.node_num, db_path=self.meshdb_path)
1000
+ resolved = meshdb.get_node_num(
1001
+ text,
1002
+ owner_node_num=self.node_num,
1003
+ db_path=self.meshdb_path,
1004
+ **self._db_scope,
1005
+ )
926
1006
  if isinstance(resolved, list):
927
1007
  raise ValueError(f"Destination '{destination}' is ambiguous: {resolved}")
928
1008
  if resolved is None:
@@ -933,7 +1013,12 @@ class VirtualNode:
933
1013
  if int(node_num) == self.node_num:
934
1014
  key = self._public_key_b64
935
1015
  return b64_decode(key) if key else None
936
- row = meshdb.get_nodeinfo(int(node_num), owner_node_num=self.node_num, db_path=self.meshdb_path)
1016
+ row = meshdb.get_nodeinfo(
1017
+ int(node_num),
1018
+ owner_node_num=self.node_num,
1019
+ db_path=self.meshdb_path,
1020
+ **self._db_scope,
1021
+ )
937
1022
  if not isinstance(row, dict):
938
1023
  return None
939
1024
  public_key = str(row.get("public_key", "")).strip()
File without changes
File without changes
File without changes
File without changes