Include the node's public key in the contact QR code (#185)

* Include the node's public key in the contact QR code #184

The contact QR code and import URL never set user.public_key. Current
Meshtastic apps refuse to import a keyless contact. On 2.7.x firmware,
an older app's add_contact also overwrites the key the radio already
holds, so direct messages to that node fail.

Read the key from the node's latest stored NODEINFO_APP packet. Share
the contact only when a 32-byte key is known. Otherwise the node page
hides the QR buttons and says why. The QR API response now also
returns public_key.

Also drop the stray blank line that fails ruff format --check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Do not share a contact when a node has reported more than one key #184

A forged NODEINFO uplinked over MQTT could otherwise become the newest
packet, and the shared contact would then replace the key a scanning
radio already trusts. The QR endpoint now reads every stored NODEINFO
from the node and refuses to share when their 32-byte keys disagree.
It reports that in public_key_conflict, and the node page says why.

Add an index on packet (from_node_id, portnum, import_time_us DESC) so
the lookup reads only the node's NODEINFO packets. Order with NULLS
LAST so PostgreSQL does not treat old rows without import_time_us as
newest. Carry is_licensed and is_unmessagable into the contact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Skip NODEINFO rows with no payload when building the contact #184

packet.payload is nullable, and MeshPacket.FromString(None) raises
TypeError, so one such row made the QR endpoint answer 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mike BennerandClaude Opus 5.5 authored and GitHub committed 2026-10-27 17:08:54 -07:00
1 parent aabb099468
commit ae7ba15263
8 files changed
+133 -15

No files matched your search

@@ -0,0 +1,37 @@
"""Add packet index on from_node_id, portnum and import_time_us
Revision ID: 4d2a7c9e1b30
Revises: e8f2c4b6d9a1
Create Date: 2026-10-26 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "4d2a7c9e1b30"
down_revision: str | None = "e8f2c4b6d9a1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
INDEX_NAME = "idx_packet_from_node_portnum_time_us"
def upgrade() -> None:
# Lets the node QR endpoint read a node's NODEINFO packets without scanning all its packets.
inspector = sa.inspect(op.get_bind())
if INDEX_NAME not in {index["name"] for index in inspector.get_indexes("packet")}:
op.create_index(
INDEX_NAME,
"packet",
["from_node_id", "portnum", sa.text("import_time_us DESC")],
unique=False,
)
def downgrade() -> None:
op.drop_index(INDEX_NAME, table_name="packet")
+3 -1
View File
@@ -246,7 +246,9 @@
"share_contact_qr": "Share Contact QR",
"copy_url": "Copy URL",
"copied": "Copied!",
"scan_qr_to_add": "Scan this QR code to add this node as a contact on another device."
"scan_qr_to_add": "Scan this QR code to add this node as a contact on another device.",
"qr_no_public_key": "No public key heard from this node yet, so it cannot be shared as a contact.",
"qr_key_conflict": "This node has reported more than one public key, so it is not shared as a contact."
},
"packet": {
"loading": "Loading packet information...",
+3 -1
View File
@@ -232,7 +232,9 @@
"share_contact_qr": "Compartir contacto QR",
"copy_url": "Copiar URL",
"copied": "¡Copiado!",
"scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo."
"scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo.",
"qr_no_public_key": "Aún no se ha recibido la clave pública de este nodo, por lo que no se puede compartir como contacto.",
"qr_key_conflict": "Este nodo ha informado más de una clave pública, por lo que no se comparte como contacto."
},
"packet": {
+3 -1
View File
@@ -227,7 +227,9 @@
"share_contact_qr": "Поделиться QR контакта",
"copy_url": "Копировать URL",
"copied": "Скопировано!",
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве."
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве.",
"qr_no_public_key": "Открытый ключ этой ноды ещё не получен, поэтому её нельзя отправить как контакт.",
"qr_key_conflict": "Эта нода сообщила более одного открытого ключа, поэтому она не отправляется как контакт."
},
"packet": {
"loading": "Загрузка информации о пакете...",
+6
View File
@@ -59,6 +59,12 @@ class Packet(Base):
Index("idx_packet_to_node_id", "to_node_id"),
Index("idx_packet_import_time_us", desc("import_time_us")),
Index("idx_packet_from_node_time_us", "from_node_id", desc("import_time_us")),
Index(
"idx_packet_from_node_portnum_time_us",
"from_node_id",
"portnum",
desc("import_time_us"),
),
)
+11
View File
@@ -113,6 +113,17 @@ async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
return result.scalars()
async def get_nodeinfo_payloads(node_id):
"""Return the payloads of every stored NODEINFO_APP packet from a node, newest first."""
async with database.async_session() as session:
result = await session.execute(
select(Packet.payload)
.where(Packet.from_node_id == node_id, Packet.portnum == PortNum.NODEINFO_APP)
.order_by(Packet.import_time_us.desc().nulls_last())
)
return result.scalars().all()
async def get_packet(packet_id):
async with database.async_session() as session:
q = select(Packet).where(Packet.id == packet_id)
+21 -2
View File
@@ -173,6 +173,11 @@
border-color: #48bb78;
color: #fff;
}
.node-actions .qr-unavailable {
align-self: center;
color: #b0bec5;
font-size: 0.85rem;
}
.copy-success {
color: #4ade80 !important;
transition: opacity 0.3s;
@@ -315,6 +320,12 @@
<button onclick="showQrCode()" id="showQrBtn">
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
</button>
<span class="qr-unavailable" id="qrUnavailable" style="display:none;" data-translate-lang="qr_no_public_key">
No public key heard from this node yet, so it cannot be shared as a contact.
</span>
<span class="qr-unavailable" id="qrKeyConflict" style="display:none;" data-translate-lang="qr_key_conflict">
This node has reported more than one public key, so it is not shared as a contact.
</span>
<button onclick="openReachReport()" id="reachReportBtn">
<span>📈</span> <span>Reliability</span>
</button>
@@ -1811,8 +1822,16 @@ async function loadNodeQr() {
try {
const qrRes = await fetch(`/api/node/${fromNodeId}/qr`);
const qrData = await qrRes.json();
if (qrRes.ok && qrData.meshtastic_url) {
currentMeshtasticUrl = qrData.meshtastic_url;
if (qrRes.ok) {
// No URL means no usable public key: none is known, or the node reported several.
currentMeshtasticUrl = qrData.meshtastic_url || "";
const canShare = Boolean(currentMeshtasticUrl);
const keyConflict = Boolean(qrData.public_key_conflict);
document.getElementById("copyUrlBtn").style.display = canShare ? "" : "none";
document.getElementById("showQrBtn").style.display = canShare ? "" : "none";
document.getElementById("qrUnavailable").style.display =
canShare || keyConflict ? "none" : "";
document.getElementById("qrKeyConflict").style.display = keyConflict ? "" : "none";
actionsDiv.style.display = "flex";
} else {
actionsDiv.style.display = "none";
+49 -10
View File
@@ -744,7 +744,6 @@ async def api_config(request):
"map_bottom_right_lat": get_float(site, "map_bottom_right_lat", 36.0),
"map_bottom_right_lon": get_float(site, "map_bottom_right_lon", -121.0),
"map_interval": get_int(site, "map_interval", 3),
"map_channel_colors": {
channel: color.strip()
for channel, color in CONFIG.get("map_channel_colors", {}).items()
@@ -1173,9 +1172,11 @@ async def api_node_qr(request):
return web.json_response({"error": "Node not found"}, status=404)
try:
from google.protobuf.message import DecodeError
from meshtastic.protobuf.admin_pb2 import SharedContact
from meshtastic.protobuf.config_pb2 import Config
from meshtastic.protobuf.mesh_pb2 import User
from meshtastic.protobuf.mesh_pb2 import MeshPacket, User
user = User()
user.id = f"!{node_id:08x}"
@@ -1200,18 +1201,52 @@ async def api_node_qr(request):
except (AttributeError, TypeError):
pass
contact = SharedContact()
contact.node_num = node_id
contact.user.CopyFrom(user)
contact.manually_verified = False
# Meshview does not store public keys, so read them from the node's NODEINFO packets.
latest_nodeinfo = None
public_keys = set()
for payload in await store.get_nodeinfo_payloads(node_id):
if not payload:
continue
try:
mesh_packet = MeshPacket.FromString(payload)
except DecodeError:
continue
nodeinfo = decode_payload.decode_payload(
PortNum.NODEINFO_APP, mesh_packet.decoded.payload
)
if not isinstance(nodeinfo, User):
continue
if latest_nodeinfo is None:
latest_nodeinfo = nodeinfo
if len(nodeinfo.public_key) == 32:
public_keys.add(nodeinfo.public_key)
if latest_nodeinfo is not None:
if len(latest_nodeinfo.public_key) == 32:
user.public_key = latest_nodeinfo.public_key
user.is_licensed = latest_nodeinfo.is_licensed
if latest_nodeinfo.HasField("is_unmessagable"):
user.is_unmessagable = latest_nodeinfo.is_unmessagable
contact_bytes = contact.SerializeToString()
import base64
contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
# A contact without a public key is refused by current Meshtastic apps and, on older
# firmware, erases the key the importing radio already has. Importing a contact also
# replaces a key the radio has pinned, so when the node's packets disagree on its key
# (one of them may be forged), share neither.
public_key_conflict = len(public_keys) > 1
meshtastic_url = None
if user.public_key and not public_key_conflict:
contact = SharedContact()
contact.node_num = node_id
contact.user.CopyFrom(user)
contact.manually_verified = False
meshtastic_url = f"https://meshtastic.org/v/#{contact_b64url}"
contact_bytes = contact.SerializeToString()
contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
meshtastic_url = f"https://meshtastic.org/v/#{contact_b64url}"
return web.json_response(
{
@@ -1219,6 +1254,10 @@ async def api_node_qr(request):
"long_name": node.long_name,
"short_name": node.short_name,
"role": node.role,
"public_key": (
base64.b64encode(user.public_key).decode("ascii") if user.public_key else None
),
"public_key_conflict": public_key_conflict,
"meshtastic_url": meshtastic_url,
}
)