Identity, keys & tags
chIRpChat’s identity model has to satisfy four pulls that fight each other:
- 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.
- Messages are signed, never encrypted (project policy: open text, verifiable origin).
- 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.
- 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.
1. Key inventory
Section titled “1. Key inventory”| 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 export → lrc1- 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.
2. Registration (HELLO/WELCOME)
Section titled “2. Registration (HELLO/WELCOME)”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. Message authenticity — three tiers
Section titled “4. Message authenticity — three tiers”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>).
5. Rotation and revocation
Section titled “5. Rotation and revocation”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 whennew_epochis 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 throughIdentityRotationStoreas 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 usingidentity.revoke/identity.unrevokevalues. 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 oncheckpoint mismatchinvolving your own UID.- Device sub-key revocation:
ROTATEwith 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 0sig(64) by IK over every preceding byte in the recordThe 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.
7. Canonical bytes
Section titled “7. Canonical bytes”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.