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.
- {vnode-0.1.10 → vnode-0.2.3}/PKG-INFO +36 -8
- {vnode-0.1.10 → vnode-0.2.3}/README.md +31 -3
- {vnode-0.1.10 → vnode-0.2.3}/pyproject.toml +5 -4
- {vnode-0.1.10 → vnode-0.2.3}/vnode/vnode/config.py +32 -20
- {vnode-0.1.10 → vnode-0.2.3}/vnode/vnode/crypto.py +70 -5
- {vnode-0.1.10 → vnode-0.2.3}/vnode/vnode/example-node.json +1 -2
- {vnode-0.1.10 → vnode-0.2.3}/vnode/vnode/runtime.py +115 -37
- {vnode-0.1.10 → vnode-0.2.3}/LICENSE +0 -0
- {vnode-0.1.10 → vnode-0.2.3}/vnode/vnode/__init__.py +0 -0
- {vnode-0.1.10 → vnode-0.2.3}/vnode/vnode/__main__.py +0 -0
- {vnode-0.1.10 → vnode-0.2.3}/vnode/vnode/cli.py +0 -0
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vnode
|
|
3
|
-
Version: 0.
|
|
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
|
+
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.
|
|
18
|
+
Requires-Dist: meshdb (>=0.3.0,<0.4)
|
|
19
19
|
Requires-Dist: meshtastic (>=2.7,<3.0)
|
|
20
|
-
Requires-Dist: mudp (>=1.
|
|
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
|
|
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
|
-
|
|
55
|
-
Meshtastic
|
|
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
|
|
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
|
-
|
|
31
|
-
Meshtastic
|
|
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.
|
|
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.
|
|
10
|
+
requires-python = ">=3.10,<3.15"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"cryptography>=43,<45",
|
|
13
|
-
"
|
|
13
|
+
"XEdDSA>=1.2,<2",
|
|
14
|
+
"meshdb>=0.3.0,<0.4",
|
|
14
15
|
"meshtastic>=2.7,<3.0",
|
|
15
|
-
"mudp>=1.
|
|
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 = "
|
|
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
|
-
(
|
|
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
|
-
|
|
153
|
-
|
|
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(
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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 =
|
|
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)
|
|
@@ -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
|
|
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)
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|