vnode 0.1.10__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.10
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.10"
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):
@@ -383,15 +424,7 @@ class VirtualNode:
383
424
  want_ack: bool = False,
384
425
  want_response: bool = False,
385
426
  ) -> int:
386
- user = mesh_pb2.User(
387
- id=self.config.node_id,
388
- long_name=self.config.long_name,
389
- short_name=self.config.short_name,
390
- hw_model=resolve_hw_model(self.config.hw_model),
391
- )
392
- user.role = resolve_role(self.config.role)
393
- if self._public_key_b64:
394
- user.public_key = b64_decode(self._public_key_b64)
427
+ user = self._build_user()
395
428
 
396
429
  data = mesh_pb2.Data()
397
430
  data.portnum = portnums_pb2.PortNum.NODEINFO_APP
@@ -622,12 +655,45 @@ class VirtualNode:
622
655
  plaintext=data.SerializeToString(),
623
656
  )
624
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
+ )
625
690
  packet.encrypted = mudp_encrypt_packet(
626
691
  self.config.channel.name,
627
692
  self.config.channel.psk,
628
693
  packet,
629
- data,
694
+ data_copy,
630
695
  )
696
+ data = data_copy
631
697
 
632
698
  raw_packet = packet.SerializeToString()
633
699
  register_pending_ack(packet, raw_packet)
@@ -767,7 +833,7 @@ class VirtualNode:
767
833
 
768
834
  def _known_node_count(self) -> int:
769
835
  try:
770
- node_db = meshdb.NodeDB(self.node_num, self.meshdb_path)
836
+ node_db = meshdb.NodeDB(self.node_num, self.meshdb_path, **self._db_scope)
771
837
  node_db.ensure_table()
772
838
  with node_db.connect() as con:
773
839
  row = con.execute(f"SELECT COUNT(*) FROM {node_db.table}").fetchone()
@@ -785,7 +851,8 @@ class VirtualNode:
785
851
  stored_packet = mesh_pb2.MeshPacket()
786
852
  stored_packet.CopyFrom(packet)
787
853
  stored_packet.rx_time = int(time.time())
788
- 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
789
856
  stored_packet.decoded.CopyFrom(data)
790
857
  self._persist_packet(stored_packet)
791
858
 
@@ -795,6 +862,7 @@ class VirtualNode:
795
862
  normalized,
796
863
  node_database_number=self.node_num,
797
864
  db_path=self.meshdb_path,
865
+ **self._db_scope,
798
866
  )
799
867
 
800
868
  def _publish_receive(self, packet: mesh_pb2.MeshPacket) -> None:
@@ -929,7 +997,12 @@ class VirtualNode:
929
997
  text = str(destination).strip()
930
998
  if text.startswith("!"):
931
999
  return parse_node_id(text)
932
- 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
+ )
933
1006
  if isinstance(resolved, list):
934
1007
  raise ValueError(f"Destination '{destination}' is ambiguous: {resolved}")
935
1008
  if resolved is None:
@@ -940,7 +1013,12 @@ class VirtualNode:
940
1013
  if int(node_num) == self.node_num:
941
1014
  key = self._public_key_b64
942
1015
  return b64_decode(key) if key else None
943
- 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
+ )
944
1022
  if not isinstance(row, dict):
945
1023
  return None
946
1024
  public_key = str(row.get("public_key", "")).strip()
File without changes
File without changes
File without changes
File without changes