MeshCore Decoder

August 1, 2026 · View on GitHub

A TypeScript library for decoding MeshCore mesh networking packets with full cryptographic support.

The upstream library powers the MeshCore Packet Analyzer.

About this fork

This is a fork of michaelhart/meshcore-decoder (v0.3.0) with the following changes:

  • Pure-JS crypto, no WASM: crypto-js and the orlp/ed25519 WebAssembly module are replaced by the tiny, audited @noble packages (@noble/hashes, @noble/ciphers, @noble/ed25519). Output is byte-for-byte identical; the git submodule, build toolchain and the 57 KB .wasm are gone. A browser bundle drops from ~375 KB to ~60 KB (19 KB gzipped) and no special WASM setup is needed anymore.
  • New payload decoders: GroupData (0x06), Multipart (0x0A) and RawCustom (0x0F) are decoded instead of being left as raw fallback.
  • Firmware 1.16.0 ACKs: the trailing bytes of the new 6-byte ACK are surfaced as AckPayload.extraData.
  • Channel-decryption convenience: MeshCoreDecoder.PUBLIC_CHANNEL_KEY, MeshCoreDecoder.addChannelKey(hex) and a default key store (empty by default), plus a lean browser entry (src/browser.ts) for bundlers.
  • Path payloads decoded correctly: a PATH payload is the same encrypted envelope as Request/Response (destination_hash + source_hash + cipher_mac + ciphertext, the returned path lives inside the ciphertext). The upstream decoder applied the decrypted body's layout to the outer payload, so real-world Path packets failed with bogus errors. PathPayload now exposes destinationHash / sourceHash / cipherMac / ciphertext.
  • Spec-alignment fixes (per docs/packet_format.md + docs/payloads.md):
    • a bare 100-byte advert (pubkey + timestamp + signature) is valid — appData is optional, as the spec says;
    • Request no longer fabricates timestamp: 0 / requestType: GetStats (they live inside the ciphertext and are absent unless decrypted);
    • packets with an unsupported payload version (header bits 6–7 ≠ v1) keep their payload raw with an explicit error instead of being silently misparsed;
    • DISCOVER_RESP requires the documented pubkey length (exactly 8 or 32 bytes);
    • TRACE packets no longer expose the per-hop SNR bytes from the packet header as path (they are SNR readings, not node hashes — see payload.decoded.snrValues); path is null for TRACE.

Features

  • Packet Decoding: Decode MeshCore packets
  • Built-in Decryption: Decrypt GroupText, TextMessage, and other encrypted payloads
  • Developer Friendly: TypeScript-first with full type safety and portability of JavaScript

Installation

This fork is not published on npm; install it straight from GitHub (the prepare script builds it on install):

Install to a single project

npm install github:kybl/meshcore-decoder

Install CLI (install globally)

npm install -g github:kybl/meshcore-decoder

Quick Start

import { 
  MeshCoreDecoder, 
  PayloadType,
  Utils,
  DecodedPacket,
  AdvertPayload 
} from '@kybl/meshcore-decoder';

// Decode a MeshCore packet
const hexData: string = '11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172';

const packet: DecodedPacket = MeshCoreDecoder.decode(hexData);

console.log(`Route Type: ${Utils.getRouteTypeName(packet.routeType)}`);
console.log(`Payload Type: ${Utils.getPayloadTypeName(packet.payloadType)}`);
console.log(`Message Hash: ${packet.messageHash}`);

if (packet.payloadType === PayloadType.Advert && packet.payload.decoded) {
  const advert: AdvertPayload = packet.payload.decoded as AdvertPayload;
  // appData is optional — a bare advert (pubkey + timestamp + signature) has none
  if (advert.appData) {
    console.log(`Device Name: ${advert.appData.name}`);
    console.log(`Device Role: ${Utils.getDeviceRoleName(advert.appData.deviceRole)}`);
    if (advert.appData.location) {
      console.log(`Location: ${advert.appData.location.latitude}, ${advert.appData.location.longitude}`);
    }
  }
}

Full Packet Structure Example

Here's what a complete decoded packet looks like:

import { MeshCoreDecoder, DecodedPacket } from '@kybl/meshcore-decoder';

const hexData: string = '11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172';

const packet: DecodedPacket = MeshCoreDecoder.decode(hexData);

console.log(JSON.stringify(packet, null, 2));

Output:

{
  "messageHash": "F9C060FE",
  "routeType": 1,
  "payloadType": 4,
  "payloadVersion": 0,
  "pathLength": 0,
  "path": null,
  "payload": {
    "raw": "7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172",
    "decoded": {
      "type": 4,
      "version": 0,
      "isValid": true,
      "publicKey": "7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C9400",
      "timestamp": 1758455660,
      "signature": "2E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E609",
      "appData": {
        "flags": 146,
        "deviceRole": 2,
        "hasLocation": true,
        "hasName": true,
        "location": {
          "latitude": 47.543968,
          "longitude": -122.108616
        },
        "name": "WW7STR/PugetMesh Cougar"
      }
    }
  },
  "totalBytes": 134,
  "isValid": true
}

Packet Support

ValueNameDescriptionDecodingDecryptionSegment Analysis
0x00RequestRequest (destination/source hashes + MAC)🚧
0x01ResponseResponse to REQ or ANON_REQ🚧
0x02Plain text messagePlain text message🚧
0x03AcknowledgmentAcknowledgmentN/A
0x04Node advertisementNode advertisementN/A
0x05Group text messageGroup text message
0x06Group datagramGroup datagram🚧
0x07Anonymous requestAnonymous request🚧
0x08Returned pathReturned path (encrypted envelope)🚧
0x09TraceTrace a path, collecting SNI for each hopN/A
0x0AMulti-part packetPacket is part of a sequence of packets🚧
0x0FCustom packetCustom packet (raw bytes, custom encryption)N/A

Legend:

  • ✅ Fully implemented
  • 🚧 Planned/In development
  • - Not applicable

Decryption of pairwise (Request/Response/TextMessage/Path) traffic requires the destination node's secret and is out of scope for a passive decoder.

Decryption Support

The quickest way is to register channel keys with the default key store (fork addition):

import { MeshCoreDecoder } from '@kybl/meshcore-decoder';

// Register the well-known public channel key (or your own secrets)
MeshCoreDecoder.addChannelKey(MeshCoreDecoder.PUBLIC_CHANNEL_KEY);

// Subsequent decode() calls decrypt matching GroupText messages automatically
const packet = MeshCoreDecoder.decode(groupTextHexData);

Alternatively, provide an explicit key store per call:

import { 
  MeshCoreDecoder, 
  PayloadType,
  CryptoKeyStore,
  DecodedPacket,
  GroupTextPayload 
} from '@kybl/meshcore-decoder';

// Create a key store with channel secret keys
const keyStore: CryptoKeyStore = MeshCoreDecoder.createKeyStore({
  channelSecrets: [
    '8b3387e9c5cdea6ac9e5edbaa115cd72', // Public channel (channel hash 11)
    'ff2b7d74e8d20f71505bda9ea8d59a1c', // A different channel's secret
  ]
});

const groupTextHexData: string = '...'; // Your encrypted GroupText packet hex

// Decode encrypted GroupText message
const encryptedPacket: DecodedPacket = MeshCoreDecoder.decode(groupTextHexData, { keyStore });

if (encryptedPacket.payloadType === PayloadType.GroupText && encryptedPacket.payload.decoded) {
  const groupText: GroupTextPayload = encryptedPacket.payload.decoded as GroupTextPayload;
  
  if (groupText.decrypted) {
    console.log(`Sender: ${groupText.decrypted.sender}`);
    console.log(`Message: ${groupText.decrypted.message}`);
    console.log(`Timestamp: ${new Date(groupText.decrypted.timestamp * 1000).toISOString()}`);
  } else {
    console.log('Message encrypted (no key available)');
  }
}

The library automatically:

  • Calculates channel hashes from your secret keys using SHA256
  • Handles hash collisions (multiple keys with same first byte) by trying all matching keys
  • Verifies message authenticity using HMAC-SHA256
  • Decrypts using AES-128 ECB

Packet Structure Analysis

For detailed packet analysis and debugging, use analyzeStructure() to get byte-level breakdowns:

import { MeshCoreDecoder, PacketStructure } from '@kybl/meshcore-decoder';

console.log('=== Packet Breakdown ===');
const hexData: string = '11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172';

console.log('Packet length:', hexData.length);
console.log('Expected bytes:', hexData.length / 2);

const structure: PacketStructure = MeshCoreDecoder.analyzeStructure(hexData);
console.log('\nMain segments:');
structure.segments.forEach((seg, i) => {
  console.log(`${i+1}. ${seg.name} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
});

console.log('\nPayload segments:');
structure.payload.segments.forEach((seg, i) => {
  console.log(`${i+1}. ${seg.name} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
  console.log(`   Description: ${seg.description}`);
});

Regions (transport codes)

MeshCore repeaters use regions (e.g. #Europe, *) to control which packets are flooded or blocked. Region information is not inside the GroupText (or other) payload; it is carried in the packet header as transport codes when the route type is Transport flood (0x00) or Transport direct (0x03).

The decoder already extracts these:

  • transportCodes on DecodedPacket: [number, number] when present.
    • transportCodes[0]: region/scope for this packet (repeaters match this to a named region).
    • transportCodes[1]: sender’s return/home region (used for replies).

Use packet.transportCodes for group chat (and any other) packets that use transport routing. With analyzeStructure(), the main segments show "Region/Scope (transport code 0)" and "Return region (transport code 1)" when present.

Calculating the region code from a region name

The transport code is derived from the region name and the packet payload (so the same region produces different codes for different packets). The library matches the MeshCore firmware logic:

  • Region key: first 16 bytes of SHA256(regionName) (e.g. "#Europe").
  • Transport code: first 2 bytes of HMAC-SHA256(regionKey, payloadType || payload) as little-endian uint16; 0 and 0xFFFF are reserved.
import {
  MeshCoreDecoder,
  calcRegionKey,
  calcTransportCodeForRegion,
  transportCodeMatchesRegion,
  PayloadType,
  hexToBytes
} from '@kybl/meshcore-decoder';

// Key for a named region (e.g. "#Europe")
const regionKey = calcRegionKey('#Europe');

// For a given packet payload, compute the transport code that would match this region
const payloadHex = packet.payload.raw; // from a decoded packet
const payloadBytes = hexToBytes(payloadHex);
const code = calcTransportCodeForRegion('#Europe', PayloadType.GroupText, payloadBytes);

// Check if a decoded packet's first transport code matches a region
if (packet.transportCodes && transportCodeMatchesRegion('#Europe', packet.payloadType, payloadHex, packet.transportCodes[0])) {
  console.log('Packet is for region #Europe');
}

Output:

=== Packet Breakdown ===
Packet length: 268
Expected bytes: 134

Main segments:
1. Header (bytes 0-0): 0x11
2. Path Length (bytes 1-1): 0x00
3. Payload (bytes 2-133): 7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172

Payload segments:
1. Public Key (bytes 0-31): 7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C9400
   Description: Ed25519 public key
2. Timestamp (bytes 32-35): 6CE7CF68
   Description: 1758455660 (2025-09-21T11:54:20Z)
3. Signature (bytes 36-99): 2E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E609
   Description: Ed25519 signature
4. App Flags (bytes 100-100): 92
   Description: Binary: 10010010 | Bits 0-3 (Role): Room server | Bit 4 (Location): Yes | Bit 5 (Feature1): No | Bit 6 (Feature2): No | Bit 7 (Name): Yes
5. Latitude (bytes 101-104): A076D502
   Description: 47.543968° (47.543968)
6. Longitude (bytes 105-108): 38C5B8F8
   Description: -122.108616° (-122.108616)
7. Node Name (bytes 109-131): 5757375354522F50756765744D65736820436F75676172
   Description: Node name: "WW7STR/PugetMesh Cougar"

The analyzeStructure() method provides:

  • Header breakdown with bit-level field analysis
  • Byte-accurate segments with start/end positions
  • Payload field parsing for all supported packet types
  • Human-readable descriptions for each field

Ed25519 Key Derivation

The library includes MeshCore-compatible Ed25519 key derivation (the orlp/ed25519 64-byte expanded-key format, implemented in pure JS via @noble/ed25519 in this fork — byte-for-byte identical to the upstream WASM build):

import { Utils } from '@kybl/meshcore-decoder';

// Derive public key from MeshCore private key (64-byte format)
const privateKey = '18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5';

const publicKey = await Utils.derivePublicKey(privateKey);
console.log('Derived Public Key:', publicKey);
// Output: 4852B69364572B52EFA1B6BB3E6D0ABED4F389A1CBFBB60A9BBA2CCE649CAF0E

// Validate a key pair
const isValid = await Utils.validateKeyPair(privateKey, publicKey);
console.log('Key pair valid:', isValid); // true

Command Line Interface

For quick analysis from the terminal, install globally and use the CLI:

# Install globally
npm install -g @kybl/meshcore-decoder

# Analyze a packet
meshcore-decoder 11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172

# With decryption (provide channel secrets)
meshcore-decoder 150011C3C1354D619BAE9590E4D177DB7EEAF982F5BDCF78005D75157D9535FA90178F785D --key 8b3387e9c5cdea6ac9e5edbaa115cd72

# Show detailed structure analysis
meshcore-decoder --structure 11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172

# JSON output
meshcore-decoder --json 11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172

# Derive public key from MeshCore private key
meshcore-decoder derive-key 18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5

# Validate key pair
meshcore-decoder derive-key 18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5 --validate 4852b69364572b52efa1b6bb3e6d0abed4f389a1cbfbb60a9bba2cce649caf0e

# Key derivation with JSON output
meshcore-decoder derive-key 18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5 --json

Using in the browser (Angular, React, ...)

The library is pure JavaScript — no WebAssembly, no special asset configuration. It works out of the box with any bundler; just import and use it. For a smaller bundle, bundlers can use the lean browser entry (src/browser.ts), which skips the CLI-only dependencies.

import { MeshCorePacketDecoder, Utils } from '@kybl/meshcore-decoder';

const packet = MeshCorePacketDecoder.decode(hexData);
const publicKey = await Utils.derivePublicKey(privateKeyHex);

Development

# Install dependencies
npm install

# Run tests
npm test

# Run tests in watch mode
npm run test:watch

# Build for production
npm run build

# Development with ts-node
npm run dev

License

MIT License

Copyright (c) 2025 Michael Hart michaelhart@michaelhart.me (https://github.com/michaelhart)

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.