Skip to content

Identity, keys & tags

chIRpChat’s identity model has to satisfy four pulls that fight each other:

  1. Identity must be a key, not a node id — several physical devices holding the same key are the same user, and any of them may receive that user’s messages.
  2. Messages are signed, never encrypted (project policy: open text, verifiable origin).
  3. A LoRa frame can’t afford a 64-byte Ed25519 signature on every chat line, and a flash-constrained node can’t afford a 32-byte public key for every user it ever hears.
  4. Spoofing must be detectable quickly, and a compromised short key must be rotatable using the long key.

There is no magic primitive that gives publicly-verifiable 8-byte signatures (truncated Ed25519 can’t be verified; BLS is 48 bytes; TESLA needs per-sender 32-byte chain state on every verifier). So chIRpChat uses a tiered design that is honest about what each tier proves.

Key Type Size Lives Purpose
Identity key (IK) Ed25519 32 B pub / 32 B seed user keyfile, shared across the user’s devices root of identity; signs registrations, rotations, checkpoints, admin ops
Session key (SK) 128-bit MAC key 16 B client ⟷ each home router per-registration tag key; derived during HELLO/WELCOME
Chat tag key (CTK) 64-bit MAC key 8 B published per epoch lets anyone check chat tags cheaply (see §4.2 caveat)
Router key (RK) Ed25519 32 B router signs beacons, WELCOME, federation links, attestation
Release key Ed25519 32 B pub / 64 B expanded priv CI release-signing secret (offline-generated); public half is operator-provisioned config on each node signs OTA release manifests and nothing else — see the note below

A user’s UID is the first 8 bytes of SHA-256(IK_pub). UIDs appear in every packet header; full public keys appear only in HELLO/KEYRESP. Collision probability across 10⁵ users is ~2.7×10⁻¹⁰; routers additionally detect collisions at registration (they hold full keys for their clients) and refuse the second key, telling it to re-key. (With ~6×10⁹ users the birthday bound becomes real; v2 reserves a header bit for 16-byte UIDs.)

The release key is deliberately outside this per-identity hierarchy. It is not a user’s IK, not a router’s RK, and compromising every node admin on the network still cannot forge a signed firmware manifest with it — the converse also holds: compromising the release key cannot forge chat messages, admin ops, or registrations, because it signs one 55-byte manifest structure and nothing sharing that structure’s shape exists elsewhere in the protocol. See RELEASING.md for how the key is generated/rotated and OTA.md §2 for how a node verifies a manifest against it (the public half is admin-provisioned via the ota.release_pubkey/ota.nightly_pubkey safe keys, not compiled into lrcd, so a suspected-compromised key can be rotated with a signed-Admin config update rather than a fleet-wide re-flash).

Multi-device identity is just key sharing: export the local seed keyfile (/msg *lrc key exportlrc1- plus eight 8-hex-digit groups), import it on the second device with /msg *lrc key import <lrc1-keyfile>, then reconnect that IRC username so the stored seed is reloaded. The seed never rides radio; the serial/Telnet TUI renders the exported keyfile as phonetic hex word groups and an ASCII QR block for local transfer. Both devices register with the same IK; the router keeps one identity record with N active device registrations and fans incoming DMs out to all of them. Devices may carry distinct device sub-keys: keyhint > 0 delegation certificates signed by the IK over "lrc device subkey v1" || keyhint || subkey_pub, so a stolen device can later be revoked without rotating the identity.

Modeled on cellular attach, executed like a signed challenge-response:

client → router HELLO { IK_pub(32), caps(4), nick_hint(≤12), nonce_c(8) } SIGNED_FULL(IK)
router → client WELCOME{ RK_pub(32), nonce_c_echo(8), nonce_r(8),
rtr_short(2), boot_id(4), chan_digest... } SIGNED_FULL(RK)

Both sides derive SK = HMAC-SHA256(x25519(IK, RK), nonce_c ‖ nonce_r)[0..16] (Ed25519 keys birationally mapped to X25519 for the DH — same trick MeshCore uses for its shared secrets). From here on, every client⟷router packet carries Tag8 = SipHash-2-4(SK, …): 8 bytes on air, unforgeable by third parties, and the router never needs the 32-byte key in its hot path again. boot_id is the router incarnation also advertised in BEACON; clients accept it only from the signed WELCOME. A changed confirmed boot_id makes the client discard stale per-router channel mirrors and pending gap state before accepting new sequenced traffic from that router. The client keeps the exact signed HELLO frame, including nonce and MSGID, until WELCOME arrives. If WELCOME is lost, it retries that same frame on the control-plane retry timer; the router may refresh the registration and return a fresh signed WELCOME, while dedup/signature checks still see one stable client request. HELLO carries the mutable FLAGS.PATH block. With no pinned router path, the client sends an empty block so relays can append their UID bytes during discovery. The router reverses those bytes in WELCOME; the client pins the reversed-back uplink path only after checking the WELCOME signature and nonce echo. Later HELLOs use that pinned path until their retry budget expires, at which point the client clears the pin and discovers again.

A client SHOULD register with 1–3 routers (redundancy). The primary is whichever it heard best; the others are standbys that also hold the identity record. Roaming = HELLO to the new router; the new router’s RTRSYNC announces the move, the old registration ages out, in-flight DMs chase via the federation (ROUTING.md §Roaming). Deregistration is BYE (sent by the client, or by the router when IRC-level PING goes unanswered past timeout — the IRC liveness signal doubles as the radio liveness signal, so the network and the chat layer can never disagree about presence).

3. What the router stores (the 16-byte budget)

Section titled “3. What the router stores (the 16-byte budget)”

Per registered client (its own few hundred): the full identity record — IK_pub (32), SK (16), CTK + epoch (9), device regs, nick. That’s fine; it’s a small table for a router’s own subscribers, mirrored to flash. ESP32 router firmware stores accepted local registrations in lrckv as deferred r<uid16> records containing the router UID, client UID, IK pubkey, SK, caps, last-seen time, and nick. On reboot, rows are accepted only for the current router key and restored last_seen values are rebased into the new monotonic clock before the router accepts post-reboot tagged client traffic. Partitionless firmware keeps the table hot-only and clients reattach through HELLO/WELCOME.

Per foreign user (potentially tens of thousands): nothing mandatory. Verification of foreign traffic rides on attestation (§4.1). Nodes that want end-to-end checking cache UID(8) + CTK(8) + epoch(1) = 17 bytes per peer — an opt-in LRU cache, not a requirement. This is the answer to “don’t make every node store 256 bits for everyone”: the default per-foreign-user storage is zero, the opt-in is 17 bytes.

4.1 Tier 1: ingress attestation (default, zero storage)

Section titled “4.1 Tier 1: ingress attestation (default, zero storage)”

A chat message travels client → home router under the session tag (Tag8(SK)). The router verifies it — it knows SK — and stamps the message into the channel sequence. From that moment the message’s authenticity claim is: “the sequencing router vouches that this came from UID X”. Pinned router-to-router federation links are themselves mutually authenticated: the TCP setup exchanges RK pubkeys and nonces, rejects peers whose RK UID is not configured as a peer pin, signs the transcript with RK, derives the base key from RK X25519 plus the ordered nonces, and MACs each following peer frame with a directional SipHash key. lrcd supports pinned links in both directions; ESP32 firmware supports the same authenticated framing for outbound stored peer targets. Legacy unpinned links exist only for lab and backward compatibility. The attestation survives federation transit only across links the operator has pinned or otherwise trusts.

This is exactly IRC’s trust model (you trust irc.example.net that the user is who the prefix says), upgraded with real cryptography on every link. For the 99% case it costs 8 bytes per message and no per-user state anywhere.

4.2 Tier 2: chat tags (cheap end-to-end spoofing tripwire)

Section titled “4.2 Tier 2: chat tags (cheap end-to-end spoofing tripwire)”

Every message also carries the sender’s chat tag: Tag8 = SipHash-2-4(CTK_epoch, canonical bytes). CTK is published (it’s in the identity record, served via KEYREQ/KEYRESP, and gossiped in RTRSYNC), so anyone holding the 8-byte CTK can check the tag. The built node slice publishes a stable initial epoch-1 CTK in KEYRESP, derived from the identity’s private key material with a fixed context string, so devices that share the same IK publish the same initial CTK. Nodes emit chat tags on channel messages and DMs, and receivers enforce them once the sender’s identity record is cached. Inbound signed ROTATE handling is built: when a cached sender announces a higher CTK epoch, receivers verify the signature against the cached IK_pub, update the cached CTK, persist it through the IdentityRotationStore seam when configured, and enforce future tags with the new CTK. Local /msg *lrc key rotate UX is built: it emits a signed ROTATE frame and uses the new CTK for later chat tags. Local cached-peer forget/revoke UX is built: /msg *lrc key forget <nick|uid16> removes cached identity and local DM signature policy state, while /msg *lrc key revoke <nick|uid16> also adds that UID to a local denylist until /msg *lrc key unrevoke <nick|uid16>. Revoked UIDs cannot refresh the cache through KEYRESP/RTRSYNC, and their chat, rotate, and checkpoint frames are ignored locally. Durable accepted rotation overlays are built for cached peers, successful local rotations are archived, and /msg *lrc key rotations [nick|uid16] lists the bounded local archive of higher-epoch ROTATE events. Allowlisted remote operators can now apply the same per-router denylist through signed Admin: /msg *lrc admin rtN set identity.revoke <uid16> and identity.unrevoke <uid16>. RTRSYNC now fans out generationed revoke and clear state across trusted peer links. A newer verified identity record that omits a previously cached device subkey evicts that subkey locally for future signed-DM checks.

Honesty note, in bold because reviewers will ask: a published MAC key means a determined attacker who fetches CTK can forge tags. Tier 2 is not a signature. What it actually buys, and why it’s still worth 8 bytes:

  • It forces an attacker to acquire CTK first (an observable KEYREQ or a radio capture of the gossip), eliminating drive-by nick spoofing.
  • Combined with Tier 1 it means forging also requires either compromising a router or the victim’s session — at which point you’ve broken Tier 1, and Tier 3 will catch you anyway.
  • It gives offline verifiability of archives against the matching checkpoint chain (below).

4.3 Tier 3: signature checkpoints (full crypto, amortized)

Section titled “4.3 Tier 3: signature checkpoints (full crypto, amortized)”

The target policy is one checkpoint every K messages (default 32) or every T minutes (default 15) of activity:

CHECKPOINT { epoch(1), from_msgid(4), to_msgid(4), rolling_sha256_32 } SIGNED_FULL(IK)

where rolling_sha256_32 is a truncated SHA-256 over the canonical bytes of messages in [from, to], in MSGID order. The built node slice can emit a CHECKPOINT after a configured channel/DM message count (default 32; checkpoint_message_interval=0 disables count-based emission) or elapsed time (default 15 min; checkpoint_time_interval_ms=0 disables timer-based emission). Checkpoint cadence and bounded archive/verification retention can be changed through allowlisted signed ADMIN SET keys: checkpoint.message_interval, checkpoint.time_interval_ms, checkpoint.verify_window, and checkpoint.audit_limit. The receiver verifies channel/DM windows when the sender’s identity record is cached and the canonical window is still retained. A forged message — even one with a valid stolen CTK tag — causes the victim’s next matching checkpoint to fail, and verifiers raise checkpoint mismatch for <nick> into affected channels or as a private *lrc notice for affected DMs. The first require-sig policy surface is built for DMs: after caching a peer identity, /msg *lrc require-sig <nick|uid16> makes incoming DMs from that peer require a valid SIGNED_FULL trailer, and /msg *lrc require-sig off <nick|uid16> clears the local policy. The matching local sender policy, /msg *lrc sign-dm <nick|uid16>, signs outgoing DMs to that peer with the local identity key while preserving chat tags and ACK retry. A bounded local checkpoint archive is built: /msg *lrc checkpoint [nick|uid16] lists recent verified windows as ok or mismatch, plus valid signed checkpoints as unverified when the canonical message window is unavailable locally. Rows include the covered MSGID range, the signed checkpoint hash, and affected channel/DM labels when known. lrcd --state-dir persists that bounded digest archive across daemon restart; it deliberately stores checkpoint metadata, not archived message contents.

So the intended full guarantee is: forgeries cost a key acquisition, are bounded to a ≤15-minute window, and are cryptographically exposed after it. For a plaintext community chat network that’s a deliberately chosen, documented trade — and users who need more can check DMs against full signatures (SIGNED_FULL is always allowed on any packet; clients can demand it per-conversation with /msg *lrc require-sig <nick>).

  • ROTATE { new_CTK(8), new_epoch(1), reason(1) } SIGNED_FULL(IK) — anyone holding IK_pub verifies. The built receiver path overlays the runtime CTK only when new_epoch is strictly greater than the effective cached epoch; equal, older, zero, and badly signed announcements are dropped. Local routine rotation can be sent with /msg *lrc key rotate; local cached-peer state can be forgotten with /msg *lrc key forget <nick|uid16> or denylisted with /msg *lrc key revoke <nick|uid16>. Accepted remote rotations persist through IdentityRotationStore as a CTK overlay separate from the signed identity record, and are cleared when a newer signed identity record carries the same effective key or when the peer is forgotten/revoked. Accepted rotation events and successful local rotations are also copied into a bounded local archive for operator inspection through /msg *lrc key rotations [nick|uid16]. A restarted node uses the latest archived row for one of its own identities as the active CTK before falling back to the deterministic epoch-1 key. Signed Admin can remotely add or remove non-local UIDs from a router’s local denylist using identity.revoke / identity.unrevoke values. Those actions persist as generationed revoke state and converge over trusted-peer RTRSYNC; clear rows remain as tombstones so stale revoke rows do not resurrect after a clear. A newer signed identity record that omits a device subkey removes that delegation from the cached verifier set. Automatic scheduling remains future work. Routine rotation target: weekly, plus immediately on checkpoint mismatch involving your own UID.
  • Device sub-key revocation: ROTATE with reason=REVOKE_DEVICE and the keyhint; routers drop that device registration everywhere.
  • IK compromise is fatal by design (as in every key-is-identity system: MeshCore, Meshtastic PKC, SSH). The keyfile format supports an optional successor commitment: SHA-256(next_IK_pub) baked into the identity record at creation, allowing a one-shot signed migration to a pre-committed successor key that routers will honor exactly once.

6. IdentityRecord (what KEYRESP returns / RTRSYNC gossips)

Section titled “6. IdentityRecord (what KEYRESP returns / RTRSYNC gossips)”
UID(8) IK_pub(32) epoch(1) CTK(8) flags(1)
nick_len(1) nick(≤12)
n_subkeys(1) × { keyhint(1) subkey_pub(32) sig(64) } — usually 0
sig(64) by IK over every preceding byte in the record

The built core codec validates that UID == SHA-256(IK_pub)[0..8], every listed subkey certificate verifies under IK_pub, and the final signature verifies under IK_pub. Nodes now answer KEYREQ for local identities and verify/cache received KEYRESP records; /msg *lrc key lookup <nick|uid16> drives the first user-visible fetch path. A requester accepts a response only when the signed record verifies and the record UID matches the packet SRC. The initial published CTK is a stable epoch-1 value derived from identity private material with the lrc initial ctk v1 domain string, and signed higher-epoch ROTATE frames can overlay that CTK at runtime. A same-epoch refresh is accepted only when the pubkey and CTK match the effective cached record. A v1 KEYRESP frame can fit at most one subkey record; larger identity histories are fetched through later storage/gossip pagination instead of silently overflowing LoRa MTU. The current cache is bounded; nodes with an IdentityRecordStore load verified records at boot, persist accepted KEYRESP/RTRSYNC records, and remove the matching cold entry when the cache limit evicts it. When a loaded channel record names the UID as founder or op, that signed identity record is pinned against cold cache eviction. The limit is a soft cap for those authority records: unpinned records are evicted or refused first, while a founder/op record can exceed the cap rather than losing long-lived channel authority through CTK rotation. lrcd --state-dir stores these as idrec-<uid>.idr files; ESP32 firmware stores the same signed payloads in lrckv i<uid16> records when the partition is present. Accepted ROTATE frames overlay the runtime CTK without rewriting the signed identity record; nodes with an IdentityRotationStore load and save those accepted higher-epoch overlays beside the signed record. IdentityRotationArchiveStore keeps a bounded local history of accepted remote higher-epoch rotations plus successful local rotations for audit and local CTK restart restore; lrcd --state-dir persists it beside the identity cache. RTRSYNC now gossips local and already verified cached identity records, except locally revoked UIDs, and also gossips trusted-peer revoke/clear UID state. Local rotation UX, local cached-peer forget/revoke UX, signed Admin per-router UID revoke/unrevoke, subkey certificate validation, and local device-subkey eviction by replacement identity record are built.

Tags, signatures, and checkpoint hashes all cover the canonical packet image: header with TH zeroed and mutable flags masked, addressing block with SEQ3 = 0xFFFFFF (tags are computed before sequencing; the router’s stamp is attested by the router, not the sender), then payload. Defined once in core/src/packet.cpp::canonical_image(); every implementation must reuse it — sign-what-you-encode bugs are the classic way these systems die.