Skip to content

Network key, channel names, PSKs, and channelHash

LowMesh encrypts the application tail (after the dynamic plaintext prefix: base + optional region + fragment + routing blocks) using AES-256 keys (psk32) in AES-256-CCM-4 mode (4-byte MIC), while those prefix bytes stay outside the ciphertext. That includes the default community domain channelHash == 0x0000, which uses the normative ROM key defaultCommunityPsk32 (see Channels & security). The base header still includes channelHash (LE uint16_t) so receivers can select the right key material — see Packet layout.

Asymmetric layer: Devices hold long-term Ed25519 keys in NVM; 1:1 Direct Messages use X25519 ECDH plus HKDF to derive a per-peer psk32 for AES-CCM-4 (see §5).

There is no numeric channelId on the wire. A receiver learns which crypto domain applies by matching channelHash against:

  • 0x0000: defaultCommunityPsk32 (fixed ROM key — all devices share it), and/or
  • Named channels: locally configured (channelNameUtf8, psk32) pairs (SHA256 recipe in Channels & security), and/or
  • Pairwise DM sessions: cached channelHash values derived from an X25519 ECDH shared secret (see Pairwise DM session below).

1) Network Master Key (NMK) — optional convenience root

PropertyValue
Size32 bytes
PurposeOptional root secret for a deployment; can be used to derive initial per-channel PSKs during provisioning (USB/BLE/QR).
StorageDevice NVM in wrapped form (device unique key) or secure element.

Deriving a channel PSK from NMK (informative)

If you want “one QR for the network” but still separate crypto domains per channel name:

psk32 = HKDF-SHA256(
  ikm = NMK,
  salt = "lowmesh/psk/v1" || deploymentId,
  info = uint16_le(N) || channelNameUtf8
)

This yields a stable psk32 for a given channelNameUtf8, which then yields a stable channelHash via the normative SHA256 recipe in Channels & security.

Why keep explicit PSKs anyway? Meshtastic-style deployments often want some channels to be standalone secrets (QR contains only that channel’s PSK), independent of the umbrella NMK. Firmware MAY store explicit psk32 per name without using HKDF.

2) Explicit per-channel PSKs in NVM (MeshCore™-like)

In NVM, a channel is stored at minimum as:

FieldSizeNotes
channelNameUtf81–63 bytesUTF-8, no BOM
psk32 bytesAES-256 key for that channel

The channelHash on the wire is always computed from exactly the same channelNameUtf8 + psk bytes you intend (see normative definition), then truncated to 16 bits for transmission. If two different local entries collide to the same 16-bit hash, that is a deployment error (rotate name/PSK).

3) Admin / operator channel key

Admin mesh traffic uses the fixed name lowmesh/admin and a dedicated adminChannelPsk (32 bytes). See Mesh admin & operator channel.

4) What gets encrypted

RegionAll channelHash values (including 0x0000)
Base + optional Region + optional Fragment (AAD prefix; excludes routing block)Plain on the wire; copied into CCM AAD with ttlRaw zero-masked at offset 0x04
Routing block (if ttlRaw & 0x80)Plain on the wire (excluded from CCM AAD)
Inner bytes for payloadTypeAES-CCM ciphertext + 4-byte MIC under the resolved psk32

Wrong PSK / wrong name / unknown peer yields CCM verification failure or meaningless plaintext; receivers MUST only accept plaintext after decrypt when channelHash resolves to defaultCommunityPsk32, a named channel, a cached DM session, or another documented key source. Unknown hashes follow DeviceRole::relayCommunityTraffic for relay behavior — see Embedded storage.

Key rotation

  1. Distribute a new psk32 (or new channelNameUtf8, if you treat names as disposable) out-of-band.
  2. Nodes MAY accept both (name,psk) variants during an overlap window (implementation-defined).
  3. Bump any visible epoch fields in higher-layer payloads when those exist.

Relationship to USB provisioning

NETWORK_KEY_SET / channel provisioning commands (see USB & BLE hex API) SHOULD write channelNameUtf8 + psk32 pairs (not a wire channelId). Never echo full keys in logs.


5) Pairwise DM session (X25519 + HKDF) — normative

LowMesh adds 1:1 Direct Message (DM) mode on top of AES-256-CCM-4. The bulk payload is still AES-CCM-4; the per-conversation key is HKDF-derived from an ECDH shared secret, not used raw and not from a human channel name.

Trust states (UI / policy)

Each DM peer contact SHOULD record a trustState:

ValueNameMeaning
0UNVERIFIEDPeer key seen on-air but not yet accepted by the user
1TOFUTrust-on-first-use: first observed key was accepted locally
2VERIFIEDKey confirmed out-of-band (QR, physical pairing, etc.)

Policy MAY restrict sensitive DM traffic to TOFU or VERIFIED peers.

Pairwise encrypted DM encrypts traffic to a discovered key; encryption alone does not prove human/device identity. QR or short fingerprint comparison is the recommended verification flow.

Identity prerequisite (Ed25519)

Each device has a long-term Ed25519 keypair in NVM — see Embedded storage. Peers MUST learn the counterparty’s 32-byte Ed25519 public key (e.g. from NODE_ADVERT / NODE_INFO_* — see Node identity) before they can open a DM session.

Session establishment

To send a DM to peer P:

  1. Let skEd_local / pkEd_local be the local Ed25519 keypair, and pkEd_peer be P’s known Ed25519 public key (32 bytes).
  2. Transpose the local Ed25519 key material to an X25519 scalar skX_local using the standard Ed25519→X25519 mapping (same as libsodium crypto_sign_ed25519_pk_to_curve25519 / ..._sk_to_curve25519 family, or equivalent in mbedTLS).
  3. Map pkEd_peer to an X25519 u-coordinate public key pkX_peer (same standard mapping).
  4. Compute the 32-byte ECDH output (reject an all-zero shared secret):
x25519SharedSecret[0..31] = X25519( skX_local, pkX_peer )
  1. Sort the two 32-byte X25519 public keys lexicographically into pkLow and pkHigh.

  2. Derive the DM AES key:

salt = SHA256(
    UTF8("LowMesh/DM/salt/1") ||
    pkLow[0..31] ||
    pkHigh[0..31]
)

dmKey = HKDF-SHA256(
    IKM  = x25519SharedSecret[0..31],
    salt = salt,
    info = UTF8("LowMesh/DM/AES-256-CCM/key/1"),
    L    = 32
)
  1. Use dmKey as psk32 for AES-256-CCM for this DM conversation. Do not use x25519SharedSecret directly as the AES key.

Wire channelHash for DMs

dmHashDigest[0..31] = SHA256(
    UTF8("LowMesh/DM/channelHash/1") ||
    dmKey[0..31]
)
channelHashDM_onWire = uint16_le( dmHashDigest[0] | (dmHashDigest[1] << 8) )

Implementation constraints (ESP32-S3, nRF52840)

Ed25519↔X25519 conversion, SHA-256, and X25519 ECDH SHOULD use optimized, vetted libraries (mbedTLS, TweetNaCl, PSA Crypto, etc.). Curve math can be CPU-heavy relative to LoRa symbol times — implementations SHOULD avoid blocking radio ISR / driver paths (defer to a worker task, precompute where possible, and cap concurrent ECDH work).

Security note

This design binds confidentiality to long-term identity keys and per-peer ECDH. It does not by itself provide forward secrecy unless you add ephemeral keys or a ratchet in a higher layer.

LowMeshOS — always open-source mesh protocol documentation