‹ Jack's Brain

A Hitchhiker's Guide to MeshCore Cryptography

Jan 20, 2026

I’ve been playing around a lot with MeshCore, a peer-to-peer, encrypted, routed radio network run by average citizens using LoRa modulation in unlicensed sub-gig bands (902-927MHz ISM band in the US). It’s very similar to Meshtastic, but a lot less chatty (pull-not-push messaging, with conservative advertisement intervals), and thus a lot more reliable at passing messages (although recent Meshtastic firmwares have introduced routing, which helps a bit). The off-grid and encrypted-by-default nature of these networks appeals to my sensibilities, so I’ve been building a few tools while getting my own repeaters set up around town (Remote Terminal for MeshCore, a server + web frontend for serial-connected radios, and a WebGPU bruteforcer library for semi-public hashtag rooms, most concretely – both experiments in development oriented around guiding LLMs as opposed to hand-writing most of the code).

While building these, I’ve had to navigate a few gotchas around packet- and crypto-level operations that I thought might be helpful to share. This writeup isn’t intended as a guide to all the crypto to be found in MeshCore, but just some basics I learned in my meanderings which might serve as signposts for other people working with MeshCore message & crypto.

WAT?

This post is aimed primarily at people who have worked with MeshCore before. If you haven’t but still want to read: radio nodes (typically operated via bluetooth) emit traffic, which is then picked up and passed forward through repeaters for up to 64 hops, or less, if the packet contains routing instructions. The basic message types most relevant to regular users are direct messages (to another user) and group texts (to a channel with a preshared key, either in literal hex key form or a seed phrase (“hashtag rooms”)).

I’m no cryptographer, and there’s a chance some of my suppositions in this post are a little off base, but I’ve done my best; feel free to drop me a line with any corrections if you spot them!

N.B. MeshCore packets have a header/preamble structure on the wire, usually indicating packet type/version and path information (length + one byte ID of repeater hop taken per hop). In this blog post, I will be exclusively dealing with payloads, which is the encrypted portion of the packet. So, any code examples won’t work with the full packet but instead only with the encrypted payload!

[As a follow on from that payload fact – if you’re developing any tool that doesn’t need 100% of all over-the-air captured packets (i.e. you care about packet content and not path-taken), you generally want to be deduping on payload value, not entire-packet, so you can disregard repeater forwards that will add pathing data which changes the packet but doesn’t reflect a new base message.]

🌱 This blogpost was lovingly written entirely by my organic hand. I used LLMs to help me understand some of the crypto concepts, root through the radio firmware source, and bug check the code examples, but there is no LLM content in this post.

For code links, I’m using 6b52fb32301c273fc78d96183501eb23ad33c5bb as a reference base, so if the codebase has moved a lot since then, it’s plausible that some of this info has decayed!

Prelude: Packet Structure

This isn’t super relevant to this blog post, but it’s not always the most evident unless you’ve prowled the docs or source. So, let’s go over it quickly.

A full meshcore packet is little endian, and has:

  • Header
    • 2 bits for a route type cite
      • Route type, i.e. whether a packet is intended to go directly to the destination via a predetermined routing, or to be flooded throughout the network, retransmitted by all repeaters that hear it. There are also transport flood and transport direct, supporting regional limiting of packets, to prevent one area’s traffic leaking into another. citeimplementation
    • 4 bits for a type indicator cite
      • Fundamentally, what a packet is – direct message, group message, node advertisement, etc. A table breaks down the values below.
    • 2 bits for a version indicator cite
      • Current payload version. v0, right now, which is current with no others supported beyond special-purpose implementations (heard about a certain region forking their mesh to have an MQTT-ingest consent flag 😏)
  • Path data cite
    • 1 byte for path length
      • Indicates the number of 1 byte repeater prefixes (first byte of pub key) that follow, indicating the path the packet took to its current broadcast point
    • N bytes for path data
  • Payload
    • Varies in structure by packet type. This structure is primarily what examples in this blogpost work with.
    • Typically contains the first byte of the pubkey of the source, the first byte of the pubkey of the destination, a two byte MAC, then the ciphertext.

Header Byte Structure

BitsMaskFieldDescription
0-10x03Route Type00: Transport Flood, 01: Flood, 10: Direct, 11: Transport Direct
2-50x3CPayload TypeSee table below (shifted left by 2 in header, presumably for later further expansion)
6-70xC0Payload Version00: v0, (no others, yet)

e.g. 0x09 == 00001001 = 01 (flood), 0010 (TextMessage), 00 (version 0).

Payload Types

Source

CodeNameDescription
0x00RequestRequest for node data (repeater stats, battery power, location, etc.). More about those fields here.
0x01ResponseDirect response to Request or Anon Request. Can be bundled in Path payloads; can include login status/perms, repeater stats, telemetery, etc.
0x02Text MessageDirect message to a node.
0x03AckProvides receipt-of-message to sender. More about acks here; can be combined into a Path payload.
0x04AdvertNode advertisement (for sharing identity (as companion node) or repeater availability (as repeater)). More about adverts here.
0x05Group textText message for a group channel (encrypted with shared secret; no identity proof (sender name embedded in message)). More about group texts here.
0x06Group dataData message for group channel (encrypted with shared secret; no identity proof (sender name embedded in message)).
0x07Anon RequestAnonymous/login request (embeds sender’s pubkey; used for repeater + room server login). More about anon reqs here.
0x08PathProvides return path data; can bundle Acks or Responses.
0x09TraceA directed payload, intended for tracing a closed-loop path, collecting SNR data at each hop (cannot be flood, so, make sure you direct it back to you!). No authn bundled; this is data gathering only.
0x0AMultipartMulti-packet sequence; currently only does ACKs (I think? see here for more info, but I’m fuzzy on this one; haven’t seen it in use on the wire).
0x0BControlSpecial payload for discovering repeaters around (request subtype 0x80 for discovery requests, subtype 0x90 for discovery response). Not flood-able. See here for more info.
0x0FRaw/CustomGeneric payload with no set structure, for custom purposes

Phew. Okay, now you know your way around packets, which we’re not really talking about. badpokerface_meme.jpg

Keys & Key Derivation

All keys in the MeshCore world are used and displayed as hex strings (at least for firmware I’ve interacted with, with the vague and poorly understood exception of T-Deck firmware, which I think is a Base64 encoding of hex (??? I do not have a T-Deck that I use; this is vibes alone)).

Key TypeSize
Public key32 bytes (64 hex chars)
Private key64 bytes (128 hex chars)
Channel secret16 bytes (zero padded to 32; the radio can use 32, but my client software of choice doesn’t support itcite, 32 hex chars)

MeshCore generally uses Ed25519 for signing and X25519 for shared secret derivation. However, the derivation of public key is a little funny. From my stumble-through understanding (with some help from LLMs), a typical Ed25519 public key derivation looks like

32 byte seed => SHA-512 => scalar (first 32 bytes, clamped) => scalar * basepoint = public key

However, MeshCore’s conventions around private keys have the first 32 bytes as a pre-clamped scalar (i.e. between 2^254 and 2^255, and divisible by 8, due to reasons I don’t really grok at all like subgroup safety and side channel resistance), which skips a step in terms of normal clamp(sha512(seed)). The radio will re-clampcite, but most clients will do the same. The remaining 32 random bytes are used as the signing component. Therefore, normal crypto libraries will behave “wrongly” (aka as all other keys typically do) if you hand them a MeshCore-compatible private key and derive the public key.

To derive the public key from the private key, you just run:

clamped scalar * basepoint = public key

i.e. in Python:

import nacl.bindings

def derive_public_key(private_hex: str) -> str:
    scalar = bytes.fromhex(private_hex)[:32]
    # direct scalar * basepoint
    public_key = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(scalar)
    return public_key.hex()

derive_public_key(
    "489E11DCC0A5E037E65C90D2327AA11A42EAFE0C9F68DEBE82B0F71C88C0874B" + # scalar
    "CC291D9B2B98A54F5C1426B7AB8156B0D684EAA4EBA755AC614A9FD32B74C308"   # signing
) # => 'deadbeef02f4abe0528175198da136b6b202f58c0648fd8017a2196b675c7b55'

And you can create a private key in the first place from clamped(32 random bytes) + 32 random bytes, e.g.

import os

def generate_private_key() -> str:
    # 32 byte random scalar
    scalar = bytearray(os.urandom(32))

    # clamp the scalar
    scalar[0] &= 248 # 0b11111000, zeros lowest three bits
    scalar[31] &= 63 # 0b00111111, zeros bit 255
    scalar[31] |= 64 # 0b01000000, sets bit 254 -- now 2^254 < value < 2^255
    # we're now cofactor-cleared (cryptonerds please school me), and bounded

    # signing component is 32 random bytes
    signing_component = os.urandom(32)
    return (scalar + signing_component).hex()

Of course, everything’s easier if you do what most demo scripts suggest and use a clamped sha512 of a 32 byte seed, but this works just as well (there’s probably a reason it’s Cryptographically Bad, but this is just an example.)

Encryption, Generally

First, rollup your payload: timestamp (uniqueness in repeated packetscite) + flags (attempt number (lower 2 bits) with message type (upper 6 bits) indicating plain textcite, command datacite, or a signed messagecite (not even gonna talk about room servers in this overview)) + message text, pad to 16 bytes, then AES-128-ECB. Attempt number actually really matters, because it means subsequent sends (if we didn’t hear an ACK from our recipient) don’t get discarded as repeats if our resend-interval is under 1000ms (i.e. when the message timestamp would increment) – unlikely but possible.

After you’ve got your ciphertext, generate a 2 byte MAC, resulting in:

[1 byte header that we don't discuss in this post]
[optional regional transport codes, which are picking up steam in latest revisions but not relevant here]
[1 byte path data length that we don't discuss in this post]
[N byte path data (first byte of repeater public key)]
# BEGIN PAYLOAD
[Payload header (dest, src, 2 byte MAC)]
[Payload: timestamp, flags, message, etc.]
# END PAYLOAD

In both theory and experience, a 2 byte MAC means MAC collisions are not hard to run into. For my library, I had to implement timestamp recency checking (i.e. for my purposes I chose within 30 days, or closer, if you know it to be closer, to rule out packets “originating” in 1980 etc.) + utf8 validity checking (i.e. no U+FFFD indicating an undecodeable UTF-8 character) for the 1 in 2^16 change of a MAC collision (happens pretty often when you’re trying 2-3 gigakeys/second).

Message payloads contain a “hash” byte to aid in key selection, since messages don’t contain a full sender identity (except for AnonReqs, but we’ll talk about those later). For contacts, it’s the first byte of the key (sufficiently random); for channels, this is the first byte of the sha256 of the channel key (don’t want to be bound by “#” or linguistically-influenced-and-thus-collision-prone letters). This helps narrow, but not perfectly select, the applicable keys for the radio to try.

By my back-of-the-napkin math, with one key-selection “hash” (1 in 2^8 collision probability) and a 2 byte MAC (1 in 2^16 collision probability), you’ve got a 1 in 2^24 (16.7M) change of a key matching the hash and passing the MAC but decrypting to garbage.

I’d love to see non-block mode crypto; that’s a given. Zero padding instead of PKCS7-or-etc. is not as ideal, either. That being said, the risk profile for short, timestamped (i.e. ersatz IV soooort of), low-frequency messages, and especially over a bandwidth-constrained medium… eh. I’m not filing a CVE. It’s reasonably proportional to the threat model. Would I leak Snowden secrets? Hell no. Would I coordinate a festival meetup location or chat about my day? Absolutely.

Direct Messages

The (debatably) simplest usage of MeshCore crypto is in direct messages. This is assumed to be a message between two contacts, both possessing each other’s public keys (i.e. received over advertisement).

DM payloads take the form of:

[1 byte destination; first byte of recipient pubkey]
[1 byte source; first byte of sender public key]
[2 byte MAC]
Ciphertext: [4 byte time stamp + 1 byte flags + message, all encrypted]

The “all encrypted” is encrypted by shared secret, derived from a pretty standard ECDH(priv_sender, pub_recipient) == ECDH(pub_sender, priv_recipient). That shared secret is then used for direct AES-128-ECB on the payload.

For example:

DM from a1b2c3 to face12: “Hello there, Mr. Face!”

As decrypted by face12, using crypto primitives in Appendix 2

# <import primitives>

# a1b2c3d3's public key (sender), face1233's private key (receiver)
face12_priv = bytes.fromhex("58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1")
a1b2c3_pub = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7")

# DM payload: [dest_hash:1][src_hash:1][MAC:2][ciphertext]
payload = bytes.fromhex("FAA1295471ADB44A98B13CA528A4B5C4FBC29B4DA3CED477519B2FBD8FD5467C31E5D58B")

dest_hash, src_hash = payload[0], payload[1]  # 0xFA = face12, 0xA1 = a1b2c3
mac, ciphertext = payload[2:4], payload[4:]

# Derive shared secret via ECDH and decrypt
shared_secret = ecdh_shared_secret(face12_priv, a1b2c3_pub)
assert verify_mac(shared_secret, ciphertext, mac), "MAC verification failed"
plaintext = decrypt_aes_ecb(shared_secret, ciphertext)

# Plaintext: [timestamp:4][flags:1][message]
timestamp = struct.unpack('<I', plaintext[:4])[0]
message = plaintext[5:].rstrip(b'\x00').decode('utf-8')
print(f"Message: {message}")  # Output: Hello there, Mr. Face!

Group Messages

Group messages (aka GroupText for certain interfaces) are a lot simpler. These are either keyed on a preshared 16 byte hex key, or on a preshared alphanum + non-sequential-dashes (for firmware I’ve used) “hashtag” room, which is keyed on hashlib.sha256("#roomname".encode()).digest()[:16]. The sender’s “identity” (if you could call it that) is simply represented in the decrypted text as the pre-colon component of the message i.e. senderName: Message follows. I haven’t parsed in detail how different clients react to names with colons, but I’d expect that naive clients probably bungle things aggressively.

As an example:

# <import primitives>

# option A: hashtag room of #bachelorette
channel_key = hashlib.sha256("#bachelorette".encode()).digest()[:16]
payload = bytes.fromhex("5C13C031C6DC206E8B1D8D300C637FCE97204C8763A06B7AC406D39381DC0D07470C019D2047D711D48BAAE988EBBCB0966DC197A7DD99BDF154304B9E3AAA10498686")

# option B: psk channel of 0378cebadd350b6c2b198f269eda1fd0
# channel_key = bytes.fromhex("0378cebadd350b6c2b198f269eda1fd0")
# payload = bytes.fromhex("3E4CA6AAA0C4866CE1ECD8D33C5792C0FCDAFE7C61CF477D86ECA6F5853BE0185A9385570512E585A7546DB5AC522B8531A3E30E165132D75C0CDC397C4094AC8BAFAA9679EF69B40882D7EA37911FE3D8576ECD3FB00B6F398DE92398D9B42548E486")

# check that channel hash matches
channel_hash = hashlib.sha256(channel_key).digest()[0]
assert payload[0] == channel_hash, f"Channel hash mismatch"

# parse payload: [channel_hash:1][MAC:2][ciphertext]
mac, ciphertext = payload[1:3], payload[3:]

# verify MAC and decrypt
shared_secret = channel_key.ljust(32, b'\x00')
assert verify_mac(shared_secret, ciphertext, mac), "MAC verification failed"
plaintext = decrypt_aes_ecb(shared_secret, ciphertext)

# plaintext: [timestamp:4][flags:1][sender_name: message]
timestamp = struct.unpack('<I', plaintext[:4])[0]
message = plaintext[5:].rstrip(b'\x00').decode()
print(f"Message: {message}")

Repeater Cryptography

Repeaters follow a funny pattern for logins, which I detail more below, but in essence, it boils down to “authenticate successfully once using the password in an AnonRequest, and the rest of your comms will be authenticated by your key”. Below is an example of a fully-decrypted-by-both-sides repeater comm exchange based on real packets, consisting of:

  • login with password
  • ack of good password auth/addition to ACLs
  • request of…
  • and receipt of… a general status call, covering voltage, airtime/duty cycle stats, etc.
# <import primitives>

# a1b2c3d3, our client
radio_priv = bytes.fromhex("1808C3512F063796E492B9FA101A7A6239F14E71F8D1D5AD086E8E228ED0A076D5ED26C82C6E64ABF1954336E42CF68E4AB288A4D38E40ED0F5870FED95C1DEB")
radio_pub = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7")

# deadbeef, the repeater
repeater_priv = bytes.fromhex("489E11DCC0A5E037E65C90D2327AA11A42EAFE0C9F68DEBE82B0F71C88C0874BCC291D9B2B98A54F5C1426B7AB8156B0D684EAA4EBA755AC614A9FD32B74C308")
repeater_pub = bytes.fromhex("deadbeef02f4abe0528175198da136b6b202f58c0648fd8017a2196b675c7b55")

###############################################################################################################
# STEP 1: AnonReq: radio -> repeater: AnonReq i.e. login with password "hunter2" (our "announce" of key + auth)
# payload: [repeater_pub:1][radio_pub:32][MAC:2][ciphertext]
###############################################################################################################

anon_req = bytes.fromhex("DE" +  # repeater/deadbeef first byte
                         "A1B2C3D3BA9F5FA8705B9845FE11CC6F01D1D49CAAF4D122AC7121663C5BEEC7" + # radio/a1b2c3d3 pub key
                         "83B6" + # MAC
                         "CEA82D827AFE95128674D5F308A80B41" # password/ciphertext: [timestamp:4][password (null-padded)]
                        )

sender_pubkey = anon_req[1:33]   # radio full pub
mac, ciphertext = anon_req[33:35], anon_req[35:]

# repeater computes shared secret from radio's sent pubkey
shared_secret = ecdh_shared_secret(repeater_priv, sender_pubkey)
assert verify_mac(shared_secret, ciphertext, mac)
plaintext = decrypt_aes_ecb(shared_secret, ciphertext)

password = plaintext[4:].rstrip(b'\x00').decode('utf-8') # strip out zero padding
print(f"AnonReq password: {password}")  # output: hunter2

###########################################################
# STEP 2: RESPONSE: repeater -> radio: login ok
# payload: [radio_pub:1][repeater_pub:1][MAC:2][ciphertext]
###########################################################

login_resp = bytes.fromhex("A1" + # radio pub first byte
                           "DE" + # repeater pub first byte
                           "5010843266E7E19DC6C73804AA274DC7B4DC" # ciphertext
                          )

mac, ciphertext = login_resp[2:4], login_resp[4:]
assert verify_mac(shared_secret, ciphertext, mac)
plaintext = decrypt_aes_ecb(shared_secret, ciphertext)

response_code = plaintext[4]
print(f"Login response: {response_code}")  # output: 0 (i.e. ok)

###########################################################
# STEP 3: REQUEST (radio -> Repeater) for status
# payload: [repeater_pub:1][radio_pub:1][MAC:2][ciphertext]
###########################################################

status_req = bytes.fromhex("DE" + # repeater pub first byte
                           "A1" + # radio pub first byte
                           "497859A301D97A4EEE3A6D2C606E3D805896" # ciphertext: current timestamp to correlate repeater response with our request
                          )

mac, ciphertext = status_req[2:4], status_req[4:]
assert verify_mac(shared_secret, ciphertext, mac)
plaintext = decrypt_aes_ecb(shared_secret, ciphertext)

req_client_timestamp_tag = struct.unpack('<I', plaintext[:4])[0]
request_type = plaintext[4]
print(f"REQ tag: {req_client_timestamp_tag}, type: 0x{request_type:02x}")  # type 0x01 = status

# =============================================================================
# STEP 4: RESPONSE (repeater -> radio) to send status
# payload: [radio_pub:1][repeater_pub:1][MAC:2][ciphertext]
# =============================================================================

status_resp = bytes.fromhex("A1" + # radio pub first byte
                            "DE" + # repeater pub first byte
                            "E5C9255460B9CB9630C053F0981100E9BBAB32A7D4164CCB639E1143B0613A6246" + # repeater status data
                            "75E941E49D9D0A8911D83FFA9AEC06C54677DED0534F5F82449380C7C12C659F1C" # repeater status data cont.
                           )

mac, ciphertext = status_resp[2:4], status_resp[4:]

# radio verifies with ecdh derivation
radio_secret = ecdh_shared_secret(radio_priv, repeater_pub)
assert radio_secret == shared_secret

assert verify_mac(radio_secret, ciphertext, mac)
plaintext = decrypt_aes_ecb(radio_secret, ciphertext)

resp_timestamp_correlation_tag = struct.unpack('<I', plaintext[:4])[0]
print(f"RESPONSE tag: {resp_timestamp_correlation_tag} (matches REQ: {resp_timestamp_correlation_tag == req_client_timestamp_tag})")
print(f"Status data: {plaintext[4:].hex()}")
# unpack according to https://github.com/meshcore-dev/MeshCore/blob/6b52fb32301c273fc78d96183501eb23ad33c5bb/examples/simple_repeater/MyMesh.h#L42-L57

Miscellaneous Learnings

Repeater Prefix Collisions

Repeaters are identified within the larger packet (which, at this point in the blog post, we kind of are discussing) as the first byte of the repeater’s public key.

“2^8,” you say, “1 in 256 collision!”

Yes. Repeater pathing, as recorded in the packet, are not intended to be used for forensic path reconstruction. Similarly, if a directed-out packet sheds all but a 1-in-256 (or 255, if you follow the “FF prefix is forbidden” strategy I’ve seen) chance of a wrong-repeater-direction, we’re still doing pretty good. So, repeaters not within audible range of each other? Collide away; you merely pollute path-already-travelled reverse-derivation. Repeaters within audible range of each other? Try to disambiguate, yes, but at the end of the day, as long as your next hops are not also audible from each other, the packet will come to a swift halt.

Repeater Login Flow

Repeaters maintain an ACL (access control list) for what user have what permissions, guest or admin. When you log into a repeater via AnonReq, the public key is stored in the ACLs with the associated permission level. That way, in the future, you don’t need to log in again, and can just begin making requests to the repeater, so future repeater access for a repeater you’ve already auth’d to can be either using the guest password (or empty, if the guest password is empty) or admin password – you’ll end up with the same (ACL-driven) access rights anyway.

Repeater Time Travel

Repeaters require packets heard with the same pubkey to travel forwards in time – if you send a command/status packet with a timestamp, then send another with a timestamp further back in time, the further-back-in-time packet will be dropped. So, if you, as I have done, reset a radio but keep the key, and forget to set your system time, you’ll have all your packets quietly dropped.cite

Auto-acks

This one is potentially straightforward for those who use MeshCore in a meaningful way, but bears repeating: the radio itself can only ack (i.e. send receipt confirmation packet to) DMs that it possesses the sender’s public key for; otherwise, it cannot decrypt the packet. A critical corollary: a radio/store-of-packets is free to later decrypt a DM after gaining the sender’s public key even if that message was not ack’d. That is to say, not receiving a message ack does not necessarily mean the recipient could not read your message.

So?

  • I love MeshCore, and I’ve loved building a local client for it (RemoteTerm for MeshCore). A lot of the knowledge in this blog post has become highly practical for me as I’ve built it, especially after-the-fact-key-acquisition for group message rooms and DMs, which my backend collates and will attempt historical decrypt jobs on as keys become available.
  • Hashtag rooms are public by design – the keyspace is small, and lends itself to single-or-multiple-word keys. Don’t discuss anything you want to remain secret in a hashtag room! Great, 32 byte ECDH-protected crypto is readily available: use it!
  • MeshCore is lively and fun in active meshes, and it springs up readily in new meshes: if you build it, they will come! Throw up a repeater on your rooftop, your local bell tower, whatever local prominence you have legal access to (being careful of the risks of unattended solar-charged LiPo cells!!!!).
  • Citizen-run, peer-to-peer, encrypted-by-default, off-grid comms are awesome, and we should do more of them.

Appendices

Appendix 1: Test packets/keys in isolation

  • Keys

    • Repeater

      • Public: deadbeef02f4abe0528175198da136b6b202f58c0648fd8017a2196b675c7b55
      • Private: 489E11DCC0A5E037E65C90D2327AA11A42EAFE0C9F68DEBE82B0F71C88C0874BCC291D9B2B98A54F5C1426B7AB8156B0D684EAA4EBA755AC614A9FD32B74C308
    • Client 1 (sender/a1b2c3d3)

      • Public: a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7
      • Private: 1808C3512F063796E492B9FA101A7A6239F14E71F8D1D5AD086E8E228ED0A076D5ED26C82C6E64ABF1954336E42CF68E4AB288A4D38E40ED0F5870FED95C1DEB
    • Client 2 (receiver/face1233)

      • Public: face123334789e2b81519afdbc39a3c9eb7ea3457ad367d3243597a484847e46
      • Private: 58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1
  • Payloads

    • Direct Message from a1b2c3d3 to face1233

      • Payload: FAA1295471ADB44A98B13CA528A4B5C4FBC29B4DA3CED477519B2FBD8FD5467C31E5D58B
      • Full packet: 0900FAA1295471ADB44A98B13CA528A4B5C4FBC29B4DA3CED477519B2FBD8FD5467C31E5D58B
      • Plaintext: "Hello there, Mr. Face!"
    • GroupText from a1b2c3d3 to hashtag room #bachelorette

      • Payload: 5C13C031C6DC206E8B1D8D300C637FCE97204C8763A06B7AC406D39381DC0D07470C019D2047D711D48BAAE988EBBCB0966DC197A7DD99BDF154304B9E3AAA10498686
      • Full packet: 15005C13C031C6DC206E8B1D8D300C637FCE97204C8763A06B7AC406D39381DC0D07470C019D2047D711D48BAAE988EBBCB0966DC197A7DD99BDF154304B9E3AAA10498686 Plaintext: A1b2c3: This is a group message in #bachelorette!
    • GroupText from a1b2bc3d to PSK channel with key 0378cebadd350b6c2b198f269eda1fd0

      • Payload: 3E4CA6AAA0C4866CE1ECD8D33C5792C0FCDAFE7C61CF477D86ECA6F5853BE0185A9385570512E585A7546DB5AC522B8531A3E30E165132D75C0CDC397C4094AC8BAFAA9679EF69B40882D7EA37911FE3D8576ECD3FB00B6F398DE92398D9B42548E486
      • Full packet: 15003E4CA6AAA0C4866CE1ECD8D33C5792C0FCDAFE7C61CF477D86ECA6F5853BE0185A9385570512E585A7546DB5AC522B8531A3E30E165132D75C0CDC397C4094AC8BAFAA9679EF69B40882D7EA37911FE3D8576ECD3FB00B6F398DE92398D9B42548E486
      • Plaintext: A1b2c3: Hello in this group chat secure by random key, not hashtag key derivation!
    • Repeater login flow from a1b2c3d3

      • AnonReq from a1b2c3d3, containing a1b2c3d3’s public key and the password (hunter2)

        • Payload: DEA1B2C3D3BA9F5FA8705B9845FE11CC6F01D1D49CAAF4D122AC7121663C5BEEC783B6CEA82D827AFE95128674D5F308A80B41
        • Full packet: 1E00DEA1B2C3D3BA9F5FA8705B9845FE11CC6F01D1D49CAAF4D122AC7121663C5BEEC783B6CEA82D827AFE95128674D5F308A80B41
      • Repeater response

        • Payload: A1DE5010843266E7E19DC6C73804AA274DC7B4DC
        • Full packet: 0500A1DE5010843266E7E19DC6C73804AA274DC7B4DC
      • Req from a1b2c3d3 for status

        • Payload: DEA1497859A301D97A4EEE3A6D2C606E3D805896
        • Full packet: 0200DEA1497859A301D97A4EEE3A6D2C606E3D805896
      • Repeater status response

        • Payload: A1DEE5C9255460B9CB9630C053F0981100E9BBAB32A7D4164CCB639E1143B0613A624675E941E49D9D0A8911D83FFA9AEC06C54677DED0534F5F82449380C7C12C659F1C
        • Full packet: 0600A1DEE5C9255460B9CB9630C053F0981100E9BBAB32A7D4164CCB639E1143B0613A624675E941E49D9D0A8911D83FFA9AEC06C54677DED0534F5F82449380C7C12C659F1C

Appendix 2: Cryptographic Primitives Used in Examples


# /// script
# dependencies = [
#   "nacl",
#   "cryptography",
# ]
# ///

import hashlib
import hmac
import struct

import nacl.bindings
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

def clamp_scalar(scalar: bytes) -> bytes:
    # Ed25519 clamping; see https://www.jcraige.com/an-explainer-on-ed25519-clamping
    scalar = bytearray(scalar[:32])
    scalar[0] &= 248 # 0b11111000, zeros lowest three bits
    scalar[31] &= 63 # 0b00111111, zeros bit 255
    scalar[31] |= 64 # 0b01000000, sets bit 254 -- now 2^254 < value < 2^255
    # we're now cofactor-cleared, and bounded
    return bytes(scalar)

def ecdh_shared_secret(sender_priv: bytes, recipient_pub: bytes) -> bytes:
    # ensure we're clamped
    clamped = clamp_scalar(sender_priv[:32])
    # twisted Edwards to Montgomery (outside my crypto knowledge scope)
    x25519_pub = nacl.bindings.crypto_sign_ed25519_pk_to_curve25519(recipient_pub)
    # actually multiply clamped sender priv and montgomery recipient pub for a shared secret
    return nacl.bindings.crypto_scalarmult(clamped, x25519_pub)

def decrypt_aes_ecb(key: bytes, ciphertext: bytes) -> bytes:
    # set up an AES-128 ECB-mode cipher using the first 16 bytes of a shared secret
    cipher = Cipher(algorithms.AES(key[:16]), modes.ECB())
    return cipher.decryptor().update(ciphertext)

def verify_mac(shared_secret: bytes, ciphertext: bytes, mac: bytes) -> bool:
    # validate HMAC-SHA256 using the first two bytes of the hash
    expected = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
    return mac == expected