└── 2025-bsidessf ├── bsidessf2025-aprs-meshtastic.pdf ├── readme.md ├── aprs_analyzer.py ├── meshtastic_visualizer.py ├── meshtastic_key_demo.py └── aprs_decoder.py /2025-bsidessf/bsidessf2025-aprs-meshtastic.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/7h3rAm/presentations/master/2025-bsidessf/bsidessf2025-aprs-meshtastic.pdf -------------------------------------------------------------------------------- /2025-bsidessf/readme.md: -------------------------------------------------------------------------------- 1 | # Decentralized Communications: Deep-Dive into APRS and Meshtastic 2 | 3 | This repository contains the presentation materials and supporting code for our [talk at BsidesSF 2025](https://bsidessf2025.sched.com/event/1x8SZ/decentralized-communications-deep-dive-into-aprs-and-meshtastic). 4 | 5 | ## Abstract 6 | 7 | This talk provides a security analysis comparing APRS (Automatic Packet Reporting System) and Meshtastic protocols for decentralized communications. We examine their technical foundations, network architectures, packet structures and security models to understand the inherent tradeoffs and vulnerabilities of each system. Both technologies serve critical roles in emergency communications, remote operations and community networks but they approach security from fundamentally different perspectives due to their historical origins and regulatory constraints. 8 | 9 | ## Presentation Overview 10 | 11 | - The Need for Decentralized Communications 12 | - RF101: Radio Comms Cheatsheet 13 | - APRS Architecture and Technical Deep-Dive 14 | - Meshtastic Architecture and Technical Deep-Dive 15 | - Security Model Comparison 16 | - Conclusions 17 | - Resources 18 | 19 | ## Resources 20 | 21 | ### APRS Resources 22 | - [APRS.org](http://www.aprs.org/) - Official APRS documentation 23 | - [APRS-IS](http://aprs-is.net/) - APRS Internet Service 24 | - [APRS Foundation](https://how.aprs.works/) - APRS Foundation Inc 25 | - [APRS.fi](https://aprs.fi/) - Live APRS tracking 26 | - [Direwolf](https://github.com/wb2osz/direwolf) - Software TNC and APRS decoder 27 | 28 | ### Meshtastic Resources 29 | - [Meshtastic.org](https://meshtastic.org/) - Official project site 30 | - [Meshtastic GitHub](https://github.com/meshtastic/meshtastic) - Source code and documentation 31 | - [Meshtastic Python Library](https://github.com/meshtastic/python) - Python API for Meshtastic 32 | - [Meshtastic Map](https://meshtastic.liamcottle.net/) - Live network visualization 33 | 34 | ### Communities 35 | - [APRS Discord](https://discord.gg/3EKxjsxExV) 36 | - [Meshtastic Discord](https://discord.gg/KGZra7eFby) 37 | - [Meshtastic Bay Area Group](https://bayme.sh/) 38 | 39 | ### Hardware Used 40 | 41 | - [Baofeng F8HP radio](https://www.google.com/search?q=baofeng+f8hp) 42 | - [Mobilinkd TNC4](https://store.mobilinkd.com/products/mobilinkd-tnc4) 43 | - [Seeed Studio SenseCAP T1000E](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html) 44 | - [SenseCAP Indicator (Meshtastic UI)](https://www.seeedstudio.com/SenseCAP-Indicator-D1L-for-Meshtastic-p-6304.html) 45 | 46 | ## Presenters 47 | - [Mayuresh Dani](https://www.linkedin.com/in/mayureshdani) - Detection Engineering, Adversary Simulation, Security Research 48 | - [Ankur Tyagi (KN6VLB)](https://www.linkedin.com/in/ankurstyagi) - Cybersecurity Enthusiast, Packet Hacker 49 | 50 | ## Citation 51 | If you find this material useful in your research or projects, please cite as: 52 | 53 | ``` 54 | Dani, M., & Tyagi, A. (2025). Decentralized Communications: Deep-Dive into APRS and Meshtastic. 55 | Presented at BsidesSF 2025, San Francisco, CA. 56 | ``` 57 | 58 | --- 59 | 60 | _"This presentation doubles as an emergency digipeater if printed on conductive ink"_ 61 | -------------------------------------------------------------------------------- /2025-bsidessf/aprs_analyzer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # aprs_analyzer.py - APRS packet capture and analysis tool 3 | 4 | import socket 5 | import sys 6 | import time 7 | import datetime 8 | import argparse 9 | import re 10 | from collections import defaultdict 11 | 12 | def parse_args(): 13 | parser = argparse.ArgumentParser(description='APRS Packet Analyzer') 14 | parser.add_argument('--host', default='localhost', 15 | help='APRS-IS server or Direwolf host') 16 | parser.add_argument('--port', type=int, default=14580, 17 | help='APRS-IS or Direwolf port') 18 | parser.add_argument('--callsign', default='NOCALL', 19 | help='Your callsign for APRS-IS') 20 | parser.add_argument('--password', default='-1', 21 | help='APRS-IS password') 22 | parser.add_argument('--filter', default='r/35.7/-120/200', 23 | help='APRS-IS filter') 24 | return parser.parse_args() 25 | 26 | def connect_to_aprs_is(args): 27 | """Connect to APRS-IS or local Direwolf instance""" 28 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 29 | sock.connect((args.host, args.port)) 30 | 31 | # Login to APRS-IS 32 | login = f"user {args.callsign} pass {args.password} vers aprs_analyzer 0.1 filter {args.filter}\r\n" 33 | sock.send(login.encode('ascii')) 34 | return sock 35 | 36 | def parse_packet(packet): 37 | """Parse an APRS packet and extract key information""" 38 | # Basic parsing of common APRS packets 39 | parts = packet.split('>') 40 | if len(parts) < 2: 41 | return None 42 | 43 | src_callsign = parts[0] 44 | dest_info = parts[1].split(':') 45 | if len(dest_info) < 2: 46 | return None 47 | 48 | path = dest_info[0] 49 | data = ':'.join(dest_info[1:]) 50 | 51 | # Extract packet type 52 | packet_type = "Unknown" 53 | if data and len(data) > 0: 54 | type_char = data[0] 55 | if type_char == '!': packet_type = "Position" 56 | elif type_char == '=': packet_type = "Position+APRS" 57 | elif type_char == '/': packet_type = "Position+Timestamp" 58 | elif type_char == '@': packet_type = "Position+Timestamp+APRS" 59 | elif type_char == ':': packet_type = "Message" 60 | elif type_char == '>': packet_type = "Status" 61 | elif type_char == '<': packet_type = "Capabilities" 62 | elif type_char == '`': packet_type = "MIC-E Data" 63 | elif type_char == "'": packet_type = "MIC-E Data (Old)" 64 | elif type_char == ')' or type_char == '`': packet_type = "Item" 65 | elif type_char == '}': packet_type = "Third-Party" 66 | elif type_char == 'T': packet_type = "Telemetry" 67 | 68 | # Check for WIDE path usage 69 | wide_paths = [] 70 | for p in path.split(','): 71 | if p.startswith('WIDE'): 72 | wide_paths.append(p) 73 | 74 | # Check for potentially encrypted data (basic heuristic) 75 | possibly_encrypted = bool(re.search(r'{.*}', data)) 76 | 77 | return { 78 | "source": src_callsign, 79 | "path": path, 80 | "type": packet_type, 81 | "data": data, 82 | "wide_paths": wide_paths, 83 | "possibly_encrypted": possibly_encrypted, 84 | "raw": packet 85 | } 86 | 87 | def analyze_security(packets, window=60): 88 | """Perform basic security analysis on a collection of packets""" 89 | results = { 90 | "unique_stations": set(), 91 | "path_stats": defaultdict(int), 92 | "packet_types": defaultdict(int), 93 | "possibly_encrypted": 0, 94 | "spoofing_candidates": [], 95 | "packets_per_station": defaultdict(int) 96 | } 97 | 98 | for packet in packets: 99 | if not packet: 100 | continue 101 | 102 | results["unique_stations"].add(packet["source"]) 103 | for path in packet["wide_paths"]: 104 | results["path_stats"][path] += 1 105 | results["packet_types"][packet["type"]] += 1 106 | results["packets_per_station"][packet["source"]] += 1 107 | 108 | if packet["possibly_encrypted"]: 109 | results["possibly_encrypted"] += 1 110 | 111 | # Very basic spoofing detection (would need refinement in real use) 112 | if "Position" in packet["type"] and packet["source"] not in results["unique_stations"]: 113 | results["spoofing_candidates"].append(packet) 114 | 115 | return results 116 | 117 | def main(): 118 | args = parse_args() 119 | sock = connect_to_aprs_is(args) 120 | 121 | print("APRS Packet Analyzer") 122 | print("====================") 123 | print(f"Connected to {args.host}:{args.port}") 124 | print("Waiting for packets...\n") 125 | 126 | recent_packets = [] 127 | try: 128 | while True: 129 | data = sock.recv(1024) 130 | if not data: 131 | break 132 | 133 | lines = data.decode('ascii', errors='ignore').strip().split('\r\n') 134 | for line in lines: 135 | if not line or line[0] == '#': # Skip comments 136 | continue 137 | 138 | print(f"\nReceived packet: {line}") 139 | parsed = parse_packet(line) 140 | if parsed: 141 | recent_packets.append(parsed) 142 | print(f"Source: {parsed['source']}") 143 | print(f"Type: {parsed['type']}") 144 | print(f"Path: {parsed['path']}") 145 | 146 | # Keep only last 5 minutes of packets 147 | current_time = time.time() 148 | recent_packets = [p for p in recent_packets if p.get("timestamp", current_time) > current_time - 300] 149 | 150 | # Every 30 seconds, perform security analysis 151 | if len(recent_packets) % 10 == 0: 152 | print("\n--- Security Analysis ---") 153 | analysis = analyze_security(recent_packets) 154 | print(f"Unique stations: {len(analysis['unique_stations'])}") 155 | print(f"Top paths: {dict(sorted(analysis['path_stats'].items(), key=lambda x: x[1], reverse=True)[:3])}") 156 | print(f"Packet types: {dict(analysis['packet_types'])}") 157 | print(f"Possibly encrypted packets: {analysis['possibly_encrypted']}") 158 | if analysis['spoofing_candidates']: 159 | print(f"Potential spoofing candidates: {len(analysis['spoofing_candidates'])}") 160 | print("------------------------\n") 161 | 162 | except KeyboardInterrupt: 163 | print("\nExiting...") 164 | finally: 165 | sock.close() 166 | 167 | if __name__ == "__main__": 168 | main() 169 | -------------------------------------------------------------------------------- /2025-bsidessf/meshtastic_visualizer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # meshtastic_visualizer.py - Visualize Meshtastic network in real-time 3 | 4 | import time 5 | import sys 6 | import json 7 | import argparse 8 | import threading 9 | from collections import defaultdict, deque 10 | 11 | import meshtastic 12 | from meshtastic import portnums_pb2, mesh_pb2 13 | from pubsub import pub 14 | import matplotlib.pyplot as plt 15 | import matplotlib.animation as animation 16 | import networkx as nx 17 | 18 | class MeshtasticVisualizer: 19 | def __init__(self, port="/dev/ttyUSB0", history_length=100): 20 | self.port = port 21 | self.nodes = {} # node_id -> node info 22 | self.connections = defaultdict(set) # node_id -> set of connected node_ids 23 | self.messages = deque(maxlen=history_length) 24 | self.routes = {} # destination -> next_hop 25 | self.lock = threading.Lock() 26 | self.fig, (self.ax1, self.ax2) = plt.subplots(1, 2, figsize=(14, 7)) 27 | self.graph = nx.Graph() 28 | 29 | # Setup plot titles 30 | self.ax1.set_title("Meshtastic Network Topology") 31 | self.ax2.set_title("Message Statistics") 32 | 33 | # Initialize interface in a separate thread to avoid blocking 34 | self.interface = None 35 | self.connected = False 36 | threading.Thread(target=self._connect_interface, daemon=True).start() 37 | 38 | def _connect_interface(self): 39 | """Connect to Meshtastic device""" 40 | try: 41 | self.interface = meshtastic.SerialInterface(self.port) 42 | self.connected = True 43 | 44 | # Subscribe to all relevant events 45 | pub.subscribe(self.on_node_updated, "meshtastic.node.updated") 46 | pub.subscribe(self.on_message, "meshtastic.receive") 47 | pub.subscribe(self.on_route_discovery, "meshtastic.route.discovery") 48 | 49 | print(f"Connected to Meshtastic device on {self.port}") 50 | except Exception as e: 51 | print(f"Error connecting to Meshtastic device: {e}") 52 | 53 | def on_node_updated(self, node): 54 | """Callback when node information is updated""" 55 | with self.lock: 56 | node_id = node["num"] 57 | self.nodes[node_id] = { 58 | "id": node_id, 59 | "user": node.get("user", {}).get("longName", f"Node {node_id}"), 60 | "position": node.get("position", {}), 61 | "last_heard": time.time() 62 | } 63 | 64 | # Update connections based on SNR reports 65 | if "snr" in node: 66 | for other_id, snr in node["snr"].items(): 67 | if snr > -10: # Only consider decent connections 68 | self.connections[node_id].add(other_id) 69 | self.connections[other_id].add(node_id) 70 | 71 | def on_message(self, packet, interface): 72 | """Callback when a message is received""" 73 | with self.lock: 74 | from_id = packet.get("fromId", "unknown") 75 | to_id = packet.get("toId", "broadcast") 76 | channel = packet.get("channel", 0) 77 | port_num = packet.get("portnum", portnums_pb2.UNKNOWN_APP) 78 | encrypted = packet.get("encrypted", False) 79 | 80 | # Get port name from enum 81 | port_name = "UNKNOWN" 82 | for name, value in vars(portnums_pb2).items(): 83 | if isinstance(value, int) and value == port_num: 84 | port_name = name 85 | break 86 | 87 | message_info = { 88 | "from_id": from_id, 89 | "to_id": to_id, 90 | "channel": channel, 91 | "port": port_name, 92 | "encrypted": encrypted, 93 | "time": time.time(), 94 | "hops": packet.get("hopLimit", 3) - packet.get("hopStart", 0) 95 | } 96 | 97 | self.messages.append(message_info) 98 | 99 | def on_route_discovery(self, route): 100 | """Callback when route discovery information is received""" 101 | with self.lock: 102 | for dest, next_hop in route.items(): 103 | self.routes[dest] = next_hop 104 | 105 | def update_graph(self, _): 106 | """Update the network graph visualization""" 107 | with self.lock: 108 | self.ax1.clear() 109 | self.ax1.set_title("Meshtastic Network Topology") 110 | 111 | # Create graph 112 | G = nx.Graph() 113 | 114 | # Add nodes 115 | for node_id, node in self.nodes.items(): 116 | # Only show nodes heard in the last 10 minutes 117 | if time.time() - node.get("last_heard", 0) < 600: 118 | G.add_node(node_id, label=node.get("user", f"Node {node_id}")) 119 | 120 | # Add edges 121 | for node_id, connections in self.connections.items(): 122 | for other_id in connections: 123 | if node_id in G.nodes and other_id in G.nodes: 124 | G.add_edge(node_id, other_id) 125 | 126 | # Position nodes using spring layout 127 | pos = nx.spring_layout(G) 128 | 129 | # Draw nodes 130 | nx.draw_networkx_nodes(G, pos, ax=self.ax1, node_size=500) 131 | 132 | # Draw edges 133 | nx.draw_networkx_edges(G, pos, ax=self.ax1) 134 | 135 | # Draw labels 136 | labels = {node: data.get('label', f"Node {node}") 137 | for node, data in G.nodes(data=True)} 138 | nx.draw_networkx_labels(G, pos, labels=labels, ax=self.ax1) 139 | 140 | # Update message statistics 141 | self.ax2.clear() 142 | self.ax2.set_title("Message Statistics") 143 | 144 | # Count message types 145 | port_counts = defaultdict(int) 146 | encrypted_count = 0 147 | unencrypted_count = 0 148 | 149 | for msg in self.messages: 150 | port_counts[msg["port"]] += 1 151 | if msg["encrypted"]: 152 | encrypted_count += 1 153 | else: 154 | unencrypted_count += 1 155 | 156 | # Plot port type distribution 157 | if port_counts: 158 | ports = list(port_counts.keys()) 159 | counts = list(port_counts.values()) 160 | self.ax2.bar(ports, counts) 161 | self.ax2.set_ylabel("Message Count") 162 | self.ax2.set_xlabel("Port Type") 163 | self.ax2.tick_params(axis='x', rotation=45) 164 | 165 | # Add encryption statistics as text 166 | total = encrypted_count + unencrypted_count 167 | if total > 0: 168 | self.ax2.text(0.05, 0.95, 169 | f"Encrypted: {encrypted_count} ({encrypted_count/total:.1%})\n" 170 | f"Unencrypted: {unencrypted_count} ({unencrypted_count/total:.1%})", 171 | transform=self.ax2.transAxes, 172 | verticalalignment='top', 173 | bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5)) 174 | 175 | return G, 176 | 177 | def start_visualization(self): 178 | """Start the visualization animation""" 179 | ani = animation.FuncAnimation(self.fig, self.update_graph, interval=2000) 180 | plt.tight_layout() 181 | plt.show() 182 | 183 | def main(): 184 | parser = argparse.ArgumentParser(description="Meshtastic Network Visualizer") 185 | parser.add_argument("--port", default="/dev/ttyUSB0", 186 | help="Serial port for Meshtastic device") 187 | args = parser.parse_args() 188 | 189 | visualizer = MeshtasticVisualizer(port=args.port) 190 | visualizer.start_visualization() 191 | 192 | if __name__ == "__main__": 193 | main() 194 | -------------------------------------------------------------------------------- /2025-bsidessf/meshtastic_key_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # meshtastic_key_demo.py - Demonstrate Meshtastic key management 3 | 4 | import sys 5 | import time 6 | import argparse 7 | import threading 8 | import binascii 9 | import random 10 | import string 11 | from datetime import datetime 12 | 13 | import meshtastic 14 | from meshtastic import portnums_pb2 15 | from pubsub import pub 16 | 17 | class KeyManagementDemo: 18 | def __init__(self, port="/dev/ttyUSB0"): 19 | self.port = port 20 | self.interface = None 21 | self.connected = False 22 | self.messages = [] 23 | self.lock = threading.Lock() 24 | 25 | def connect(self): 26 | """Connect to Meshtastic device""" 27 | try: 28 | print(f"Connecting to Meshtastic device on {self.port}...") 29 | self.interface = meshtastic.SerialInterface(self.port) 30 | self.connected = True 31 | 32 | # Subscribe to message receipts 33 | pub.subscribe(self.on_message, "meshtastic.receive") 34 | pub.subscribe(self.on_node_updated, "meshtastic.node.updated") 35 | 36 | # Wait for interface to gather node data 37 | time.sleep(2) 38 | 39 | print(f"Connected to Meshtastic device: {self.interface.myInfo.my_node_num}") 40 | return True 41 | except Exception as e: 42 | print(f"Error connecting to Meshtastic device: {e}") 43 | return False 44 | 45 | def on_message(self, packet, interface): 46 | """Handle incoming messages""" 47 | with self.lock: 48 | from_id = packet.get("fromId", "?") 49 | to_id = packet.get("toId", "broadcast") 50 | port_num = packet.get("portnum", portnums_pb2.UNKNOWN_APP) 51 | channel = packet.get("channel", 0) 52 | 53 | # Get port name from enum 54 | port_name = "UNKNOWN" 55 | for name, value in vars(portnums_pb2).items(): 56 | if isinstance(value, int) and value == port_num: 57 | port_name = name 58 | break 59 | 60 | # Get actual payload bytes 61 | payload = packet.get("decoded", {}).get("payload", b"") 62 | 63 | # Format message info 64 | message_info = { 65 | "time": datetime.now().strftime("%H:%M:%S"), 66 | "from": f"0x{from_id:x}" if isinstance(from_id, int) else from_id, 67 | "to": f"0x{to_id:x}" if isinstance(to_id, int) else to_id, 68 | "port": port_name, 69 | "channel": channel, 70 | "encrypted": packet.get("encrypted", False), 71 | "payload": payload, 72 | "payload_hex": binascii.hexlify(payload).decode('ascii') if payload else "", 73 | "text": payload.decode('utf-8', errors='replace') if payload else "" 74 | } 75 | 76 | self.messages.append(message_info) 77 | 78 | # Print message information 79 | print(f"\nMessage received at {message_info['time']}:") 80 | print(f" From: {message_info['from']}") 81 | print(f" To: {message_info['to']}") 82 | print(f" Port: {message_info['port']}") 83 | print(f" Channel: {message_info['channel']}") 84 | print(f" Encrypted: {message_info['encrypted']}") 85 | 86 | if message_info["text"]: 87 | print(f" Text: {message_info['text']}") 88 | 89 | if message_info["payload_hex"]: 90 | print(f" Hex: {message_info['payload_hex']}") 91 | 92 | def on_node_updated(self, node): 93 | """Handle node updates""" 94 | node_num = node.get("num", "?") 95 | user = node.get("user", {}) 96 | position = node.get("position", {}) 97 | 98 | print(f"\nNode updated: 0x{node_num:x}" if isinstance(node_num, int) else f"Node updated: {node_num}") 99 | 100 | if user: 101 | print(f" ID: {user.get('id', '?')}") 102 | print(f" Short name: {user.get('shortName', '?')}") 103 | print(f" Long name: {user.get('longName', '?')}") 104 | 105 | if position: 106 | print(f" Latitude: {position.get('latitude', '?')}") 107 | print(f" Longitude: {position.get('longitude', '?')}") 108 | 109 | def show_device_config(self): 110 | """Display the device configuration""" 111 | if not self.connected: 112 | print("Not connected to any device") 113 | return 114 | 115 | print("\nDevice Configuration:") 116 | 117 | # Show channels 118 | channels = self.interface.getChannelList() 119 | print("\nChannels:") 120 | for i, channel in enumerate(channels): 121 | if channel.get("role") != "DISABLED": 122 | print(f" Channel {i}:") 123 | print(f" Name: {channel.get('name', 'Unnamed')}") 124 | print(f" Role: {channel.get('role', 'PRIMARY')}") 125 | print(f" Modem config: {channel.get('psk', 'DEFAULT')}") 126 | 127 | # Show PSK if available (in a secure way) 128 | psk = channel.get("psk") 129 | if psk: 130 | psk_hex = binascii.hexlify(psk).decode('ascii') 131 | print(f" PSK: {psk_hex[:4]}...{psk_hex[-4:]} ({len(psk)} bytes)") 132 | else: 133 | print(" PSK: None (unencrypted)") 134 | 135 | def generate_random_key(self, size=32): 136 | """Generate a random encryption key""" 137 | return bytes(random.randint(0, 255) for _ in range(size)) 138 | 139 | def set_channel_config(self, index, name, psk=None): 140 | """Configure a specific channel""" 141 | if not self.connected: 142 | print("Not connected to any device") 143 | return False 144 | 145 | try: 146 | # Get existing channel settings 147 | channels = self.interface.getChannelList() 148 | 149 | # Prepare new channel settings 150 | channel_settings = { 151 | "role": "PRIMARY" if index == 0 else "SECONDARY", 152 | "name": name 153 | } 154 | 155 | # Add PSK if provided 156 | if psk: 157 | channel_settings["psk"] = psk 158 | 159 | # Update the channel 160 | self.interface.setChannel(index, channel_settings) 161 | print(f"Channel {index} configured successfully") 162 | return True 163 | except Exception as e: 164 | print(f"Error configuring channel: {e}") 165 | return False 166 | 167 | def send_text_message(self, text, channel=0): 168 | """Send a text message on the specified channel""" 169 | if not self.connected: 170 | print("Not connected to any device") 171 | return False 172 | 173 | try: 174 | self.interface.sendText(text, destinationId=None, wantAck=True, channelIndex=channel) 175 | print(f"Message sent on channel {channel}: {text}") 176 | return True 177 | except Exception as e: 178 | print(f"Error sending message: {e}") 179 | return False 180 | 181 | def run_demo(self): 182 | """Run the key management demonstration""" 183 | if not self.connect(): 184 | print("Failed to connect to Meshtastic device") 185 | return 186 | 187 | print("\n=== Meshtastic Key Management Demo ===\n") 188 | 189 | # Show initial configuration 190 | self.show_device_config() 191 | 192 | # Demo menu 193 | while True: 194 | print("\nOptions:") 195 | print("1. Configure unencrypted channel") 196 | print("2. Configure encrypted channel with random key") 197 | print("3. Configure encrypted channel with specific key") 198 | print("4. Send message on primary channel") 199 | print("5. Send message on secondary channel") 200 | print("6. Show device configuration") 201 | print("7. Exit") 202 | 203 | choice = input("\nSelect option (1-7): ") 204 | 205 | if choice == "1": 206 | name = input("Enter channel name: ") 207 | self.set_channel_config(1, name) 208 | 209 | elif choice == "2": 210 | name = input("Enter channel name: ") 211 | key = self.generate_random_key() 212 | self.set_channel_config(1, name, key) 213 | print(f"Generated random key: {binascii.hexlify(key).decode('ascii')}") 214 | 215 | elif choice == "3": 216 | name = input("Enter channel name: ") 217 | hex_key = input("Enter hex key (64 characters for 32 bytes): ") 218 | try: 219 | key = binascii.unhexlify(hex_key) 220 | self.set_channel_config(1, name, key) 221 | except: 222 | print("Invalid hex key format") 223 | 224 | elif choice == "4": 225 | text = input("Enter message for primary channel: ") 226 | self.send_text_message(text, 0) 227 | 228 | elif choice == "5": 229 | text = input("Enter message for secondary channel: ") 230 | self.send_text_message(text, 1) 231 | 232 | elif choice == "6": 233 | self.show_device_config() 234 | 235 | elif choice == "7": 236 | print("Exiting...") 237 | break 238 | 239 | else: 240 | print("Invalid option") 241 | 242 | def main(): 243 | parser = argparse.ArgumentParser(description="Meshtastic Key Management Demo") 244 | parser.add_argument("--port", default="/dev/ttyUSB0", help="Serial port for Meshtastic device") 245 | args = parser.parse_args() 246 | 247 | demo = KeyManagementDemo(port=args.port) 248 | demo.run_demo() 249 | 250 | if __name__ == "__main__": 251 | main() 252 | -------------------------------------------------------------------------------- /2025-bsidessf/aprs_decoder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # aprs_decoder.py - Decode APRS packet data into human-readable format 3 | # 4 | # Note for zsh users: The '!' character is special in zsh and gets expanded. 5 | # Use one of these approaches: 6 | # 1. Quote the packet string: ./aprs_decoder.py 'N0CALL>APU25N:!4237.14N...' 7 | # 2. Escape the ! character: ./aprs_decoder.py N0CALL>APU25N:\!4237.14N... 8 | # 3. Use single-quoted heredoc: ./aprs_decoder.py <<< 'N0CALL>APU25N:!4237.14N...' 9 | # 4. Disable zsh globbing: set -o noglob (then run command, then set +o noglob) 10 | 11 | import sys 12 | import argparse 13 | import json 14 | import re 15 | from datetime import datetime 16 | 17 | 18 | def parse_args(): 19 | """parse command line arguments""" 20 | parser = argparse.ArgumentParser(description='decode aprs packet data') 21 | parser.add_argument('packet', nargs='?', help='aprs packet string to decode') 22 | parser.add_argument('-j', '--json', action='store_true', 23 | help='output in json format') 24 | parser.add_argument('-f', '--fields', nargs='+', 25 | help='specific fields to output (source, destination, path, type, data)') 26 | parser.add_argument('-i', '--input-file', 27 | help='read packet from file instead of argument/stdin') 28 | parser.add_argument('-e', '--escape-shell', action='store_true', 29 | help='escape ! characters in output for shell compatibility') 30 | parser.add_argument('-b', '--batch', action='store_true', 31 | help='process multiple packets (one per line) from stdin or file') 32 | parser.add_argument('-a', '--array', action='store_true', 33 | help='with --json and --batch, output a JSON array instead of one JSON object per line') 34 | parser.add_argument('-v', '--verbose', action='store_true', 35 | help='show verbose debugging information') 36 | return parser.parse_args() 37 | 38 | 39 | def decode_position(position_data): 40 | """decode aprs position data""" 41 | # Create a default result dict to ensure we always return something valid 42 | result = { 43 | "error": None, 44 | "latitude": None, 45 | "longitude": None, 46 | "symbol_table": None, 47 | "symbol_code": None, 48 | "comment": "" 49 | } 50 | 51 | # Check if we have enough data to parse a position 52 | if len(position_data) < 19: 53 | result["error"] = "invalid position format" 54 | return result 55 | 56 | # Parse latitude 57 | try: 58 | lat_deg = int(position_data[0:2]) 59 | lat_min = float(position_data[2:7]) 60 | lat_dir = position_data[7] 61 | 62 | # Parse longitude 63 | lon_deg = int(position_data[9:12]) 64 | lon_min = float(position_data[12:17]) 65 | lon_dir = position_data[17] 66 | 67 | # Symbol code 68 | symbol_table = position_data[8] 69 | symbol_code = position_data[18] 70 | 71 | # Calculate decimal degrees 72 | latitude = lat_deg + (lat_min / 60.0) 73 | if lat_dir == 'S': 74 | latitude = -latitude 75 | 76 | longitude = lon_deg + (lon_min / 60.0) 77 | if lon_dir == 'W': 78 | longitude = -longitude 79 | 80 | # Get comment if any 81 | comment = position_data[19:] if len(position_data) > 19 else "" 82 | 83 | # Update result with valid data 84 | result.update({ 85 | "latitude": round(latitude, 6), 86 | "longitude": round(longitude, 6), 87 | "symbol_table": symbol_table, 88 | "symbol_code": symbol_code, 89 | "comment": comment 90 | }) 91 | 92 | except (ValueError, IndexError) as e: 93 | result["error"] = f"could not parse position data: {str(e)}" 94 | 95 | return result 96 | 97 | 98 | def decode_message(message_data): 99 | """decode aprs message data""" 100 | # Default result structure 101 | result = { 102 | "error": None, 103 | "addressee": None, 104 | "content": None, 105 | "message_id": None 106 | } 107 | 108 | # Message format: :ADDRESSEE:Message text{nn 109 | match = re.match(r'^:([^:]+):(.+?)(?:{([0-9A-Z]+))?$', message_data) 110 | if not match: 111 | result["error"] = "invalid message format" 112 | return result 113 | 114 | addressee = match.group(1).strip() 115 | content = match.group(2) 116 | msgid = match.group(3) if match.group(3) else None 117 | 118 | result.update({ 119 | "addressee": addressee, 120 | "content": content, 121 | "message_id": msgid 122 | }) 123 | 124 | return result 125 | 126 | 127 | def decode_status(status_data): 128 | """decode aprs status data""" 129 | # Status format: >status_text 130 | return {"error": None, "status": status_data} 131 | 132 | 133 | def decode_telemetry(telemetry_data): 134 | """decode basic telemetry data""" 135 | # Basic telemetry handling 136 | return {"error": None, "telemetry_raw": telemetry_data} 137 | 138 | 139 | def parse_packet(packet): 140 | """parse an aprs packet and extract key information""" 141 | # Default structure to ensure we always return valid data 142 | result = { 143 | "source": "unknown", 144 | "destination": "unknown", 145 | "path": [], 146 | "type": "unknown", 147 | "data": {"raw": packet, "error": None}, 148 | "raw": packet, 149 | "error": None 150 | } 151 | 152 | try: 153 | # Split into source, path+destination, and data 154 | parts = packet.split('>') 155 | if len(parts) < 2: 156 | result["error"] = "invalid packet format" 157 | return result 158 | 159 | result["source"] = parts[0] 160 | 161 | # Split path and data 162 | remainder = parts[1].split(':', 1) 163 | if len(remainder) < 2: 164 | result["error"] = "invalid packet format - no data section" 165 | result["destination"] = remainder[0] if remainder else "unknown" 166 | return result 167 | 168 | path_dest = remainder[0] 169 | data = remainder[1] 170 | 171 | # Parse path components 172 | path_components = path_dest.split(',') 173 | result["destination"] = path_components[0] if path_components else "" 174 | result["path"] = path_components[1:] if len(path_components) > 1 else [] 175 | 176 | # Determine packet type and decode data 177 | result["data"] = {"raw": data} 178 | 179 | if not data: 180 | result["type"] = "unknown" 181 | elif data[0] == '!': 182 | result["type"] = "position" 183 | result["data"].update(decode_position(data[1:])) 184 | elif data[0] == '=': 185 | result["type"] = "position_with_timestamp" 186 | result["data"].update(decode_position(data[1:])) 187 | elif data[0] == '/': 188 | result["type"] = "position_with_timestamp" 189 | # Skip timestamp (7 chars) and decode position 190 | if len(data) > 8: 191 | result["data"].update(decode_position(data[8:])) 192 | else: 193 | result["data"]["error"] = "position data missing" 194 | elif data[0] == '@': 195 | result["type"] = "position_with_timestamp_messaging" 196 | # Skip timestamp (7 chars) and decode position 197 | if len(data) > 8: 198 | result["data"].update(decode_position(data[8:])) 199 | else: 200 | result["data"]["error"] = "position data missing" 201 | elif data[0] == ':': 202 | result["type"] = "message" 203 | result["data"].update(decode_message(data)) 204 | elif data[0] == '>': 205 | result["type"] = "status" 206 | result["data"].update(decode_status(data[1:])) 207 | elif data[0] == 'T': 208 | result["type"] = "telemetry" 209 | result["data"].update(decode_telemetry(data[1:])) 210 | else: 211 | # Try to guess if it's a position report missing the prefix 212 | if len(data) >= 19 and re.match(r'^[0-9]', data) and data[7] in 'NS' and data[17] in 'EW': 213 | result["type"] = "position_possible_missing_prefix" 214 | result["data"].update(decode_position(data)) 215 | else: 216 | result["type"] = "unknown" 217 | result["data"]["format"] = "unrecognized" 218 | 219 | except Exception as e: 220 | result["error"] = f"parsing error: {str(e)}" 221 | 222 | return result 223 | 224 | 225 | def format_output(parsed_data, as_json=False, fields=None): 226 | """format the output based on command line options""" 227 | if as_json: 228 | return json.dumps(parsed_data, indent=2) 229 | 230 | # If specific fields requested, only include those 231 | if fields: 232 | output = [] 233 | for field in fields: 234 | if field in parsed_data: 235 | if field == 'data': 236 | # Data is a nested dict, so we'll use json for that 237 | output.append(f"{field}: {json.dumps(parsed_data[field])}") 238 | else: 239 | output.append(f"{field}: {parsed_data[field]}") 240 | return "\n".join(output) 241 | 242 | # Default text output 243 | lines = [] 244 | if parsed_data["error"]: 245 | lines.append(f"error: {parsed_data['error']}") 246 | 247 | lines.append(f"source: {parsed_data['source']}") 248 | lines.append(f"destination: {parsed_data['destination']}") 249 | lines.append(f"path: {','.join(parsed_data['path'])}") 250 | lines.append(f"type: {parsed_data['type']}") 251 | 252 | # Format data section based on packet type 253 | data = parsed_data["data"] 254 | if "error" in data and data["error"]: 255 | lines.append(f"data_error: {data['error']}") 256 | 257 | if parsed_data["type"] in ["position", "position_with_timestamp", "position_possible_missing_prefix"]: 258 | if "latitude" in data and data["latitude"] is not None: 259 | lines.append(f"latitude: {data['latitude']}") 260 | lines.append(f"longitude: {data['longitude']}") 261 | lines.append(f"symbol: {data['symbol_table']}{data['symbol_code']}") 262 | if data.get("comment"): 263 | lines.append(f"comment: {data['comment']}") 264 | 265 | elif parsed_data["type"] == "message": 266 | if "addressee" in data and data["addressee"]: 267 | lines.append(f"addressee: {data['addressee']}") 268 | lines.append(f"content: {data['content']}") 269 | if data.get("message_id"): 270 | lines.append(f"message_id: {data['message_id']}") 271 | 272 | elif parsed_data["type"] == "status" and "status" in data: 273 | lines.append(f"status: {data['status']}") 274 | 275 | elif parsed_data["type"] == "telemetry" and "telemetry_raw" in data: 276 | lines.append(f"telemetry: {data['telemetry_raw']}") 277 | 278 | else: 279 | lines.append(f"data: {data.get('raw', '')}") 280 | 281 | return "\n".join(lines) 282 | 283 | 284 | def process_packet(packet, args): 285 | """process a single packet and return formatted output""" 286 | # Handle escaped ! characters 287 | if '\\!' in packet: 288 | packet = packet.replace('\\!', '!') 289 | if args.verbose: 290 | print("note: removed backslash escapes from ! characters", file=sys.stderr) 291 | 292 | # Fix missing ! character in position reports 293 | # This happens with zsh when ! is expanded 294 | if ":" in packet and ">" in packet: 295 | parts = packet.split(':', 1) 296 | data_part = parts[1] if len(parts) > 1 else "" 297 | if re.match(r'^[0-9]', data_part) and len(data_part) >= 19: 298 | if data_part[7] in 'NS' and data_part[17] in 'EW': 299 | packet = parts[0] + ':!' + data_part 300 | if args.verbose: 301 | print("note: detected position report missing prefix, added !", file=sys.stderr) 302 | 303 | # Parse the packet 304 | parsed = parse_packet(packet) 305 | 306 | # Format the output 307 | output = format_output(parsed, args.json, args.fields) 308 | 309 | # Escape ! characters in output if requested 310 | if args.escape_shell and not args.json: 311 | output = output.replace('!', '\\!') 312 | 313 | return output 314 | 315 | 316 | def main(): 317 | args = parse_args() 318 | 319 | # Handle batch mode 320 | if args.batch: 321 | process_batch(args) 322 | return 323 | 324 | # Get input from file, args, or stdin (in that priority) 325 | if args.input_file: 326 | try: 327 | with open(args.input_file, 'r') as f: 328 | packet = f.read().strip() 329 | except IOError as e: 330 | print(f"error: could not read input file: {e}", file=sys.stderr) 331 | sys.exit(1) 332 | elif args.packet: 333 | packet = args.packet.strip() 334 | else: 335 | # Read from stdin 336 | packet = sys.stdin.read().strip() 337 | 338 | if not packet: 339 | print("error: no packet data provided", file=sys.stderr) 340 | sys.exit(1) 341 | 342 | # Process and output 343 | output = process_packet(packet, args) 344 | print(output) 345 | 346 | 347 | def process_batch(args): 348 | """Process multiple packets, one per line""" 349 | # Get input from file or stdin 350 | if args.input_file: 351 | try: 352 | with open(args.input_file, 'r') as f: 353 | lines = f.readlines() 354 | except IOError as e: 355 | print(f"error: could not read input file: {e}", file=sys.stderr) 356 | sys.exit(1) 357 | else: 358 | # Read from stdin 359 | lines = sys.stdin.readlines() 360 | 361 | if not lines: 362 | print("error: no packet data provided", file=sys.stderr) 363 | sys.exit(1) 364 | 365 | # Parse each line as a packet 366 | results = [] 367 | for line in lines: 368 | line = line.strip() 369 | if not line or line.startswith('#'): # Skip empty lines and comments 370 | continue 371 | 372 | # Parse the packet 373 | parsed = parse_packet(line) 374 | 375 | if args.json: 376 | if args.array: 377 | # Collect results for JSON array output 378 | results.append(parsed) 379 | else: 380 | # Print each result as a JSON object 381 | output = json.dumps(parsed) 382 | print(output) 383 | else: 384 | # Text output for each packet 385 | output = format_output(parsed, False, args.fields) 386 | if args.escape_shell: 387 | output = output.replace('!', '\\!') 388 | print(output) 389 | print("---") # Separator between packets 390 | 391 | # If JSON array output is requested, print all results at once 392 | if args.json and args.array and results: 393 | print(json.dumps(results, indent=2)) 394 | 395 | 396 | if __name__ == "__main__": 397 | main() 398 | --------------------------------------------------------------------------------